From cde3a7887418e2f441a29134da5fe45e54682bcf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 22:16:07 +0100 Subject: [PATCH 001/155] Move public crossings onto typed Bifrost transport --- Dockerfile | 7 - README.md | 32 +- docs/agent-transport.md | 16 +- docs/bridge.md | 79 +- docs/verse-service-contract.md | 60 +- .../Motions/MotionEveSurfaceService.cs | 32 +- src/Bifrost.Web/Program.cs | 369 ------ tests/Bifrost.Web.Tests/BridgeCliTests.cs | 383 ++++-- .../PlatformEndpointsTests.cs | 1154 +---------------- .../ProviderAdvertisementTests.cs | 29 +- tools/agent-transport.mjs | 129 +- tools/bifrost-bridge.mjs | 253 ++-- tools/bifrost-crossing-documents.mjs | 336 +++++ tools/bifrost-discord-command-documents.mjs | 47 + tools/cultmesh-bridge-commands.mjs | 450 +++++++ tools/dispatch-agent-requests.mjs | 88 +- tools/governance-threads.mjs | 129 +- tools/provider-advertisement.mjs | 260 +++- 18 files changed, 1733 insertions(+), 2120 deletions(-) create mode 100644 tools/bifrost-crossing-documents.mjs create mode 100644 tools/bifrost-discord-command-documents.mjs create mode 100644 tools/cultmesh-bridge-commands.mjs diff --git a/Dockerfile b/Dockerfile index b591b8d..b83befa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,6 @@ RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ /p:UseAppHost=false FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /app ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \ ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \ @@ -23,7 +19,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \ EXPOSE 5080 COPY --from=build /app/publish . -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD curl -fsS http://127.0.0.1:5080/healthz || exit 1 - ENTRYPOINT ["dotnet", "Bifrost.Web.dll"] diff --git a/README.md b/README.md index 0935365..938e48f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - GitHub App webhook ingestion for issue, pull request, and review sync - Heimdall-signed external patron support intake for Patreon and PayPal support facts - Eve Motion Verse surface and command endpoint for governance participation, with Razor motion forms demoted to a transitional browser lowering -- local bridge tooling for agent-owned GitHub draft PRs and PR comments, Discord posts and DMs, Reddit self-posts, and receipt-only future-surface requests +- local bridge tooling for agent-owned GitHub draft PRs and PR comments, Discord posts and DMs, Reddit self-posts, and fail-closed future-surface request probes +- canonical `bifrost.crossing_receipt.v1` history in `.bifrost/bridge-receipts.cc` for GitHub, Discord, Reddit, governance, and agent-transport crossings - local bridge tooling for Reddit self-posts in `r/GameCultOrg`, including Persona flair labels through the Bifrost Reddit app - CultCache-backed governance topic threads for feature requests, discussion comments, Persona approvals, and dispatch promotion - CultCache/CultNet-backed agent intake tooling for repo Persona update requests @@ -74,23 +75,15 @@ For the full staged roadmap, including contribution points, revenue share, patro - set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in - POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access - set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path -- set `Bridge:LocalBridgeToken` to allow the transitional local bridge actuator to register and receipt governed actions through `/bridge/actions/*` -- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit, or before recording a receipt-only future-surface request -- the same bridge URL/token pair lets `tools/dispatch-agent-requests.mjs` record `/dispatch/runs/*` receipts for launched Codex work, so Bifrost keeps a live run trail even when a turn fails -- those hosted runtime receipt lanes are local-bridge-owned writes, not ordinary member-session writes: `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` now require the configured bridge token and do not inherit browser-user identity into agent activity rows -- those hosted runtime receipts also validate linkage instead of accepting free-floating text: dispatch runs require an existing request-lane receipt for the same request id, governance promotion receipts require a known promoted dispatch request, and request-lane rows cannot silently drift to a different repo or agent identity midstream -- GitHub bridge actions that claim Bifrost-owned provenance now validate that provenance too: a `bifrost_agent_transport_request` source must point at a known request-lane request for the same repo, and governance-backed bridge actions must point at a known Bifrost topic instead of a decorative source string -- governance-backed GitHub publication is also no longer allowed to jump the queue: a Bifrost governance topic must have been approved or promoted before it can justify a GitHub bridge action -- non-GitHub bridge activity is no longer allowed to drift off-book either: `discord-post`, `discord-dm`, `reddit-post`, and `other-request` now require `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` by default, with `--allow-unreceipted-activity true` reserved for explicit local recovery and refused inside dispatched work -- Persona and agent `discord-post`, `reddit-post`, and `other-request` bridge actions also require a Bifrost identity plus Heimdall-backed capability/account reference in the hosted ledger; pass `--identity`, `--heimdall-capability-ref`, and the Epiphany provenance flags or set the matching environment variables. Future-surface `other-request` actions must also carry `--surface-name`, and Bifrost rejects Heimdall references that do not match that named surface. The bridge token is an actuator credential, not identity or provider authority. +- HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`. +- external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed. +- Persona and agent public crossings still require Bifrost identity, Heimdall capability/account reference, and Epiphany provenance, but those facts travel with typed command/receipt documents rather than a local HTTP bridge token. - Bifrost-dispatched Codex turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked, and direct `gh` usage is limited to explicit read-only commands unless `tools/bifrost-bridge.mjs` authorizes the bridge-owned GitHub write path - dispatched Codex turns now default to `workspace-write` instead of full machine access, and the app-server sandbox policy disables network access unless the operator explicitly chooses a broader sandbox -- `tools/agent-transport.mjs` also uses that bridge configuration to record `/transport/receipts` entries for queue, claim, release, and close events on the request lane -- `tools/agent-transport.mjs apply-snapshot` is also treated as a mutating import path now: it fails closed without the bridge receipt config and derives request-lane receipts from the imported state delta -- `tools/governance-threads.mjs` uses that same bridge configuration to record `/governance/receipts` entries for topic opens, comments, approvals, and promotions -- those agent activity scripts now fail closed without the bridge URL/token pair; `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` is the explicit operator-recovery hatch -- GitHub bridge mutations now fail closed without those values; `--allow-ungated-github true` is reserved for explicit operator recovery -- dispatched Codex turns now lock those recovery hatches too: local-only `--allow-ungated-github` and `--allow-unreceipted-activity` escapes are refused inside dispatched work so a worker cannot silently downgrade itself out of Bifrost receipts +- `tools/agent-transport.mjs` records queue, claim, release, close, and snapshot activity in the typed request-lane store. +- `tools/governance-threads.mjs` records topic opens, comments, approvals, and promotions in the typed governance store. +- GitHub bridge mutations now fail closed without typed CultMesh command provenance; ungated and HTTP-ledger hatches are removed. +- dispatched Codex turns inherit the same invariant: a worker cannot downgrade itself out of Bifrost gate or receipt policy - dispatched Codex turns also now scrub ambient GitHub auth state from the child environment: `GH_TOKEN` / `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompting is disabled before the worker starts - set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads - set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production @@ -99,10 +92,13 @@ For the full staged roadmap, including contribution points, revenue share, patro - test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed - dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true` - 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` +- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc` - inspect the interactive Motion Verse surface at `/eve/governance/surface` while signed in as an active member - 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` +- container smoke tests may hit `/healthz` and `/readyz` as product probes. + Provider advertisements, Docker health, and deploy gates must come from + Bifrost's typed CultMesh/Idunn operator state; `BIFROST_HEALTH_URL` and + `BIFROST_READY_URL` are not accepted by the provider exporter. ## Read First diff --git a/docs/agent-transport.md b/docs/agent-transport.md index 683fedb..74a0287 100644 --- a/docs/agent-transport.md +++ b/docs/agent-transport.md @@ -125,19 +125,15 @@ node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transp `enqueue` also mirrors the queued request into `#bifrost` by default. Use `--mirror-content-file` when a Persona has a better human-facing line than the fallback receipt; use `--mirror-dry-run true` only for smoke tests. Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord. -The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. When `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` are configured, the dispatcher also records `/dispatch/runs/*` receipts in the hosted app so Bifrost keeps a first-class run ledger for launch, completion, cancellation, and failure. -Those hosted runtime receipt endpoints are owned by the local bridge token, not by ordinary member sessions. A signed-in member can govern work and request crossings, but they cannot POST `/dispatch/runs/*`, `/transport/receipts`, or `/governance/receipts` without the configured bridge token and pretend to be the worker runtime. -Those hosted receipt endpoints now also cross-check each other. Dispatch starts require an existing request-lane receipt for the same request id, governance promotions that cite `dispatchRequestId` must point at a known request-lane request, and additional request-lane receipts for one request id cannot drift onto a different repo or agent identity. The ledger is still transitional, but it has stopped politely accepting shape-shifting runtime history. -That same Bifrost request provenance now matters at the GitHub crossing too. When a dispatched worker uses `tools/bifrost-bridge.mjs` with the inherited `bifrost_agent_transport_request` source, the hosted bridge checks that the cited request exists in the request-lane ledger and still belongs to the same repo before it authorizes the GitHub mutation. -Governance-backed GitHub publication is stricter now too. If a bridge action cites a Bifrost governance topic as its source, the hosted bridge requires that the topic has actually been approved or promoted before it will authorize a GitHub mutation. An opened topic or comment thread by itself is no longer enough to claim GitHub authority. -The rest of the bridge-owned external activity now follows the same receipt discipline. If dispatched work posts a Discord receipt, sends a Discord DM, or opens a Reddit organizing thread through `tools/bifrost-bridge.mjs`, the normal path requires the hosted bridge base URL/token so Bifrost records the crossing. `--allow-unreceipted-activity true` is now an explicit local recovery hatch and is refused inside dispatched work. +The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. Runtime receipt truth lives in Bifrost's typed request, governance, dispatch, and bridge command stores; the old hosted bridge base URL/token path is no longer a live transport. +That same Bifrost request provenance now matters at the GitHub crossing too. Dispatched work must carry typed Bifrost CultMesh command provenance before a bridge actuator may cross into GitHub, Discord, Reddit, or another public surface. Those dispatched turns also inherit Bifrost-owned GitHub gates through environment config. `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked inside dispatched work even with `--no-verify`, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub mutation it owns. -Those dispatched turns also lock the local-only recovery hatches. `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail. +Those dispatched turns inherit the same gate and receipt invariant, so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail. Those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts. `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled. That does not prove every imaginable direct-HTTP escape is impossible, but it removes the normal ambient credential path from dispatched work. Those dispatched turns now also default to the `workspace-write` sandbox instead of full machine access, and the app-server launch path now tells Codex to keep `networkAccess: false` unless the operator explicitly chooses a broader sandbox. The worker can still read the repo and do its job, but GitHub authority no longer leaks in through the default launch posture. -When that same bridge configuration is present, `tools/agent-transport.mjs` records `/transport/receipts` entries for queue, claim, release, and close operations on the request lane. The packet still lives in CultCache. The hosted receipt row is the Bifrost-side witness that an agent or dispatcher touched it. -`apply-snapshot` is treated as a mutating import path too. It now fails closed without that same bridge configuration and derives request-lane receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back. -Those mutating scripts now fail closed without that bridge configuration. Use `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` only for explicit local recovery work where you are intentionally accepting a blind spot. +`tools/agent-transport.mjs` records queue, claim, release, and close operations directly in the typed request-lane store, then writes canonical `bifrost.crossing_receipt.v1` entries to `.bifrost/bridge-receipts.cc`. The packet lives in CultCache/CultMesh; hosted HTTP receipt rows are not a live authority. +`apply-snapshot` is treated as a mutating import path too. It derives canonical crossing receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back. +Those mutating scripts fail closed if the removed HTTP bridge env is present. Repair the typed CultMesh route instead of accepting a blind spot. The `snapshot` command writes a MessagePack-encoded `cultnet.snapshot_response_raw.v0` message. This keeps CultCache payload bytes intact for peers that share the document binding. diff --git a/docs/bridge.md b/docs/bridge.md index 5f38da1..46eac51 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -45,86 +45,61 @@ It intentionally uses boring local credentials while Heimdall's managed GitHub c - GitHub actions use the local `gh` authenticated account. - Discord posts use `BIFROST_DISCORD_BOT_TOKEN` or `DISCORD_BOT_TOKEN`. - Reddit self-posts use `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optional `BIFROST_REDDIT_CLIENT_SECRET`. -- Future outside-world requests use `other-request` as a receipt-only bridge action until the target surface has a named Bifrost contract and actuator. +- Future outside-world requests must have a named typed command/receipt contract before they get a surface actuator. The CLI is not the final permission system. It is the working bridge actuator Bifrost owns now, so VoidBot and repo Personas can stop carrying this machinery themselves. -It now fails closed for all external write surfaces by default. GitHub mutations already required the hosted bridge gate; Discord posts, Discord DMs, and Reddit self-posts now require the same `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` receipt path unless an operator explicitly uses `--allow-unreceipted-activity true` for local recovery. Dispatched work is not allowed to use that recovery hatch. -`other-request` also fails closed by default. It does not execute a transport; it asks Bifrost to authorize and receipt the request under `BridgeTargetSurface.Other` so new Persona mouths can start behind identity, Heimdall linkage, and provenance policy before anyone writes a surface-specific actuator. +It now fails closed for external write surfaces unless the action carries typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`. The old HTTP bridge ledger environment (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. +`other-request` fails closed by default. It does not execute a transport; it must become a typed Bifrost command/receipt document under a named surface before anyone writes a surface-specific actuator. -## Bridge Action Ledger +## Crossing Receipts -The hosted app now exposes a first-class bridge action ledger for governed transport requests: - -- `POST /bridge/actions/request` -- `GET /bridge/actions/{id}` -- `POST /bridge/actions/{id}/start` -- `POST /bridge/actions/{id}/complete` -- `POST /bridge/actions/{id}/fail` - -The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt. +The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt in typed CultCache/CultMesh documents. Persona and agent actions that cross into Discord, Reddit, or a future outside-world surface (`Other` until the surface has a first-class enum value) must carry a Bifrost identity plus a Heimdall-backed capability/account reference -at request time. The local bridge token only proves that the actuator may call -the ledger; it is not the actor identity and not outside-provider authority. The -ledger stores the Bifrost identity, Heimdall reference, Epiphany run id, lane id, +at request time. The command document stores the Bifrost identity, Heimdall reference, Epiphany run id, lane id, and agent identity beside the action so later receipts can answer which agent did what without opening local transcripts or trusting helper-script folklore. -The hosted app also exposes a dispatch-run ledger for the older Bifrost-launched worker bridge: +The canonical receipt document is `bifrost.crossing_receipt.v1`, stored in +`.bifrost/bridge-receipts.cc`. It records one crossing attempt with command, +source, authority, actor, target, external proof, lifecycle status, and error +details. Surface-specific receipts, including +`bifrost.bridge.discord_post_receipt.v1`, are details that reference the +canonical receipt. They do not own crossing completion. -- `POST /dispatch/runs/start` -- `POST /dispatch/runs/{id}/complete` -- `POST /dispatch/runs/{id}/fail` - -That dispatcher was a transitional way for VoidBot-era Persona consensus to queue Codex workers. It is not the blessed Epiphany launch organ. Keep its receipts as historical/quarantine evidence where they exist, but the forward path is Epiphany-owned execution plus Bifrost-owned crossing receipts. +The old hosted dispatch-run and bridge-action ledgers are historical/quarantine evidence where records already exist. The forward path is Epiphany-owned execution plus Bifrost-owned typed crossing receipts. For Epiphany work, a bridge action says "this Epiphany run/lane requested a governed crossing into GitHub/Discord/Reddit and Bifrost receipted the result." The receipt payload should carry Bifrost identity, Epiphany run id, lane id, agent identity, source object, authority reference, Heimdall capability/account reference, and any external receipt facts such as PR URL, comment id, commit SHA, or changed paths. -The hosted app also exposes a request-lane receipt ledger for transport lifecycle events: - -- `POST /transport/receipts` - -This covers the activity that happens before or around a visible Codex turn: queueing a request, claiming it, releasing it, and closing it. The `.cc` packet is still the transport document. The hosted receipt row is the operator-facing witness that the activity happened. - -The hosted app also exposes a governance activity receipt ledger: - -- `POST /governance/receipts` - -This covers the other side of the swarm's work: topic opens, comments, approvals, and promotions to dispatch. The topic and comment documents are still the governance substrate. The hosted receipt row is the Bifrost-side witness that an agent or operator performed the governance action. - -These runtime receipt lanes are not generic member write surfaces. In the current machine, `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` are owned by the configured local bridge token. Active members may govern and request bridge actions through the app, but they may not mint worker-history rows directly through a browser session and impersonate runtime activity. -They also now validate linkage across the hosted ledger instead of accepting isolated claims. A dispatch run must point at an already-seen request-lane receipt for the same request id. A governance promotion receipt that cites `dispatchRequestId` must point at a known request-lane request. Repeated request-lane rows for one request id may add activity, but they may not quietly change the repo or agent identity the request belongs to. -GitHub bridge actions that cite Bifrost-owned provenance now validate that provenance too. If a bridge action says its source is `bifrost_agent_transport_request`, the hosted bridge checks that the request exists in the request-lane ledger and still belongs to the same repo. If a bridge action says its source is `bifrost_governance_topic` or legacy `governance_topic`, the hosted bridge checks that the topic exists in the governance receipt trail before authorizing the crossing. -For GitHub publication, mere topic existence is no longer enough. A governance-backed GitHub bridge action now requires that the cited topic has reached at least `TopicApproved` or `TopicPromoted` in the governance receipt trail. Opened/commented-only topics are evidence of discussion, not yet permission to mutate GitHub. +Request-lane receipts are canonical crossing receipts for typed request-store mutations: queue, claim, release, close, and snapshot import. Governance receipts are canonical crossing receipts for typed governance-store mutations: topic opens, comments, approvals, and promotions. Runtime HTTP receipt posts are not a live authority. Current gate behavior: - active Bifrost members may request bridge actions from an authenticated app session -- the transitional local bridge actuator may call the same endpoints with `X-Bifrost-Bridge-Token` +- daemon and agent crossings must use typed CultMesh command/receipt documents - agent, Persona, and service actions must cite provenance: `authorityReference`, `sourceKind` plus `sourceId`, `workItemId`, or `motionId` -- completed actions must report a receipt URL, external receipt id, or serialized receipt payload -- GitHub mutation commands fail closed unless Bifrost authorization is configured; `--allow-ungated-github true` is the explicit operator-recovery hatch -- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability token or verifiable capability/account reference. New outside-world bridge surfaces must start behind that same identity/capability gate, using `Other` only as a temporary enum bucket until the target surface gets a named contract. The local bridge token is a transitional service actuator credential, not a substitute for Bifrost identity or Heimdall-backed outside-account authority. +- completed visible actions must write `bifrost.crossing_receipt.v1` with a receipt URL, external receipt id, or serialized receipt payload where the target surface provides one +- GitHub mutation commands fail closed unless typed Bifrost CultMesh command provenance is present +- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability reference or verifiable capability/account reference. New outside-world bridge surfaces must start behind that same identity/capability gate. Bifrost records claim `jti`, `account_id`, `access_revision`, `exp`, grant id/ref, and revoked/expired behavior as references or redacted snapshots, not bearer tokens. Current CLI wiring: -- set `BIFROST_BRIDGE_BASE_URL` to the Bifrost app base URL -- set `BIFROST_BRIDGE_TOKEN` to the configured local bridge token -- GitHub commands now require both values by default, even for dry-run, so the normal path cannot silently bypass the gate +- provide `BIFROST_CULTMESH_COMMAND_ID` or `--cultmesh-command-id` from the typed command document being processed +- GitHub commands require typed CultMesh command provenance by default, even for dry-run, so the normal path cannot silently bypass the gate - pass `--identity`, `--source-kind`, `--source-id`, `--authority-ref`, `--work-item-id`, or `--motion-id` so agent actions satisfy policy - Epiphany callers should also pass `--epiphany-run-id`, `--epiphany-lane-id`, `--epiphany-agent-identity`, and `--heimdall-capability-ref` or provide `EPIPHANY_RUN_ID`, `EPIPHANY_LANE_ID`, `EPIPHANY_AGENT_IDENTITY`, and `HEIMDALL_CAPABILITY_REF` in the environment. The CLI stores the Bifrost identity and capability/account reference or fingerprint-shaped value, never the Heimdall bearer token. - the dispatcher now preloads `BIFROST_BRIDGE_SOURCE_KIND`, `BIFROST_BRIDGE_SOURCE_ID`, and `BIFROST_BRIDGE_AUTHORITY_REF` into Codex turns launched from Bifrost update requests, so normal bridge use inside a dispatched turn carries request provenance by default - those dispatched turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked even when `--no-verify` is used, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub write path -- those dispatched turns also lock local-only recovery hatches: `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so a worker cannot opt itself out of Bifrost gate or receipt policy +- those dispatched turns inherit the same gate and receipt invariant, so a worker cannot opt itself out of Bifrost gate or receipt policy - those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts: `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled - those dispatched turns now default to the `workspace-write` sandbox in both `codex exec` and app-server launch paths, and the non-danger app-server sandbox policy sets `networkAccess: false` so GitHub mutation authority stays with Bifrost instead of bleeding back into the child runtime by default -- the dispatcher also uses that same bridge configuration to post dispatch-run start/complete/fail receipts, including the worker pid, thread/turn ids when available, and result/log paths for the local operator trail -- the agent transport CLI uses that same bridge configuration to post request-lane receipts for queue, claim, release, and close events, so Bifrost does not go blind between intake and dispatch -- the governance threads CLI uses that same bridge configuration to post governance receipts for topic opens, comments, approvals, and promotions, so Bifrost does not lose the pre-dispatch reasoning trail -- those agent-activity CLIs now fail closed without that bridge configuration; `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` is the deliberate operator-recovery hatch when you knowingly want local-only activity +- the dispatcher writes local run artifacts and request-store state for start/complete/fail visibility +- the agent transport CLI writes canonical crossing receipts for queue, claim, release, close, and snapshot import events to `.bifrost/bridge-receipts.cc` +- the governance threads CLI writes canonical crossing receipts for topic opens, comments, approvals, and promotions to `.bifrost/bridge-receipts.cc` +- those agent-activity CLIs now fail closed without that bridge configuration -If the app bridge configuration is absent, `tools/bifrost-bridge.mjs` keeps working as a local actuator. When configured, it asks Bifrost for authorization before acting and reports success or failure back to the same bridge action row. +If hosted app authorization is absent, `tools/bifrost-bridge.mjs` keeps working as a local actuator only when typed CultMesh command provenance is present. It writes requested, running, completed, failed, or cancelled crossing receipts directly to the canonical receipt store. ### GitHub Draft PR @@ -213,7 +188,7 @@ node .\tools\bifrost-bridge.mjs other-request ` --content "Persona asks Bifrost to authorize this public crossing." ``` -The command records and completes a Bifrost bridge action using `targetSurface: Other` and `actionKind: Other`. It is the quarantine reliquary for public surfaces that are real enough to need identity and receipts, but not yet stable enough to deserve a first-class enum, actuator, or UI. Do not put provider bearer tokens in `--heimdall-capability-ref`; that value is the Heimdall-issued account/capability reference or a verifiable fingerprint-shaped reference. +The command is a dry-run shape probe for public surfaces that are real enough to need identity and receipts, but not yet stable enough to deserve a first-class enum, actuator, or UI. Live writes fail closed until that surface has a typed Bifrost command/receipt contract. Do not put provider bearer tokens in `--heimdall-capability-ref`; that value is the Heimdall-issued account/capability reference or a verifiable fingerprint-shaped reference. ## Discord Native Interface Target diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index cc29656..6e7d16e 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -34,6 +34,9 @@ Current witnesses: - `.bifrost/agent-transport.cc` stores `bifrost.agent-transport.update-request` documents and can emit raw CultNet snapshots. +- `.bifrost/bridge-receipts.cc` stores canonical + `bifrost.crossing_receipt.v1` documents for governed public crossing + attempts. Required witness path for the hosted service: @@ -103,12 +106,12 @@ cache directly for debugging and development, but that is not the xeno boundary. Bifrost has a read-only first-cut provider advertisement command: ```powershell -node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc -node tools/provider-advertisement.mjs publish-odin --odin-cultmesh-rudp 127.0.0.1:17871 +node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc +$env:BIFROST_ODIN_CULTMESH_URI="cultmesh://odin/rendezvous/provider-catalog"; node tools/provider-advertisement.mjs publish-odin ``` It writes Bifrost-owned Eve discovery documents into -`.bifrost/provider-advertisement.cc`: +`.bifrost/provider-store.cc`: - `gamecult.eve.provider_advertisement.v1` names Bifrost's Account, Patron, Project, Work, Motion, and Operator Verse surfaces; current and planned @@ -125,9 +128,11 @@ This export does not migrate Postgres state, read secrets, execute bridge actions, or make Razor Pages, HTTP probes, Discord mirrors, Reddit threads, or local dispatch JSON canonical. The operator surface may display those probes, but the provider owned `.cc` witness remains the discovery and dashboard source. -When `BIFROST_ODIN_CULTMESH_RUDP` or `--odin-cultmesh-rudp` is configured, the -same provider advertisement can be published once to Odin's CultMesh/RUDP -document catalog through the provider-advertisement tool. +When `BIFROST_ODIN_CULTMESH_URI` or `--odin-cultmesh-uri` is configured, the +same provider advertisement can be published once to Odin's CultMesh rendezvous +URI through the provider-advertisement tool. Concrete RUDP bootstrap endpoints +belong behind CultMesh URI resolution, not in Bifrost provider publication +configuration. For protocol-debug inspection without writing a witness: @@ -146,6 +151,19 @@ Product surfaces are the canonical interface compositions for: - work claims, review, completion, receipts, and contributor credit; - bridge receipts and public handoff targets. +Canonical crossing receipts use `bifrost.crossing_receipt.v1` in +`.bifrost/bridge-receipts.cc`. A receipt records `receiptId`, `commandId`, +`crossingKind`, lifecycle status, Bifrost actor, source provenance, authority +reference, optional Heimdall claim/grant references, optional Epiphany run/lane +identity, target locator, external receipt facts, error details, and related +receipt ids. Surface-specific receipts such as +`bifrost.bridge.discord_post_receipt.v1` may remain as details, but they must +reference the canonical crossing receipt and cannot decide crossing completion. +Heimdall values recorded here are stable references or redacted snapshots: +claim `jti`, `account_id`, `access_revision`, `exp`, grant id/ref, and +revoked/expired behavior. Provider bearer tokens do not belong in Bifrost +receipts. + Operator surfaces are the canonical interface compositions for: - readiness, build/version, config validation, and deploy target; @@ -160,11 +178,18 @@ Operator surfaces are the canonical interface compositions for: - Reddit bridge readiness for `r/GameCultOrg` organizing posts and Persona flair. The Motion Verse has begun the migration from Razor-owned behavior to Eve-owned -presentation. `/eve/governance/surface` emits a `gamecult.eve.surface.v1` -composition for motions and voting. `/eve/governance/commands` accepts -`motion.create`, `motion.vote`, and `motion.close` commands, then routes those -commands through the same Bifrost motion governance service used by the Razor -forms. +presentation. Bifrost publishes the motion surface and command target as +CultMesh addresses discovered through Odin: + +```text +cultmesh://asgard.starfire.bifrost/eve/governance/surface +cultmesh://asgard.starfire.bifrost/commands/motion +``` + +The transitional browser product may lower motion state for members, but it is +not a Verse command transport. `motion.create`, `motion.vote`, and +`motion.close` are command documents for the CultMesh route, then commit through +the same Bifrost motion governance service used by the Razor forms. The current Razor Pages app is now a transitional browser lowering for motions, not the behavior owner. Existing health/readiness endpoints lower operator @@ -226,10 +251,10 @@ Bifrost. ## Migration Order 1. Catalog current Bifrost state owners: EF/Postgres tables, `.cc` stores, - bridge receipts, Discord mirrors, Reddit receipts, webhook cache, and app + crossing receipts, Discord mirrors, Reddit receipts, webhook cache, and app readiness state. 2. Define schema ids for missing Bifrost documents: work item, motion, vote, - ledger entry, bridge action, bridge receipt, member capability snapshot, + ledger entry, bridge action, crossing receipt, member capability snapshot, service registration, and Eve surface publication. 3. Add Postgres-to-CultCache witness export for alpha entities before replacing the transactional store. @@ -253,10 +278,15 @@ Demoted surfaces and stores: - Razor Pages views are no longer the canonical Bifrost presentation owner; they are browser lowerings of Bifrost product/operator surfaces. -- `/healthz`, `/readyz`, and any future HTTP dashboard/status JSON are probes or - xenos-boundary exports, not service truth. +- `/healthz`, `/readyz`, and any future HTTP dashboard/status JSON are product + smoke probes or xenos-boundary exports, not service truth. Provider + advertisements and Odin-facing operator readiness must reject HTTP probe + configuration and derive status from typed CultMesh/Idunn state. - `.bifrost/discord-webhook-cache.json` is webhook address cache only; it does not prove governance, speech authority, or receipt completion. +- `bifrost.bridge.discord_post_receipt.v1` and other surface receipts are + surface-specific evidence only. `bifrost.crossing_receipt.v1` is the + canonical crossing receipt owner. - `.bifrost/agent-dispatch/**/request.json`, `dispatch.json`, `result.json`, and app-server protocol JSON are local dispatch/protocol artifacts. They may be evidence attached to typed receipts, but they cannot decide request status, diff --git a/src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs b/src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs index 1ba39cf..dbe5d96 100644 --- a/src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionEveSurfaceService.cs @@ -4,6 +4,8 @@ namespace Bifrost.Web.Features.Motions; public sealed class MotionEveSurfaceService { + private const string MotionCommandTarget = "cultmesh://asgard.starfire.bifrost/commands/motion"; + public EveSurfaceDocument BuildSurface(MotionGovernanceState state) { var motionNodes = state.Motions.Count > 0 @@ -23,7 +25,9 @@ public sealed class MotionEveSurfaceService ["subtitle"] = "Motions, weighted votes, thresholds, and receipts", ["actor"] = state.ActorName, ["effectiveVotingWeight"] = state.EffectiveVotingWeight, - ["canonicalCommandTarget"] = "/eve/governance/commands" + ["canonicalCommandTarget"] = MotionCommandTarget, + ["commandTransport"] = "cultmesh-command-document", + ["commandSchema"] = "bifrost.motion_command.v0" }, [ new EveNode( @@ -33,8 +37,8 @@ public sealed class MotionEveSurfaceService { ["title"] = "Open Motion", ["command"] = "motion.create", - ["target"] = "/eve/governance/commands", - ["method"] = "POST" + ["target"] = MotionCommandTarget, + ["transport"] = "cultmesh-command-document" }, [ SelectNode("scope", "Scope", EnumNames()), @@ -121,7 +125,11 @@ public sealed class MotionEveSurfaceService new EveNode( $"motion-commands-{motion.Id}", "commands", - new Dictionary { ["target"] = "/eve/governance/commands" }, + new Dictionary + { + ["target"] = MotionCommandTarget, + ["transport"] = "cultmesh-command-document" + }, commandNodes) ]); } @@ -139,8 +147,8 @@ public sealed class MotionEveSurfaceService { ["label"] = label, ["command"] = command, - ["target"] = "/eve/governance/commands", - ["method"] = "POST", + ["target"] = MotionCommandTarget, + ["transport"] = "cultmesh-command-document", ["payload"] = payload, ["tone"] = tone }); @@ -193,15 +201,3 @@ public sealed record EveNode( IReadOnlyList? Children = null); public sealed record EveOption(string Value, string Label); - -public sealed record MotionEveCommandRequest( - string Command, - Guid? MotionId, - MotionScope? Scope, - Guid? ProjectId, - MotionCategory? Category, - string? Title, - string? Summary, - DateTimeOffset? ClosesAtUtc, - VoteChoice? Choice, - string? Comment); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 8c9e95b..695b478 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -470,282 +470,6 @@ app.MapPost("/patronage/stripe/webhook", async ( }; }).AllowAnonymous(); -var bridgeActions = app.MapGroup("/bridge/actions"); - -bridgeActions.MapPost("/request", async ( - HttpRequest request, - BridgeActionRequest command, - ICurrentBifrostActorAccessor actorAccessor, - BridgeActionService bridgeActionService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - var actor = await actorAccessor.GetAsync(cancellationToken); - var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); - if (!caller.IsAllowedTransport) - { - return Results.Unauthorized(); - } - - var result = await bridgeActionService.RequestAsync(command, caller, cancellationToken); - return result.Status == BridgeActionStatus.Denied - ? JsonResult(result, appJsonSerializerOptions, StatusCodes.Status403Forbidden) - : JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); -}); - -bridgeActions.MapGet("/{id:guid}", async ( - Guid id, - HttpRequest request, - ICurrentBifrostActorAccessor actorAccessor, - BifrostDbContext dbContext, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - var actor = await actorAccessor.GetAsync(cancellationToken); - var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); - if (!caller.IsAllowedTransport) - { - return Results.Unauthorized(); - } - - var action = await dbContext.BridgeActions - .AsNoTracking() - .SingleOrDefaultAsync(x => x.Id == id, cancellationToken); - - if (action is null) - { - return Results.NotFound(); - } - - if (!caller.CanOperate(action)) - { - return Results.Forbid(); - } - - return JsonResult(BridgeActionResult.From(action), appJsonSerializerOptions); -}); - -bridgeActions.MapPost("/{id:guid}/start", async ( - Guid id, - HttpRequest request, - ICurrentBifrostActorAccessor actorAccessor, - BridgeActionService bridgeActionService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - var actor = await actorAccessor.GetAsync(cancellationToken); - var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); - if (!caller.IsAllowedTransport) - { - return Results.Unauthorized(); - } - - try - { - var result = await bridgeActionService.StartAsync(id, caller, cancellationToken); - return result is null - ? Results.NotFound() - : JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); - } - catch (UnauthorizedAccessException) - { - return Results.Forbid(); - } - catch (BridgeActionException error) - { - return Results.BadRequest(error.Message); - } -}); - -bridgeActions.MapPost("/{id:guid}/complete", async ( - Guid id, - HttpRequest request, - BridgeActionReceiptRequest command, - ICurrentBifrostActorAccessor actorAccessor, - BridgeActionService bridgeActionService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - var actor = await actorAccessor.GetAsync(cancellationToken); - var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); - if (!caller.IsAllowedTransport) - { - return Results.Unauthorized(); - } - - try - { - var result = await bridgeActionService.CompleteAsync(id, command, caller, cancellationToken); - return result is null - ? Results.NotFound() - : JsonResult(result, appJsonSerializerOptions); - } - catch (UnauthorizedAccessException) - { - return Results.Forbid(); - } - catch (BridgeActionException error) - { - return Results.BadRequest(error.Message); - } -}); - -bridgeActions.MapPost("/{id:guid}/fail", async ( - Guid id, - HttpRequest request, - BridgeActionFailureRequest command, - ICurrentBifrostActorAccessor actorAccessor, - BridgeActionService bridgeActionService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - var actor = await actorAccessor.GetAsync(cancellationToken); - var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); - if (!caller.IsAllowedTransport) - { - return Results.Unauthorized(); - } - - try - { - var result = await bridgeActionService.FailAsync(id, command, caller, cancellationToken); - return result is null - ? Results.NotFound() - : JsonResult(result, appJsonSerializerOptions); - } - catch (UnauthorizedAccessException) - { - return Results.Forbid(); - } -}); - -var dispatchRuns = app.MapGroup("/dispatch/runs"); - -dispatchRuns.MapPost("/start", async ( - HttpRequest request, - AgentDispatchRunStartRequest command, - AgentDispatchRunService agentDispatchRunService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - if (!HasValidLocalBridgeToken(request, bridgeOptions.Value)) - { - return Results.Unauthorized(); - } - - try - { - var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken); - return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); - } - catch (AgentDispatchRunException error) - { - return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); - } -}); - -dispatchRuns.MapPost("/{id:guid}/complete", async ( - Guid id, - HttpRequest request, - AgentDispatchRunCompletionRequest command, - AgentDispatchRunService agentDispatchRunService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - if (!HasValidLocalBridgeToken(request, bridgeOptions.Value)) - { - return Results.Unauthorized(); - } - - try - { - var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken); - return result is null - ? Results.NotFound() - : JsonResult(result, appJsonSerializerOptions); - } - catch (AgentDispatchRunException error) - { - return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); - } -}); - -dispatchRuns.MapPost("/{id:guid}/fail", async ( - Guid id, - HttpRequest request, - AgentDispatchRunFailureRequest command, - AgentDispatchRunService agentDispatchRunService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - if (!HasValidLocalBridgeToken(request, bridgeOptions.Value)) - { - return Results.Unauthorized(); - } - - try - { - var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken); - return result is null - ? Results.NotFound() - : JsonResult(result, appJsonSerializerOptions); - } - catch (AgentDispatchRunException error) - { - return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); - } -}); - -var transportReceipts = app.MapGroup("/transport/receipts"); - -transportReceipts.MapPost("", async ( - HttpRequest request, - AgentTransportReceiptRequest command, - AgentTransportReceiptService agentTransportReceiptService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - if (!HasValidLocalBridgeToken(request, bridgeOptions.Value)) - { - return Results.Unauthorized(); - } - - try - { - var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken); - return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); - } - catch (AgentTransportReceiptException error) - { - return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); - } -}); - -var governanceReceipts = app.MapGroup("/governance/receipts"); - -governanceReceipts.MapPost("", async ( - HttpRequest request, - GovernanceActivityReceiptRequest command, - GovernanceActivityReceiptService governanceActivityReceiptService, - IOptions bridgeOptions, - CancellationToken cancellationToken) => -{ - if (!HasValidLocalBridgeToken(request, bridgeOptions.Value)) - { - return Results.Unauthorized(); - } - - try - { - var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken); - return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); - } - catch (GovernanceActivityReceiptException error) - { - return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); - } -}); - var eveGovernance = app.MapGroup("/eve/governance") .RequireAuthorization(ActiveMemberRequirement.PolicyName); @@ -763,77 +487,6 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); -eveGovernance.MapPost("/commands", async ( - MotionEveCommandRequest command, - ICurrentBifrostActorAccessor actorAccessor, - MotionGovernanceService motionGovernanceService, - MotionEveSurfaceService motionEveSurfaceService, - CancellationToken cancellationToken) => -{ - var actor = await actorAccessor.GetAsync(cancellationToken); - - try - { - switch (command.Command) - { - case "motion.create": - if (command.Scope is null || - command.Category is null || - command.ClosesAtUtc is null || - string.IsNullOrWhiteSpace(command.Title) || - string.IsNullOrWhiteSpace(command.Summary)) - { - return Results.BadRequest("motion.create requires scope, category, title, summary, and closesAtUtc."); - } - - await motionGovernanceService.CreateMotionAsync( - actor, - new CreateMotionCommand( - command.Scope.Value, - command.ProjectId, - command.Category.Value, - command.Title, - command.Summary, - command.ClosesAtUtc.Value), - cancellationToken); - break; - case "motion.vote": - if (command.MotionId is null || command.Choice is null) - { - return Results.BadRequest("motion.vote requires motionId and choice."); - } - - await motionGovernanceService.CastVoteAsync( - actor, - command.MotionId.Value, - command.Choice.Value, - command.Comment, - cancellationToken); - break; - case "motion.close": - if (command.MotionId is null) - { - return Results.BadRequest("motion.close requires motionId."); - } - - await motionGovernanceService.CloseMotionAsync(actor, command.MotionId.Value, cancellationToken); - break; - default: - return Results.BadRequest($"Unknown Eve governance command '{command.Command}'."); - } - } - catch (UnauthorizedAccessException) - { - return Results.Forbid(); - } - - var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken); - var surface = motionEveSurfaceService.BuildSurface(state); - return Results.Text( - JsonSerializer.Serialize(surface, appJsonSerializerOptions), - "application/json"); -}); - app.MapGet("/auth/heimdall/wait", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, @@ -926,28 +579,6 @@ static JsonSerializerOptions CreateAppJsonSerializerOptions() return options; } -static BridgeCaller BuildBridgeCaller( - CurrentBifrostActor actor, - HttpRequest request, - BridgeOptions bridgeOptions) -{ - var isLocalBridge = HasValidLocalBridgeToken(request, bridgeOptions); - - return new BridgeCaller( - actor.IsActiveMember, - isLocalBridge, - actor.UserAccount?.Id, - actor.DisplayName); -} - -static bool HasValidLocalBridgeToken(HttpRequest request, BridgeOptions bridgeOptions) -{ - var bridgeToken = request.Headers["X-Bifrost-Bridge-Token"].ToString(); - return - bridgeOptions.HasLocalBridgeToken && - string.Equals(bridgeToken, bridgeOptions.LocalBridgeToken, StringComparison.Ordinal); -} - public partial class Program; public sealed record NativeBifrostRegistrationRequest( diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index af412c6..1464fd5 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -24,7 +24,27 @@ public sealed class BridgeCliTests } [Fact] - public async Task GitHub_bridge_command_allows_explicit_operator_recovery_dry_run() + public async Task GitHub_bridge_command_allows_bifrost_gate_dry_run() + { + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "github-pr-comment", + "--repo-root", RepoRoot, + "--identity", "nibu", + "--pr", "1", + "--content", "test comment", + "--target-repository-full-name", "GameCult/Bifrost", + "--dry-run", "true", + ], TypedCommandEnv()); + + Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); + using var payload = JsonDocument.Parse(result.Stdout); + Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString()); + Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean()); + } + + [Fact] + public async Task GitHub_bridge_recovery_hatch_is_rejected() { var result = await RunNodeAsync([ "tools/bifrost-bridge.mjs", @@ -38,30 +58,6 @@ public sealed class BridgeCliTests "--dry-run", "true", ]); - Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); - using var payload = JsonDocument.Parse(result.Stdout); - Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString()); - Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean()); - } - - [Fact] - public async Task GitHub_bridge_recovery_hatch_is_rejected_inside_dispatched_turn() - { - var result = await RunNodeAsync([ - "tools/bifrost-bridge.mjs", - "github-pr-comment", - "--repo-root", RepoRoot, - "--identity", "nibu", - "--pr", "1", - "--content", "test comment", - "--target-repository-full-name", "GameCult/Bifrost", - "--allow-ungated-github", "true", - "--dry-run", "true", - ], new Dictionary - { - ["BIFROST_LOCK_RECOVERY_HATCHES"] = "true", - }); - Assert.NotEqual(0, result.ExitCode); Assert.Contains("cannot use --allow-ungated-github", result.Stderr, StringComparison.OrdinalIgnoreCase); } @@ -70,6 +66,7 @@ public sealed class BridgeCliTests public async Task GitHub_pr_comment_returns_concrete_github_receipt() { var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}"); + var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc"); Directory.CreateDirectory(fakeToolsDir); var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js"); await File.WriteAllTextAsync(fakeGhPath, """ @@ -97,8 +94,8 @@ console.log(JSON.stringify({ "--epiphany-lane-id", "hands-publication", "--epiphany-agent-identity", "huginn", "--heimdall-capability-ref", "heimdall-capability-abc", - "--allow-ungated-github", "true", - ]); + "--receipt-store", receiptStorePath, + ], TypedCommandEnv()); Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); using var payload = JsonDocument.Parse(result.Stdout); @@ -114,6 +111,57 @@ console.log(JSON.stringify({ Assert.Equal("hands-publication", provenance.GetProperty("epiphanyLaneId").GetString()); Assert.Equal("huginn", provenance.GetProperty("epiphanyAgentIdentity").GetString()); Assert.Equal("heimdall-capability-abc", provenance.GetProperty("heimdallCapabilityRef").GetString()); + + var crossingReceiptId = payload.RootElement.GetProperty("crossingReceiptId").GetString(); + Assert.Equal("crossing_test-cultmesh-command", crossingReceiptId); + + using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath); + var receipt = Assert.Single(receipts.RootElement.EnumerateArray()); + Assert.Equal("bifrost.crossing_receipt.v1", receipt.GetProperty("schemaVersion").GetString()); + Assert.Equal("github.pr_comment", receipt.GetProperty("crossingKind").GetString()); + Assert.Equal("completed", receipt.GetProperty("status").GetString()); + Assert.True(receipt.GetProperty("ok").GetBoolean()); + Assert.Equal("repo-work-public-proof-huginn", receipt.GetProperty("source").GetProperty("id").GetString()); + Assert.Equal("bifrost_publication_gate", receipt.GetProperty("authority").GetProperty("authorityRef").GetString()); + Assert.Equal("heimdall-capability-abc", receipt.GetProperty("authority").GetProperty("heimdallCapabilityRef").GetString()); + Assert.Equal("https://github.com/GameCult/Bifrost/pull/1#issuecomment-123456", receipt.GetProperty("externalReceipt").GetProperty("url").GetString()); + } + + [Fact] + public async Task GitHub_pr_comment_writes_failed_crossing_receipt_on_actuator_failure() + { + var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}"); + var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc"); + Directory.CreateDirectory(fakeToolsDir); + var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js"); + await File.WriteAllTextAsync(fakeGhPath, """ +console.error("synthetic gh failure"); +process.exit(42); +""", Encoding.UTF8); + + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "github-pr-comment", + "--repo-root", RepoRoot, + "--identity", "nibu", + "--pr", "1", + "--content", "test comment", + "--target-repository-full-name", "GameCult/Bifrost", + "--gh-executable", "node", + "--gh-exec-args", fakeGhPath, + "--source-kind", "epiphany_repo_work", + "--source-id", "repo-work-public-proof-huginn", + "--authority-ref", "bifrost_publication_gate", + "--receipt-store", receiptStorePath, + ], TypedCommandEnv()); + + Assert.NotEqual(0, result.ExitCode); + using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath); + var receipt = Assert.Single(receipts.RootElement.EnumerateArray()); + Assert.Equal("github.pr_comment", receipt.GetProperty("crossingKind").GetString()); + Assert.Equal("failed", receipt.GetProperty("status").GetString()); + Assert.False(receipt.GetProperty("ok").GetBoolean()); + Assert.Contains("synthetic gh failure", receipt.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal); } [Fact] @@ -128,11 +176,28 @@ console.log(JSON.stringify({ ]); Assert.NotEqual(0, result.ExitCode); - Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Discord_bridge_command_allows_explicit_operator_recovery_dry_run() + public async Task Discord_bridge_command_allows_bifrost_receipt_gate_dry_run() + { + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "discord-post", + "--channel-id", "1501196543150264332", + "--content", "test message", + "--dry-run", "true", + ], TypedCommandEnv()); + + Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); + using var payload = JsonDocument.Parse(result.Stdout); + Assert.Equal("discord-post", payload.RootElement.GetProperty("action").GetString()); + Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean()); + } + + [Fact] + public async Task Discord_bridge_recovery_hatch_is_rejected() { var result = await RunNodeAsync([ "tools/bifrost-bridge.mjs", @@ -143,29 +208,55 @@ console.log(JSON.stringify({ "--dry-run", "true", ]); - Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); - using var payload = JsonDocument.Parse(result.Stdout); - Assert.Equal("discord-post", payload.RootElement.GetProperty("action").GetString()); - Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean()); + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Discord_bridge_recovery_hatch_is_rejected_inside_dispatched_turn() + public async Task Discord_cultmesh_command_receipt_references_canonical_crossing_receipt() { - var result = await RunNodeAsync([ - "tools/bifrost-bridge.mjs", - "discord-post", + var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-provider-store-{Guid.NewGuid():N}.cc"); + var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc"); + var commandId = $"discord-command-{Guid.NewGuid():N}"; + + var written = await RunNodeAsync([ + "tools/cultmesh-bridge-commands.mjs", + "write-smoke", + "--store", storePath, + "--command-id", commandId, "--channel-id", "1501196543150264332", "--content", "test message", - "--allow-unreceipted-activity", "true", - "--dry-run", "true", - ], new Dictionary - { - ["BIFROST_LOCK_RECOVERY_HATCHES"] = "true", - }); + ]); + Assert.Equal(0, written.ExitCode); - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase); + var processed = await RunNodeAsync([ + "tools/cultmesh-bridge-commands.mjs", + "process", + "--store", storePath, + "--receipt-store", receiptStorePath, + "--command-id", commandId, + ]); + Assert.Equal(0, processed.ExitCode); + using var processedPayload = JsonDocument.Parse(processed.Stdout); + Assert.False(processedPayload.RootElement.GetProperty("ok").GetBoolean()); + + var surfaceReceiptResult = await RunNodeAsync([ + "tools/cultmesh-bridge-commands.mjs", + "receipt", + "--store", storePath, + "--command-id", commandId, + ]); + Assert.Equal(0, surfaceReceiptResult.ExitCode); + using var surfaceReceiptPayload = JsonDocument.Parse(surfaceReceiptResult.Stdout); + var surfaceReceipt = surfaceReceiptPayload.RootElement.GetProperty("receipt"); + Assert.Equal("failed", surfaceReceipt.GetProperty("status").GetString()); + Assert.Equal($"crossing_{commandId}", surfaceReceipt.GetProperty("canonicalReceiptId").GetString()); + + using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath); + var receipt = Assert.Single(receipts.RootElement.EnumerateArray()); + Assert.Equal("discord.post", receipt.GetProperty("crossingKind").GetString()); + Assert.Equal("failed", receipt.GetProperty("status").GetString()); + Assert.Equal($"crossing_{commandId}", receipt.GetProperty("receiptId").GetString()); } [Fact] @@ -180,7 +271,7 @@ console.log(JSON.stringify({ ]); Assert.NotEqual(0, result.ExitCode); - Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -197,11 +288,11 @@ console.log(JSON.stringify({ ]); Assert.NotEqual(0, result.ExitCode); - Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Other_bridge_request_allows_explicit_operator_recovery_dry_run() + public async Task Other_bridge_request_allows_bifrost_receipt_gate_dry_run() { var result = await RunNodeAsync([ "tools/bifrost-bridge.mjs", @@ -211,9 +302,8 @@ console.log(JSON.stringify({ "--target-locator", "future://surface/thread/1", "--content", "test message", "--heimdall-capability-ref", "heimdall:future-surface:capability:epiphany-persona", - "--allow-unreceipted-activity", "true", "--dry-run", "true", - ]); + ], TypedCommandEnv()); Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); using var payload = JsonDocument.Parse(result.Stdout); @@ -227,7 +317,7 @@ console.log(JSON.stringify({ } [Fact] - public async Task Other_bridge_request_recovery_hatch_is_rejected_inside_dispatched_turn() + public async Task Other_bridge_request_recovery_hatch_is_rejected() { var result = await RunNodeAsync([ "tools/bifrost-bridge.mjs", @@ -238,10 +328,7 @@ console.log(JSON.stringify({ "--content", "test message", "--allow-unreceipted-activity", "true", "--dry-run", "true", - ], new Dictionary - { - ["BIFROST_LOCK_RECOVERY_HATCHES"] = "true", - }); + ]); Assert.NotEqual(0, result.ExitCode); Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase); @@ -259,11 +346,11 @@ console.log(JSON.stringify({ ]); Assert.NotEqual(0, result.ExitCode); - Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Agent_transport_mutation_fails_closed_without_bifrost_receipt_gate() + public async Task Agent_transport_mutation_rejects_removed_http_bridge_config() { var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc"); var result = await RunNodeAsync([ @@ -275,10 +362,10 @@ console.log(JSON.stringify({ "--request", "Do the thing.", "--store", storePath, "--allow-unmirrored", "true", - ]); + ], RemovedHttpBridgeEnv()); Assert.NotEqual(0, result.ExitCode); - Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -293,15 +380,42 @@ console.log(JSON.stringify({ "--title", "Discord inheritance guard", "--request", "Do not post fixture noise.", "--store", storePath, - "--allow-unreceipted-activity", "true", - ]); + ], TypedCommandEnv()); Assert.NotEqual(0, result.ExitCode); Assert.Contains("require a Discord mirror", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Agent_transport_mutation_allows_explicit_operator_recovery() + public async Task Agent_transport_mutation_allows_bifrost_receipt_gate() + { + var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc"); + var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc"); + var result = await RunNodeAsync([ + "tools/agent-transport.mjs", + "enqueue", + "--repo", "Bifrost", + "--agent", "nibu", + "--title", "Receipt gate recovery", + "--request", "Do the thing.", + "--store", storePath, + "--receipt-store", receiptStorePath, + "--allow-unmirrored", "true", + ], TypedCommandEnv()); + + Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); + using var payload = JsonDocument.Parse(result.Stdout); + Assert.Equal("queued", payload.RootElement.GetProperty("status").GetString()); + + using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath); + var receipt = Assert.Single(receipts.RootElement.EnumerateArray()); + Assert.Equal("agent_transport.request", receipt.GetProperty("crossingKind").GetString()); + Assert.Equal("completed", receipt.GetProperty("status").GetString()); + Assert.Equal(payload.RootElement.GetProperty("id").GetString(), receipt.GetProperty("source").GetProperty("requestId").GetString()); + } + + [Fact] + public async Task Agent_transport_recovery_hatch_is_rejected() { var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc"); var result = await RunNodeAsync([ @@ -316,36 +430,12 @@ console.log(JSON.stringify({ "--allow-unreceipted-activity", "true", ]); - Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); - using var payload = JsonDocument.Parse(result.Stdout); - Assert.Equal("queued", payload.RootElement.GetProperty("status").GetString()); - } - - [Fact] - public async Task Agent_transport_recovery_hatch_is_rejected_inside_dispatched_turn() - { - var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc"); - var result = await RunNodeAsync([ - "tools/agent-transport.mjs", - "enqueue", - "--repo", "Bifrost", - "--agent", "nibu", - "--title", "Receipt gate recovery", - "--request", "Do the thing.", - "--store", storePath, - "--allow-unmirrored", "true", - "--allow-unreceipted-activity", "true", - ], new Dictionary - { - ["BIFROST_LOCK_RECOVERY_HATCHES"] = "true", - }); - Assert.NotEqual(0, result.ExitCode); Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Agent_transport_apply_snapshot_fails_closed_without_bifrost_receipt_gate() + public async Task Agent_transport_apply_snapshot_rejects_removed_http_bridge_config() { var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc"); var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc"); @@ -360,8 +450,7 @@ console.log(JSON.stringify({ "--request", "Do the thing.", "--store", sourceStorePath, "--allow-unmirrored", "true", - "--allow-unreceipted-activity", "true", - ]); + ], TypedCommandEnv()); await RunNodeAsync([ "tools/agent-transport.mjs", @@ -375,14 +464,14 @@ console.log(JSON.stringify({ "apply-snapshot", "--store", targetStorePath, "--in", snapshotPath, - ]); + ], RemovedHttpBridgeEnv()); Assert.NotEqual(0, result.ExitCode); - Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Agent_transport_apply_snapshot_allows_explicit_operator_recovery() + public async Task Agent_transport_apply_snapshot_allows_bifrost_receipt_gate() { var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc"); var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc"); @@ -397,8 +486,7 @@ console.log(JSON.stringify({ "--request", "Do the thing.", "--store", sourceStorePath, "--allow-unmirrored", "true", - "--allow-unreceipted-activity", "true", - ]); + ], TypedCommandEnv()); await RunNodeAsync([ "tools/agent-transport.mjs", @@ -412,8 +500,7 @@ console.log(JSON.stringify({ "apply-snapshot", "--store", targetStorePath, "--in", snapshotPath, - "--allow-unreceipted-activity", "true", - ]); + ], TypedCommandEnv()); Assert.Equal(0, result.ExitCode); @@ -431,7 +518,7 @@ console.log(JSON.stringify({ } [Fact] - public async Task Governance_mutation_fails_closed_without_bifrost_receipt_gate() + public async Task Governance_mutation_rejects_removed_http_bridge_config() { var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc"); var result = await RunNodeAsync([ @@ -443,16 +530,17 @@ console.log(JSON.stringify({ "--summary", "A topic body.", "--store", storePath, "--allow-unmirrored", "true", - ]); + ], RemovedHttpBridgeEnv()); Assert.NotEqual(0, result.ExitCode); - Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Governance_mutation_allows_explicit_operator_recovery() + public async Task Governance_mutation_allows_bifrost_receipt_gate() { var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc"); + var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc"); var result = await RunNodeAsync([ "tools/governance-threads.mjs", "open", @@ -461,17 +549,23 @@ console.log(JSON.stringify({ "--title", "Governance receipt recovery", "--summary", "A topic body.", "--store", storePath, + "--receipt-store", receiptStorePath, "--allow-unmirrored", "true", - "--allow-unreceipted-activity", "true", - ]); + ], TypedCommandEnv()); Assert.Equal(0, result.ExitCode); using var payload = JsonDocument.Parse(result.Stdout); Assert.Equal("open", payload.RootElement.GetProperty("status").GetString()); + + using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath); + var receipt = Assert.Single(receipts.RootElement.EnumerateArray()); + Assert.Equal("governance.topic", receipt.GetProperty("crossingKind").GetString()); + Assert.Equal("completed", receipt.GetProperty("status").GetString()); + Assert.Equal(payload.RootElement.GetProperty("id").GetString(), receipt.GetProperty("source").GetProperty("topicId").GetString()); } [Fact] - public async Task Governance_recovery_hatch_is_rejected_inside_dispatched_turn() + public async Task Governance_recovery_hatch_is_rejected() { var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc"); var result = await RunNodeAsync([ @@ -484,17 +578,14 @@ console.log(JSON.stringify({ "--store", storePath, "--allow-unmirrored", "true", "--allow-unreceipted-activity", "true", - ], new Dictionary - { - ["BIFROST_LOCK_RECOVERY_HATCHES"] = "true", - }); + ]); Assert.NotEqual(0, result.ExitCode); Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Dispatch_worker_fails_closed_without_bifrost_receipt_gate() + public async Task Dispatch_worker_rejects_removed_http_bridge_config() { var tempDir = Path.Combine(Path.GetTempPath(), $"bifrost-dispatch-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); @@ -525,8 +616,8 @@ console.log(JSON.stringify({ "claimedAt": "2026-06-17T00:00:00Z", "closedAt": "" } -""", Encoding.UTF8); - await File.WriteAllTextAsync(promptPath, "Hello", Encoding.UTF8); +""", new UTF8Encoding(false)); + await File.WriteAllTextAsync(promptPath, "Hello", new UTF8Encoding(false)); var result = await RunNodeAsync([ "tools/dispatch-agent-requests.mjs", @@ -536,10 +627,10 @@ console.log(JSON.stringify({ "--prompt-file", promptPath, "--log", logPath, "--launch-mode", "codex-exec", - ]); + ], RemovedHttpBridgeEnv()); Assert.NotEqual(0, result.ExitCode); - Assert.Contains("requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -629,8 +720,7 @@ writeFileSync(process.env.DISPATCH_ENV_DUMP, JSON.stringify({ "--title", "Dispatch env seed", "--request", "Seed the live request lane.", "--allow-unmirrored", "true", - "--allow-unreceipted-activity", "true", - ]); + ], TypedCommandEnv()); var result = await RunNodeAsync([ "tools/dispatch-agent-requests.mjs", @@ -642,13 +732,12 @@ writeFileSync(process.env.DISPATCH_ENV_DUMP, JSON.stringify({ "--launch-mode", "codex-exec", "--codex-executable", "node", "--codex-exec-args", fakeCodexPath, - ], new Dictionary + ], TypedCommandEnv(new Dictionary { - ["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = "true", ["GH_TOKEN"] = "parent-gh-token", ["GITHUB_TOKEN"] = "parent-github-token", ["DISPATCH_ENV_DUMP"] = dumpPath, - }); + })); Assert.Equal(0, result.ExitCode); Assert.True(File.Exists(dumpPath), $"Dispatch env dump was not created. stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); @@ -771,8 +860,7 @@ rl.on("line", (line) => { "--title", "Dispatch app-server seed", "--request", "Seed the live request lane.", "--allow-unmirrored", "true", - "--allow-unreceipted-activity", "true", - ]); + ], TypedCommandEnv()); var result = await RunNodeAsync([ "tools/dispatch-agent-requests.mjs", @@ -785,11 +873,10 @@ rl.on("line", (line) => { "--codex-executable", "node", "--codex-exec-args", fakeServerPath, "--no-discord", "true", - ], new Dictionary + ], TypedCommandEnv(new Dictionary { - ["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = "true", ["DISPATCH_APP_SERVER_DUMP"] = dumpPath, - }); + })); Assert.Equal(0, result.ExitCode); Assert.True(File.Exists(dumpPath), "Fake app-server did not record dispatch policy."); @@ -887,7 +974,7 @@ console.log("https://github.com/GameCult/Bifrost/pull/999"); """, Encoding.UTF8); var environment = BuildDispatchedGitGateEnv(); - environment["BIFROST_ALLOW_UNGATED_GITHUB"] = "true"; + environment["BIFROST_CULTMESH_COMMAND_ID"] = "test-cultmesh-command"; environment["BIFROST_REAL_GH"] = ResolveExecutable("node"); environment["BIFROST_REAL_GH_ARGS"] = fakeGhPath; @@ -1058,6 +1145,56 @@ exit /b 0 private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + private static Dictionary TypedCommandEnv( + IReadOnlyDictionary? extras = null) + { + var environment = new Dictionary + { + ["BIFROST_CULTMESH_COMMAND_ID"] = "test-cultmesh-command", + }; + + if (extras is null) + { + return environment; + } + + foreach (var pair in extras) + { + environment[pair.Key] = pair.Value; + } + + return environment; + } + + private static Dictionary RemovedHttpBridgeEnv() + => new() + { + ["BIFROST_BRIDGE_BASE_URL"] = "http://127.0.0.1:1/", + ["BIFROST_BRIDGE_TOKEN"] = "removed-test-token", + }; + + private static async Task ReadCrossingReceiptsAsync(string storePath) + { + var result = await RunNodeAsync([ + "-e", + """ +import('./tools/bifrost-crossing-documents.mjs') + .then(async module => { + const store = await module.openCrossingReceiptStore(process.argv[1]); + console.log(JSON.stringify(store.getAll())); + }) + .catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +""", + storePath, + ]); + + Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); + return JsonDocument.Parse(result.Stdout); + } + private static async Task RunNodeAsync(string[] args, IReadOnlyDictionary? environmentOverrides = null) { using var process = new Process diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index ce4cb0d..dd7543b 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -508,11 +508,12 @@ public sealed class PlatformEndpointsTests : IClassFixture(); - var vote = verificationContext.Votes.Single(x => x.MotionId == motionId); - Assert.Equal(VoteChoice.For, vote.Choice); - Assert.Equal("Cast from Eve.", vote.Comment); - Assert.Contains(verificationContext.AuditEvents, x => x.Action == "motion.voted"); + Assert.Empty(verificationContext.Votes.Where(x => x.MotionId == motionId)); + Assert.DoesNotContain(verificationContext.AuditEvents, x => x.Action == "motion.voted"); } - [Fact] - public async Task Local_bridge_token_can_authorize_and_receipt_agent_github_action() + [Theory] + [InlineData("/bridge/actions/request")] + [InlineData("/bridge/actions/action-123/start")] + [InlineData("/bridge/actions/action-123/complete")] + [InlineData("/dispatch/runs/start")] + [InlineData("/dispatch/runs/run-123/complete")] + [InlineData("/dispatch/runs/run-123/fail")] + [InlineData("/transport/receipts")] + [InlineData("/governance/receipts")] + public async Task Removed_http_bridge_routes_are_not_mapped(string path) { - using var client = _factory.CreateClient(); - await PostTransportReceiptAsync(client, new - { - requestId = "req_bridge_123", - title = "Queue the GitHub bridge hardening pass", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - activityKind = "Claimed", - status = "claimed", - actorName = "bifrost-dispatcher", - note = "Request claimed for Bifrost." - }); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Agent", - actorName = "nibu", - targetSurface = "GitHub", - actionKind = "GitHubDraftPullRequest", - targetRepositoryFullName = "GameCult/Bifrost", - targetLocator = "pulls", - sourceKind = "bifrost_agent_transport_request", - sourceId = "req_bridge_123", - authorityReference = "dispatch-approved", - title = "Draft motion implementation PR", - summary = "Open a draft PR for the approved topic." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Authorized", action.Status); - - using var startRequest = new HttpRequestMessage(HttpMethod.Post, $"/bridge/actions/{action.Id}/start"); - startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - using var startResponse = await client.SendAsync(startRequest); - Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode); - - var completionPayload = JsonSerializer.Serialize(new - { - receiptUrl = "https://github.com/GameCult/Bifrost/pull/99", - externalReceiptId = "99", - receiptPayload = "{\"pr\":99}" - }); - using var completeRequest = new HttpRequestMessage(HttpMethod.Post, $"/bridge/actions/{action.Id}/complete"); - completeRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - completeRequest.Content = new StringContent(completionPayload, Encoding.UTF8, "application/json"); - - using var completeResponse = await client.SendAsync(completeRequest); - - Assert.Equal(HttpStatusCode.OK, completeResponse.StatusCode); - using var scope = _factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); + var bridgeActions = await dbContext.BridgeActions.CountAsync(); + var dispatchRuns = await dbContext.AgentDispatchRuns.CountAsync(); + var transportReceipts = await dbContext.AgentTransportReceipts.CountAsync(); + var governanceReceipts = await dbContext.GovernanceActivityReceipts.CountAsync(); - Assert.Equal(BridgeActionStatus.Completed, savedAction.Status); - Assert.Equal("https://github.com/GameCult/Bifrost/pull/99", savedAction.ReceiptUrl); - Assert.Equal("99", savedAction.ExternalReceiptId); - Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(BridgeAction) && x.Action == "bridge.completed"); - } - - [Fact] - public async Task Agent_github_action_without_provenance_is_denied_and_recorded() - { using var client = _factory.CreateClient(); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Agent", - actorName = "nibu", - targetSurface = "GitHub", - actionKind = "GitHubDraftPullRequest", - targetRepositoryFullName = "GameCult/Bifrost", - targetLocator = "pulls", - title = "Unproven draft", - summary = "This should not pass policy." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); + using var request = new HttpRequestMessage(HttpMethod.Post, path); request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); + request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); using var response = await client.SendAsync(request); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("must cite", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(BridgeAction) && x.Action == "bridge.denied"); - } - - [Fact] - public async Task Agent_github_action_with_unknown_bifrost_request_is_denied_and_recorded() - { - using var client = _factory.CreateClient(); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Agent", - actorName = "nibu", - targetSurface = "GitHub", - actionKind = "GitHubDraftPullRequest", - targetRepositoryFullName = "GameCult/Bifrost", - targetLocator = "pulls", - sourceKind = "bifrost_agent_transport_request", - sourceId = "req_missing_123", - authorityReference = "dispatch-approved", - title = "Unbacked request bridge action", - summary = "This should not pass policy." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("unknown request", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Agent_github_action_with_mismatched_bifrost_request_repo_is_denied_and_recorded() - { - using var client = _factory.CreateClient(); - await PostTransportReceiptAsync(client, new - { - requestId = "req_bridge_mismatch_123", - title = "Queue the VoidBot bridge hardening pass", - targetRepoName = "VoidBot", - targetRepositoryFullName = "GameCult/VoidBot", - targetAgentIdentity = "nibu", - activityKind = "Claimed", - status = "claimed", - actorName = "bifrost-dispatcher", - note = "Request claimed for VoidBot." - }); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Agent", - actorName = "nibu", - targetSurface = "GitHub", - actionKind = "GitHubDraftPullRequest", - targetRepositoryFullName = "GameCult/Bifrost", - targetLocator = "pulls", - sourceKind = "bifrost_agent_transport_request", - sourceId = "req_bridge_mismatch_123", - authorityReference = "dispatch-approved", - title = "Repo mismatch bridge action", - summary = "This should not pass policy." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("target repository does not match", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Agent_github_action_with_unapproved_governance_topic_is_denied_and_recorded() - { - using var client = _factory.CreateClient(); - await PostGovernanceReceiptAsync(client, new - { - topicId = "topic_open_only_123", - commentId = "comment_open_only_123", - dispatchRequestId = string.Empty, - title = "Open topic only", - jurisdictionRepoName = "Bifrost", - jurisdictionAgentIdentity = "nibu", - activityKind = "TopicOpened", - actorKind = "face", - actorName = "nibu", - note = "Topic opened." - }); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Agent", - actorName = "nibu", - targetSurface = "GitHub", - actionKind = "GitHubDraftPullRequest", - targetRepositoryFullName = "GameCult/Bifrost", - targetLocator = "pulls", - sourceKind = "governance_topic", - sourceId = "topic_open_only_123", - authorityReference = "dispatch-approved", - title = "Open-only topic bridge action", - summary = "This should not pass policy." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("not been approved or promoted", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Agent_github_action_with_approved_governance_topic_is_authorized() - { - using var client = _factory.CreateClient(); - await PostGovernanceReceiptAsync(client, new - { - topicId = "topic_approved_123", - commentId = "comment_approved_123", - dispatchRequestId = string.Empty, - title = "Approved governance topic", - jurisdictionRepoName = "Bifrost", - jurisdictionAgentIdentity = "nibu", - activityKind = "TopicApproved", - actorKind = "face", - actorName = "nibu", - note = "Topic approved." - }); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Agent", - actorName = "nibu", - targetSurface = "GitHub", - actionKind = "GitHubDraftPullRequest", - targetRepositoryFullName = "GameCult/Bifrost", - targetLocator = "pulls", - sourceKind = "bifrost_governance_topic", - sourceId = "topic_approved_123", - authorityReference = "dispatch-approved", - title = "Approved topic bridge action", - summary = "This should pass policy." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Authorized", action.Status); - } - - [Fact] - public async Task Persona_reddit_action_without_bifrost_identity_or_heimdall_reference_is_denied() - { - using var client = _factory.CreateClient(); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Epiphany Persona", - targetSurface = "Reddit", - actionKind = "RedditPost", - targetLocator = "r/GameCultOrg", - sourceKind = "epiphany_persona_reddit", - sourceId = "persona-speech-audit-missing-identity", - authorityReference = "epiphany.persona_speech_audit", - title = "Persona Reddit thread", - summary = "This should not pass without Bifrost identity plus Heimdall reference." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("Bifrost identity", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Persona_discord_action_requires_heimdall_reference_after_bifrost_identity() - { - using var client = _factory.CreateClient(); - await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona"); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Epiphany Persona", - targetSurface = "Discord", - actionKind = "DiscordPost", - targetLocator = "channel/1501196543150264332", - sourceKind = "epiphany_persona_speech", - sourceId = "persona-speech-audit-missing-heimdall", - authorityReference = "epiphany.persona_speech_audit", - bifrostIdentity = "epiphany.Persona", - title = "Persona Discord post", - summary = "This should not pass without an outside-account capability reference." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("Heimdall", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Persona_discord_action_with_unlinked_heimdall_account_is_denied() - { - using var client = _factory.CreateClient(); - var identity = $"native.{Guid.NewGuid():N}"; - await EnsureBifrostIdentityRegisteredAsync(identity); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Native Persona", - targetSurface = "Discord", - actionKind = "DiscordPost", - targetLocator = "channel/1501196543150264332", - sourceKind = "epiphany_persona_speech", - sourceId = "persona-speech-audit-unlinked-heimdall", - authorityReference = "epiphany.persona_speech_audit", - bifrostIdentity = identity, - heimdallCapabilityReference = "heimdall:discord:capability:native-persona", - title = "Persona Discord post", - summary = "This should not pass until the Bifrost identity is linked to Heimdall." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("linked to a Heimdall account", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Persona_discord_action_with_unregistered_bifrost_identity_is_denied() - { - using var client = _factory.CreateClient(); - var identity = $"unregistered.{Guid.NewGuid():N}"; - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Epiphany Persona", - targetSurface = "Discord", - actionKind = "DiscordPost", - targetLocator = "channel/1501196543150264332", - sourceKind = "epiphany_persona_speech", - sourceId = "persona-speech-audit-unregistered-identity", - authorityReference = "epiphany.persona_speech_audit", - bifrostIdentity = identity, - heimdallCapabilityReference = "heimdall:discord:capability:epiphany-persona", - title = "Persona Discord post", - summary = "This should not pass with an unregistered Bifrost identity." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("registered Bifrost identity", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Persona_reddit_action_with_bifrost_identity_and_heimdall_reference_is_authorized_and_stored() - { - using var client = _factory.CreateClient(); - await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona"); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Epiphany Persona", - targetSurface = "Reddit", - actionKind = "RedditPost", - targetLocator = "r/GameCultOrg", - sourceKind = "epiphany_persona_reddit", - sourceId = "persona-speech-audit-authorized", - authorityReference = "epiphany.persona_speech_audit", - bifrostIdentity = "epiphany.Persona", - heimdallCapabilityReference = "heimdall:reddit:capability:epiphany-persona", - epiphanyRunId = "epiphany-run-identity-gate", - epiphanyLaneId = "Persona", - epiphanyAgentIdentity = "epiphany.Persona", - title = "Persona Reddit thread", - summary = "This should pass with Bifrost identity and Heimdall reference." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Authorized", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Authorized, savedAction.Status); - Assert.Equal("epiphany.Persona", savedAction.BifrostIdentity); - Assert.Equal("heimdall:reddit:capability:epiphany-persona", savedAction.HeimdallCapabilityReference); - Assert.Equal("epiphany-run-identity-gate", savedAction.EpiphanyRunId); - Assert.Equal("Persona", savedAction.EpiphanyLaneId); - Assert.Equal("epiphany.Persona", savedAction.EpiphanyAgentIdentity); - } - - [Fact] - public async Task Persona_other_surface_action_without_bifrost_identity_is_denied() - { - using var client = _factory.CreateClient(); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Epiphany Persona", - targetSurface = "Other", - actionKind = "Other", - targetLocator = "future://public-surface/channel", - targetSurfaceName = "future-surface", - sourceKind = "epiphany_persona_public_surface", - sourceId = "persona-speech-audit-future-surface-missing-identity", - authorityReference = "epiphany.persona_speech_audit", - title = "Persona future public surface post", - summary = "This future outside-world crossing must not pass without identity provenance." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("outside-world", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Bifrost identity", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Persona_other_surface_action_without_target_surface_name_is_denied() - { - using var client = _factory.CreateClient(); - await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona"); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Epiphany Persona", - targetSurface = "Other", - actionKind = "Other", - targetLocator = "future://public-surface/channel", - sourceKind = "epiphany_persona_public_surface", - sourceId = "persona-speech-audit-future-surface-missing-name", - authorityReference = "epiphany.persona_speech_audit", - bifrostIdentity = "epiphany.Persona", - heimdallCapabilityReference = "heimdall:future-surface:capability:epiphany-persona", - title = "Persona future public surface post", - summary = "Bifrost must know which future surface this Other action targets." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Contains("must name", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Persona_other_surface_action_with_wrong_surface_heimdall_reference_is_denied() - { - using var client = _factory.CreateClient(); - await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona"); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Epiphany Persona", - targetSurface = "Other", - actionKind = "Other", - targetLocator = "at://did:example/app.bsky.feed.post/123", - targetSurfaceName = "bluesky", - sourceKind = "epiphany_persona_public_surface", - sourceId = "persona-speech-audit-future-surface-wrong-ref", - authorityReference = "epiphany.persona_speech_audit", - bifrostIdentity = "epiphany.Persona", - heimdallCapabilityReference = "heimdall:mastodon:capability:epiphany-persona", - title = "Persona future public surface post", - summary = "The Heimdall reference must match the named future surface." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Denied", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); - Assert.Equal("bluesky", savedAction.TargetSurfaceName); - Assert.Contains("does not match", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Persona_other_surface_action_with_bifrost_identity_and_heimdall_reference_is_authorized() - { - using var client = _factory.CreateClient(); - await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona"); - - var requestPayload = JsonSerializer.Serialize(new - { - actorKind = "Persona", - actorName = "Epiphany Persona", - targetSurface = "Other", - actionKind = "Other", - targetLocator = "at://did:example/app.bsky.feed.post/123", - targetSurfaceName = "bluesky", - sourceKind = "epiphany_persona_public_surface", - sourceId = "persona-speech-audit-future-surface-authorized", - authorityReference = "epiphany.persona_speech_audit", - bifrostIdentity = "epiphany.Persona", - heimdallCapabilityReference = "heimdall:bluesky:capability:epiphany-persona", - epiphanyRunId = "epiphany-run-future-surface", - epiphanyLaneId = "Persona", - epiphanyAgentIdentity = "epiphany.Persona", - title = "Persona future public surface post", - summary = "This future outside-world crossing is allowed only with Bifrost and Heimdall provenance." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - var action = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(action); - Assert.Equal("Authorized", action.Status); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); - - Assert.Equal(BridgeActionStatus.Authorized, savedAction.Status); - Assert.Equal(BridgeTargetSurface.Other, savedAction.TargetSurface); - Assert.Equal("bluesky", savedAction.TargetSurfaceName); - Assert.Equal("epiphany.Persona", savedAction.BifrostIdentity); - Assert.Equal("heimdall:bluesky:capability:epiphany-persona", savedAction.HeimdallCapabilityReference); - Assert.Equal("epiphany-run-future-surface", savedAction.EpiphanyRunId); - Assert.Equal("Persona", savedAction.EpiphanyLaneId); - Assert.Equal("epiphany.Persona", savedAction.EpiphanyAgentIdentity); - } - - [Fact] - public async Task Local_bridge_token_can_record_dispatch_run_lifecycle() - { - using var client = _factory.CreateClient(); - _ = await client.GetAsync("/App"); - await PostTransportReceiptAsync(client, new - { - requestId = "req_dispatch_123", - title = "Queue the GitHub bridge hardening pass", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - activityKind = "Claimed", - status = "claimed", - actorName = "bifrost-dispatcher", - note = "Request claimed for Bifrost." - }); - - var startPayload = JsonSerializer.Serialize(new - { - requestId = "req_dispatch_123", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - launchMode = "app-server", - workerProcessId = 4242, - threadId = "thread_1", - turnId = "turn_1", - logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/codex.log", - resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/result.json", - note = "Codex app-server turn started." - }); - - using var startRequest = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start"); - startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - startRequest.Content = new StringContent(startPayload, Encoding.UTF8, "application/json"); - - using var startResponse = await client.SendAsync(startRequest); - - Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode); - var started = await startResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(started); - Assert.Equal("Started", started.Status); - - var completePayload = JsonSerializer.Serialize(new - { - status = "Completed", - threadId = "thread_1", - turnId = "turn_1", - resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/result.json", - note = "Codex app turn completed for thread_1/turn_1." - }); - - using var completeRequest = new HttpRequestMessage(HttpMethod.Post, $"/dispatch/runs/{started.Id}/complete"); - completeRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - completeRequest.Content = new StringContent(completePayload, Encoding.UTF8, "application/json"); - - using var completeResponse = await client.SendAsync(completeRequest); - - Assert.Equal(HttpStatusCode.OK, completeResponse.StatusCode); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedRun = dbContext.AgentDispatchRuns.Single(x => x.Id == started.Id); - - Assert.Equal(AgentDispatchRunStatus.Completed, savedRun.Status); - Assert.Equal("req_dispatch_123", savedRun.RequestId); - Assert.Equal("turn_1", savedRun.TurnId); - Assert.Null(savedRun.StartedByUserAccountId); - Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(AgentDispatchRun) && x.Action == "agent-dispatch.completed"); - } - - [Fact] - public async Task Local_bridge_token_can_record_dispatch_run_failure() - { - using var client = _factory.CreateClient(); - _ = await client.GetAsync("/App"); - await PostTransportReceiptAsync(client, new - { - requestId = "req_dispatch_fail_123", - title = "Queue the GitHub bridge hardening pass", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - activityKind = "Claimed", - status = "claimed", - actorName = "bifrost-dispatcher", - note = "Request claimed for Bifrost." - }); - - var startPayload = JsonSerializer.Serialize(new - { - requestId = "req_dispatch_fail_123", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - launchMode = "app-server", - workerProcessId = 4242, - threadId = string.Empty, - turnId = string.Empty, - logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/codex.log", - resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/result.json", - note = "Codex app-server launch started." - }); - - using var startRequest = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start"); - startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - startRequest.Content = new StringContent(startPayload, Encoding.UTF8, "application/json"); - - using var startResponse = await client.SendAsync(startRequest); - - Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode); - var started = await startResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(started); - - var failPayload = JsonSerializer.Serialize(new - { - threadId = string.Empty, - turnId = string.Empty, - resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/result.json", - note = "Codex dispatch failed.", - error = "Codex app-server thread/start returned no thread id." - }); - - using var failRequest = new HttpRequestMessage(HttpMethod.Post, $"/dispatch/runs/{started.Id}/fail"); - failRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - failRequest.Content = new StringContent(failPayload, Encoding.UTF8, "application/json"); - - using var failResponse = await client.SendAsync(failRequest); - - Assert.Equal(HttpStatusCode.OK, failResponse.StatusCode); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedRun = dbContext.AgentDispatchRuns.Single(x => x.Id == started.Id); - - Assert.Equal(AgentDispatchRunStatus.Failed, savedRun.Status); - Assert.Equal("Codex app-server thread/start returned no thread id.", savedRun.Error); - Assert.Null(savedRun.StartedByUserAccountId); - Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(AgentDispatchRun) && x.Action == "agent-dispatch.failed"); - } - - [Fact] - public async Task Local_bridge_token_can_record_transport_receipt() - { - using var client = _factory.CreateClient(); - _ = await client.GetAsync("/App"); - - var payload = JsonSerializer.Serialize(new - { - requestId = "req_transport_123", - title = "Queue the GitHub bridge hardening pass", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - activityKind = "Claimed", - status = "claimed", - actorName = "bifrost-dispatcher", - note = "Request claimed for Bifrost." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/transport/receipts"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - var receipt = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(receipt); - Assert.Equal("req_transport_123", receipt.RequestId); - Assert.Equal("Claimed", receipt.ActivityKind); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedReceipt = dbContext.AgentTransportReceipts.Single(x => x.Id == receipt.Id); - - Assert.Equal("Claimed", savedReceipt.ActivityKind.ToString()); - Assert.Equal("bifrost-dispatcher", savedReceipt.ActorName); - Assert.Null(savedReceipt.ActorUserAccountId); - Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(AgentTransportReceipt) && x.Action == "agent-transport.claimed"); - } - - [Fact] - public async Task Local_bridge_token_can_record_governance_receipt() - { - using var client = _factory.CreateClient(); - _ = await client.GetAsync("/App"); - await PostTransportReceiptAsync(client, new - { - requestId = "req_transport_123", - title = "Queue the GitHub bridge hardening pass", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - activityKind = "Queued", - status = "queued", - actorName = "bifrost-dispatcher", - note = "Request queued for Bifrost." - }); - - var payload = JsonSerializer.Serialize(new - { - topicId = "topic_123", - commentId = "comment_123", - dispatchRequestId = "req_transport_123", - title = "Queue the GitHub bridge hardening pass", - jurisdictionRepoName = "Bifrost", - jurisdictionAgentIdentity = "nibu", - activityKind = "TopicPromoted", - actorKind = "face", - actorName = "nibu", - note = "Governance topic promoted to update request req_transport_123." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/governance/receipts"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - var receipt = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(receipt); - Assert.Equal("topic_123", receipt.TopicId); - Assert.Equal("TopicPromoted", receipt.ActivityKind); - - using var scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var savedReceipt = dbContext.GovernanceActivityReceipts.Single(x => x.Id == receipt.Id); - - Assert.Equal("TopicPromoted", savedReceipt.ActivityKind.ToString()); - Assert.Equal("nibu", savedReceipt.ActorName); - Assert.Null(savedReceipt.ActorUserAccountId); - Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(GovernanceActivityReceipt) && x.Action == "governance.topic-promoted"); - } - - [Fact] - public async Task Active_member_session_cannot_record_dispatch_run_without_local_bridge_token() - { - using var client = _factory.CreateClient(); - _ = await client.GetAsync("/App"); - - var payload = JsonSerializer.Serialize(new - { - requestId = "req_dispatch_member_123", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - launchMode = "app-server", - workerProcessId = 4242, - threadId = "thread_member", - turnId = "turn_member", - logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_member_123/codex.log", - resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_member_123/result.json", - note = "Should be rejected without the local bridge token." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start"); - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Dispatch_run_without_request_lane_receipt_is_rejected() - { - using var client = _factory.CreateClient(); - - var payload = JsonSerializer.Serialize(new - { - requestId = "req_dispatch_missing_123", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - launchMode = "app-server", - workerProcessId = 4242, - threadId = "thread_missing", - turnId = "turn_missing", - logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_missing_123/codex.log", - resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_missing_123/result.json", - note = "Should fail because the request lane has no receipt." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - Assert.Contains("existing request-lane receipt", body, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Governance_promotion_receipt_requires_known_dispatch_request() - { - using var client = _factory.CreateClient(); - - var payload = JsonSerializer.Serialize(new - { - topicId = "topic_unknown_dispatch_123", - commentId = "comment_unknown_dispatch_123", - dispatchRequestId = "req_unknown_transport_123", - title = "Promote an unknown dispatch request", - jurisdictionRepoName = "Bifrost", - jurisdictionAgentIdentity = "nibu", - activityKind = "TopicPromoted", - actorKind = "face", - actorName = "nibu", - note = "Should fail because the dispatch request is unknown." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/governance/receipts"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - Assert.Contains("unknown dispatch request", body, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Request_lane_receipt_cannot_change_repo_identity_for_existing_request() - { - using var client = _factory.CreateClient(); - await PostTransportReceiptAsync(client, new - { - requestId = "req_transport_conflict_123", - title = "Queue the GitHub bridge hardening pass", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - activityKind = "Queued", - status = "queued", - actorName = "bifrost-dispatcher", - note = "Request queued for Bifrost." - }); - - var conflictPayload = JsonSerializer.Serialize(new - { - requestId = "req_transport_conflict_123", - title = "Queue the GitHub bridge hardening pass", - targetRepoName = "VoidBot", - targetRepositoryFullName = "GameCult/VoidBot", - targetAgentIdentity = "nibu", - activityKind = "Claimed", - status = "claimed", - actorName = "bifrost-dispatcher", - note = "This should not be allowed to drift." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/transport/receipts"); - request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); - request.Content = new StringContent(conflictPayload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - Assert.Contains("target repo", body, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Active_member_session_cannot_record_transport_receipt_without_local_bridge_token() - { - using var client = _factory.CreateClient(); - _ = await client.GetAsync("/App"); - - var payload = JsonSerializer.Serialize(new - { - requestId = "req_transport_member_123", - title = "Member should not mint runtime receipts", - targetRepoName = "Bifrost", - targetRepositoryFullName = "GameCult/Bifrost", - targetAgentIdentity = "nibu", - activityKind = "Claimed", - status = "claimed", - actorName = "test-admin", - note = "Should be rejected without the local bridge token." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/transport/receipts"); - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task Active_member_session_cannot_record_governance_receipt_without_local_bridge_token() - { - using var client = _factory.CreateClient(); - _ = await client.GetAsync("/App"); - - var payload = JsonSerializer.Serialize(new - { - topicId = "topic_member_123", - commentId = "comment_member_123", - dispatchRequestId = "req_transport_member_123", - title = "Member should not mint governance runtime receipts", - jurisdictionRepoName = "Bifrost", - jurisdictionAgentIdentity = "nibu", - activityKind = "TopicPromoted", - actorKind = "member", - actorName = "test-admin", - note = "Should be rejected without the local bridge token." - }); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/governance/receipts"); - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); - - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(bridgeActions, await dbContext.BridgeActions.CountAsync()); + Assert.Equal(dispatchRuns, await dbContext.AgentDispatchRuns.CountAsync()); + Assert.Equal(transportReceipts, await dbContext.AgentTransportReceipts.CountAsync()); + Assert.Equal(governanceReceipts, await dbContext.GovernanceActivityReceipts.CountAsync()); } private static string ComputeSignature(string secret, string payload) @@ -1734,32 +660,6 @@ public sealed class PlatformEndpointsTests : IClassFixture row is not null && row.Contains("reddit: prepared", StringComparison.Ordinal)); Assert.Contains(readinessRows, row => row is not null && row.Contains("patron: live", StringComparison.Ordinal) && row.Contains("Bifrost stores no provider tokens", StringComparison.Ordinal)); + + var receiptMetric = root + .GetProperty("surface") + .GetProperty("root") + .GetProperty("children") + .EnumerateArray() + .SelectMany(panel => panel.GetProperty("children").EnumerateArray()) + .Single(node => node.GetProperty("id").GetString() == "metric-crossing-receipts"); + + Assert.Contains("total", receiptMetric.GetProperty("props").GetProperty("value").GetString(), StringComparison.Ordinal); } private static void AssertEndpoint(JsonElement root, string id, string schemaId, string lowering) @@ -108,6 +120,15 @@ public sealed class ProviderAdvertisementTests Assert.Equal(purpose, schema.GetProperty("purpose").GetString()); } + private static void AssertWitness(JsonElement root, string path, string schemaId, string status) + { + var witness = root.GetProperty("witnesses") + .EnumerateArray() + .Single(item => item.GetProperty("path").GetString() == path); + Assert.Equal(schemaId, witness.GetProperty("schemaIds").GetString()); + Assert.Equal(status, witness.GetProperty("status").GetString()); + } + private static void AssertBoundaryCommand(JsonElement root, string area, string command) { var boundary = Boundary(root, area); diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index 7453c15..5f7c54e 100644 --- a/tools/agent-transport.mjs +++ b/tools/agent-transport.mjs @@ -6,6 +6,11 @@ import { existsSync, readFileSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + commandIdFromOptions, + crossingProvenanceFromOptions, + writeCrossingReceipt, +} from "./bifrost-crossing-documents.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); @@ -164,29 +169,23 @@ function isMutatingCommand(command) { } function ensureTransportReceiptGate(options) { - if (wantsUnreceiptedActivity(options) && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") { + if (wantsUnreceiptedActivity(options)) { throw new Error( - "Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " + - "Request-lane mutations from a dispatched turn must keep Bifrost receipts.", + "Bifrost request-lane mutations cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " + + "Use the typed Bifrost CultCache/CultMesh request store instead of mutating off-book.", ); } - if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) { - return; + if (hasRemovedHttpBridgeConfig()) { + throw new Error( + "BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " + + "Bifrost request-lane receipts are written to the typed CultCache/CultMesh store.", + ); } - - throw new Error( - "Bifrost agent transport mutations require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep request-lane receipts. " + - "Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.", - ); } -function hasBifrostBridgeConfig() { - return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN)); -} - -function allowsUnreceiptedActivity(options) { - return wantsUnreceiptedActivity(options); +function hasRemovedHttpBridgeConfig() { + return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN)); } function wantsUnreceiptedActivity(options) { @@ -236,7 +235,7 @@ async function enqueue(cache, options) { status: request.status, actorName: request.createdByAgent ?? "system", note: "Update request queued.", - }); + }, options); printJson(request); } @@ -293,7 +292,7 @@ function allowsUnmirrored(options) { } function bridgeRecoveryArgs(options) { - return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : []; + return []; } function renderRequestMirrorFallback(request) { @@ -371,7 +370,7 @@ async function claim(cache, options) { status: claimed.status, actorName: claimed.claimedByAgent ?? claimed.targetAgentIdentity ?? "codex", note: `Request claimed for ${claimed.targetRepoName}.`, - }); + }, options); printJson(claimed); } @@ -401,7 +400,7 @@ async function closeRequest(cache, options) { status: closed.status, actorName: current.claimedByAgent ?? current.targetAgentIdentity ?? "system", note: closed.closeNote || `Request closed as ${closed.status}.`, - }); + }, options); printJson(closed); } @@ -431,7 +430,7 @@ async function releaseRequest(cache, options) { status: released.status, actorName: current.claimedByAgent ?? current.targetAgentIdentity ?? "system", note: released.closeNote || "Claim released back to the queue.", - }); + }, options); printJson(released); } @@ -474,7 +473,7 @@ async function applySnapshot(cache, options) { try { for (const { request, receipt } of receipts) { - await recordTransportReceiptOrThrow(request, receipt); + await recordTransportReceiptOrThrow(request, receipt, options); } } catch (error) { await restoreRequests(cache, beforeRequests, afterRequests); @@ -582,36 +581,53 @@ function stableJson(value) { return JSON.stringify(value); } -async function recordTransportReceiptOrThrow(request, receipt) { - const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL); - const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN); - if (!baseUrl || !token) { - return; - } - - const response = await fetch(new URL("/transport/receipts", ensureTrailingSlash(baseUrl)), { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Bifrost-Bridge-Token": token, - }, - body: JSON.stringify({ - requestId: request.id, - title: request.title, - targetRepoName: request.targetRepoName, - targetRepositoryFullName: request.targetRepositoryFullName ?? "", - targetAgentIdentity: request.targetAgentIdentity ?? "", - activityKind: receipt.activityKind, - status: receipt.status, - actorName: receipt.actorName, - note: receipt.note, - }), +async function recordTransportReceiptOrThrow(request, receipt, options = {}) { + const commandId = commandIdFromOptions(options) || `agent-transport:${request.id}:${receipt.activityKind}`; + const provenance = crossingProvenanceFromOptions({ + ...options, + "cultmesh-command-id": commandId, + "source-kind": request.sourceKind || "bifrost.agent-transport.update-request", + "source-id": request.id, + "request-id": request.id, + "authority-ref": "bifrost_agent_transport", + identity: receipt.actorName, }); - - const text = await response.text(); - if (response.status !== 202) { - throw new Error(`Bifrost transport receipt failed with ${response.status}: ${text}`); - } + const now = new Date().toISOString(); + await writeCrossingReceipt({ + receiptId: `crossing_${slugifyReceiptPart(commandId)}_${slugifyReceiptPart(receipt.activityKind)}`, + commandId, + crossingKind: "agent_transport.request", + status: request.status === "cancelled" ? "cancelled" : "completed", + ok: true, + requestedAt: request.createdAt || now, + startedAt: request.updatedAt || now, + completedAt: now, + actor: { + kind: "agent", + name: receipt.actorName || "system", + bifrostIdentity: receipt.actorName || "system", + }, + source: provenance.source, + authority: provenance.authority, + epiphany: provenance.epiphany, + target: { + surface: "agent-transport", + locator: request.id, + repositoryFullName: request.targetRepositoryFullName || request.targetRepoName, + requestId: request.id, + status: request.status, + }, + externalReceipt: { + id: request.id, + transport: "cultcache", + url: "", + }, + surfaceReceipt: { + activityKind: receipt.activityKind, + note: receipt.note || "", + requestStatus: receipt.status, + }, + }, options); } function filteredRequests(cache, options) { @@ -781,6 +797,15 @@ function equalsIgnoreCase(left, right) { && left.toLowerCase() === right.toLowerCase(); } +function slugifyReceiptPart(value) { + return String(value) + .trim() + .toLowerCase() + .replace(/[^a-z0-9._:-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120) || "receipt"; +} + function resolveOptionPath(path) { return resolve(process.cwd(), path); } @@ -861,8 +886,6 @@ Mirror options for enqueue: --mirror-content-file Read custom mirror text from a file --mirror-dry-run true Exercise mirror plumbing without posting to Discord --allow-unmirrored true Fixture/debug escape hatch; production writes should not use this - --allow-unreceipted-activity true Operator-recovery hatch; lets a mutation proceed without hosted Bifrost receipts - Examples: node tools/agent-transport.mjs enqueue --repo AetheriaLore --agent nibu --title "Wavecrafters" --request-file packet.md --priority 80 node tools/agent-transport.mjs claim --repo AetheriaLore --agent nibu --claimed-by nibu diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 6ac8ebd..0385070 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -4,6 +4,10 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + beginCrossingReceipt, + crossingProvenanceFromOptions, +} from "./bifrost-crossing-documents.mjs"; const PERSONA_WEBHOOK_NAME = "Bifrost Persona Pipe"; const PERSONA_WEBHOOK_CACHE_PATH = resolve(".bifrost/discord-webhook-cache.json"); @@ -190,6 +194,7 @@ async function createGitHubDraftPr(options) { commitSha, changedPaths, prUrl, + crossingReceiptId: bridgeAction?.receiptId ?? "", provenance: buildBridgeProvenance(options), }); } @@ -283,6 +288,7 @@ async function commentGitHubPr(options) { pr, receiptUrl, externalReceiptId, + crossingReceiptId: bridgeAction?.receiptId ?? "", provenance: buildBridgeProvenance(options), }); } catch (error) { @@ -314,10 +320,6 @@ async function postDiscordMessage(options) { return; } - if (!token) { - throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord."); - } - const bridgeAction = await beginBridgeAction(options, { actorKind: personaName ? "Persona" : "Service", actorName: personaName ?? "bifrost", @@ -332,6 +334,9 @@ async function postDiscordMessage(options) { let result; try { await bridgeAction?.start(); + if (!token) { + throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord."); + } result = personaName ? await postDiscordPersonaMessage(token, channelId, content, { personaName, @@ -361,6 +366,7 @@ async function postDiscordMessage(options) { messageId: result.id, transport: result.transport, url: `https://discord.com/channels/${result.guildId ?? "@me"}/${channelId}/${result.id}`, + crossingReceiptId: bridgeAction?.receiptId ?? "", provenance: buildBridgeProvenance(options), }); } @@ -382,10 +388,6 @@ async function sendDiscordDm(options) { return; } - if (!token) { - throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM."); - } - const bridgeAction = await beginBridgeAction(options, { actorKind: "Service", actorName: "bifrost", @@ -401,6 +403,9 @@ async function sendDiscordDm(options) { let result; try { await bridgeAction?.start(); + if (!token) { + throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM."); + } channelId = await openDiscordDmChannel(token, recipientId); result = await postDiscordBotMessage(token, channelId, content, undefined); await bridgeAction?.complete({ @@ -425,6 +430,7 @@ async function sendDiscordDm(options) { messageId: result.id, transport: result.transport, url: `https://discord.com/channels/@me/${channelId}/${result.id}`, + crossingReceiptId: bridgeAction?.receiptId ?? "", provenance: buildBridgeProvenance(options), }); } @@ -502,6 +508,7 @@ async function postRedditThread(options) { personaFlairText, thingId: result.thingId, url: result.url, + crossingReceiptId: bridgeAction?.receiptId ?? "", provenance: buildBridgeProvenance(options), }); } @@ -529,45 +536,10 @@ async function requestOtherBridgeAction(options) { return; } - const bridgeAction = await beginBridgeAction(options, { - actorKind: "Agent", - actorName: identity, - targetSurface: "Other", - actionKind: "Other", - targetSurfaceName: surfaceName, - targetRepositoryFullName: options["target-repository-full-name"] ?? "", - targetLocator, - title, - summary: content, - }); - - try { - await bridgeAction?.start(); - await bridgeAction?.complete({ - receiptUrl: `bifrost://bridge/actions/${bridgeAction.id}`, - externalReceiptId: bridgeAction.id, - receiptPayload: JSON.stringify(buildReceiptPayload(options, { - surfaceName, - targetLocator, - title, - })), - }); - } catch (error) { - await bridgeAction?.fail(error); - throw error; - } - - printJson({ - action: "other-request", - ok: true, - bridgeActionId: bridgeAction.id, - receiptUrl: `bifrost://bridge/actions/${bridgeAction.id}`, - identity, - surfaceName, - targetLocator, - title, - provenance: buildBridgeProvenance(options), - }); + throw new Error( + "other-request was a receipt-only HTTP bridge hatch and has been removed. " + + "Define a typed Bifrost CultMesh command/receipt document for this surface before executing it.", + ); } async function getRedditAccessToken() { @@ -1078,76 +1050,86 @@ function resolveGitCommand() { } async function beginBridgeAction(options, action) { - const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL); - if (!baseUrl) { - return null; - } - - const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN); - if (!token) { - throw new Error("Set BIFROST_BRIDGE_TOKEN or pass --bifrost-token when using the Bifrost bridge action ledger."); - } - - const payload = { - ...action, - sourceKind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "", - sourceId: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? "", - authorityReference: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? "", - bifrostIdentity: optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "", - heimdallCapabilityReference: - optionalString(options["heimdall-capability-ref"]) ?? - optionalString(process.env.HEIMDALL_CAPABILITY_REF) ?? - optionalString(process.env.BIFROST_HEIMDALL_CAPABILITY_REF) ?? - "", - epiphanyRunId: optionalString(options["epiphany-run-id"]) ?? optionalString(process.env.EPIPHANY_RUN_ID) ?? "", - epiphanyLaneId: optionalString(options["epiphany-lane-id"]) ?? optionalString(process.env.EPIPHANY_LANE_ID) ?? "", - epiphanyAgentIdentity: optionalString(options["epiphany-agent-identity"]) ?? optionalString(process.env.EPIPHANY_AGENT_IDENTITY) ?? "", - workItemId: optionalString(options["work-item-id"]) ?? null, - motionId: optionalString(options["motion-id"]) ?? null, - }; - - const requested = await postBridgeJson( - baseUrl, - token, - "/bridge/actions/request", - payload, - new Set([202, 403]), - ); - - if (requested.status === 403) { - throw new Error(`Bifrost denied bridge action: ${requested.body?.policyDecision ?? requested.text}`); - } - - const id = requested.body?.id; - if (!id) { - throw new Error(`Bifrost bridge request returned no action id: ${requested.text}`); - } + rejectRemovedHttpBridgeConfig(options); + const provenance = crossingProvenanceFromOptions(options); + const actorName = action.actorName ?? provenance.actor.name ?? "bifrost"; + const targetSurface = normalizeSurface(action.targetSurface); + const crossingKind = crossingKindForAction(action.actionKind); + const lifecycle = await beginCrossingReceipt(options, { + commandId: provenance.commandId, + crossingKind, + actor: { + kind: action.actorKind ?? provenance.actor.kind ?? "Service", + name: actorName, + bifrostIdentity: provenance.actor.bifrostIdentity || actorName, + }, + source: provenance.source, + authority: provenance.authority, + epiphany: provenance.epiphany, + target: { + surface: targetSurface, + repositoryFullName: action.targetRepositoryFullName ?? "", + locator: action.targetLocator ?? "", + title: action.title ?? "", + summary: action.summary ?? "", + }, + }); return { - id, + receiptId: lifecycle.receiptId, + commandId: lifecycle.commandId, async start() { - await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/start`, undefined, new Set([202])); + return lifecycle.start(); }, - async complete(receipt) { - await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/complete`, receipt, new Set([200])); + async complete(result) { + return lifecycle.complete({ + externalReceipt: { + url: result?.receiptUrl ?? "", + id: result?.externalReceiptId ?? "", + transport: targetSurface.toLowerCase(), + payload: parseOptionalJson(result?.receiptPayload), + }, + }); }, async fail(error) { - const failureReason = error instanceof Error ? error.message : String(error); - try { - await postBridgeJson( - baseUrl, - token, - `/bridge/actions/${id}/fail`, - { failureReason }, - new Set([200, 400]), - ); - } catch { - // Best effort: the underlying command error is still the real failure. - } + return lifecycle.fail(error); }, }; } +function crossingKindForAction(actionKind) { + switch (actionKind) { + case "GitHubDraftPullRequest": + return "github.draft_pr"; + case "GitHubPullRequestComment": + return "github.pr_comment"; + case "DiscordPost": + return "discord.post"; + case "DiscordDirectMessage": + return "discord.dm"; + case "RedditPost": + return "reddit.post"; + default: + return "future_surface.request"; + } +} + +function normalizeSurface(value) { + return optionalString(value) ?? "future-surface"; +} + +function parseOptionalJson(value) { + const text = optionalString(value); + if (!text) { + return {}; + } + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } +} + function parseRequiredJson(text, label) { try { return JSON.parse(text); @@ -1158,61 +1140,69 @@ function parseRequiredJson(text, label) { } function ensureGitHubBridgeGate(options) { - const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL); - const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN); + rejectRemovedHttpBridgeConfig(options); + const cultMeshCommandId = + optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID); const allowUngated = options["allow-ungated-github"] === "true" || process.env.BIFROST_ALLOW_UNGATED_GITHUB === "true"; - if (allowUngated && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") { + if (allowUngated) { throw new Error( - "Dispatched Bifrost work cannot use --allow-ungated-github or BIFROST_ALLOW_UNGATED_GITHUB. " + - "GitHub mutations from a dispatched turn must go through the normal Bifrost gate and receipt path.", + "Bifrost GitHub mutations cannot use --allow-ungated-github or BIFROST_ALLOW_UNGATED_GITHUB. " + + "Use a typed Bifrost CultMesh command request/receipt route instead of mutating off-book.", ); } - if (baseUrl && token) { - return; - } - - if (allowUngated) { + if (cultMeshCommandId) { return; } throw new Error( "GitHub bridge actions require Bifrost authorization and receipt logging. " + - "Set BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN, or use --allow-ungated-github true only for explicit operator recovery.", + "Provide BIFROST_CULTMESH_COMMAND_ID from a typed CultMesh command request; HTTP bridge hatches have been removed.", ); } function ensureBridgeReceiptGate(options) { - const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL); - const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN); + rejectRemovedHttpBridgeConfig(options); + const cultMeshCommandId = + optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID); const allowUnreceipted = options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true"; - if (allowUnreceipted && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") { + if (allowUnreceipted) { throw new Error( - "Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " + - "External bridge activity from a dispatched turn must go through the normal Bifrost receipt path.", + "Bifrost bridge activity cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " + + "Use a typed Bifrost CultMesh command request/receipt route instead of mutating off-book.", ); } - if (baseUrl && token) { - return; - } - - if (allowUnreceipted) { + if (cultMeshCommandId) { return; } throw new Error( - "Bridge activity requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can receipt the external crossing. " + - "Set BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN, or use --allow-unreceipted-activity true only for explicit operator recovery.", + "Bridge activity requires a Bifrost CultMesh command id so Bifrost can receipt the external crossing. " + + "Use a typed CultMesh command request/receipt route; HTTP and unreceipted bridge hatches have been removed.", ); } +function rejectRemovedHttpBridgeConfig(options) { + if ( + optionalString(options["bifrost-base-url"]) || + optionalString(options["bifrost-token"]) || + optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || + optionalString(process.env.BIFROST_BRIDGE_TOKEN) + ) { + throw new Error( + "BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN and --bifrost-base-url / --bifrost-token were removed. " + + "Use typed Bifrost CultMesh command request/receipt documents discovered through Odin.", + ); + } +} + async function postBridgeJson(baseUrl, token, path, payload, allowedStatuses) { const url = new URL(path, ensureTrailingSlash(baseUrl)); const response = await fetch(url, { @@ -1380,8 +1370,7 @@ Examples: node tools/bifrost-bridge.mjs other-request --identity epiphany.Persona --surface-name bluesky --target-locator at://did:example/app.bsky.feed.post/123 --heimdall-capability-ref heimdall:bluesky:capability:epiphany-persona --content "Persona asks to speak." GitHub note: - GitHub actions require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can gate and receipt the crossing. - Use --allow-ungated-github true only for explicit operator recovery. + GitHub actions require typed Bifrost CultMesh command provenance such as BIFROST_CULTMESH_COMMAND_ID so Bifrost can gate and receipt the crossing. Provenance note: --identity names the Bifrost identity acting through the bridge. diff --git a/tools/bifrost-crossing-documents.mjs b/tools/bifrost-crossing-documents.mjs new file mode 100644 index 0000000..ac363f9 --- /dev/null +++ b/tools/bifrost-crossing-documents.mjs @@ -0,0 +1,336 @@ +import { createRequire } from "node:module"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const projectsRoot = resolve(repoRoot, ".."); + +export const crossingReceiptDocumentType = "bifrost.crossing_receipt"; +export const crossingReceiptSchemaId = "bifrost.crossing_receipt.v1"; +export const defaultCrossingReceiptStorePath = resolve(repoRoot, ".bifrost", "bridge-receipts.cc"); +export const crossingReceiptStatuses = Object.freeze(["requested", "running", "completed", "failed", "cancelled"]); + +const validStatuses = new Set(crossingReceiptStatuses); +let cachedRuntime; +let cachedDefinition; + +export function crossingReceiptDefinition(defineDocumentTypeInput) { + const defineDocumentType = defineDocumentTypeInput ?? loadCultCacheRuntime().defineDocumentType; + cachedDefinition ??= defineDocumentType({ + type: crossingReceiptDocumentType, + schemaId: crossingReceiptSchemaId, + schemaName: crossingReceiptDocumentType, + schemaVersion: crossingReceiptSchemaId, + contentHash: crossingReceiptSchemaId, + global: false, + name: "receiptId", + indexes: { + commandId: "commandId", + crossingKind: "crossingKind", + status: "status", + }, + schema: { parse: parseCrossingReceipt }, + members: [ + { slot: 1, memberName: "receiptId", typeName: "string", isName: true }, + { slot: 2, memberName: "commandId", typeName: "string", indexAlias: "commandId" }, + { slot: 3, memberName: "crossingKind", typeName: "string", indexAlias: "crossingKind" }, + { slot: 4, memberName: "status", typeName: "string", indexAlias: "status" }, + { slot: 5, memberName: "ok", typeName: "boolean" }, + { slot: 6, memberName: "requestedAt", typeName: "string" }, + { slot: 7, memberName: "startedAt", typeName: "string" }, + { slot: 8, memberName: "completedAt", typeName: "string" }, + { slot: 9, memberName: "actor", typeName: "object" }, + { slot: 10, memberName: "source", typeName: "object" }, + { slot: 11, memberName: "authority", typeName: "object" }, + { slot: 12, memberName: "epiphany", typeName: "object" }, + { slot: 13, memberName: "target", typeName: "object" }, + { slot: 14, memberName: "externalReceipt", typeName: "object" }, + { slot: 15, memberName: "error", typeName: "object" }, + { slot: 16, memberName: "supersedes", typeName: "string" }, + { slot: 17, memberName: "relatedReceiptIds", typeName: "string", isMany: true }, + { slot: 18, memberName: "surfaceReceipt", typeName: "object" }, + ], + }); + return cachedDefinition; +} + +export async function openCrossingReceiptStore(storePath = defaultCrossingReceiptStorePath) { + const { CultCache, SingleFileMessagePackBackingStore } = loadCultCacheRuntime(); + await mkdir(dirname(storePath), { recursive: true }); + const cache = CultCache.builder() + .withDocumentType(crossingReceiptDefinition()) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + await cache.pullAllBackingStores(); + return { + storePath, + definition: crossingReceiptDefinition(), + cache, + async put(receipt) { + const parsed = buildCrossingReceipt(receipt); + await cache.put(crossingReceiptDefinition(), parsed.receiptId, parsed); + return parsed; + }, + get(receiptId) { + return cache.get(crossingReceiptDefinition(), receiptId); + }, + getAll() { + return cache.getAll(crossingReceiptDefinition()); + }, + }; +} + +export function resolveCrossingReceiptStorePath(options = {}) { + return resolveOptionPath( + options["receipt-store"] ?? + process.env.BIFROST_CROSSING_RECEIPT_STORE ?? + process.env.BIFROST_BRIDGE_RECEIPT_STORE ?? + defaultCrossingReceiptStorePath, + ); +} + +export async function writeCrossingReceipt(receipt, options = {}) { + const store = await openCrossingReceiptStore(resolveCrossingReceiptStorePath(options)); + return store.put(receipt); +} + +export async function beginCrossingReceipt(options, input) { + const requestedAt = input.requestedAt ?? new Date().toISOString(); + const commandId = requiredString(input.commandId ?? commandIdFromOptions(options), "commandId"); + const receiptId = input.receiptId ?? `crossing_${commandId}`; + const base = buildCrossingReceipt({ + schemaName: crossingReceiptDocumentType, + schemaVersion: crossingReceiptSchemaId, + receiptId, + commandId, + crossingKind: requiredString(input.crossingKind, "crossingKind"), + status: "requested", + ok: false, + requestedAt, + startedAt: "", + completedAt: "", + actor: normalizeObject(input.actor), + source: normalizeObject(input.source), + authority: normalizeObject(input.authority), + epiphany: normalizeObject(input.epiphany), + target: normalizeObject(input.target), + externalReceipt: {}, + error: {}, + supersedes: optionalString(input.supersedes) ?? "", + relatedReceiptIds: normalizeStringArray(input.relatedReceiptIds), + surfaceReceipt: normalizeObject(input.surfaceReceipt), + }); + await writeCrossingReceipt(base, options); + let current = base; + + return { + receiptId, + commandId, + requested: base, + async start(extra = {}) { + current = await writeCrossingReceipt({ + ...current, + ...extra, + status: "running", + ok: false, + startedAt: extra.startedAt ?? new Date().toISOString(), + }, options); + return current; + }, + async complete(extra = {}) { + current = await writeCrossingReceipt({ + ...current, + ...extra, + status: "completed", + ok: true, + startedAt: extra.startedAt ?? current.startedAt, + completedAt: extra.completedAt ?? new Date().toISOString(), + externalReceipt: normalizeObject(extra.externalReceipt ?? current.externalReceipt), + surfaceReceipt: normalizeObject(extra.surfaceReceipt ?? current.surfaceReceipt), + }, options); + return current; + }, + async fail(error, extra = {}) { + current = await writeCrossingReceipt({ + ...current, + ...extra, + status: "failed", + ok: false, + startedAt: extra.startedAt ?? current.startedAt, + completedAt: extra.completedAt ?? new Date().toISOString(), + error: normalizeError(error), + externalReceipt: normalizeObject(extra.externalReceipt ?? current.externalReceipt), + surfaceReceipt: normalizeObject(extra.surfaceReceipt ?? current.surfaceReceipt), + }, options); + return current; + }, + }; +} + +export function buildCrossingReceipt(input) { + return parseCrossingReceipt({ + schemaName: crossingReceiptDocumentType, + schemaVersion: crossingReceiptSchemaId, + ...input, + }); +} + +export function crossingProvenanceFromOptions(options = {}) { + const commandId = commandIdFromOptions(options); + return { + commandId, + actor: { + bifrostIdentity: optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "", + kind: optionalString(options["actor-kind"]) ?? "", + name: optionalString(options["actor-name"]) ?? optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "", + }, + source: { + kind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "cultmesh-command", + id: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? commandId, + topicId: optionalString(options["topic-id"]) ?? "", + requestId: optionalString(options["request-id"]) ?? "", + workItemId: optionalString(options["work-item-id"]) ?? "", + motionId: optionalString(options["motion-id"]) ?? "", + channelId: optionalString(options["source-channel-id"]) ?? "", + messageId: optionalString(options["source-message-id"]) ?? "", + }, + authority: { + authorityRef: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? `cultmesh-command:${commandId}`, + heimdallCapabilityRef: + optionalString(options["heimdall-capability-ref"]) ?? + optionalString(process.env.HEIMDALL_CAPABILITY_REF) ?? + optionalString(process.env.BIFROST_HEIMDALL_CAPABILITY_REF) ?? + "", + heimdallClaimJti: optionalString(options["heimdall-claim-jti"]) ?? optionalString(process.env.HEIMDALL_CLAIM_JTI) ?? "", + heimdallAccountId: optionalString(options["heimdall-account-id"]) ?? optionalString(process.env.HEIMDALL_ACCOUNT_ID) ?? "", + heimdallAccessRevision: optionalString(options["heimdall-access-revision"]) ?? optionalString(process.env.HEIMDALL_ACCESS_REVISION) ?? "", + heimdallGrantRef: optionalString(options["heimdall-grant-ref"]) ?? optionalString(process.env.HEIMDALL_GRANT_REF) ?? "", + heimdallClaimExpiresAt: optionalString(options["heimdall-claim-exp"]) ?? optionalString(process.env.HEIMDALL_CLAIM_EXP) ?? "", + policyDecisionId: optionalString(options["policy-decision-id"]) ?? optionalString(process.env.BIFROST_POLICY_DECISION_ID) ?? "", + }, + epiphany: { + runId: optionalString(options["epiphany-run-id"]) ?? optionalString(process.env.EPIPHANY_RUN_ID) ?? "", + laneId: optionalString(options["epiphany-lane-id"]) ?? optionalString(process.env.EPIPHANY_LANE_ID) ?? "", + agentIdentity: optionalString(options["epiphany-agent-identity"]) ?? optionalString(process.env.EPIPHANY_AGENT_IDENTITY) ?? "", + }, + }; +} + +export function commandIdFromOptions(options = {}) { + return optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID) ?? ""; +} + +function parseCrossingReceipt(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("Bifrost crossing receipt must be an object."); + } + + const receipt = { + schemaName: optionalString(input.schemaName) ?? crossingReceiptDocumentType, + schemaVersion: optionalString(input.schemaVersion) ?? crossingReceiptSchemaId, + receiptId: requiredString(input.receiptId, "receiptId"), + commandId: requiredString(input.commandId, "commandId"), + crossingKind: requiredString(input.crossingKind, "crossingKind"), + status: requiredString(input.status, "status"), + ok: Boolean(input.ok), + requestedAt: requiredString(input.requestedAt, "requestedAt"), + startedAt: optionalString(input.startedAt) ?? "", + completedAt: optionalString(input.completedAt) ?? "", + actor: normalizeObject(input.actor), + source: normalizeObject(input.source), + authority: normalizeObject(input.authority), + epiphany: normalizeObject(input.epiphany), + target: normalizeObject(input.target), + externalReceipt: normalizeObject(input.externalReceipt), + error: normalizeObject(input.error), + supersedes: optionalString(input.supersedes) ?? "", + relatedReceiptIds: normalizeStringArray(input.relatedReceiptIds), + surfaceReceipt: normalizeObject(input.surfaceReceipt), + }; + + if (!validStatuses.has(receipt.status)) { + throw new Error(`Bifrost crossing receipt status "${receipt.status}" is not valid.`); + } + if (!receipt.source.kind && !receipt.source.id) { + throw new Error("Bifrost crossing receipt requires source provenance."); + } + if (!receipt.authority.authorityRef && !receipt.authority.heimdallCapabilityRef && !receipt.authority.heimdallClaimJti) { + throw new Error("Bifrost crossing receipt requires authority provenance."); + } + return receipt; +} + +function loadCultCacheRuntime() { + if (cachedRuntime) { + return cachedRuntime; + } + const packagePath = resolveFirstExisting("CultCache TypeScript runtime", [ + resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"), + resolve(projectsRoot, "CultCacheTS", "package.json"), + resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"), + ]); + const requireCultCache = createRequire(packagePath); + cachedRuntime = requireCultCache("cultcache-ts"); + return cachedRuntime; +} + +function resolveFirstExisting(label, candidates) { + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + throw new Error(`${label} is unavailable. Tried: ${candidates.join(", ")}`); +} + +function normalizeError(error) { + if (!error) { + return {}; + } + if (error instanceof Error) { + return { + code: error.name || "Error", + message: error.message, + }; + } + return { + code: "Error", + message: String(error), + }; +} + +function normalizeObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function normalizeStringArray(value) { + if (value === undefined || value === null) { + return []; + } + if (!Array.isArray(value)) { + return []; + } + return value.map((item) => optionalString(item)).filter(Boolean); +} + +function requiredString(value, field) { + const normalized = optionalString(value); + if (!normalized) { + throw new Error(`Bifrost crossing receipt field "${field}" must be a non-empty string.`); + } + return normalized; +} + +function optionalString(value) { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveOptionPath(path) { + return resolve(process.cwd(), path); +} diff --git a/tools/bifrost-discord-command-documents.mjs b/tools/bifrost-discord-command-documents.mjs new file mode 100644 index 0000000..83b2cb6 --- /dev/null +++ b/tools/bifrost-discord-command-documents.mjs @@ -0,0 +1,47 @@ +export const discordPostCommandDocumentType = "bifrost.bridge.discord_post_command"; +export const discordPostCommandSchemaId = "bifrost.bridge.discord_post_command.v1"; +export const discordPostReceiptDocumentType = "bifrost.bridge.discord_post_receipt"; +export const discordPostReceiptSchemaId = "bifrost.bridge.discord_post_receipt.v1"; + +let cachedDefinitions; + +export function discordPostCommandDefinition(defineDocumentType) { + cachedDefinitions ??= {}; + cachedDefinitions.command ??= defineDocumentType({ + type: discordPostCommandDocumentType, + schemaName: discordPostCommandDocumentType, + schemaId: discordPostCommandSchemaId, + schemaVersion: discordPostCommandSchemaId, + contentHash: discordPostCommandSchemaId, + global: false, + name: "commandId", + schema: objectSchema("Bifrost Discord post command"), + }); + return cachedDefinitions.command; +} + +export function discordPostReceiptDefinition(defineDocumentType) { + cachedDefinitions ??= {}; + cachedDefinitions.receipt ??= defineDocumentType({ + type: discordPostReceiptDocumentType, + schemaName: discordPostReceiptDocumentType, + schemaId: discordPostReceiptSchemaId, + schemaVersion: discordPostReceiptSchemaId, + contentHash: discordPostReceiptSchemaId, + global: false, + name: "commandId", + schema: objectSchema("Bifrost Discord post receipt"), + }); + return cachedDefinitions.receipt; +} + +function objectSchema(label) { + return { + parse(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error(`${label} must be an object.`); + } + return input; + }, + }; +} diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs new file mode 100644 index 0000000..007c3de --- /dev/null +++ b/tools/cultmesh-bridge-commands.mjs @@ -0,0 +1,450 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { crossingReceiptDefinition } from "./bifrost-crossing-documents.mjs"; +import { + discordPostCommandDefinition, + discordPostCommandDocumentType as commandDocumentType, + discordPostCommandSchemaId as commandSchemaId, + discordPostReceiptDefinition, + discordPostReceiptDocumentType as receiptDocumentType, + discordPostReceiptSchemaId as receiptSchemaId, +} from "./bifrost-discord-command-documents.mjs"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const projectsRoot = resolve(repoRoot, ".."); +const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); + +async function main() { + loadBifrostLocalEnv(); + const [command, ...rawArgs] = process.argv.slice(2); + const options = parseArgs(rawArgs); + + if (!command || command === "help" || command === "--help" || command === "-h") { + printHelp(); + return; + } + + switch (command) { + case "process": + await processCommands(options); + return; + case "write-smoke": + await writeSmokeCommand(options); + return; + case "receipt": + await printReceipt(options); + return; + case "delete": + await deleteCommand(options); + return; + default: + throw new Error(`Unknown command "${command}". Run "node tools/cultmesh-bridge-commands.mjs help".`); + } +} + +async function processCommands(options) { + const storePath = resolve(options.store ?? defaultStorePath); + const commandIdFilter = optionalString(options["command-id"]); + const node = await openCommandNode(storePath); + await node.cache?.pullAllBackingStores?.(); + const commands = listCommandRecords(node) + .filter((command) => command.status === "pending" || command.status === "running") + .filter((command) => !commandIdFilter || command.commandId === commandIdFilter) + .sort((left, right) => String(left.createdAt).localeCompare(String(right.createdAt))); + + const processed = []; + for (const command of commands) { + processed.push(await processOneCommand(node, command, options)); + } + await node.flush?.(); + + printJson({ + ok: processed.every((receipt) => receipt.status === "completed"), + action: "process", + storePath, + commandCount: commands.length, + processed, + }); +} + +async function writeSmokeCommand(options) { + const storePath = resolve(options.store ?? defaultStorePath); + const commandId = optionalString(options["command-id"]) ?? `bifrost-smoke-${Date.now()}`; + const command = { + schemaName: commandDocumentType, + schemaVersion: commandSchemaId, + commandId, + command: "discord-post", + status: "pending", + requestedBy: "bifrost-smoke", + requestedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + source: { + kind: "smoke", + id: commandId, + }, + payload: { + channelId: requireOption(options, "channel-id"), + content: optionalString(options.content) ?? "Bifrost CultMesh Discord command smoke.", + personaName: optionalString(options["persona-name"]), + personaAvatarUrl: optionalString(options["persona-avatar-url"]), + replyToMessageId: optionalString(options["reply-to-message-id"]), + }, + }; + const node = await openCommandNode(storePath); + await node.put(commandDefinition(), command.commandId, command); + await node.flush?.(); + printJson({ ok: true, action: "write-smoke", storePath, commandId }); +} + +async function printReceipt(options) { + const commandId = requireOption(options, "command-id"); + const storePath = resolve(options.store ?? defaultStorePath); + const node = await openCommandNode(storePath); + await node.cache?.pullAllBackingStores?.(); + const receipt = unwrapRecord(await node.get(receiptDefinition(), commandId)); + if (!receipt) { + printJson({ ok: false, commandId, receipt: null }); + return; + } + printJson({ ok: receipt.status === "completed", commandId, receipt }); +} + +async function deleteCommand(options) { + const commandId = requireOption(options, "command-id"); + const storePath = resolve(options.store ?? defaultStorePath); + const node = await openCommandNode(storePath); + await node.cache?.pullAllBackingStores?.(); + const deletedCommand = typeof node.cache?.delete === "function" + ? await node.cache.delete(commandDefinition(), commandId) + : false; + const deletedReceipt = typeof node.cache?.delete === "function" + ? await node.cache.delete(receiptDefinition(), commandId) + : false; + await node.flush?.(); + printJson({ ok: Boolean(deletedCommand || deletedReceipt), action: "delete", storePath, commandId, deletedCommand, deletedReceipt }); +} + +async function processOneCommand(node, command, options) { + const running = { + ...command, + status: "running", + updatedAt: new Date().toISOString(), + }; + await node.put(commandDefinition(), command.commandId, running); + + const payload = command.payload && typeof command.payload === "object" ? command.payload : {}; + const tempDir = resolve(repoRoot, ".bifrost", "cultmesh-command-payloads", command.commandId); + const contentPath = resolve(tempDir, "content.md"); + await mkdir(tempDir, { recursive: true }); + await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8"); + + try { + const args = [ + "tools/bifrost-bridge.mjs", + "discord-post", + "--channel-id", + requireString(payload.channelId, "payload.channelId"), + "--content-file", + contentPath, + "--cultmesh-command-id", + command.commandId, + "--source-kind", + optionalString(command.source?.kind) ?? "cultmesh-command", + "--source-id", + optionalString(command.source?.id) ?? command.commandId, + "--identity", + optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost", + ]; + pushOption(args, "--persona-name", payload.personaName); + pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); + pushOption(args, "--reply-to-message-id", payload.replyToMessageId); + pushOption(args, "--receipt-store", options["receipt-store"]); + + const result = spawnSync(process.execPath, args, { + cwd: repoRoot, + encoding: "utf8", + windowsHide: true, + env: { + ...process.env, + BIFROST_CULTMESH_COMMAND_ID: command.commandId, + }, + }); + if (result.status !== 0 || result.error) { + throw new Error(renderSpawnFailure(result)); + } + const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt"); + const receipt = buildReceipt(command, { + status: "completed", + ok: true, + action: "discord-post", + channelId: posted.channelId, + messageId: posted.messageId, + transport: posted.transport, + url: posted.url, + canonicalReceiptId: posted.crossingReceiptId, + payload: posted, + }); + await node.put(receiptDefinition(), command.commandId, receipt); + await node.put(commandDefinition(), command.commandId, { + ...running, + status: "completed", + updatedAt: receipt.completedAt, + receiptId: receipt.receiptId, + }); + return receipt; + } catch (error) { + const receipt = buildReceipt(command, { + status: "failed", + ok: false, + action: "discord-post", + canonicalReceiptId: `crossing_${command.commandId}`, + error: error instanceof Error ? error.message : String(error), + }); + await node.put(receiptDefinition(), command.commandId, receipt); + await node.put(commandDefinition(), command.commandId, { + ...running, + status: "failed", + updatedAt: receipt.completedAt, + receiptId: receipt.receiptId, + error: receipt.error, + }); + return receipt; + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +} + +function buildReceipt(command, result) { + const completedAt = new Date().toISOString(); + return { + schemaName: receiptDocumentType, + schemaVersion: receiptSchemaId, + receiptId: command.commandId, + commandId: command.commandId, + command: command.command, + status: result.status, + ok: result.ok, + action: result.action, + channelId: result.channelId ?? command.payload?.channelId ?? "", + messageId: result.messageId ?? "", + transport: result.transport ?? "", + url: result.url ?? "", + canonicalReceiptId: result.canonicalReceiptId ?? "", + error: result.error ?? "", + requestedAt: command.requestedAt ?? command.createdAt ?? "", + completedAt, + source: command.source ?? {}, + actor: command.actor ?? {}, + payload: result.payload ?? {}, + }; +} + +function listCommandRecords(node) { + if (typeof node.cache?.getAll !== "function") { + return []; + } + return node.cache.getAll(commandDefinition()) + .map(unwrapRecord) + .filter((entry) => entry && entry.command === "discord-post" && entry.commandId); +} + +async function openCommandNode(storePath) { + const { CultMesh, defineDocumentType } = loadCultMeshRuntime(); + return CultMesh.createNode(storePath, { + documents: [ + commandDefinition(defineDocumentType), + receiptDefinition(defineDocumentType), + crossingReceiptDefinition(defineDocumentType), + genericDocument(defineDocumentType, "gamecult.eve.provider_advertisement", "gamecult.eve.provider_advertisement.v1", "providerId"), + genericDocument(defineDocumentType, "gamecult.eve.surface_state", "gamecult.eve.surface_state.v1", "providerId"), + genericDocument(defineDocumentType, "gamecult.eve.interface_binding", "gamecult.eve.interface_binding.v1", "bindingId"), + ], + }); +} + +function commandDefinition(defineDocumentTypeInput) { + const defineDocumentType = defineDocumentTypeInput ?? loadCultMeshRuntime().defineDocumentType; + return discordPostCommandDefinition(defineDocumentType); +} + +function receiptDefinition(defineDocumentTypeInput) { + const defineDocumentType = defineDocumentTypeInput ?? loadCultMeshRuntime().defineDocumentType; + return discordPostReceiptDefinition(defineDocumentType); +} + +function genericDocument(defineDocumentType, type, schemaId, name) { + return defineDocumentType({ + type, + schemaName: type, + schemaId, + schemaVersion: schemaId, + contentHash: schemaId, + global: false, + name, + schema: parseObjectDocument(type), + }); +} + +function loadCultMeshRuntime() { + const candidates = [ + resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"), + resolve(projectsRoot, "CultMeshTS", "package.json"), + ]; + + for (const packageJson of candidates) { + if (!existsSync(packageJson)) { + continue; + } + try { + const requireCult = createRequire(packageJson); + const { CultMesh } = requireCult("cultmesh-ts"); + const { defineDocumentType } = requireCult("cultcache-ts"); + if (CultMesh && defineDocumentType) { + return { CultMesh, defineDocumentType }; + } + } catch { + } + } + + throw new Error("CultMesh/CultCache TypeScript runtime is unavailable."); +} + +function parseObjectDocument(label) { + return { + parse(input) { + if (!input || typeof input !== "object") { + throw new Error(`${label} must be an object.`); + } + return input; + }, + }; +} + +function loadBifrostLocalEnv() { + if (process.env.BIFROST_SKIP_LOCAL_ENV === "true") { + return; + } + loadLocalEnv(resolve(repoRoot, ".env")); + loadLocalEnv(resolve(projectsRoot, "VoidBot", ".env")); +} + +function loadLocalEnv(path) { + if (!existsSync(path)) { + return; + } + const content = readFileSync(path, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match || process.env[match[1]] !== undefined) { + continue; + } + process.env[match[1]] = match[2].replace(/^"(.*)"$/, "$1"); + } +} + +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 requireOption(options, name) { + const value = optionalString(options[name]); + if (!value) { + throw new Error(`Missing required option --${name}.`); + } + return value; +} + +function requireString(value, name) { + const text = optionalString(value); + if (!text) { + throw new Error(`Missing required ${name}.`); + } + return text; +} + +function optionalString(value) { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function pushOption(args, name, value) { + const text = optionalString(value); + if (text) { + args.push(name, text); + } +} + +function unwrapRecord(record) { + const candidate = Array.isArray(record) && record.length === 1 ? record[0] : record?.value ?? record; + return candidate && typeof candidate === "object" ? candidate : null; +} + +function parseJson(value, label) { + try { + return JSON.parse(value); + } catch { + throw new Error(`${label} returned non-JSON output: ${value}`); + } +} + +function renderSpawnFailure(result) { + const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; + const stdout = typeof result.stdout === "string" ? result.stdout.trim() : ""; + const error = result.error instanceof Error ? result.error.message : ""; + return [ + `Bifrost Discord actuator failed with status ${result.status ?? "unknown"}.`, + error, + stderr, + stdout, + ].filter(Boolean).join(" "); +} + +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function printHelp() { + process.stdout.write(`Bifrost CultMesh bridge commands + +Usage: + node tools/cultmesh-bridge-commands.mjs process [--store ] [--command-id ] + node tools/cultmesh-bridge-commands.mjs receipt --command-id [--store ] + node tools/cultmesh-bridge-commands.mjs delete --command-id [--store ] + node tools/cultmesh-bridge-commands.mjs write-smoke --channel-id [--store ] + +The command request document is ${commandDocumentType} / ${commandSchemaId}. +The receipt document is ${receiptDocumentType} / ${receiptSchemaId}. +`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/tools/dispatch-agent-requests.mjs b/tools/dispatch-agent-requests.mjs index 4fcddfb..2376641 100644 --- a/tools/dispatch-agent-requests.mjs +++ b/tools/dispatch-agent-requests.mjs @@ -90,7 +90,6 @@ async function dispatchQueuedRequests(options) { ...optionalArg("--channel-id", options["channel-id"]), ...optionalArg("--persona-name", options["persona-name"]), ...optionalArg("--persona-avatar-url", options["persona-avatar-url"]), - ...(options["allow-unreceipted-activity"] === "true" ? ["--allow-unreceipted-activity", "true"] : []), ...(options["no-discord"] === "true" ? ["--no-discord", "true"] : []), ], { @@ -124,21 +123,26 @@ async function dispatchQueuedRequests(options) { } function ensureDispatchReceiptGate(options) { - if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) { - return; + if (wantsUnreceiptedActivity(options)) { + throw new Error( + "Bifrost dispatch cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " + + "Use the typed Bifrost CultCache/CultMesh dispatch store instead of launching off-book work.", + ); } - throw new Error( - "Bifrost agent dispatch requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep request, run, and bridge receipts aligned. " + - "Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.", - ); + if (hasRemovedHttpBridgeConfig()) { + throw new Error( + "BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " + + "Bifrost dispatch receipts are written to the typed CultCache/CultMesh request/run artifacts.", + ); + } } -function hasBifrostBridgeConfig() { - return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN)); +function hasRemovedHttpBridgeConfig() { + return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN)); } -function allowsUnreceiptedActivity(options) { +function wantsUnreceiptedActivity(options) { return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true"; } @@ -550,7 +554,7 @@ function resolveDispatchReceiptChannelId(options) { } function bridgeRecoveryArgs(options) { - return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : []; + return []; } function renderDispatchReceiptContent(request) { @@ -840,61 +844,9 @@ async function writeDispatchResult(logPath, result) { } async function beginDispatchRun(request, input) { - const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL); - const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN); - if (!baseUrl || !token) { - return null; - } - - const started = await postDispatchRunJson( - baseUrl, - token, - "/dispatch/runs/start", - { - requestId: request.id, - targetRepoName: request.targetRepoName, - targetRepositoryFullName: request.targetRepositoryFullName ?? "", - targetAgentIdentity: request.targetAgentIdentity ?? "", - launchMode: input.launchMode, - workerProcessId: input.workerProcessId, - threadId: input.threadId, - turnId: input.turnId, - logPath: input.logPath, - resultPath: input.resultPath, - note: input.note, - }, - new Set([202]), - ); - - const id = started.body?.id; - if (!id) { - throw new Error(`Bifrost dispatch run start returned no run id: ${started.text}`); - } - - return { - async complete(payload) { - await postDispatchRunJson( - baseUrl, - token, - `/dispatch/runs/${id}/complete`, - payload, - new Set([200]), - ); - }, - async fail(payload) { - try { - await postDispatchRunJson( - baseUrl, - token, - `/dispatch/runs/${id}/fail`, - payload, - new Set([200]), - ); - } catch { - // Best effort: the run failure still lands in the local result/log files. - } - }, - }; + void request; + void input; + return null; } async function postDispatchRunJson(baseUrl, token, path, payload, allowedStatuses) { @@ -1098,10 +1050,6 @@ Commands: Example: node tools/dispatch-agent-requests.mjs dispatch --repo AquaSynth --agent aqua --max 1 - node tools/dispatch-agent-requests.mjs dispatch --repo AquaSynth --agent aqua --allow-unreceipted-activity true - -Recovery hatch: - BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true or --allow-unreceipted-activity true `); } diff --git a/tools/governance-threads.mjs b/tools/governance-threads.mjs index de1f272..f2c34d5 100644 --- a/tools/governance-threads.mjs +++ b/tools/governance-threads.mjs @@ -6,6 +6,11 @@ import { existsSync, readFileSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + commandIdFromOptions, + crossingProvenanceFromOptions, + writeCrossingReceipt, +} from "./bifrost-crossing-documents.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); @@ -209,7 +214,7 @@ async function openTopic(cache, options) { actorKind: "agent", actorName: topic.createdByActor ?? "system", note: "Governance topic opened.", - }); + }, options); } catch (error) { await cache.delete(topicDefinition, topic.id); throw error; @@ -249,7 +254,7 @@ async function addComment(cache, options) { actorKind: comment.authorKind, actorName: comment.authorId, note: `Governance topic ${comment.stance} comment recorded.`, - }); + }, options); } catch (error) { await cache.delete(commentDefinition, comment.id); throw error; @@ -304,7 +309,7 @@ async function approveTopic(cache, options) { actorKind: "face", actorName: approvedBy, note: "Governance topic approved.", - }); + }, options); } catch (error) { await cache.put(topicDefinition, topic.id, topic); if (comment) { @@ -389,7 +394,7 @@ async function promoteTopic(cache, options) { actorKind: "system", actorName: topic.approvedByAgent ?? topic.createdByActor ?? "bifrost", note: `Governance topic promoted to update request ${request.id}.`, - }); + }, options); } catch (error) { await cache.put(topicDefinition, topic.id, topic); await cache.delete(commentDefinition, receipt.id); @@ -470,7 +475,7 @@ function allowsUnmirrored(options) { } function bridgeRecoveryArgs(options) { - return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : []; + return []; } function renderGovernanceMirrorContent(topic, eventLabel, content) { @@ -523,37 +528,55 @@ function truncateMarkdown(value, maxLength) { return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; } -async function recordGovernanceReceiptOrThrow(topic, receipt) { - const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL); - const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN); - if (!baseUrl || !token) { - return; - } - - const response = await fetch(new URL("/governance/receipts", ensureTrailingSlash(baseUrl)), { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Bifrost-Bridge-Token": token, - }, - body: JSON.stringify({ - topicId: topic.id, - commentId: receipt.commentId, - dispatchRequestId: receipt.dispatchRequestId, - title: topic.title, - jurisdictionRepoName: topic.jurisdictionRepoName, - jurisdictionAgentIdentity: topic.jurisdictionAgentIdentity ?? "", - activityKind: receipt.activityKind, - actorKind: receipt.actorKind, - actorName: receipt.actorName, - note: receipt.note, - }), +async function recordGovernanceReceiptOrThrow(topic, receipt, options = {}) { + const commandId = commandIdFromOptions(options) || `governance:${topic.id}:${receipt.activityKind}`; + const provenance = crossingProvenanceFromOptions({ + ...options, + "cultmesh-command-id": commandId, + "source-kind": "bifrost.governance.topic", + "source-id": topic.id, + "topic-id": topic.id, + "authority-ref": `bifrost_governance_${receipt.activityKind}`, + identity: receipt.actorName, }); - - const text = await response.text(); - if (response.status !== 202) { - throw new Error(`Bifrost governance receipt failed with ${response.status}: ${text}`); - } + const now = new Date().toISOString(); + await writeCrossingReceipt({ + receiptId: `crossing_${slugifyReceiptPart(commandId)}_${slugifyReceiptPart(receipt.activityKind)}`, + commandId, + crossingKind: "governance.topic", + status: topic.status === "cancelled" ? "cancelled" : "completed", + ok: true, + requestedAt: topic.createdAt || now, + startedAt: topic.updatedAt || now, + completedAt: now, + actor: { + kind: receipt.actorKind || "system", + name: receipt.actorName || "bifrost", + bifrostIdentity: receipt.actorName || "bifrost", + }, + source: provenance.source, + authority: provenance.authority, + epiphany: provenance.epiphany, + target: { + surface: "governance", + locator: topic.id, + repositoryFullName: topic.jurisdictionRepoName, + topicId: topic.id, + dispatchRequestId: receipt.dispatchRequestId || "", + status: topic.status, + }, + externalReceipt: { + id: receipt.commentId || receipt.dispatchRequestId || topic.id, + transport: "cultcache", + url: "", + }, + surfaceReceipt: { + activityKind: receipt.activityKind, + note: receipt.note || "", + commentId: receipt.commentId || "", + dispatchRequestId: receipt.dispatchRequestId || "", + }, + }, options); } function isMutatingCommand(command) { @@ -561,29 +584,23 @@ function isMutatingCommand(command) { } function ensureGovernanceReceiptGate(options) { - if (wantsUnreceiptedActivity(options) && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") { + if (wantsUnreceiptedActivity(options)) { throw new Error( - "Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " + - "Governance mutations from a dispatched turn must keep Bifrost receipts.", + "Bifrost governance mutations cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " + + "Use the typed Bifrost CultCache/CultMesh governance store instead of mutating off-book.", ); } - if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) { - return; + if (hasRemovedHttpBridgeConfig()) { + throw new Error( + "BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " + + "Bifrost governance receipts are written to the typed CultCache/CultMesh store.", + ); } - - throw new Error( - "Bifrost governance mutations require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep governance receipts. " + - "Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.", - ); } -function hasBifrostBridgeConfig() { - return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN)); -} - -function allowsUnreceiptedActivity(options) { - return wantsUnreceiptedActivity(options); +function hasRemovedHttpBridgeConfig() { + return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN)); } function wantsUnreceiptedActivity(options) { @@ -843,6 +860,15 @@ function equalsIgnoreCase(left, right) { && left.toLowerCase() === right.toLowerCase(); } +function slugifyReceiptPart(value) { + return String(value) + .trim() + .toLowerCase() + .replace(/[^a-z0-9._:-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120) || "receipt"; +} + function runNodeJson(args, cwd) { const result = spawnSync(process.execPath, args, { cwd, @@ -925,7 +951,6 @@ Mirror options: --mirror-content-file Read mirror text from a file --mirror-dry-run true Exercise mirror plumbing without posting to Discord --allow-unmirrored true Fixture/debug escape hatch; production writes should not use this - --allow-unreceipted-activity true Operator-recovery hatch; lets a mutation proceed without hosted Bifrost receipts `); } diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index c5d580f..41e46ea 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -5,11 +5,27 @@ import { mkdir } from "node:fs/promises"; import { existsSync, statSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + crossingReceiptDefinition, + crossingReceiptDocumentType, + crossingReceiptSchemaId, + openCrossingReceiptStore, + resolveCrossingReceiptStorePath, +} from "./bifrost-crossing-documents.mjs"; +import { + discordPostCommandDefinition, + discordPostCommandDocumentType, + discordPostCommandSchemaId, + discordPostReceiptDefinition, + discordPostReceiptDocumentType, + discordPostReceiptSchemaId, +} from "./bifrost-discord-command-documents.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const projectsRoot = resolve(repoRoot, ".."); -const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-advertisement.cc"); +const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); +const defaultOdinCultMeshUri = "cultmesh://odin/rendezvous/provider-catalog"; const cultCacheRequire = createRequire(resolveCultCachePackagePath()); const cultMeshRequire = createRequire(resolveCultMeshPackagePath()); @@ -146,6 +162,8 @@ const currentMachine = "starfire"; const canonicalService = `${rootVerse}.bifrost`; const locatedService = `${rootVerse}.${currentMachine}.bifrost`; const plannedLocatedService = `${rootVerse}.yggdrasil.bifrost`; +const motionSurfaceCultMeshUri = `cultmesh://${locatedService}/eve/governance/surface`; +const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`; const advertisementDefinition = defineDocumentType({ type: documentType, @@ -153,7 +171,12 @@ const advertisementDefinition = defineDocumentType({ schemaName: documentType, schemaVersion: schemaId, schema: { - parse: parseAdvertisement, + // Persisted provider stores can contain an older advertisement revision + // beside live command and receipt documents. Decode that generated + // discovery metadata so export can replace it without discarding the + // command ledger; only newly built advertisements must satisfy the current + // contract below. + parse: (input) => parseAdvertisement(input, { requireCurrentContract: false }), }, name: "providerId", indexes: { @@ -215,6 +238,8 @@ async function main() { return; } + rejectRemovedHttpProbeConfig(options); + switch (command) { case "export": await exportAdvertisement(options); @@ -255,6 +280,9 @@ async function exportAdvertisement(options) { .withDocumentType(advertisementDefinition) .withDocumentType(surfaceDefinition) .withDocumentType(interfaceBindingDefinition) + .withDocumentType(discordPostCommandDefinition(defineDocumentType)) + .withDocumentType(discordPostReceiptDefinition(defineDocumentType)) + .withDocumentType(crossingReceiptDefinition(defineDocumentType)) .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) .build(); @@ -294,15 +322,16 @@ function buildAdvertisement(options) { endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]), endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]), endpoint("operator-commands", `${locatedService}/commands`, "bifrost.bridge_action.v0", ["command"]), - endpoint("bridge-action-ledger", `${locatedService}/bridge/actions`, "bifrost.bridge_action.v0", ["command", "bridge", "receipt"]), + endpoint("crossing-receipts", `${locatedService}/receipts/crossings`, crossingReceiptSchemaId, ["receipt", "bridge", "cultmesh"]), + endpoint("discord-post-commands", `${locatedService}/commands/discord-post`, discordPostCommandSchemaId, ["command", "discord", "cultmesh"]), + endpoint("discord-post-receipts", `${locatedService}/receipts/discord-post`, discordPostReceiptSchemaId, ["receipt", "discord", "cultmesh"]), endpoint("patron-support-intake", `${locatedService}/heimdall/patron-support/events`, "bifrost.patron_support_event.v0", ["heimdall", "patronage", "intake"]), endpoint("github-webhooks", `${locatedService}/github/webhooks`, "bifrost.work_item.v0", ["github", "work-sync", "review-sync"]), - endpoint("motion-surface", "https://bifrost.gamecult.org/eve/governance/surface", "gamecult.eve.surface.v1", ["product", "governance", "motion"]), - endpoint("motion-commands", "https://bifrost.gamecult.org/eve/governance/commands", "bifrost.motion_command.v0", ["command", "governance", "motion"]), + endpoint("motion-surface", motionSurfaceCultMeshUri, "gamecult.eve.surface.v1", ["cultmesh", "governance", "motion"]), + endpoint("motion-commands", motionCommandCultMeshUri, "bifrost.motion_command.v0", ["command", "cultmesh", "governance", "motion"]), ], routes: [ - route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true), - route("websocket-bridge", "ws://192.168.1.66:8797/eve/deck", "compatibility-eve-deck", true), + route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true), ], authority: { owner: "Bifrost", @@ -334,22 +363,25 @@ function buildAdvertisement(options) { schema("bifrost.ledger_entry.v0", "bifrost.ledger-entry", "planned contributor/patron ledger witness"), schema("bifrost.bridge_action.v0", "bifrost.bridge-action", "current hosted governed crossing command witness"), schema("bifrost.bridge_receipt.v0", "bifrost.bridge-receipt", "current hosted governed crossing result witness"), + schema(crossingReceiptSchemaId, crossingReceiptDocumentType, "canonical Bifrost-owned public crossing receipt"), + schema(discordPostCommandSchemaId, discordPostCommandDocumentType, "CultMesh command request for Bifrost-owned Discord persona/bot posts"), + schema(discordPostReceiptSchemaId, discordPostReceiptDocumentType, "CultMesh receipt for Bifrost-owned Discord persona/bot posts"), schema("bifrost.patron_support_event.v0", "bifrost.patron-support-event", "current hosted Heimdall-signed patron support fact consumed by Bifrost"), 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/provider-store.cc", `${schemaId}; ${surfaceSchemaId}; ${interfaceBindingSchemaId}; ${discordPostCommandSchemaId}; ${discordPostReceiptSchemaId}`, "current", "typed provider advertisement, operator surface, Eve interface binding, and Bifrost Discord command request/receipt store"), 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 Persona 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"), + witness(".bifrost/bridge-receipts.cc", crossingReceiptSchemaId, "current", "canonical governed public crossing receipt history"), + witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "future product Eve/CultUI surfaces not yet in the provider store"), ], surfaces: [ - surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-advertisement.cc", "eve", [ + surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-store.cc", "eve", [ "service health", "compact service status", "topic and request status", @@ -445,12 +477,15 @@ function buildAdvertisement(options) { "prepare governed public crossings", "execute approved handoffs", "record receipts", + "own canonical bifrost.crossing_receipt.v1 documents for every crossing attempt", "open GitHub draft PRs and PR comments through Bifrost gate", "post Discord messages and DMs through Bifrost gate with Heimdall-linked actor capability", "record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist", "post Persona-flaired Reddit organizing threads", + "accept typed CultMesh Discord post command requests and write typed Discord post receipts", ], [ "does not treat local protocol JSON as work authority", + "surface-specific receipts cannot decide crossing completion", "does not touch secrets in this advertisement", ]), ], @@ -463,40 +498,54 @@ function buildAdvertisement(options) { ], demotions: [ "Razor Pages are browser lowerings, not the canonical presentation owner.", - "HTTP health/readiness JSON is a probe, not service truth.", + "HTTP health/readiness JSON is product smoke output only; provider readiness comes from typed CultMesh/Idunn state.", "Discord messages are mirrors and input surfaces until Bifrost commits typed state.", "Reddit threads are organizing surfaces until Bifrost commits typed votes, priority signals, comments, or receipts.", "Local dispatch JSON is evidence for receipts, not command authority.", "This advertisement is read-only discovery metadata and does not migrate runtime state.", ], + commands: [ + command("discord.post", "write", "Write a bifrost.bridge.discord_post_command.v1 document into the provider CultMesh store and wait for a bifrost.bridge.discord_post_receipt.v1 receipt.", { + transport: "cultmesh-command-document", + commandDocumentType: discordPostCommandDocumentType, + commandSchemaId: discordPostCommandSchemaId, + receiptDocumentType: discordPostReceiptDocumentType, + receiptSchemaId: discordPostReceiptSchemaId, + storeRoute: ".bifrost/provider-store.cc", + cultMeshUri: `${locatedService}/commands/discord-post`, + }), + ], }); } async function collectStats(options) { const generatedAt = options["generated-at"] ?? new Date().toISOString(); - const health = await fetchProbe("http://127.0.0.1:5080/healthz"); - const ready = await fetchProbe("http://127.0.0.1:5080/readyz"); const transport = runJsonTool(["tools/agent-transport.mjs", "list", "--json"]); const governance = runJsonTool(["tools/governance-threads.mjs", "list", "--json"]); + const crossingReceipts = await readCrossingReceipts(options); const docker = dockerContainers(); const witnesses = [ - witnessStat(".bifrost/provider-advertisement.cc"), + witnessStat(".bifrost/provider-store.cc"), witnessStat(".bifrost/governance-threads.cc"), witnessStat(".bifrost/agent-transport.cc"), + witnessStat(".bifrost/bridge-receipts.cc"), witnessStat(".bifrost/discord-webhook-cache.json"), ]; const topics = Array.isArray(governance.value) ? governance.value : []; const requests = Array.isArray(transport.value) ? transport.value : []; const topicCounts = countBy(topics, "status"); const requestCounts = countBy(requests, "status"); + const receiptCounts = countBy(crossingReceipts.value, "status"); const recentWindowHours = 24; const dockerHealthy = docker.items.filter((item) => /healthy/i.test(item.status)).length; const dockerRunning = docker.items.filter((item) => /^Up\b/i.test(item.status)).length; + const bridge = buildBridgeStats(); + const service = buildTypedServiceStatus({ witnesses, transport, governance, bridge }); return { generatedAt, - health, - ready, + health: service.health, + ready: service.ready, docker, governance: { ok: governance.ok, @@ -518,16 +567,27 @@ async function collectStats(options) { recentChannelCounts: countRecentBy(requests, "sourceKind", recentWindowHours), latestUpdatedAt: latestTimestamp(requests.map((item) => item.updatedAt)), }, + crossingReceipts: { + ok: crossingReceipts.ok, + error: crossingReceipts.error, + count: crossingReceipts.value.length, + statusCounts: receiptCounts, + recentCount: countRecent(crossingReceipts.value, recentWindowHours), + gapRows: buildReceiptGapRows(crossingReceipts.value), + latestUpdatedAt: latestTimestamp(crossingReceipts.value.map((item) => item.completedAt || item.startedAt || item.requestedAt)), + }, witnesses, - bridge: buildBridgeStats(), + bridge, summary: { - status: health.ok && ready.ok ? "ready" : "degraded", - health: health.value ?? health.error, - ready: ready.value ?? ready.error, + status: service.ready.ok ? "ready" : "degraded", + health: service.health.value, + ready: service.ready.value, dockerRunning, dockerHealthy, governanceTopics: topics.length, transportRequests: requests.length, + crossingReceipts: crossingReceipts.value.length, + crossingReceiptGaps: buildReceiptGapRows(crossingReceipts.value).length, recentGovernanceTopics: countRecent(topics, recentWindowHours), recentTransportRequests: countRecent(requests, recentWindowHours), openTopics: topicCounts.open ?? 0, @@ -537,26 +597,67 @@ async function collectStats(options) { }; } -async function fetchProbe(url) { +async function readCrossingReceipts(options) { try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 2500); - const response = await fetch(url, { signal: controller.signal }); - clearTimeout(timeout); - const text = (await response.text()).trim(); - return { - ok: response.ok, - statusCode: response.status, - value: text, - }; + const storePath = resolveCrossingReceiptStorePath(options); + if (!existsSync(storePath)) { + return { ok: true, error: "", value: [] }; + } + const store = await openCrossingReceiptStore(storePath); + return { ok: true, error: "", value: store.getAll() }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error), + value: [], }; } } +function buildReceiptGapRows(receipts) { + return receipts + .filter((receipt) => receipt.status === "failed" || receipt.status === "running" || receipt.status === "requested") + .sort((left, right) => String(right.requestedAt).localeCompare(String(left.requestedAt))) + .slice(0, 8) + .map((receipt) => + `${receipt.receiptId}: ${receipt.crossingKind} ${receipt.status}; source=${receipt.source?.kind || "unknown"}/${receipt.source?.id || "unknown"}; target=${receipt.target?.surface || "unknown"} ${receipt.target?.locator || ""}`.trim(), + ); +} + +function buildTypedServiceStatus({ witnesses, transport, governance, bridge }) { + const providerStore = witnesses.find((item) => item.path === ".bifrost/provider-store.cc"); + const storesReady = witnesses.every((item) => item.exists); + const healthOk = Boolean(providerStore?.exists) && transport.ok && governance.ok; + const readyOk = healthOk && storesReady && bridge.ready; + return { + health: { + ok: healthOk, + value: healthOk + ? `typed-store ${providerStore.path} / command stores readable` + : `typed-store ${providerStore?.exists ? "present" : "missing"} / command stores ${transport.ok && governance.ok ? "readable" : "degraded"}`, + }, + ready: { + ok: readyOk, + value: readyOk + ? "CultMesh command/surface store ready" + : `CultMesh command/surface store ${storesReady ? "present" : "incomplete"} / bridge ${bridge.ready ? "ready" : "not-ready"}`, + }, + }; +} + +function rejectRemovedHttpProbeConfig(options) { + const removed = [ + ["--health-url", options["health-url"]], + ["--ready-url", options["ready-url"]], + ["BIFROST_HEALTH_URL", process.env.BIFROST_HEALTH_URL], + ["BIFROST_READY_URL", process.env.BIFROST_READY_URL], + ].filter(([, value]) => optionalString(value)); + + if (removed.length > 0) { + throw new Error(`${removed.map(([name]) => name).join(" / ")} were removed from provider advertisement status. Publish Bifrost readiness through typed CultMesh/Idunn state instead of HTTP probes.`); + } +} + function runJsonTool(args) { const result = spawnSync(process.execPath, args, { cwd: repoRoot, @@ -637,16 +738,19 @@ function witnessStat(relativePath) { } async function publishOdinAdvertisement(options) { - const endpoint = options["odin-cultmesh-rudp"] ?? options.odin ?? process.env.BIFROST_ODIN_CULTMESH_RUDP; - if (!endpoint) { - throw new Error("Bifrost Odin publication requires --odin-cultmesh-rudp or BIFROST_ODIN_CULTMESH_RUDP."); + if (options["odin-cultmesh-rudp"] || process.env.BIFROST_ODIN_CULTMESH_RUDP) { + throw new Error("--odin-cultmesh-rudp and BIFROST_ODIN_CULTMESH_RUDP were removed. Configure --odin-cultmesh-uri cultmesh://odin/rendezvous/provider-catalog and let CultMesh resolve Odin's transport."); + } + const odinCultMeshUri = options["odin-cultmesh-uri"] ?? options.odin ?? process.env.BIFROST_ODIN_CULTMESH_URI ?? defaultOdinCultMeshUri; + if (!odinCultMeshUri) { + throw new Error("Bifrost Odin publication requires an Odin CultMesh URI."); } const advertisement = buildAdvertisement(options); await CultMesh.publishRudpDocumentOnce( "bifrost", 0x0d1d0002, - normalizeRudpEndpoint(endpoint), + odinCultMeshUri, defineCultNetDocumentBinding({ definition: advertisementDefinition }), advertisement.providerId, advertisement, @@ -661,7 +765,7 @@ async function publishOdinAdvertisement(options) { documentType, schemaId, providerId: advertisement.providerId, - odinCultMeshRudp: endpoint, + odinCultMeshUri, }); } @@ -674,17 +778,13 @@ function buildBridgeStats() { const redditCredentialSource = process.env.BIFROST_REDDIT_CLIENT_ID && process.env.BIFROST_REDDIT_REFRESH_TOKEN ? "BIFROST_REDDIT_CLIENT_ID+BIFROST_REDDIT_REFRESH_TOKEN" : "missing"; - const bridgeLedgerCredentialSource = process.env.BIFROST_BRIDGE_BASE_URL && process.env.BIFROST_BRIDGE_TOKEN - ? "BIFROST_BRIDGE_BASE_URL+BIFROST_BRIDGE_TOKEN" - : "missing"; - const surfaces = [ { id: "github", label: "GitHub", prepared: true, ready: true, - authority: "Bifrost bridge gate plus GitHub webhook sync", + authority: "Bifrost typed CultMesh command gate plus GitHub webhook sync", credentialSource: "GitHub app/OAuth/gh runtime", note: "draft PRs, PR comments, and webhook work sync are hooked", }, @@ -693,7 +793,7 @@ function buildBridgeStats() { label: "Discord", prepared: true, ready: discordCredentialSource !== "missing", - authority: "Bifrost bridge gate plus Heimdall-linked account/capability reference", + authority: "Bifrost typed CultMesh command gate plus Heimdall-linked account/capability reference", credentialSource: discordCredentialSource, note: discordCredentialSource === "missing" ? "transport actuator token is not visible to this process" @@ -704,7 +804,7 @@ function buildBridgeStats() { label: "Reddit", prepared: true, ready: redditCredentialSource !== "missing", - authority: "Bifrost bridge gate plus Heimdall-linked reddit capability reference", + authority: "Bifrost typed CultMesh command gate plus Heimdall-linked reddit capability reference", credentialSource: redditCredentialSource, note: redditCredentialSource === "missing" ? "reddit transport credentials are not visible to this process" @@ -714,10 +814,10 @@ function buildBridgeStats() { id: "other", label: "Other", prepared: true, - ready: true, - authority: "Bifrost receipt-only future-surface gate with named Heimdall reference", - credentialSource: "not-required-for-receipt-only", - note: "records governed future-surface requests without provider transport", + ready: false, + authority: "typed CultMesh command contract required before use", + credentialSource: "not-configured", + note: "the old receipt-only HTTP bridge hatch is removed", }, { id: "patron", @@ -739,10 +839,10 @@ function buildBridgeStats() { githubWebhookSync: true, otherRequest: true, patronSupportIntake: true, - bridgeLedgerConfigured: bridgeLedgerCredentialSource !== "missing", credentialSource: discordCredentialSource, redditCredentialSource, - bridgeLedgerCredentialSource, + bridgeLedgerConfigured: false, + bridgeLedgerCredentialSource: "removed-http-bridge", patronSupportAuthority: "Heimdall HMAC via /heimdall/patron-support/events", prepared: surfaces.every((surface) => surface.prepared), ready: surfaces.every((surface) => surface.ready), @@ -778,10 +878,13 @@ function buildOperatorSurface(stats) { panelNode("activity", "Activity", [ metricNode("topics", "Topics", `${stats.summary.governanceTopics} total / ${stats.summary.openTopics} open / ${stats.summary.recentGovernanceTopics} in ${stats.summary.recentWindowHours}h`, stats.governance.ok ? "ok" : "warn"), metricNode("requests", "Requests", `${stats.summary.transportRequests} total / ${stats.summary.queuedRequests} queued / ${stats.summary.recentTransportRequests} in ${stats.summary.recentWindowHours}h`, stats.transport.ok ? "ok" : "warn"), + metricNode("crossing-receipts", "Crossing Receipts", `${stats.summary.crossingReceipts} total / ${stats.summary.crossingReceiptGaps} gaps`, stats.crossingReceipts.ok && stats.summary.crossingReceiptGaps === 0 ? "ok" : "warn"), listNode("status", "Status", [ `topics: ${compactCounts(stats.governance.statusCounts)}`, `requests: ${compactCounts(stats.transport.statusCounts)}`, + `crossing receipts: ${compactCounts(stats.crossingReceipts.statusCounts)}`, ]), + listNode("receipt-gaps", "Receipt Gaps", stats.crossingReceipts.gapRows), listNode("channels", "Dispatch Channels", compactChannelLines(stats.transport.channelCounts, stats.transport.recentChannelCounts, stats.summary.recentWindowHours)), ]), ]; @@ -796,6 +899,17 @@ function buildOperatorSurface(stats) { schema: "gamecult.eve.surface.v1", id: "bifrost-operator-dashboard", title: "Bifrost Operator Dashboard", + commands: [ + command("discord.post", "write", "Post to Discord through Bifrost's CultMesh command request/receipt documents.", { + transport: "cultmesh-command-document", + commandDocumentType: discordPostCommandDocumentType, + commandSchemaId: discordPostCommandSchemaId, + receiptDocumentType: discordPostReceiptDocumentType, + receiptSchemaId: discordPostReceiptSchemaId, + storeRoute: ".bifrost/provider-store.cc", + cultMeshUri: `${locatedService}/commands/discord-post`, + }), + ], root: { id: "bifrost-root", kind: "dashboard", @@ -833,8 +947,7 @@ function buildInterfaceBinding(surface, stats) { endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]), ], routes: [ - route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true), - route("websocket-bridge", "ws://192.168.1.66:8797/eve/deck", "compatibility-eve-deck", true), + route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true), ], capabilities: [ "operator-stats", @@ -1008,6 +1121,16 @@ function boundary(area, owner, commands, forbiddenAuthority) { return { area, owner, commands, forbiddenAuthority }; } +function command(id, mode, purpose, metadata = {}) { + return { + id, + command: id, + mode, + purpose, + ...metadata, + }; +} + function style(area, capabilities) { return { area, capabilities }; } @@ -1020,7 +1143,7 @@ function route(id, address, transport, demoted) { return { id, address, transport, demoted }; } -function parseAdvertisement(input) { +function parseAdvertisement(input, { requireCurrentContract = true } = {}) { if (!input || typeof input !== "object") { throw new Error("Provider advertisement must be an object."); } @@ -1047,6 +1170,10 @@ function parseAdvertisement(input) { demotions: requireStringArray(input.demotions, "demotions"), }; + if (!requireCurrentContract) { + return advertisement; + } + 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) { @@ -1055,7 +1182,7 @@ function parseAdvertisement(input) { } } - const requiredEndpoints = new Set(["bridge-action-ledger", "patron-support-intake", "github-webhooks"]); + const requiredEndpoints = new Set(["crossing-receipts", "discord-post-commands", "discord-post-receipts", "patron-support-intake", "github-webhooks"]); const endpointIds = new Set(advertisement.endpoints.map((endpoint) => endpoint.id)); for (const id of requiredEndpoints) { if (!endpointIds.has(id)) { @@ -1063,7 +1190,7 @@ function parseAdvertisement(input) { } } - const requiredSchemas = new Set(["bifrost.bridge_action.v0", "bifrost.bridge_receipt.v0", "bifrost.patron_support_event.v0"]); + const requiredSchemas = new Set([crossingReceiptSchemaId, "bifrost.bridge_action.v0", "bifrost.bridge_receipt.v0", "bifrost.patron_support_event.v0"]); const schemaIds = new Set(advertisement.schemas.map((schema) => schema.id)); for (const id of requiredSchemas) { if (!schemaIds.has(id)) { @@ -1148,14 +1275,6 @@ function resolveOptionPath(path) { return resolve(process.cwd(), path); } -function normalizeRudpEndpoint(endpoint) { - const text = String(endpoint || "").trim(); - if (!text) { - throw new Error("Odin CultMesh/RUDP endpoint must be non-empty."); - } - return text.toLowerCase().startsWith("rudp://") ? text : `rudp://${text}`; -} - function printJson(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } @@ -1164,24 +1283,25 @@ function printHelp() { process.stdout.write(`Bifrost Eve provider advertisement Commands: - export Write advertisement, operator surface, and interface binding to a CultCache .cc witness - publish-odin Publish the provider advertisement once to Odin over CultMesh/RUDP + export Write advertisement, operator surface, and interface binding to the Bifrost provider store + publish-odin Publish the provider advertisement once to Odin through its CultMesh rendezvous URI print Print the advertisement as protocol-debug JSON without writing state print-surface Print the current operator Eve surface JSON without writing state print-binding Print the current Eve interface binding JSON without writing state schema Print document type metadata Options: - --out Override export path; defaults to .bifrost/provider-advertisement.cc - --odin-cultmesh-rudp - Odin CultMesh/RUDP document catalog endpoint; also reads BIFROST_ODIN_CULTMESH_RUDP + --out Override export path; defaults to .bifrost/provider-store.cc + --odin-cultmesh-uri + Odin CultMesh rendezvous URI; also reads BIFROST_ODIN_CULTMESH_URI --generated-at Pin generatedAt for deterministic fixture checks Examples: node tools/provider-advertisement.mjs print node tools/provider-advertisement.mjs print-binding - node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc - node tools/provider-advertisement.mjs publish-odin --odin-cultmesh-rudp 127.0.0.1:17871 + node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc + node tools/provider-advertisement.mjs print-surface + $env:BIFROST_ODIN_CULTMESH_URI="cultmesh://odin/rendezvous/provider-catalog"; node tools/provider-advertisement.mjs publish-odin `); } From 6104f6c51457f60a702e369625e4cf910ca4e59c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 22:18:59 +0100 Subject: [PATCH 002/155] Load typed transport from packaged CultLib --- tools/bifrost-crossing-documents.mjs | 3 ++- tools/cultmesh-bridge-commands.mjs | 3 ++- tools/provider-advertisement.mjs | 13 +++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tools/bifrost-crossing-documents.mjs b/tools/bifrost-crossing-documents.mjs index ac363f9..c6ec6e3 100644 --- a/tools/bifrost-crossing-documents.mjs +++ b/tools/bifrost-crossing-documents.mjs @@ -6,6 +6,7 @@ import { dirname, resolve } from "node:path"; const repoRoot = resolve(import.meta.dirname, ".."); const projectsRoot = resolve(repoRoot, ".."); +const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projectsRoot, "CultLib")); export const crossingReceiptDocumentType = "bifrost.crossing_receipt"; export const crossingReceiptSchemaId = "bifrost.crossing_receipt.v1"; @@ -267,7 +268,7 @@ function loadCultCacheRuntime() { return cachedRuntime; } const packagePath = resolveFirstExisting("CultCache TypeScript runtime", [ - resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"), + resolve(cultLibRoot, "packages", "cultcache-ts", "package.json"), resolve(projectsRoot, "CultCacheTS", "package.json"), resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"), ]); diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 007c3de..6dbb57c 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -16,6 +16,7 @@ import { const repoRoot = resolve(import.meta.dirname, ".."); const projectsRoot = resolve(repoRoot, ".."); +const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projectsRoot, "CultLib")); const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); async function main() { @@ -292,7 +293,7 @@ function genericDocument(defineDocumentType, type, schemaId, name) { function loadCultMeshRuntime() { const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"), + resolve(cultLibRoot, "packages", "cultmesh-ts", "package.json"), resolve(projectsRoot, "CultMeshTS", "package.json"), ]; diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 41e46ea..35d5016 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -24,6 +24,7 @@ import { const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const projectsRoot = resolve(repoRoot, ".."); +const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projectsRoot, "CultLib")); const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); const defaultOdinCultMeshUri = "cultmesh://odin/rendezvous/provider-catalog"; @@ -43,7 +44,7 @@ const { defineCultNetDocumentBinding } = cultNetRuntime; function loadCultCacheRuntime() { const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "dist", "index.js"), + resolve(cultLibRoot, "packages", "cultcache-ts", "dist", "index.js"), resolve(projectsRoot, "CultCacheTS", "dist", "index.js"), ]; @@ -63,7 +64,7 @@ function loadCultCacheRuntime() { function resolveCultCachePackagePath() { const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"), + resolve(cultLibRoot, "packages", "cultcache-ts", "package.json"), resolve(projectsRoot, "CultCacheTS", "package.json"), resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"), ]; @@ -79,7 +80,7 @@ function resolveCultCachePackagePath() { function loadCultMeshRuntime() { const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "dist", "index.js"), + resolve(cultLibRoot, "packages", "cultmesh-ts", "dist", "index.js"), resolve(projectsRoot, "CultMeshTS", "dist", "index.js"), ]; @@ -99,7 +100,7 @@ function loadCultMeshRuntime() { function resolveCultMeshPackagePath() { const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"), + resolve(cultLibRoot, "packages", "cultmesh-ts", "package.json"), resolve(projectsRoot, "CultMeshTS", "package.json"), resolve(projectsRoot, "CultMeshTS", "node_modules", "cultmesh-ts", "package.json"), ]; @@ -115,7 +116,7 @@ function resolveCultMeshPackagePath() { function loadCultNetRuntime() { const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultnet-ts", "dist", "index.js"), + resolve(cultLibRoot, "packages", "cultnet-ts", "dist", "index.js"), resolve(projectsRoot, "CultNetTS", "dist", "index.js"), ]; @@ -135,7 +136,7 @@ function loadCultNetRuntime() { function resolveCultNetPackagePath() { const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultnet-ts", "package.json"), + resolve(cultLibRoot, "packages", "cultnet-ts", "package.json"), resolve(projectsRoot, "CultNetTS", "package.json"), resolve(projectsRoot, "CultNetTS", "node_modules", "cultnet-ts", "package.json"), ]; From 03c6f353a88cc36f7153c84af1b5e949f059ce31 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 22:20:03 +0100 Subject: [PATCH 003/155] Load CultLib entrypoints without workspace links --- tools/bifrost-crossing-documents.mjs | 11 +++++------ tools/cultmesh-bridge-commands.mjs | 20 +++++++++++++------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/tools/bifrost-crossing-documents.mjs b/tools/bifrost-crossing-documents.mjs index c6ec6e3..b7d60ac 100644 --- a/tools/bifrost-crossing-documents.mjs +++ b/tools/bifrost-crossing-documents.mjs @@ -267,13 +267,12 @@ function loadCultCacheRuntime() { if (cachedRuntime) { return cachedRuntime; } - const packagePath = resolveFirstExisting("CultCache TypeScript runtime", [ - resolve(cultLibRoot, "packages", "cultcache-ts", "package.json"), - resolve(projectsRoot, "CultCacheTS", "package.json"), - resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"), + const entryPoint = resolveFirstExisting("CultCache TypeScript runtime", [ + resolve(cultLibRoot, "packages", "cultcache-ts", "dist", "index.js"), + resolve(projectsRoot, "CultCacheTS", "dist", "index.js"), ]); - const requireCultCache = createRequire(packagePath); - cachedRuntime = requireCultCache("cultcache-ts"); + const requireCultCache = createRequire(entryPoint); + cachedRuntime = requireCultCache(entryPoint); return cachedRuntime; } diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 6dbb57c..9e5b372 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -293,18 +293,24 @@ function genericDocument(defineDocumentType, type, schemaId, name) { function loadCultMeshRuntime() { const candidates = [ - resolve(cultLibRoot, "packages", "cultmesh-ts", "package.json"), - resolve(projectsRoot, "CultMeshTS", "package.json"), + { + mesh: resolve(cultLibRoot, "packages", "cultmesh-ts", "dist", "index.js"), + cache: resolve(cultLibRoot, "packages", "cultcache-ts", "dist", "index.js"), + }, + { + mesh: resolve(projectsRoot, "CultMeshTS", "dist", "index.js"), + cache: resolve(projectsRoot, "CultCacheTS", "dist", "index.js"), + }, ]; - for (const packageJson of candidates) { - if (!existsSync(packageJson)) { + for (const candidate of candidates) { + if (!existsSync(candidate.mesh) || !existsSync(candidate.cache)) { continue; } try { - const requireCult = createRequire(packageJson); - const { CultMesh } = requireCult("cultmesh-ts"); - const { defineDocumentType } = requireCult("cultcache-ts"); + const requireCult = createRequire(candidate.mesh); + const { CultMesh } = requireCult(candidate.mesh); + const { defineDocumentType } = requireCult(candidate.cache); if (CultMesh && defineDocumentType) { return { CultMesh, defineDocumentType }; } From 3bd250f811cbcdee79bd0199168f5326c07fd7d7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 16 Jul 2026 18:28:13 +0100 Subject: [PATCH 004/155] Gate deployments with typed release authority --- tests/Bifrost.Web.Tests/BridgeCliTests.cs | 139 ++++++++++++++ tools/bifrost-bridge.mjs | 101 +++++++++++ .../bifrost-repository-release-authority.mjs | 169 ++++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 tools/bifrost-repository-release-authority.mjs diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index 1464fd5..a8efe6d 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -6,6 +6,145 @@ namespace Bifrost.Web.Tests; public sealed class BridgeCliTests { + [Fact] + public async Task Authorize_release_requires_typed_command_provenance() + { + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", "authorize-release", + "--repository", "GameCult/Epiphany", + "--ref", "refs/heads/main", + "--commit-sha", new string('a', 40), + "--policy-decision-id", "policy-1", + ]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("BIFROST_CULTMESH_COMMAND_ID", result.Stderr, StringComparison.Ordinal); + } + + [Fact] + public async Task Authorize_release_rejects_remote_ref_mismatch() + { + var fakeGit = await WriteFakeGitAsync(new string('b', 40), "refs/heads/main"); + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", "authorize-release", + "--repository", "GameCult/Epiphany", + "--ref", "refs/heads/main", + "--commit-sha", new string('a', 40), + "--policy-decision-id", "policy-1", + "--authority-ref", "release-policy", + "--identity", "bifrost", + "--source-kind", "epiphany-release", + "--source-id", "release-1", + "--epiphany-run-id", "run-1", + "--epiphany-lane-id", "soul", + "--epiphany-agent-identity", "epiphany", + "--git-executable", "node", + "--git-exec-args", fakeGit, + ], TypedCommandEnv()); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("does not exactly match", result.Stderr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Authorize_then_revoke_release_preserves_decision_and_receipt_provenance() + { + var sha = new string('a', 40); + var fakeGit = await WriteFakeGitAsync(sha, "refs/heads/main"); + var store = Path.Combine(Path.GetTempPath(), $"bifrost-release-authority-{Guid.NewGuid():N}.cc"); + var receiptStore = Path.Combine(Path.GetTempPath(), $"bifrost-release-receipts-{Guid.NewGuid():N}.cc"); + var authorized = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", "authorize-release", + "--repository", "GameCult/Epiphany", + "--ref", "refs/heads/main", + "--commit-sha", sha, + "--policy-decision-id", "policy-epiphany-main", + "--authority-ref", "bifrost-release-policy", + "--identity", "bifrost", + "--source-kind", "epiphany-release", + "--source-id", "epiphany-main-release", + "--epiphany-run-id", "run-epiphany-main", + "--epiphany-lane-id", "soul-release", + "--epiphany-agent-identity", "epiphany", + "--release-authority-store", store, + "--receipt-store", receiptStore, + "--git-executable", "node", + "--git-exec-args", fakeGit, + ], TypedCommandEnv()); + + Assert.True(authorized.ExitCode == 0, $"stdout:{authorized.Stdout}\nstderr:{authorized.Stderr}"); + using (var payload = JsonDocument.Parse(authorized.Stdout)) + { + var authority = payload.RootElement.GetProperty("authority"); + Assert.Equal("bifrost.repository_release_authority.v1", authority.GetProperty("schemaVersion").GetString()); + Assert.Equal($"release:GameCult/Epiphany:refs/heads/main:{sha}", authority.GetProperty("authorityId").GetString()); + Assert.Equal("GameCult/Epiphany", authority.GetProperty("repositoryFullName").GetString()); + Assert.Equal("authorize", authority.GetProperty("decision").GetString()); + Assert.Equal("authorized", authority.GetProperty("status").GetString()); + Assert.Equal("policy-epiphany-main", authority.GetProperty("policyDecisionId").GetString()); + Assert.Equal("crossing_test-cultmesh-command", authority.GetProperty("crossingReceiptId").GetString()); + Assert.Equal($"https://github.com/GameCult/Epiphany/commit/{sha}", authority.GetProperty("externalReceiptUrl").GetString()); + } + + using (var receipts = await ReadCrossingReceiptsAsync(receiptStore)) + { + var receipt = Assert.Single(receipts.RootElement.EnumerateArray()); + Assert.Equal("completed", receipt.GetProperty("status").GetString()); + Assert.True(receipt.GetProperty("ok").GetBoolean()); + Assert.Equal("github.repository_release_verification", receipt.GetProperty("crossingKind").GetString()); + Assert.Equal(sha, receipt.GetProperty("externalReceipt").GetProperty("id").GetString()); + Assert.Equal("refs/heads/main", receipt.GetProperty("externalReceipt").GetProperty("upstreamRef").GetString()); + } + + var competingSha = new string('b', 40); + var competingGit = await WriteFakeGitAsync(competingSha, "refs/heads/main"); + var competing = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", "authorize-release", + "--repository", "GameCult/Epiphany", + "--ref", "refs/heads/main", + "--commit-sha", competingSha, + "--policy-decision-id", "policy-epiphany-main-2", + "--authority-ref", "bifrost-release-policy", + "--identity", "bifrost", + "--source-kind", "epiphany-release", + "--source-id", "epiphany-main-release-2", + "--epiphany-run-id", "run-epiphany-main-2", + "--epiphany-lane-id", "soul-release", + "--epiphany-agent-identity", "epiphany", + "--release-authority-store", store, + "--receipt-store", receiptStore, + "--git-executable", "node", + "--git-exec-args", competingGit, + ], TypedCommandEnv()); + + Assert.NotEqual(0, competing.ExitCode); + Assert.Contains("revoke it before authorizing another commit", competing.Stderr, StringComparison.OrdinalIgnoreCase); + + var revoked = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", "revoke-release", + "--repository", "GameCult/Epiphany", + "--ref", "refs/heads/main", + "--commit-sha", sha, + "--reason", "release withdrawn", + "--release-authority-store", store, + ], TypedCommandEnv()); + + Assert.True(revoked.ExitCode == 0, $"stdout:{revoked.Stdout}\nstderr:{revoked.Stderr}"); + using var revokedPayload = JsonDocument.Parse(revoked.Stdout); + var revokedAuthority = revokedPayload.RootElement.GetProperty("authority"); + Assert.Equal("revoked", revokedAuthority.GetProperty("status").GetString()); + Assert.Equal("release withdrawn", revokedAuthority.GetProperty("revocationReason").GetString()); + Assert.Equal("policy-epiphany-main", revokedAuthority.GetProperty("policyDecisionId").GetString()); + Assert.Equal("crossing_test-cultmesh-command", revokedAuthority.GetProperty("crossingReceiptId").GetString()); + } + + private static async Task WriteFakeGitAsync(string sha, string gitRef) + { + var path = Path.Combine(Path.GetTempPath(), $"bifrost-fake-git-{Guid.NewGuid():N}.mjs"); + await File.WriteAllTextAsync(path, $"process.stdout.write('{sha}\\t{gitRef}\\n');\n", Encoding.UTF8); + return path; + } + [Fact] public async Task GitHub_bridge_command_fails_closed_without_bifrost_gate() { diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 0385070..1e872e6 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -8,6 +8,15 @@ import { beginCrossingReceipt, crossingProvenanceFromOptions, } from "./bifrost-crossing-documents.mjs"; +import { + buildAuthorizedRelease, + canonicalCommitSha, + canonicalRef, + canonicalRepository, + openRepositoryReleaseAuthorityStore, + releaseAuthorityId, + revokeAuthorizedRelease, +} from "./bifrost-repository-release-authority.mjs"; const PERSONA_WEBHOOK_NAME = "Bifrost Persona Pipe"; const PERSONA_WEBHOOK_CACHE_PATH = resolve(".bifrost/discord-webhook-cache.json"); @@ -45,11 +54,101 @@ async function main() { case "other-request": await requestOtherBridgeAction(options); return; + case "authorize-release": + await authorizeRepositoryRelease(options); + return; + case "revoke-release": + await revokeRepositoryRelease(options); + return; default: throw new Error(`Unknown command "${command}". Run "node tools/bifrost-bridge.mjs help".`); } } +async function authorizeRepositoryRelease(options) { + ensureGitHubBridgeGate(options); + const commandId = optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID); + if (!commandId) throw new Error("authorize-release requires BIFROST_CULTMESH_COMMAND_ID or --cultmesh-command-id."); + const repository = canonicalRepository(requireOption(options, "repository")); + const ref = canonicalRef(requireOption(options, "ref")); + const commitSha = canonicalCommitSha(requireOption(options, "commit-sha")); + const policyDecisionId = requireOption(options, "policy-decision-id"); + const authorityReference = requireOption(options, "authority-ref"); + const actorIdentity = requireOption(options, "identity"); + const sourceKind = requireOption(options, "source-kind"); + const sourceId = requireOption(options, "source-id"); + const epiphanyRunId = requireOption(options, "epiphany-run-id"); + const epiphanyLaneId = requireOption(options, "epiphany-lane-id"); + const epiphanyAgentIdentity = requireOption(options, "epiphany-agent-identity"); + const remoteUrl = options["remote-url"] ?? `https://github.com/${repository}.git`; + const gitCommand = optionalString(options["git-executable"]) ?? resolveGitCommand(); + const gitPrefixArgs = optionalString(options["git-exec-args"])?.split("\u001f").filter(Boolean) ?? []; + const remote = run(gitCommand, [...gitPrefixArgs, "ls-remote", "--refs", remoteUrl, ref], repoRoot).stdout.trim().split(/\r?\n/).filter(Boolean); + if (remote.length !== 1) throw new Error(`Remote ref ${repository}:${ref} did not resolve exactly once.`); + const [remoteSha, remoteRef] = remote[0].trim().split(/\s+/); + if (canonicalCommitSha(remoteSha) !== commitSha || remoteRef !== ref) { + throw new Error(`Remote ref ${repository}:${ref} does not exactly match requested commit ${commitSha}.`); + } + const storePath = resolve(options["release-authority-store"] ?? process.env.BIFROST_REPOSITORY_RELEASE_AUTHORITY_STORE ?? ".bifrost/repository-release-authority.cc"); + const store = await openRepositoryReleaseAuthorityStore(storePath); + const authorityId = releaseAuthorityId(repository, ref, commitSha); + const existing = store.get(authorityId); + if (existing) { + if (existing.commandId === commandId && existing.status === "authorized") { + printJson({ ok: true, action: "authorize-release", authority: existing, idempotent: true }); + return; + } + throw new Error(`Release authority ${authorityId} already exists; immutable authority records cannot be replaced.`); + } + const competing = store.getAll().find((authority) => + authority.repositoryFullName === repository && + authority.upstreamRef === ref && + authority.status === "authorized" + ); + if (competing) { + throw new Error( + `Release authority ${competing.authorityId} already authorizes ${repository}:${ref}; revoke it before authorizing another commit.`, + ); + } + const externalReceiptUrl = `https://github.com/${repository}/commit/${commitSha}`; + const crossing = await beginCrossingReceipt({ ...options, "receipt-id": undefined }, { + commandId, + crossingKind: "github.repository_release_verification", + actor: { kind: "service", name: actorIdentity, bifrostIdentity: actorIdentity }, + source: { kind: sourceKind, id: sourceId }, + authority: { authorityRef: authorityReference, policyDecisionId }, + epiphany: { runId: epiphanyRunId, laneId: epiphanyLaneId, agentIdentity: epiphanyAgentIdentity }, + target: { surface: "GitHub", repositoryFullName: repository, upstreamRef: ref, commitSha }, + }); + await crossing.start(); + await crossing.complete({ externalReceipt: { url: externalReceiptUrl, id: commitSha, repositoryFullName: repository, upstreamRef: ref, commitSha } }); + const authority = await store.put(buildAuthorizedRelease({ + repositoryFullName: repository, upstreamRef: ref, commitSha, commandId, + crossingReceiptId: crossing.receiptId, policyDecisionId, authorityReference, + actorIdentity, sourceKind, sourceId, epiphanyRunId, epiphanyLaneId, + epiphanyAgentIdentity, externalReceiptUrl, externalReceiptId: commitSha, + authorizedAt: new Date().toISOString(), expiresAt: optionalString(options["expires-at"]) ?? "", + })); + printJson({ ok: true, action: "authorize-release", authority }); +} + +async function revokeRepositoryRelease(options) { + ensureGitHubBridgeGate(options); + const commandId = optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID); + if (!commandId) throw new Error("revoke-release requires BIFROST_CULTMESH_COMMAND_ID or --cultmesh-command-id."); + const repository = canonicalRepository(requireOption(options, "repository")); + const ref = canonicalRef(requireOption(options, "ref")); + const commitSha = canonicalCommitSha(requireOption(options, "commit-sha")); + const reason = requireOption(options, "reason"); + const storePath = resolve(options["release-authority-store"] ?? process.env.BIFROST_REPOSITORY_RELEASE_AUTHORITY_STORE ?? ".bifrost/repository-release-authority.cc"); + const store = await openRepositoryReleaseAuthorityStore(storePath); + const authorityId = releaseAuthorityId(repository, ref, commitSha); + const current = store.get(authorityId); + if (!current) throw new Error(`Release authority ${authorityId} does not exist.`); + const authority = await store.put(revokeAuthorizedRelease(current, { commandId, reason, revokedAt: new Date().toISOString() })); + printJson({ ok: true, action: "revoke-release", authority }); +} + function loadBifrostLocalEnv() { if (process.env.BIFROST_SKIP_LOCAL_ENV === "true") { return; @@ -1360,6 +1459,8 @@ Commands: discord-dm Send a Discord DM through Bifrost's bridge-owned bot token reddit-post Create a self-post in r/GameCultOrg through the Bifrost Reddit app other-request Record a Bifrost-gated request for a future outside-world surface + authorize-release Authorize one exact remote GitHub ref and commit for downstream deployment + revoke-release Revoke the current authorization for one GitHub repository ref Examples: node tools/bifrost-bridge.mjs github-draft-pr --repo-root E:/Projects/AetheriaLore --identity nibu --title "Nibu: Glitchcraft" --path Aetheria/Articles/Nibu/glitchcraft.md --content-file article.md diff --git a/tools/bifrost-repository-release-authority.mjs b/tools/bifrost-repository-release-authority.mjs new file mode 100644 index 0000000..f1bf4a2 --- /dev/null +++ b/tools/bifrost-repository-release-authority.mjs @@ -0,0 +1,169 @@ +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const projectsRoot = resolve(repoRoot, ".."); +const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projectsRoot, "CultLib")); + +export const repositoryReleaseAuthorityDocumentType = "bifrost.repository_release_authority"; +export const repositoryReleaseAuthoritySchemaId = "bifrost.repository_release_authority.v1"; +export const defaultRepositoryReleaseAuthorityStorePath = resolve(repoRoot, ".bifrost", "repository-release-authority.cc"); + +let cachedRuntime; +let cachedDefinition; + +export function repositoryReleaseAuthorityDefinition(defineDocumentTypeInput) { + const defineDocumentType = defineDocumentTypeInput ?? loadCultCacheRuntime().defineDocumentType; + cachedDefinition ??= defineDocumentType({ + type: repositoryReleaseAuthorityDocumentType, + schemaId: repositoryReleaseAuthoritySchemaId, + schemaName: repositoryReleaseAuthorityDocumentType, + schemaVersion: repositoryReleaseAuthoritySchemaId, + contentHash: repositoryReleaseAuthoritySchemaId, + global: false, + name: "authorityId", + indexes: { repositoryFullName: "repositoryFullName", upstreamRef: "upstreamRef", commitSha: "commitSha", status: "status" }, + schema: { parse: parseRepositoryReleaseAuthority }, + members: [ + { slot: 1, memberName: "authorityId", typeName: "string", isName: true }, + { slot: 2, memberName: "commandId", typeName: "string" }, + { slot: 3, memberName: "crossingReceiptId", typeName: "string" }, + { slot: 4, memberName: "repositoryFullName", typeName: "string", indexAlias: "repositoryFullName" }, + { slot: 5, memberName: "upstreamRef", typeName: "string", indexAlias: "upstreamRef" }, + { slot: 6, memberName: "commitSha", typeName: "string", indexAlias: "commitSha" }, + { slot: 7, memberName: "decision", typeName: "string" }, + { slot: 8, memberName: "status", typeName: "string", indexAlias: "status" }, + { slot: 9, memberName: "policyDecisionId", typeName: "string" }, + { slot: 10, memberName: "authorityReference", typeName: "string" }, + { slot: 11, memberName: "actorIdentity", typeName: "string" }, + { slot: 12, memberName: "sourceKind", typeName: "string" }, + { slot: 13, memberName: "sourceId", typeName: "string" }, + { slot: 14, memberName: "epiphanyRunId", typeName: "string" }, + { slot: 15, memberName: "epiphanyLaneId", typeName: "string" }, + { slot: 16, memberName: "epiphanyAgentIdentity", typeName: "string" }, + { slot: 17, memberName: "externalReceiptUrl", typeName: "string" }, + { slot: 18, memberName: "externalReceiptId", typeName: "string" }, + { slot: 19, memberName: "authorizedAt", typeName: "string" }, + { slot: 20, memberName: "expiresAt", typeName: "string" }, + { slot: 21, memberName: "revokedAt", typeName: "string" }, + { slot: 22, memberName: "revocationReason", typeName: "string" }, + ], + }); + return cachedDefinition; +} + +export async function openRepositoryReleaseAuthorityStore(storePath = defaultRepositoryReleaseAuthorityStorePath) { + const { CultCache, SingleFileMessagePackBackingStore } = loadCultCacheRuntime(); + await mkdir(dirname(storePath), { recursive: true }); + const cache = CultCache.builder().withDocumentType(repositoryReleaseAuthorityDefinition()).withGenericStore(new SingleFileMessagePackBackingStore(storePath)).build(); + await cache.pullAllBackingStores(); + return { + storePath, + async put(document) { + const parsed = parseRepositoryReleaseAuthority(document); + await cache.put(repositoryReleaseAuthorityDefinition(), parsed.authorityId, parsed); + return parsed; + }, + get(authorityId) { return cache.get(repositoryReleaseAuthorityDefinition(), authorityId); }, + getAll() { return cache.getAll(repositoryReleaseAuthorityDefinition()); }, + }; +} + +export function releaseAuthorityId(repositoryFullName, upstreamRef, commitSha) { + return `release:${canonicalRepository(repositoryFullName)}:${canonicalRef(upstreamRef)}:${canonicalCommitSha(commitSha)}`; +} + +export function buildAuthorizedRelease(input) { + const repositoryFullName = canonicalRepository(input.repositoryFullName); + const upstreamRef = canonicalRef(input.upstreamRef); + const commitSha = canonicalCommitSha(input.commitSha); + return parseRepositoryReleaseAuthority({ + schemaName: repositoryReleaseAuthorityDocumentType, + schemaVersion: repositoryReleaseAuthoritySchemaId, + authorityId: releaseAuthorityId(repositoryFullName, upstreamRef, commitSha), + commandId: input.commandId, + crossingReceiptId: input.crossingReceiptId, + repositoryFullName, + upstreamRef, + commitSha, + decision: "authorize", + status: "authorized", + policyDecisionId: input.policyDecisionId, + authorityReference: input.authorityReference, + actorIdentity: input.actorIdentity, + sourceKind: input.sourceKind, + sourceId: input.sourceId, + epiphanyRunId: input.epiphanyRunId, + epiphanyLaneId: input.epiphanyLaneId, + epiphanyAgentIdentity: input.epiphanyAgentIdentity, + externalReceiptUrl: input.externalReceiptUrl, + externalReceiptId: input.externalReceiptId, + authorizedAt: input.authorizedAt, + expiresAt: input.expiresAt ?? "", + revokedAt: "", + revocationReason: "", + }); +} + +export function revokeAuthorizedRelease(current, input) { + const parsed = parseRepositoryReleaseAuthority(current); + if (parsed.status !== "authorized") throw new Error(`Release authority ${parsed.authorityId} is already revoked.`); + return parseRepositoryReleaseAuthority({ ...parsed, status: "revoked", revokedAt: requiredString(input.revokedAt, "revokedAt"), revocationReason: requiredString(input.reason, "revocationReason") }); +} + +export function canonicalRepository(value) { + const repository = requiredString(value, "repositoryFullName").replace(/^https?:\/\/github\.com\//i, "").replace(/\.git$/i, "").replace(/^\/+|\/+$/g, ""); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error("repositoryFullName must be a canonical GitHub owner/name."); + return repository; +} + +export function canonicalRef(value) { + const ref = requiredString(value, "upstreamRef"); + if (!/^refs\/(heads|tags)\/[A-Za-z0-9._\/-]+$/.test(ref) || ref.includes("..") || ref.endsWith("/")) throw new Error("upstreamRef must be a canonical refs/heads/... or refs/tags/... name."); + return ref; +} + +export function canonicalCommitSha(value) { + const sha = requiredString(value, "commitSha").toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error("commitSha must be an exact 40-character hexadecimal Git commit SHA."); + return sha; +} + +function parseRepositoryReleaseAuthority(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Bifrost repository release authority must be an object."); + const repositoryFullName = canonicalRepository(input.repositoryFullName); + const upstreamRef = canonicalRef(input.upstreamRef); + const commitSha = canonicalCommitSha(input.commitSha); + const document = { + schemaName: repositoryReleaseAuthorityDocumentType, + schemaVersion: repositoryReleaseAuthoritySchemaId, + authorityId: requiredString(input.authorityId, "authorityId"), commandId: requiredString(input.commandId, "commandId"), + crossingReceiptId: requiredString(input.crossingReceiptId, "crossingReceiptId"), repositoryFullName, upstreamRef, commitSha, + decision: requiredString(input.decision, "decision"), status: requiredString(input.status, "status"), policyDecisionId: requiredString(input.policyDecisionId, "policyDecisionId"), + authorityReference: requiredString(input.authorityReference, "authorityReference"), actorIdentity: requiredString(input.actorIdentity, "actorIdentity"), + sourceKind: requiredString(input.sourceKind, "sourceKind"), sourceId: requiredString(input.sourceId, "sourceId"), + epiphanyRunId: requiredString(input.epiphanyRunId, "epiphanyRunId"), epiphanyLaneId: requiredString(input.epiphanyLaneId, "epiphanyLaneId"), epiphanyAgentIdentity: requiredString(input.epiphanyAgentIdentity, "epiphanyAgentIdentity"), + externalReceiptUrl: requiredString(input.externalReceiptUrl, "externalReceiptUrl"), externalReceiptId: requiredString(input.externalReceiptId, "externalReceiptId"), + authorizedAt: requiredString(input.authorizedAt, "authorizedAt"), expiresAt: optionalString(input.expiresAt) ?? "", revokedAt: optionalString(input.revokedAt) ?? "", revocationReason: optionalString(input.revocationReason) ?? "", + }; + if (document.authorityId !== releaseAuthorityId(repositoryFullName, upstreamRef, commitSha)) throw new Error("authorityId must be derived from repositoryFullName, upstreamRef, and commitSha."); + if (document.decision !== "authorize") throw new Error("Repository release authority decision must be authorize."); + if (!new Set(["authorized", "revoked"]).has(document.status)) throw new Error(`Repository release authority status "${document.status}" is not valid.`); + if (document.status === "authorized" && (document.revokedAt || document.revocationReason)) throw new Error("Authorized release authority cannot carry revocation state."); + if (document.status === "revoked" && (!document.revokedAt || !document.revocationReason)) throw new Error("Revoked release authority requires revocation time and reason."); + return document; +} + +function loadCultCacheRuntime() { + if (cachedRuntime) return cachedRuntime; + const candidates = [resolve(cultLibRoot, "packages", "cultcache-ts", "dist", "index.js"), resolve(projectsRoot, "CultCacheTS", "dist", "index.js")]; + const entryPoint = candidates.find(existsSync); + if (!entryPoint) throw new Error(`CultCache TypeScript runtime is unavailable. Tried: ${candidates.join(", ")}`); + cachedRuntime = createRequire(entryPoint)(entryPoint); + return cachedRuntime; +} + +function requiredString(value, field) { const result = optionalString(value); if (!result) throw new Error(`Bifrost repository release authority field "${field}" must be a non-empty string.`); return result; } +function optionalString(value) { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed || undefined; } From c65cbfa527b7e5000c6c6c6763e8335367a69a2a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 18 Jul 2026 20:32:10 +0100 Subject: [PATCH 005/155] Gate organizational Persona feedback --- docs/bridge.md | 20 +++++ tests/persona-feedback-cli.test.mjs | 76 +++++++++++++++++++ tools/bifrost-feedback-identity.mjs | 35 +++++++++ tools/persona-feedback-documents.mjs | 41 ++++++++++ tools/persona-feedback-idunn-health.mjs | 38 ++++++++++ tools/persona-feedback.mjs | 76 +++++++++++++++++++ ...ify-persona-feedback-release-authority.mjs | 23 ++++++ 7 files changed, 309 insertions(+) create mode 100644 tests/persona-feedback-cli.test.mjs create mode 100644 tools/bifrost-feedback-identity.mjs create mode 100644 tools/persona-feedback-documents.mjs create mode 100644 tools/persona-feedback-idunn-health.mjs create mode 100644 tools/persona-feedback.mjs create mode 100644 tools/verify-persona-feedback-release-authority.mjs diff --git a/docs/bridge.md b/docs/bridge.md index 46eac51..5a7fa92 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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: diff --git a/tests/persona-feedback-cli.test.mjs b/tests/persona-feedback-cli.test.mjs new file mode 100644 index 0000000..33d17be --- /dev/null +++ b/tests/persona-feedback-cli.test.mjs @@ -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;} diff --git a/tools/bifrost-feedback-identity.mjs b/tools/bifrost-feedback-identity.mjs new file mode 100644 index 0000000..72b7258 --- /dev/null +++ b/tools/bifrost-feedback-identity.mjs @@ -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;} diff --git a/tools/persona-feedback-documents.mjs b/tools/persona-feedback-documents.mjs new file mode 100644 index 0000000..a0f4b11 --- /dev/null +++ b/tools/persona-feedback-documents.mjs @@ -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}.`);} diff --git a/tools/persona-feedback-idunn-health.mjs b/tools/persona-feedback-idunn-health.mjs new file mode 100644 index 0000000..577e339 --- /dev/null +++ b/tools/persona-feedback-idunn-health.mjs @@ -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));} diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs new file mode 100644 index 0000000..04c5b99 --- /dev/null +++ b/tools/persona-feedback.mjs @@ -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;icurrent 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`);} diff --git a/tools/verify-persona-feedback-release-authority.mjs b/tools/verify-persona-feedback-release-authority.mjs new file mode 100644 index 0000000..0e96cd0 --- /dev/null +++ b/tools/verify-persona-feedback-release-authority.mjs @@ -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; +} From 82dd058d42ba9bd86118a86284ecfa7c19428285 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 12:00:50 +0100 Subject: [PATCH 006/155] Add signed Epiphany operator admission boundary --- .env.local.example | 8 +++++ docs/bridge.md | 4 +++ docs/verse-service-contract.md | 2 ++ .../ProviderAdvertisementTests.cs | 4 +++ tests/epiphany-operator-command.test.mjs | 14 ++++++++ tools/bifrost-signed-identity.mjs | 14 ++++++++ tools/epiphany-operator-command-documents.mjs | 20 +++++++++++ tools/epiphany-operator-commands.mjs | 34 +++++++++++++++++++ tools/provider-advertisement.mjs | 20 +++++++++++ 9 files changed, 120 insertions(+) create mode 100644 tests/epiphany-operator-command.test.mjs create mode 100644 tools/bifrost-signed-identity.mjs create mode 100644 tools/epiphany-operator-command-documents.mjs create mode 100644 tools/epiphany-operator-commands.mjs diff --git a/.env.local.example b/.env.local.example index ab751e7..8f7b01d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,3 +2,11 @@ # The default compose.local.yaml is self-contained for local smoke testing. COMPOSE_PROJECT_NAME=bifrost-local + +# Epiphany operator admission. No live transport default is assumed. +BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE=.bifrost/epiphany-operator-requests.cc +BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY=.bifrost/private/epiphany-operator-ed25519.seed +BIFROST_EPIPHANY_OPERATOR_OWNER_DISCORD_ID= +BIFROST_EPIPHANY_OPERATOR_TARGET_RUNTIME_ID=epiphany-yggdrasil +BIFROST_EPIPHANY_OPERATOR_RESULT_IDENTITY_ID= +BIFROST_EPIPHANY_OPERATOR_CULTMESH_URI= diff --git a/docs/bridge.md b/docs/bridge.md index 5a7fa92..6f8c1a6 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,6 +196,10 @@ The command is a dry-run shape probe for public surfaces that are real enough to 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. +Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v0` document containing one of `status`, `sleep`, `wake`, or `directive`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the existing guild/actor link with a Heimdall actor reference. It derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v0` with a dedicated operator Ed25519 seed. The feedback seed is not reused. + +Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. Operator execution crosses a narrow authenticated transport port. The live CultNet RUDP request/reply adapter is still required; configuration deliberately has no localhost default. Until that adapter exists, admission fails closed. A returned `epiphany.operator_command.result.v0` must arrive under the configured Epiphany transport identity and match command id, packet digest, and target runtime. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this 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. diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 6e7d16e..542655f 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -55,6 +55,8 @@ Required witness path for the hosted service: Bifrost publishes under these CultMesh namespaces: +The operator bridge additionally advertises `commands/epiphany-operator` and `receipts/epiphany-operator`. The command endpoint consumes strict VoidBot inbox documents and emits dedicated Bifrost-signed admissions; the receipt endpoint exposes only Epiphany-origin, command-bound results. The transport adapter is intentionally unresolved until a live authenticated CultNet RUDP request/reply implementation is installed on Yggdrasil. + - `gamecult.bifrost.service` for service registration, health, build/version, schema catalog, and command capability discovery. - `gamecult.bifrost.governance` for motions, topics, comments, votes, diff --git a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs index 897069a..9aa1c4f 100644 --- a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs +++ b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs @@ -22,6 +22,8 @@ public sealed class ProviderAdvertisementTests AssertEndpoint(root, "crossing-receipts", "bifrost.crossing_receipt.v1", "bridge"); AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage"); AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github"); + AssertEndpoint(root, "epiphany-operator-requests", "voidbot.discord.epiphany_operator_request.v0", "operator"); + AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.result.v0", "epiphany"); AssertSchemaPurpose(root, "bifrost.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt"); AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost"); AssertWitness(root, ".bifrost/bridge-receipts.cc", "bifrost.crossing_receipt.v1", "current"); @@ -33,6 +35,8 @@ public sealed class ProviderAdvertisementTests AssertBoundaryForbiddenAuthority(root, "bridge", "surface-specific receipts cannot decide crossing completion"); AssertBoundaryCommand(root, "patron", "consume Heimdall-signed Patreon and PayPal support facts"); AssertBoundaryForbiddenAuthority(root, "patron", "does not store Patreon or PayPal provider tokens"); + AssertBoundaryCommand(root, "epiphany-operator", "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity"); + AssertBoundaryForbiddenAuthority(root, "epiphany-operator", "does not synthesize successful Epiphany results"); } [Fact] diff --git a/tests/epiphany-operator-command.test.mjs b/tests/epiphany-operator-command.test.mjs new file mode 100644 index 0000000..2c7c9bc --- /dev/null +++ b/tests/epiphany-operator-command.test.mjs @@ -0,0 +1,14 @@ +import test from "node:test";import assert from "node:assert/strict";import {mkdtemp} from "node:fs/promises";import {tmpdir} from "node:os";import {resolve} from "node:path";import {createRequire} from "node:module"; +import {admitOperatorRequest,enrollOperatorIdentity,ImmutableOperatorLedger,OPERATOR_SIGNING_PURPOSE} from "../tools/epiphany-operator-commands.mjs"; +import {admissionTuple,packetTuple,parseOperatorRequest} from "../tools/epiphany-operator-command-documents.mjs"; +import {verifyPurpose} from "../tools/bifrost-signed-identity.mjs"; +const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); +const actorLink={guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1"}; +function request(command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} +function result(admission){return {providerIdentityId:"epiphany-host",result:{schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"}};} +async function identity(){const d=await mkdtemp(resolve(tmpdir(),"bifrost-operator-"));return enrollOperatorIdentity(resolve(d,"operator.seed"));} +test("signs the exact Rust packet and admission tuples",async()=>{const id=await identity(),out=await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z"),transport:{execute:async a=>result(a)}});assert.equal(out.admission.packetSha256,`sha256-${(await import("node:crypto")).createHash("sha256").update(encode(packetTuple(out.admission.packet))).digest("hex")}`);assert.ok(verifyPurpose(id.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(out.admission)),out.admission.providerSignature));assert.equal(out.result.disposition,"observed");}); +test("requires exact owner and linked Heimdall actor",async()=>{const id=await identity();for(const args of [{ownerDiscordId:"other",actorLink},{ownerDiscordId:"owner",actorLink:null},{ownerDiscordId:"owner",actorLink:{...actorLink,heimdallActorRef:""}}])await assert.rejects(()=>admitOperatorRequest({request:request(),identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z"),transport:{execute:async a=>result(a)},...args}));}); +test("strict request, ttl, command bounds, and expiry fail closed",()=>{assert.throws(()=>parseOperatorRequest({...request(),extra:true}));assert.throws(()=>parseOperatorRequest({...request(),expiresAt:"2026-07-19T10:01:01Z"}));assert.throws(()=>parseOperatorRequest(request({kind:"argv",args:["whoami"]})));assert.throws(()=>parseOperatorRequest(request({kind:"sleep",reason:""})));}); +test("does not synthesize success without an authenticated transport result",async()=>{const id=await identity(),base={request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z")};await assert.rejects(()=>admitOperatorRequest(base),/live CultNet RUDP adapter is missing/);await assert.rejects(()=>admitOperatorRequest({...base,transport:{execute:async a=>({...result(a),providerIdentityId:"impostor"})}}),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:{execute:async a=>({...result(a),result:{...result(a).result,packetSha256:"sha256-wrong"}})}}),/not bound/);}); +test("immutable ledger rejects request and result collisions",()=>{const ledger=new ImmutableOperatorLedger(),r=request(),a={x:1};ledger.admit(r.requestId,r,a);assert.throws(()=>ledger.admit(r.requestId,{...r,nonce:"different"},a),/collision/);ledger.complete(r.requestId,{x:1},"epiphany");assert.throws(()=>ledger.complete(r.requestId,{x:2},"epiphany"),/collision/);}); diff --git a/tools/bifrost-signed-identity.mjs b/tools/bifrost-signed-identity.mjs new file mode 100644 index 0000000..2603003 --- /dev/null +++ b/tools/bifrost-signed-identity.mjs @@ -0,0 +1,14 @@ +import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as ed25519Sign, verify as ed25519Verify } from "node:crypto"; +import { chmod, mkdir, open, readFile, stat } from "node:fs/promises"; +import { dirname } 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 async function enrollSignedIdentity(path,label="Bifrost identity") { await mkdir(dirname(path),{recursive:true}); const {privateKey}=generateKeyPairSync("ed25519"),seed=Buffer.from(privateKey.export({format:"jwk"}).d,"base64url"),handle=await open(path,"wx",0o600); try{await handle.writeFile(seed);await handle.sync();}finally{await handle.close();} await chmod(path,0o600); return loadSignedIdentity(path,label); } +export async function loadSignedIdentity(path,label="Bifrost identity") { const info=await stat(path); if(!info.isFile())throw new Error(`${label} must be a regular file.`); if(process.platform!=="win32"&&(info.mode&0o077)!==0)throw new Error(`${label} private key must have mode 0600.`); if(process.platform!=="win32"&&typeof process.geteuid==="function"&&info.uid!==process.geteuid())throw new Error(`${label} private key must be owned by the service user.`); const seed=await readFile(path);if(seed.length!==32)throw new Error(`${label} seed must be exactly 32 bytes.`); const key=createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),seed]),format:"der",type:"pkcs8"}),publicKey=createPublicKey(key).export({format:"der",type:"spki"}).subarray(-32),identityId=createHash("sha256").update(ID_DOMAIN).update(publicKey).digest("hex"); return {identityId,publicKey,key}; } +export function signPurpose(identity,purpose,payload){return ed25519Sign(null,domainMessage(purpose,payload),identity.key);} +export function verifyPurpose(publicKey,purpose,payload,signature){const key=createPublicKey({key:Buffer.concat([Buffer.from("302a300506032b6570032100","hex"),Buffer.from(publicKey)]),format:"der",type:"spki"});return ed25519Verify(null,domainMessage(purpose,payload),key,signature);} +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")}`};} +function domainMessage(purpose,payload){const p=Buffer.from(purpose,"utf8");return Buffer.concat([SIGNATURE_DOMAIN,u64(p.length),p,u64(payload.length),payload]);} +function u64(value){const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(value));return b;} diff --git a/tools/epiphany-operator-command-documents.mjs b/tools/epiphany-operator-command-documents.mjs new file mode 100644 index 0000000..1bbebe7 --- /dev/null +++ b/tools/epiphany-operator-command-documents.mjs @@ -0,0 +1,20 @@ +export const operatorRequestType="voidbot.discord.epiphany_operator_request"; +export const operatorRequestSchema="voidbot.discord.epiphany_operator_request.v0"; +export const operatorAdmissionType="bifrost.operator_command.delivery"; +export const operatorAdmissionSchema="bifrost.operator_command.delivery.v0"; +export const operatorResultType="epiphany.operator_command.result"; +export const operatorResultSchema="epiphany.operator_command.result.v0"; + +export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",parseOperatorAdmission),result:make(operatorResultType,operatorResultSchema,"resultId",parseOperatorResult)};} +export function parseOperatorRequest(value){const v=exactRecord(value,["schemaName","schemaVersion","requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","producerId","producerRuntimeId","authorityClass","status","command"],"operator request"); exact(v.schemaName,operatorRequestType,"schemaName");exact(v.schemaVersion,operatorRequestSchema,"schemaVersion"); for(const k of ["requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","producerRuntimeId"])text(v[k],k); exact(v.producerId,"voidbot","producerId");exact(v.authorityClass,"operator_request_only","authorityClass");exact(v.status,"pending","status"); const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>60000)throw new Error("operator request TTL must be positive and at most 60 seconds.");parseCommand(v.command);return v;} +export function parseOperatorAdmission(v){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");exact(v.schemaVersion,operatorAdmissionSchema,"schemaVersion");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");if(!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("providerSignature must be 64 bytes.");parsePacket(v.packet);return v;} +export function parseOperatorResult(v){v=exactRecord(v,["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"],"operator result");exact(v.schemaVersion,operatorResultSchema,"schemaVersion");for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k);if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid.");if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state.");for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);date(v.completedAt,"completedAt");return v;} +export function packetTuple(p){return [p.commandId,p.nonce,p.sourceEventId,p.sourceActorId,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.issuedAt,p.expiresAt,commandTuple(p.command)];} +export function admissionTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];} +function parsePacket(v){v=exactRecord(v,["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","command"],"operator packet");for(const k of ["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId"])text(v[k],k);date(v.issuedAt,"issuedAt");date(v.expiresAt,"expiresAt");parseCommand(v.command);return v;} +function parseCommand(v){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error("command must be an object.");if(v.kind==="status"||v.kind==="wake")return exactRecord(v,["kind"],"command");if(v.kind==="sleep"){exactRecord(v,["kind","reason"],"command");text(v.reason,"reason");if(v.reason.length>512)throw new Error("sleep reason exceeds 512 characters.");return v;}if(v.kind==="directive"){exactRecord(v,["kind","objective"],"command");text(v.objective,"objective");if(v.objective.length>4096)throw new Error("directive objective exceeds 4096 characters.");return v;}throw new Error("command kind is invalid.");} +function commandTuple(v){if(v.kind==="sleep")return {kind:"sleep",reason:v.reason};if(v.kind==="directive")return {kind:"directive",objective:v.objective};return {kind:v.kind};} +function exactRecord(v,keys,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);const actual=Object.keys(v).sort(),expected=[...keys].sort();if(actual.join("\0")!==expected.join("\0"))throw new Error(`${label} has unknown or missing fields.`);return v;} +function text(v,label){if(typeof v!=="string"||!v.trim())throw new Error(`${label} is required.`);return v;} +function exact(v,e,label){if(v!==e)throw new Error(`${label} must be ${e}.`);} +function date(v,label){text(v,label);const n=Date.parse(v);if(!Number.isFinite(n)||!/T/.test(v))throw new Error(`${label} must be RFC3339.`);return n;} diff --git a/tools/epiphany-operator-commands.mjs b/tools/epiphany-operator-commands.mjs new file mode 100644 index 0000000..0f1d3f5 --- /dev/null +++ b/tools/epiphany-operator-commands.mjs @@ -0,0 +1,34 @@ +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { admissionTuple, operatorAdmissionSchema, parseOperatorAdmission, parseOperatorRequest, parseOperatorResult, packetTuple } from "./epiphany-operator-command-documents.mjs"; +import { enrollSignedIdentity, loadSignedIdentity, signPurpose, trustAnchor } from "./bifrost-signed-identity.mjs"; + +export const OPERATOR_SIGNING_PURPOSE="bifrost.operator-command.delivery.v0"; +export const defaultOperatorIdentityPath=()=>process.env.BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY??(process.platform==="win32"?resolve(".bifrost","private","epiphany-operator-ed25519.seed"):"/var/lib/gamecult/bifrost/epiphany-operator/operator-ed25519.seed"); + +// The transport is deliberately a narrow port. The live CultNet RUDP request/reply +// adapter is not yet present in Bifrost; callers must inject an authenticated port. +export async function admitOperatorRequest({request,ownerDiscordId,actorLink,identity,encode,transport,expectedEpiphanyIdentityId,now=new Date(),ledger=new ImmutableOperatorLedger()}){ + parseOperatorRequest(request); + if(request.sourceActorDiscordId!==ownerDiscordId)throw new Error("Epiphany operator command requires the exact configured Discord owner."); + if(!actorLink||actorLink.guildId!==request.discordGuildId||actorLink.discordAuthorId!==request.sourceActorDiscordId||!actorLink.bifrostActorId?.trim()||!actorLink.heimdallActorRef?.trim())throw new Error("Epiphany operator command requires the exact existing Bifrost actor link and Heimdall actor reference."); + const at=now instanceof Date?now:new Date(now);if(!Number.isFinite(at.getTime())||Date.parse(request.issuedAt)>at.getTime()||Date.parse(request.expiresAt)x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 35d5016..8873f8d 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -165,6 +165,9 @@ const locatedService = `${rootVerse}.${currentMachine}.bifrost`; const plannedLocatedService = `${rootVerse}.yggdrasil.bifrost`; const motionSurfaceCultMeshUri = `cultmesh://${locatedService}/eve/governance/surface`; const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`; +const epiphanyOperatorRequestSchemaId = "voidbot.discord.epiphany_operator_request.v0"; +const epiphanyOperatorAdmissionSchemaId = "bifrost.operator_command.delivery.v0"; +const epiphanyOperatorResultSchemaId = "epiphany.operator_command.result.v0"; const advertisementDefinition = defineDocumentType({ type: documentType, @@ -330,6 +333,8 @@ function buildAdvertisement(options) { endpoint("github-webhooks", `${locatedService}/github/webhooks`, "bifrost.work_item.v0", ["github", "work-sync", "review-sync"]), endpoint("motion-surface", motionSurfaceCultMeshUri, "gamecult.eve.surface.v1", ["cultmesh", "governance", "motion"]), endpoint("motion-commands", motionCommandCultMeshUri, "bifrost.motion_command.v0", ["command", "cultmesh", "governance", "motion"]), + endpoint("epiphany-operator-requests", `${locatedService}/commands/epiphany-operator`, epiphanyOperatorRequestSchemaId, ["command", "discord", "operator", "cultmesh"]), + endpoint("epiphany-operator-results", `${locatedService}/receipts/epiphany-operator`, epiphanyOperatorResultSchemaId, ["receipt", "epiphany", "operator", "cultmesh"]), ], routes: [ route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true), @@ -369,6 +374,9 @@ function buildAdvertisement(options) { schema(discordPostReceiptSchemaId, discordPostReceiptDocumentType, "CultMesh receipt for Bifrost-owned Discord persona/bot posts"), schema("bifrost.patron_support_event.v0", "bifrost.patron-support-event", "current hosted Heimdall-signed patron support fact consumed by Bifrost"), schema("bifrost.member_capability_snapshot.v0", "bifrost.member-capability-snapshot", "planned Heimdall-consumed membership capability witness"), + schema(epiphanyOperatorRequestSchemaId, "voidbot.discord.epiphany_operator_request", "strict owner-authored Discord operator request inbox"), + schema(epiphanyOperatorAdmissionSchemaId, "bifrost.operator_command.delivery", "Bifrost-signed expiring Epiphany operator admission"), + schema(epiphanyOperatorResultSchemaId, "epiphany.operator_command.result", "Epiphany-origin command result returned across the authenticated transport boundary"), ], witnesses: [ witness(".bifrost/provider-store.cc", `${schemaId}; ${surfaceSchemaId}; ${interfaceBindingSchemaId}; ${discordPostCommandSchemaId}; ${discordPostReceiptSchemaId}`, "current", "typed provider advertisement, operator surface, Eve interface binding, and Bifrost Discord command request/receipt store"), @@ -380,6 +388,7 @@ function buildAdvertisement(options) { 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", crossingReceiptSchemaId, "current", "canonical governed public crossing receipt history"), witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "future product Eve/CultUI surfaces not yet in the provider store"), + witness(".bifrost/epiphany-operator-requests.cc", epiphanyOperatorRequestSchemaId, "configured", "dedicated VoidBot-writable Bifrost operator request inbox; never an Epiphany store"), ], surfaces: [ surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-store.cc", "eve", [ @@ -489,6 +498,17 @@ function buildAdvertisement(options) { "surface-specific receipts cannot decide crossing completion", "does not touch secrets in this advertisement", ]), + boundary("epiphany-operator", "Bifrost admission; Epiphany execution", [ + "admit exact owner-only status, sleep, wake, and directive requests", + "require an existing Bifrost actor link and Heimdall actor reference", + "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity", + "relay only Epiphany-origin results bound to the admitted command", + ], [ + "does not accept arbitrary argv or shell commands", + "does not mount or mutate Epiphany stores", + "does not synthesize successful Epiphany results", + "ordinary Persona feedback remains feedback_only", + ]), ], styleCapabilities: [ style("density", ["compact TUI grids", "operator scan tables", "member-facing board/list/detail lowerings"]), From e0e612c69cfef821d7a878d1c93433a7d798f1ec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 13:12:04 +0100 Subject: [PATCH 007/155] Complete authenticated Epiphany operator bridge --- .env.local.example | 9 +- docs/bridge.md | 4 +- docs/verse-service-contract.md | 4 +- .../ProviderAdvertisementTests.cs | 2 +- tests/epiphany-operator-command.test.mjs | 15 ++-- tests/epiphany-operator-cultnet-rudp.test.mjs | 24 ++++++ .../epiphany-operator-discord-outbox.test.mjs | 12 +++ tests/epiphany-operator-ledger.test.mjs | 12 +++ tests/epiphany-operator-rust-interop.test.mjs | 14 +++ tests/epiphany-operator-worker.test.mjs | 20 +++++ tools/epiphany-operator-command-documents.mjs | 26 +++++- tools/epiphany-operator-commands.mjs | 71 ++++++++++----- tools/epiphany-operator-cultnet-rudp.mjs | 86 +++++++++++++++++++ tools/epiphany-operator-discord-outbox.mjs | 19 ++++ tools/epiphany-operator-ledger.mjs | 20 +++++ tools/epiphany-operator-worker.mjs | 75 ++++++++++++++++ tools/provider-advertisement.mjs | 13 ++- 17 files changed, 385 insertions(+), 41 deletions(-) create mode 100644 tests/epiphany-operator-cultnet-rudp.test.mjs create mode 100644 tests/epiphany-operator-discord-outbox.test.mjs create mode 100644 tests/epiphany-operator-ledger.test.mjs create mode 100644 tests/epiphany-operator-rust-interop.test.mjs create mode 100644 tests/epiphany-operator-worker.test.mjs create mode 100644 tools/epiphany-operator-cultnet-rudp.mjs create mode 100644 tools/epiphany-operator-discord-outbox.mjs create mode 100644 tools/epiphany-operator-ledger.mjs create mode 100644 tools/epiphany-operator-worker.mjs diff --git a/.env.local.example b/.env.local.example index 8f7b01d..fad7427 100644 --- a/.env.local.example +++ b/.env.local.example @@ -3,10 +3,15 @@ COMPOSE_PROJECT_NAME=bifrost-local -# Epiphany operator admission. No live transport default is assumed. +# Epiphany operator admission. Endpoint and trust anchor are explicit; there is +# no localhost or identity-string fallback. BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE=.bifrost/epiphany-operator-requests.cc +BIFROST_EPIPHANY_OPERATOR_LEDGER_STORE=.bifrost/epiphany-operator-ledger.cc +BIFROST_EPIPHANY_OPERATOR_DISCORD_DELIVERY_STORE=.bifrost/epiphany-operator-discord-deliveries.cc BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY=.bifrost/private/epiphany-operator-ed25519.seed +BIFROST_EPIPHANY_OPERATOR_RUDP=rudp://yggdrasil:49100 +BIFROST_EPIPHANY_EXECUTOR_TRUST_ANCHOR=.bifrost/trust/epiphany-operator-executor.cc BIFROST_EPIPHANY_OPERATOR_OWNER_DISCORD_ID= BIFROST_EPIPHANY_OPERATOR_TARGET_RUNTIME_ID=epiphany-yggdrasil -BIFROST_EPIPHANY_OPERATOR_RESULT_IDENTITY_ID= +BIFROST_PERSONA_FEEDBACK_PRIVATE_STORE=.bifrost/persona-feedback-private.cc BIFROST_EPIPHANY_OPERATOR_CULTMESH_URI= diff --git a/docs/bridge.md b/docs/bridge.md index 6f8c1a6..a5e4944 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,9 +196,9 @@ The command is a dry-run shape probe for public surfaces that are real enough to 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. -Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v0` document containing one of `status`, `sleep`, `wake`, or `directive`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the existing guild/actor link with a Heimdall actor reference. It derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v0` with a dedicated operator Ed25519 seed. The feedback seed is not reused. +Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v0` document containing one of `status`, `sleep`, `wake`, or `directive`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the exact persisted guild/actor mapping from its private actor-link store. The actor link is the admitted mapping authority; its Heimdall reference remains provenance until a signed live Heimdall claim surface exists and is not represented as authorization. Bifrost derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v0` with a dedicated operator Ed25519 seed. The feedback seed is not reused. -Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. Operator execution crosses a narrow authenticated transport port. The live CultNet RUDP request/reply adapter is still required; configuration deliberately has no localhost default. Until that adapter exists, admission fails closed. A returned `epiphany.operator_command.result.v0` must arrive under the configured Epiphany transport identity and match command id, packet digest, and target runtime. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this path. +Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. One supervised worker serializes requests across the CultNet RUDP boundary and requires the trusted Epiphany executor anchor. Success requires a host-signed `epiphany.operator_command.sealed_result.v0` whose payload digest, command id, packet digest, target runtime, completion time, and executor identity verify exactly; a transport acknowledgement is not success. Admissions and sealed results are persisted in the typed Bifrost operator ledger. Permanent pre-admission refusals are separately terminalized there, while transport failures remain retryable. An expired request cannot mint a new admission, but an exact admission already durably recorded before expiry may be retransmitted to recover Epiphany's one idempotent result without depending on mutable actor-link or signer state. The worker publishes a separate immutable Discord-delivery projection bound to the original request digest and interaction id; it exposes only bounded result/refusal fields and sealed-result fingerprints, never raw signatures or private state. VoidBot reads that outbox and owns Discord response/checkpoint state. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this 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. diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 542655f..a8a504a 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -55,7 +55,9 @@ Required witness path for the hosted service: Bifrost publishes under these CultMesh namespaces: -The operator bridge additionally advertises `commands/epiphany-operator` and `receipts/epiphany-operator`. The command endpoint consumes strict VoidBot inbox documents and emits dedicated Bifrost-signed admissions; the receipt endpoint exposes only Epiphany-origin, command-bound results. The transport adapter is intentionally unresolved until a live authenticated CultNet RUDP request/reply implementation is installed on Yggdrasil. +The operator bridge additionally advertises `commands/epiphany-operator` and `receipts/epiphany-operator`. The command endpoint consumes strict VoidBot inbox documents and emits dedicated Bifrost-signed admissions. Bifrost sends each admission as a typed `cultnet.document_put_raw.v0` document over reliable ordered UDP and waits for the correlated application receipt; transport acknowledgement alone is never success. The receipt endpoint exposes only `epiphany.operator_command.sealed_result.v0`: an exact, bounded result whose payload digest and command, packet, runtime, completion time, and executor identity are covered by the trusted Epiphany host's Ed25519 signature. Missing, late, malformed, foreign-key, replay-colliding, or incorrectly bound results fail closed. + +One supervised `tools/epiphany-operator-worker.mjs serve` process owns this route. It reads the dedicated VoidBot request inbox and Bifrost's persisted actor-link mapping, serializes commands through one RUDP client authority, and persists admissions, sealed results, and non-retryable terminal refusals in `.bifrost/epiphany-operator-ledger.cc`. It then publishes one immutable `bifrost.discord.epiphany_operator_delivery.v0` document per request to `.bifrost/epiphany-operator-discord-deliveries.cc`; Bifrost owns publication and VoidBot owns correlation plus the ephemeral Discord response. The delivery binds the original request digest and interaction coordinates to either the verified sealed-result payload/signature/provider digests or a terminal refusal code, and never contains raw signature material or private state. The exact configured Discord owner plus that persisted actor mapping are the current admission authority; any Heimdall reference on the link is provenance, not a substituted capability claim. The worker requires an explicit raw MessagePack Epiphany executor trust anchor and an explicit `rudp://` endpoint. There is no shell, argv, direct Epiphany-store, localhost, identity-string, or transport-ack fallback. - `gamecult.bifrost.service` for service registration, health, build/version, schema catalog, and command capability discovery. diff --git a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs index 9aa1c4f..1a026ca 100644 --- a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs +++ b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs @@ -23,7 +23,7 @@ public sealed class ProviderAdvertisementTests AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage"); AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github"); AssertEndpoint(root, "epiphany-operator-requests", "voidbot.discord.epiphany_operator_request.v0", "operator"); - AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.result.v0", "epiphany"); + AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.sealed_result.v0", "epiphany"); AssertSchemaPurpose(root, "bifrost.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt"); AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost"); AssertWitness(root, ".bifrost/bridge-receipts.cc", "bifrost.crossing_receipt.v1", "current"); diff --git a/tests/epiphany-operator-command.test.mjs b/tests/epiphany-operator-command.test.mjs index 2c7c9bc..afb473d 100644 --- a/tests/epiphany-operator-command.test.mjs +++ b/tests/epiphany-operator-command.test.mjs @@ -1,14 +1,17 @@ import test from "node:test";import assert from "node:assert/strict";import {mkdtemp} from "node:fs/promises";import {tmpdir} from "node:os";import {resolve} from "node:path";import {createRequire} from "node:module"; -import {admitOperatorRequest,enrollOperatorIdentity,ImmutableOperatorLedger,OPERATOR_SIGNING_PURPOSE} from "../tools/epiphany-operator-commands.mjs"; +import {admitOperatorRequest,enrollOperatorIdentity,OPERATOR_SIGNING_PURPOSE} from "../tools/epiphany-operator-commands.mjs"; import {admissionTuple,packetTuple,parseOperatorRequest} from "../tools/epiphany-operator-command-documents.mjs"; import {verifyPurpose} from "../tools/bifrost-signed-identity.mjs"; const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); const actorLink={guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1"}; function request(command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} -function result(admission){return {providerIdentityId:"epiphany-host",result:{schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"}};} +function result(admission){const value={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result:value,resultPayloadSha256:"sha256-result",commandId:value.commandId,packetSha256:value.packetSha256,targetRuntimeId:value.targetRuntimeId,completedAt:value.completedAt,providerIdentityId:"epiphany-host",privateStateExposed:false,executorSignature:new Uint8Array(64)};return {providerIdentityId:"epiphany-host",result:value,receipt};} +const transport=(execute)=>({trustedIdentityId:"epiphany-host",execute,verifyReceipt:(_admission,receipt)=>receipt}); +class TestLedger{constructor(){this.requests=new Map();this.admissions=new Map();this.completions=new Map();this.terminals=new Map();}admit(id,request,value){const oldRequest=this.requests.get(id),old=this.admissions.get(id);if(oldRequest&&JSON.stringify(oldRequest)!==JSON.stringify(request)||old&&JSON.stringify(old)!==JSON.stringify(value))throw new Error("operator request identity collision");this.requests.set(id,request);this.admissions.set(id,value);}complete(_id,result,_provider,receipt){this.completions.set(result.commandId,receipt??{...result,result,providerIdentityId:"epiphany-host"});}getRequest(id){return this.requests.get(id);}getAdmission(id){return this.admissions.get(id);}getCompletion(id){return this.completions.get(id);}terminalize(value){this.terminals.set(value.requestId,value);}getTerminal(id){return this.terminals.get(id);}} async function identity(){const d=await mkdtemp(resolve(tmpdir(),"bifrost-operator-"));return enrollOperatorIdentity(resolve(d,"operator.seed"));} -test("signs the exact Rust packet and admission tuples",async()=>{const id=await identity(),out=await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z"),transport:{execute:async a=>result(a)}});assert.equal(out.admission.packetSha256,`sha256-${(await import("node:crypto")).createHash("sha256").update(encode(packetTuple(out.admission.packet))).digest("hex")}`);assert.ok(verifyPurpose(id.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(out.admission)),out.admission.providerSignature));assert.equal(out.result.disposition,"observed");}); -test("requires exact owner and linked Heimdall actor",async()=>{const id=await identity();for(const args of [{ownerDiscordId:"other",actorLink},{ownerDiscordId:"owner",actorLink:null},{ownerDiscordId:"owner",actorLink:{...actorLink,heimdallActorRef:""}}])await assert.rejects(()=>admitOperatorRequest({request:request(),identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z"),transport:{execute:async a=>result(a)},...args}));}); +test("signs the exact Rust packet and admission tuples",async()=>{const id=await identity(),out=await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a))});assert.equal(out.admission.packetSha256,`sha256-${(await import("node:crypto")).createHash("sha256").update(encode(packetTuple(out.admission.packet))).digest("hex")}`);assert.ok(verifyPurpose(id.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(out.admission)),out.admission.providerSignature));assert.equal(out.result.disposition,"observed");}); +test("requires exact owner and admitted Bifrost actor link",async()=>{const id=await identity();for(const args of [{ownerDiscordId:"other",actorLink},{ownerDiscordId:"owner",actorLink:null}])await assert.rejects(()=>admitOperatorRequest({request:request(),identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a)),...args}));const provenanceOnly={...actorLink,heimdallActorRef:""};assert.equal((await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink:provenanceOnly,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a))})).result.disposition,"observed");}); test("strict request, ttl, command bounds, and expiry fail closed",()=>{assert.throws(()=>parseOperatorRequest({...request(),extra:true}));assert.throws(()=>parseOperatorRequest({...request(),expiresAt:"2026-07-19T10:01:01Z"}));assert.throws(()=>parseOperatorRequest(request({kind:"argv",args:["whoami"]})));assert.throws(()=>parseOperatorRequest(request({kind:"sleep",reason:""})));}); -test("does not synthesize success without an authenticated transport result",async()=>{const id=await identity(),base={request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z")};await assert.rejects(()=>admitOperatorRequest(base),/live CultNet RUDP adapter is missing/);await assert.rejects(()=>admitOperatorRequest({...base,transport:{execute:async a=>({...result(a),providerIdentityId:"impostor"})}}),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:{execute:async a=>({...result(a),result:{...result(a).result,packetSha256:"sha256-wrong"}})}}),/not bound/);}); -test("immutable ledger rejects request and result collisions",()=>{const ledger=new ImmutableOperatorLedger(),r=request(),a={x:1};ledger.admit(r.requestId,r,a);assert.throws(()=>ledger.admit(r.requestId,{...r,nonce:"different"},a),/collision/);ledger.complete(r.requestId,{x:1},"epiphany");assert.throws(()=>ledger.complete(r.requestId,{x:2},"epiphany"),/collision/);}); +test("does not synthesize success without an authenticated transport result",async()=>{const id=await identity(),base={request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger()};await assert.rejects(()=>admitOperatorRequest(base),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:transport(async a=>({...result(a),providerIdentityId:"impostor"}))}),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:transport(async a=>({...result(a),result:{...result(a).result,packetSha256:"sha256-wrong"}}))}),/not bound/);}); +test("retransmits an exact durable admission after expiry without mutable actor or signer state",async()=>{const id=await identity(),ledger=new TestLedger(),r=request(),base={request:r,ownerDiscordId:"owner",actorLink,identity:id,encode,ledger};await assert.rejects(()=>admitOperatorRequest({...base,now:new Date("2026-07-19T10:00:01Z"),transport:transport(async()=>{throw new Error("lost result")})}),/lost result/);let calls=0;const recovered=await admitOperatorRequest({...base,ownerDiscordId:"rotated-owner",actorLink:null,identity:null,now:new Date("2026-07-19T10:02:00Z"),transport:transport(async a=>{calls++;return result(a)})});assert.equal(calls,1);assert.equal(recovered.recovered,true);const fresh={...r,requestId:"interaction-2",commandId:"interaction-2",nonce:"interaction-2",sourceEventId:"interaction-2",discordMessageId:"interaction-2"};await assert.rejects(()=>admitOperatorRequest({...base,request:fresh,ledger:new TestLedger(),now:new Date("2026-07-19T10:02:00Z"),transport:transport(async a=>result(a))}),error=>error.code==="expired-new-request");}); +test("does not hide command-id collisions behind an existing completion",async()=>{const id=await identity(),ledger=new TestLedger(),base={ownerDiscordId:"owner",actorLink,identity:id,encode,ledger,now:new Date("2026-07-19T10:00:01Z"),transport:transport(async a=>result(a))};await admitOperatorRequest({...base,request:request()});const collision={...request(),requestId:"interaction-alien",nonce:"alien",sourceEventId:"alien",discordMessageId:"alien"};await assert.rejects(()=>admitOperatorRequest({...base,request:collision}),error=>error.code==="command-id-collision");}); diff --git a/tests/epiphany-operator-cultnet-rudp.test.mjs b/tests/epiphany-operator-cultnet-rudp.test.mjs new file mode 100644 index 0000000..705253d --- /dev/null +++ b/tests/epiphany-operator-cultnet-rudp.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { createSocket } from "node:dgram"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; +import { resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; +import { EPIPHANY_RESULT_SIGNING_PURPOSE,EpiphanyOperatorCultNetRudpTransport,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; +import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; + +const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")); +const msgpack=requireCult("@msgpack/msgpack"),cultnet=requireCult("cultnet-ts"); +function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} +async function fixture(){const root=await mkdtemp(resolve(tmpdir(),"epiphany-result-")),identity=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(identity,"2026-07-19T11:00:00Z"),a=admission();const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:a.packet.commandId,packetSha256:a.packetSha256,targetRuntimeId:a.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};const receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:identity.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));return {a,identity,anchor,receipt};} + +test("verifies the exact host-sealed Epiphany receipt",async()=>{const {a,anchor,receipt}=await fixture();assert.equal(verifySealedReceipt({receipt,admission:a,trustedIdentity:anchor,encode:msgpack.encode}),receipt);}); +test("refuses wrong key, signature, digest, binding, predating completion, and schema",async()=>{const {a,anchor,receipt}=await fixture(),other=await fixture();const cases=[{...receipt,providerIdentityId:other.identity.identityId},{...receipt,executorSignature:new Uint8Array(64)},{...receipt,resultPayloadSha256:"sha256-wrong"},{...receipt,packetSha256:"sha256-wrong"},{...receipt,completedAt:"2026-07-19T11:59:59Z",result:{...receipt.result,completedAt:"2026-07-19T11:59:59Z"}},{...receipt,schemaVersion:"epiphany.operator_command.sealed_result.v9"}];for(const value of cases)assert.throws(()=>verifySealedReceipt({receipt:value,admission:a,trustedIdentity:anchor,encode:msgpack.encode}));}); +test("accepts a correctly signed post-expiry receipt for durable exact recovery",async()=>{const {a,anchor,identity,receipt}=await fixture(),completedAt="2026-07-19T12:02:00Z",result={...receipt.result,completedAt},recovered={...receipt,result,completedAt,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,executorSignature:new Uint8Array()};recovered.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(recovered))));assert.equal(verifySealedReceipt({receipt:recovered,admission:a,trustedIdentity:anchor,encode:msgpack.encode}),recovered);}); +test("RUDP adapter requires a correlated typed application result and times out otherwise",async()=>{const {a,anchor,receipt}=await fixture();class FakeConnection extends EventEmitter{connected=false;connect(){this.connected=true;}send(){if(this.reply)setImmediate(()=>this.emit("frame",this.reply));}close(){this.closed=true;}}const fakeSocket=()=>{const s=new EventEmitter();s.bind=()=>setImmediate(()=>s.emit("listening"));return s;};const make=(reply,timeoutMs=100)=>{const connection=new FakeConnection();connection.reply=reply;return {connection,transport:new EpiphanyOperatorCultNetRudpTransport({endpoint:"rudp://127.0.0.1:49100",trustedEpiphanyIdentity:anchor,timeoutMs,cultnetRuntime:cultnet,msgpackRuntime:msgpack,socketFactory:fakeSocket,connectionFactory:()=>connection})};};const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:"operator-result-command-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:a.packet.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:a.packet.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}};const ok=make({channelId:"schema",payload:msgpack.encode(message)});assert.equal((await ok.transport.execute(a)).result.resultId,"result-1");const absent=make(undefined);await assert.rejects(()=>absent.transport.execute(a),/Timed out waiting for the correlated/);const wrong=make({channelId:"schema",payload:msgpack.encode({...message,document:{...message.document,recordKey:"alien"}})});await assert.rejects(()=>wrong.transport.execute(a),/substituted result authority/);}); +test("real CultNet RUDP socket path waits for the sealed application document",async()=>{const {a,anchor,receipt}=await fixture(),socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:a.packet.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const request=msgpack.decode(frame.payload);assert.equal(request.document.schemaId,"bifrost.operator_command.delivery");const response={schemaVersion:"cultnet.document_put_raw.v0",messageId:"operator-result-command-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:a.packet.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:a.packet.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}};server.send("schema",msgpack.encode(cultnet.encodeCultNetMessageForWire(response,"cultnet.schema.v0")));});try{const port=socket.address().port,transport=new EpiphanyOperatorCultNetRudpTransport({endpoint:`rudp://127.0.0.1:${port}`,trustedEpiphanyIdentity:anchor,timeoutMs:1000,cultnetRuntime:cultnet,msgpackRuntime:msgpack});const output=await transport.execute(a);assert.equal(output.receipt.providerIdentityId,anchor.identityId);assert.equal(output.result.commandId,a.packet.commandId);}finally{server.close();}}); diff --git a/tests/epiphany-operator-discord-outbox.test.mjs b/tests/epiphany-operator-discord-outbox.test.mjs new file mode 100644 index 0000000..2c43d8d --- /dev/null +++ b/tests/epiphany-operator-discord-outbox.test.mjs @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; + +const requireCache=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),requireNet=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),{encode}=requireNet("@msgpack/msgpack"); +function request(id="request-1"){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:id,commandId:id,nonce:id,sourceEventId:id,sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:id,targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};} +function receipt(id="request-1"){return {schemaVersion:"epiphany.operator_command.sealed_result.v0",result:{disposition:"observed",detail:"bounded",operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged"},resultPayloadSha256:`sha256-${"1".repeat(64)}`,providerIdentityId:"executor",executorSignature:new Uint8Array(64),completedAt:"2026-07-19T12:00:01Z",commandId:id};} +test("Discord outbox publishes immutable request-bound completed and refused deliveries",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-outbox-")),path=resolve(root,"deliveries.cc"),outbox=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode}),r=request(),completed=await outbox.publishCompleted(r,receipt());assert.equal(completed.deliveryId,r.requestId);assert.match(completed.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.match(completed.executorSignatureSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(completed.operatorStatus,"sleeping");assert.equal(completed.brakeStatus,"engaged");assert.equal(outbox.isExactDelivery(r),true);assert.equal(outbox.isExactDelivery({...r,discordChannelId:"alien"}),false);assert.equal(completed.privateStateExposed,false);await outbox.publishCompleted(r,receipt());await assert.rejects(()=>outbox.publishCompleted(r,{...receipt(),result:{...receipt().result,disposition:"applied",detail:"changed"}}),/collision/);const refusedRequest=request("request-2"),refused=await outbox.publishRefused(refusedRequest,{failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:02Z"});assert.equal(refused.status,"refused");assert.equal(refused.failureCode,"foreign-owner");assert.equal(refused.sealedResultPayloadSha256,"");const reopened=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode});assert.equal(reopened.get(r.requestId).status,"completed");assert.equal(reopened.get(refusedRequest.requestId).status,"refused");}); diff --git a/tests/epiphany-operator-ledger.test.mjs b/tests/epiphany-operator-ledger.test.mjs new file mode 100644 index 0000000..252ad0f --- /dev/null +++ b/tests/epiphany-operator-ledger.test.mjs @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; + +const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),runtime=requireCult("cultcache-ts"); +function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} +function receipt(){const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:"command-1",packetSha256:"sha256-packet",targetRuntimeId:"epiphany-yggdrasil",disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};return {schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:"sha256-result",commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:"epiphany",privateStateExposed:false,executorSignature:new Uint8Array(64)};} +test("durable operator ledger survives restart and refuses admission/result collisions",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-ledger-")),path=resolve(root,"ledger.cc"),first=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime}),a=admission(),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:a.admissionId,commandId:a.packet.commandId,nonce:a.packet.nonce,sourceEventId:a.packet.sourceEventId,sourceActorDiscordId:"owner",discordGuildId:a.packet.discordGuildId,discordChannelId:a.packet.discordChannelId,discordMessageId:a.packet.discordMessageId,targetRuntimeId:a.packet.targetRuntimeId,issuedAt:a.packet.issuedAt,expiresAt:a.packet.expiresAt,producerId:"voidbot",producerRuntimeId:a.sourceObserverRuntimeId,authorityClass:"operator_request_only",status:"pending",command:a.packet.command},r=receipt(),terminal={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:"rejected-1",commandId:"rejected-1",status:"refused",failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:00Z",retryable:false,privateStateExposed:false};await first.admit(a.admissionId,request,a);await first.complete(a.admissionId,r.result,r.providerIdentityId,r);await first.terminalize(terminal);const reopened=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime});assert.equal(reopened.getRequest(a.admissionId).sourceActorDiscordId,"owner");assert.equal(reopened.getAdmission(a.admissionId).packetSha256,a.packetSha256);assert.equal(reopened.getCompletion(r.commandId).result.resultId,r.result.resultId);assert.equal(reopened.getTerminal(terminal.requestId).failureCode,"foreign-owner");await reopened.admit(a.admissionId,request,a);await reopened.complete(a.admissionId,r.result,r.providerIdentityId,r);await reopened.terminalize(terminal);await assert.rejects(()=>reopened.admit(a.admissionId,{...request,sourceActorDiscordId:"alien"},a),/collision/);await assert.rejects(()=>reopened.admit(a.admissionId,request,{...a,packetSha256:"sha256-alien"}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,detail:"alien"},r.providerIdentityId,{...r,result:{...r.result,detail:"alien"}}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,packetSha256:"sha256-alien"},r.providerIdentityId,{...r,packetSha256:"sha256-alien",result:{...r.result,packetSha256:"sha256-alien"}}),/exact durable admission/);await assert.rejects(()=>reopened.terminalize({...terminal,failureCode:"alien"}),/collision/);}); diff --git a/tests/epiphany-operator-rust-interop.test.mjs b/tests/epiphany-operator-rust-interop.test.mjs new file mode 100644 index 0000000..b5eb5a1 --- /dev/null +++ b/tests/epiphany-operator-rust-interop.test.mjs @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { admissionTuple,parseOperatorAdmission } from "../tools/epiphany-operator-command-documents.mjs"; +import { OPERATOR_SIGNING_PURPOSE } from "../tools/epiphany-operator-commands.mjs"; +import { loadEpiphanyOperatorTrustAnchor,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; +import { verifyPurpose } from "../tools/bifrost-signed-identity.mjs"; + +const fixture=process.env.EPIPHANY_OPERATOR_RUST_FIXTURE??resolve("..","EpiphanyAgent",".epiphany-smoke","operator-command-interop-rust"),requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{decode,encode}=requireCult("@msgpack/msgpack"); +test("consumes and verifies the Rust-emitted operator admission and sealed receipt",{skip:!existsSync(resolve(fixture,"manifest.json"))&&"Rust interop fixture is not present in this standalone checkout"},async()=>{const manifest=JSON.parse(await readFile(resolve(fixture,"manifest.json"),"utf8")),admissionBytes=await readFile(resolve(fixture,manifest.admissionFile)),receiptBytes=await readFile(resolve(fixture,manifest.sealedResultFile));assert.equal(`sha256-${createHash("sha256").update(admissionBytes).digest("hex")}`,manifest.admissionSha256);assert.equal(`sha256-${createHash("sha256").update(receiptBytes).digest("hex")}`,manifest.sealedResultSha256);const admission=parseOperatorAdmission(decode(admissionBytes)),receipt=decode(receiptBytes),bifrostAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.bifrostRawTrustAnchorFile),{decode}),executorAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.executorRawTrustAnchorFile),{decode});assert.equal(verifyPurpose(bifrostAnchor.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(admission)),admission.providerSignature),true);assert.equal(verifySealedReceipt({receipt,admission,trustedIdentity:executorAnchor,encode}).commandId,admission.packet.commandId);assert.throws(()=>verifySealedReceipt({receipt:{...receipt,packetSha256:"sha256-hostile"},admission,trustedIdentity:executorAnchor,encode}),/not bound/);assert.equal(manifest.rudpConnectionId,0xe91f0001);assert.equal(manifest.privateStateExposed,false);}); diff --git a/tests/epiphany-operator-worker.test.mjs b/tests/epiphany-operator-worker.test.mjs new file mode 100644 index 0000000..c5cd1de --- /dev/null +++ b/tests/epiphany-operator-worker.test.mjs @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createSocket } from "node:dgram"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp,writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { buildOperatorDefinitions,resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; +import { buildFeedbackDefinitions } from "../tools/persona-feedback-documents.mjs"; +import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; +import { EPIPHANY_RESULT_SIGNING_PURPOSE } from "../tools/epiphany-operator-cultnet-rudp.mjs"; +import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; +import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; +import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; + +const requireCache=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),requireNet=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),cultnet=requireNet("cultnet-ts"),msgpack=requireNet("@msgpack/msgpack"); +test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); +function collect(child){return new Promise(resolveExit=>{let stdout="",stderr="";child.stdout.on("data",x=>stdout+=x);child.stderr.on("data",x=>stderr+=x);child.on("exit",code=>resolveExit({code,stdout,stderr}));});} diff --git a/tools/epiphany-operator-command-documents.mjs b/tools/epiphany-operator-command-documents.mjs index 1bbebe7..ec5bb8b 100644 --- a/tools/epiphany-operator-command-documents.mjs +++ b/tools/epiphany-operator-command-documents.mjs @@ -4,17 +4,35 @@ export const operatorAdmissionType="bifrost.operator_command.delivery"; export const operatorAdmissionSchema="bifrost.operator_command.delivery.v0"; export const operatorResultType="epiphany.operator_command.result"; export const operatorResultSchema="epiphany.operator_command.result.v0"; +export const operatorResultReceiptType="epiphany.operator_command.sealed_result"; +export const operatorResultReceiptSchema="epiphany.operator_command.sealed_result.v0"; +export const operatorTerminalType="bifrost.operator_command.terminal"; +export const operatorTerminalSchema="bifrost.operator_command.terminal.v0"; +export const operatorDiscordDeliveryType="bifrost.discord.epiphany_operator_delivery"; +export const operatorDiscordDeliverySchema="bifrost.discord.epiphany_operator_delivery.v0"; -export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",parseOperatorAdmission),result:make(operatorResultType,operatorResultSchema,"resultId",parseOperatorResult)};} +export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",parseOperatorAdmission),result:make(operatorResultType,operatorResultSchema,"resultId",parseOperatorResult),sealedResult:make(operatorResultReceiptType,operatorResultReceiptSchema,"commandId",parseOperatorResultReceipt),terminal:make(operatorTerminalType,operatorTerminalSchema,"requestId",parseOperatorTerminal),discordDelivery:make(operatorDiscordDeliveryType,operatorDiscordDeliverySchema,"deliveryId",parseOperatorDiscordDelivery)};} export function parseOperatorRequest(value){const v=exactRecord(value,["schemaName","schemaVersion","requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","producerId","producerRuntimeId","authorityClass","status","command"],"operator request"); exact(v.schemaName,operatorRequestType,"schemaName");exact(v.schemaVersion,operatorRequestSchema,"schemaVersion"); for(const k of ["requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","producerRuntimeId"])text(v[k],k); exact(v.producerId,"voidbot","producerId");exact(v.authorityClass,"operator_request_only","authorityClass");exact(v.status,"pending","status"); const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>60000)throw new Error("operator request TTL must be positive and at most 60 seconds.");parseCommand(v.command);return v;} -export function parseOperatorAdmission(v){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");exact(v.schemaVersion,operatorAdmissionSchema,"schemaVersion");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");if(!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("providerSignature must be 64 bytes.");parsePacket(v.packet);return v;} +export function parseOperatorAdmission(v){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");exact(v.schemaVersion,operatorAdmissionSchema,"schemaVersion");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");v.providerSignature=bytes(v.providerSignature,"providerSignature",64);parsePacket(v.packet);return v;} export function parseOperatorResult(v){v=exactRecord(v,["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"],"operator result");exact(v.schemaVersion,operatorResultSchema,"schemaVersion");for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k);if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid.");if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state.");for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);date(v.completedAt,"completedAt");return v;} +export function parseOperatorResultReceipt(v){v=exactRecord(v,["schemaVersion","result","resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId","privateStateExposed","executorSignature"],"operator sealed result receipt");exact(v.schemaVersion,operatorResultReceiptSchema,"schemaVersion");parseOperatorResult(v.result);for(const k of ["resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId"])text(v[k],k);date(v.completedAt,"completedAt");if(v.privateStateExposed!==false)throw new Error("operator sealed result receipt must not expose private state.");v.executorSignature=bytes(v.executorSignature,"executorSignature",64);return v;} +export function parseOperatorTerminal(v){v=exactRecord(v,["schemaVersion","requestId","commandId","status","failureCode","recordedAt","retryable","privateStateExposed"],"operator terminal disposition");exact(v.schemaVersion,operatorTerminalSchema,"schemaVersion");for(const k of ["requestId","commandId","failureCode","recordedAt"])text(v[k],k);exact(v.status,"refused","status");date(v.recordedAt,"recordedAt");if(v.retryable!==false||v.privateStateExposed!==false)throw new Error("operator terminal disposition must be non-retryable and private-state-free.");return v;} +export function parseOperatorDiscordDelivery(v){v=exactRecord(v,["schemaVersion","deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId","recordedAt","privateStateExposed"],"operator Discord delivery");exact(v.schemaVersion,operatorDiscordDeliverySchema,"schemaVersion");for(const k of ["deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","recordedAt"])text(v[k],k);if(!/^sha256-[0-9a-f]{64}$/.test(v.requestPayloadSha256))throw new Error("operator Discord delivery request digest is invalid.");if(!["completed","refused"].includes(v.status))throw new Error("operator Discord delivery status is invalid.");for(const k of ["disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);for(const k of ["detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus"])if(v[k].length>512)throw new Error(`operator Discord delivery ${k} exceeds 512 characters.`);if(v.status==="completed"&&(!v.disposition||!/^sha256-[0-9a-f]{64}$/.test(v.sealedResultPayloadSha256)||!/^sha256-[0-9a-f]{64}$/.test(v.executorSignatureSha256)||!v.resultProviderIdentityId))throw new Error("completed operator Discord delivery lacks sealed-result binding.");if(v.status==="refused"&&!v.failureCode)throw new Error("refused operator Discord delivery lacks failure code.");date(v.recordedAt,"recordedAt");if(v.privateStateExposed!==false)throw new Error("operator Discord delivery must not expose private state.");return v;} +export function operatorRequestTuple(v){return [v.schemaName,v.schemaVersion,v.requestId,v.commandId,v.nonce,v.sourceEventId,v.sourceActorDiscordId,v.discordGuildId,v.discordChannelId,v.discordMessageId,v.targetRuntimeId,v.issuedAt,v.expiresAt,v.producerId,v.producerRuntimeId,v.authorityClass,v.status,commandTuple(v.command)];} export function packetTuple(p){return [p.commandId,p.nonce,p.sourceEventId,p.sourceActorId,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.issuedAt,p.expiresAt,commandTuple(p.command)];} export function admissionTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];} +// rmp-serde's compact serializer encodes the nested Rust wire-result struct as +// this positional array inside the signed tuple. The separately hashed result +// payload is encoded with named fields. +export function wireResultTuple(v){return [v.schemaVersion,v.resultId,v.commandId,v.packetSha256,v.targetRuntimeId,v.disposition,v.consequenceKind,v.consequenceRef,v.completedAt,v.privateStateExposed,v.operatorStatus,v.stateStatus,v.coordinatorAction,v.brakeStatus,v.detail];} +export function resultReceiptTuple(v){return [v.schemaVersion,wireResultTuple(v.result),v.resultPayloadSha256,v.commandId,v.packetSha256,v.targetRuntimeId,v.completedAt,v.providerIdentityId,v.privateStateExposed];} function parsePacket(v){v=exactRecord(v,["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","command"],"operator packet");for(const k of ["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId"])text(v[k],k);date(v.issuedAt,"issuedAt");date(v.expiresAt,"expiresAt");parseCommand(v.command);return v;} function parseCommand(v){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error("command must be an object.");if(v.kind==="status"||v.kind==="wake")return exactRecord(v,["kind"],"command");if(v.kind==="sleep"){exactRecord(v,["kind","reason"],"command");text(v.reason,"reason");if(v.reason.length>512)throw new Error("sleep reason exceeds 512 characters.");return v;}if(v.kind==="directive"){exactRecord(v,["kind","objective"],"command");text(v.objective,"objective");if(v.objective.length>4096)throw new Error("directive objective exceeds 4096 characters.");return v;}throw new Error("command kind is invalid.");} -function commandTuple(v){if(v.kind==="sleep")return {kind:"sleep",reason:v.reason};if(v.kind==="directive")return {kind:"directive",objective:v.objective};return {kind:v.kind};} +// rmp-serde's compact enum form is the snake-case unit variant string, or a +// two-element [variant, sole-field] tuple for these one-field struct variants. +function commandTuple(v){if(v.kind==="sleep")return ["sleep",v.reason];if(v.kind==="directive")return ["directive",v.objective];return v.kind;} function exactRecord(v,keys,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);const actual=Object.keys(v).sort(),expected=[...keys].sort();if(actual.join("\0")!==expected.join("\0"))throw new Error(`${label} has unknown or missing fields.`);return v;} function text(v,label){if(typeof v!=="string"||!v.trim())throw new Error(`${label} is required.`);return v;} function exact(v,e,label){if(v!==e)throw new Error(`${label} must be ${e}.`);} -function date(v,label){text(v,label);const n=Date.parse(v);if(!Number.isFinite(n)||!/T/.test(v))throw new Error(`${label} must be RFC3339.`);return n;} +function date(v,label){text(v,label);if(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(v))throw new Error(`${label} must be RFC3339.`);const n=Date.parse(v);if(!Number.isFinite(n))throw new Error(`${label} must be RFC3339.`);return n;} +function bytes(v,label,length){const normalized=v instanceof Uint8Array?v:Array.isArray(v)&&v.every(x=>Number.isInteger(x)&&x>=0&&x<=255)?Uint8Array.from(v):null;if(!normalized||normalized.length!==length)throw new Error(`${label} must be ${length} bytes.`);return normalized;} diff --git a/tools/epiphany-operator-commands.mjs b/tools/epiphany-operator-commands.mjs index 0f1d3f5..db934c9 100644 --- a/tools/epiphany-operator-commands.mjs +++ b/tools/epiphany-operator-commands.mjs @@ -1,34 +1,59 @@ import { createHash } from "node:crypto"; import { resolve } from "node:path"; -import { admissionTuple, operatorAdmissionSchema, parseOperatorAdmission, parseOperatorRequest, parseOperatorResult, packetTuple } from "./epiphany-operator-command-documents.mjs"; -import { enrollSignedIdentity, loadSignedIdentity, signPurpose, trustAnchor } from "./bifrost-signed-identity.mjs"; +import { admissionTuple,operatorAdmissionSchema,parseOperatorAdmission,parseOperatorRequest,parseOperatorResult,parseOperatorResultReceipt,packetTuple } from "./epiphany-operator-command-documents.mjs"; +import { enrollSignedIdentity,loadSignedIdentity,signPurpose,trustAnchor } from "./bifrost-signed-identity.mjs"; export const OPERATOR_SIGNING_PURPOSE="bifrost.operator-command.delivery.v0"; export const defaultOperatorIdentityPath=()=>process.env.BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY??(process.platform==="win32"?resolve(".bifrost","private","epiphany-operator-ed25519.seed"):"/var/lib/gamecult/bifrost/epiphany-operator/operator-ed25519.seed"); -// The transport is deliberately a narrow port. The live CultNet RUDP request/reply -// adapter is not yet present in Bifrost; callers must inject an authenticated port. -export async function admitOperatorRequest({request,ownerDiscordId,actorLink,identity,encode,transport,expectedEpiphanyIdentityId,now=new Date(),ledger=new ImmutableOperatorLedger()}){ - parseOperatorRequest(request); - if(request.sourceActorDiscordId!==ownerDiscordId)throw new Error("Epiphany operator command requires the exact configured Discord owner."); - if(!actorLink||actorLink.guildId!==request.discordGuildId||actorLink.discordAuthorId!==request.sourceActorDiscordId||!actorLink.bifrostActorId?.trim()||!actorLink.heimdallActorRef?.trim())throw new Error("Epiphany operator command requires the exact existing Bifrost actor link and Heimdall actor reference."); - const at=now instanceof Date?now:new Date(now);if(!Number.isFinite(at.getTime())||Date.parse(request.issuedAt)>at.getTime()||Date.parse(request.expiresAt)at.getTime()||Date.parse(request.expiresAt)x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/epiphany-operator-cultnet-rudp.mjs b/tools/epiphany-operator-cultnet-rudp.mjs new file mode 100644 index 0000000..095316a --- /dev/null +++ b/tools/epiphany-operator-cultnet-rudp.mjs @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; +import { createSocket } from "node:dgram"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + operatorAdmissionType, + operatorResultReceiptType, + parseOperatorAdmission, + parseOperatorResultReceipt, + resultReceiptTuple, +} from "./epiphany-operator-command-documents.mjs"; +import { verifyPurpose } from "./bifrost-signed-identity.mjs"; + +export const EPIPHANY_RESULT_SIGNING_PURPOSE="epiphany.operator-command.sealed-result.v0"; +const RUDP_CONNECTION_ID=0xe91f0001; + +export class EpiphanyOperatorCultNetRudpTransport { + constructor({endpoint,runtimeId="bifrost-yggdrasil",trustedEpiphanyIdentity,timeoutMs=5000,cultnetRuntime,msgpackRuntime,socketFactory=createSocket,connectionFactory}={}){ + this.endpoint=parseEndpoint(endpoint); + this.runtimeId=required(runtimeId,"Bifrost transport runtime id"); + this.trustedIdentity=parseTrustAnchor(trustedEpiphanyIdentity); + if(!Number.isInteger(timeoutMs)||timeoutMs<100||timeoutMs>60000)throw new Error("Epiphany operator result timeout must be 100..=60000 milliseconds."); + this.timeoutMs=timeoutMs; + const loaded=(!cultnetRuntime||!msgpackRuntime)?loadCultNetRuntime():{}; + this.cultnet=cultnetRuntime??loaded.cultnet; + this.msgpack=msgpackRuntime??loaded.msgpack; + this.socketFactory=socketFactory; + this.connectionFactory=connectionFactory??(options=>new this.cultnet.CultNetRudpSocketTransportConnection(options)); + this.tail=Promise.resolve(); + } + + get trustedIdentityId(){return this.trustedIdentity.identityId;} + verifyReceipt(admission,receipt){return verifySealedReceipt({receipt,admission,trustedIdentity:this.trustedIdentity,encode:this.msgpack.encode});} + + execute(admission){ + const work=this.tail.then(()=>this.#executeOne(admission)); + this.tail=work.catch(()=>undefined); + return work; + } + + async #executeOne(admission){ + parseOperatorAdmission(admission); + const socket=this.socketFactory("udp4"); + await bindSocket(socket); + const connection=this.connectionFactory({runtimeId:this.runtimeId,socket,mode:"client",remoteHost:this.endpoint.host,remotePort:this.endpoint.port,connectionId:RUDP_CONNECTION_ID,resendDelayMs:50,resendPollMs:10}); + try{ + connection.connect(); + await waitForConnected(connection,this.timeoutMs); + const response=waitForReceipt(connection,this.msgpack,this.timeoutMs,admission); + const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`operator-request-${admission.admissionId}`,document:{schemaId:operatorAdmissionType,recordKey:admission.admissionId,storedAt:admission.packet.issuedAt,payloadEncoding:"messagepack",payload:this.msgpack.encode(admission),sourceRuntimeId:admission.sourceObserverRuntimeId,sourceAgentId:admission.providerIdentityId,sourceRole:"bifrost-operator-admission",tags:["cultnet.transport.rudp.v0"]}}; + connection.send("schema",this.msgpack.encode(this.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0"))); + const receipt=await response; + this.verifyReceipt(admission,receipt); + return {providerIdentityId:receipt.providerIdentityId,result:receipt.result,receipt}; + }finally{connection.close();} + } +} + +export async function loadEpiphanyOperatorTrustAnchor(path,{decode}={}){const runtime=decode?{decode}:loadCultNetRuntime().msgpack,value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==6)throw new Error("Epiphany operator trust anchor must be the raw compact 6-field MessagePack crossing artifact.");return parseTrustAnchor({schemaVersion:value[0],identityId:value[1],publicKey:value[2],assurance:value[3],identityCreatedAt:value[4],sourceIdentityRecordSha256:value[5]});} + +export function verifySealedReceipt({receipt,admission,trustedIdentity,encode}){ + parseOperatorAdmission(admission);parseOperatorResultReceipt(receipt);const anchor=parseTrustAnchor(trustedIdentity); + if(receipt.providerIdentityId!==anchor.identityId)throw new Error("Epiphany sealed result used an untrusted host identity."); + if(receipt.commandId!==admission.packet.commandId||receipt.packetSha256!==admission.packetSha256||receipt.targetRuntimeId!==admission.packet.targetRuntimeId)throw new Error("Epiphany sealed result receipt is not bound to the admitted command."); + if(receipt.result.commandId!==receipt.commandId||receipt.result.packetSha256!==receipt.packetSha256||receipt.result.targetRuntimeId!==receipt.targetRuntimeId||receipt.result.completedAt!==receipt.completedAt)throw new Error("Epiphany sealed result receipt substituted its embedded application result."); + // Expiry gates first admission, not exact crash recovery. Epiphany may seal + // an already-admitted command after its lease while recovering the one + // idempotent consequence; Bifrost's durable ledger owns whether such a + // post-expiry retransmission was authorized. + const completed=Date.parse(receipt.completedAt),issued=Date.parse(admission.packet.issuedAt); + if(completed{const finish=(fn,value)=>{clearTimeout(timer);connection.off?.("frame",onFrame);connection.off?.("error",onError);fn(value);};const onError=error=>finish(reject,error);const onFrame=frame=>{try{if(frame.channelId!=="schema")throw new Error("Epiphany operator response arrived on a foreign CultNet channel.");const message=msgpack.decode(frame.payload);if(!message||message.schemaVersion!=="cultnet.document_put_raw.v0")throw new Error("Epiphany operator response is not a CultNet raw typed document.");const d=message.document;if(!d||d.schemaId!==operatorResultReceiptType||d.recordKey!==admission.packet.commandId||d.payloadEncoding!=="messagepack"||d.sourceRole!=="epiphany-operator-command-executor"||d.sourceRuntimeId!==admission.packet.targetRuntimeId)throw new Error("Epiphany operator response envelope substituted result authority.");const receipt=msgpack.decode(d.payload);if(d.sourceAgentId!==receipt?.providerIdentityId)throw new Error("Epiphany operator response envelope substituted executor identity.");finish(resolveReceipt,receipt);}catch(error){finish(reject,error);}};const timer=setTimeout(()=>finish(reject,new Error("Timed out waiting for the correlated Epiphany application result.")),timeoutMs);connection.on("frame",onFrame);connection.on("error",onError);});} +async function waitForConnected(connection,timeoutMs){const started=Date.now();while(!connection.connected){if(Date.now()-started>=timeoutMs)throw new Error("Timed out establishing the Epiphany CultNet RUDP session.");await new Promise(done=>setTimeout(done,5));}} +function bindSocket(socket){return new Promise((resolveBind,reject)=>{const fail=error=>{socket.off("listening",ready);reject(error)};const ready=()=>{socket.off("error",fail);resolveBind()};socket.once("error",fail);socket.once("listening",ready);socket.bind(0,"0.0.0.0");});} +function parseEndpoint(value){required(value,"Epiphany operator RUDP endpoint");const url=new URL(value);if(url.protocol!=="rudp:"||!url.hostname||!url.port||(url.pathname&&url.pathname!=="/"))throw new Error("Epiphany operator endpoint must be rudp://host:port.");const port=Number(url.port);if(!Number.isInteger(port)||port<1||port>65535)throw new Error("Epiphany operator endpoint port is invalid.");return {host:url.hostname,port};} +function parseTrustAnchor(value){if(!value||typeof value!=="object"||Array.isArray(value))throw new Error("Trusted Epiphany host identity is required.");const keys=Object.keys(value).sort(),expected=["assurance","identityCreatedAt","identityId","publicKey","schemaVersion","sourceIdentityRecordSha256"].sort();if(keys.join("\0")!==expected.join("\0")||value.schemaVersion!=="epiphany.host_identity_trust_anchor.v0"||typeof value.identityId!=="string"||!value.assurance?.trim()||!/^sha256-[0-9a-f]{64}$/.test(value.sourceIdentityRecordSha256)||!Number.isFinite(Date.parse(value.identityCreatedAt)))throw new Error("Trusted Epiphany host identity violates its exact schema.");value.publicKey=value.publicKey instanceof Uint8Array?value.publicKey:Array.isArray(value.publicKey)&&value.publicKey.every(x=>Number.isInteger(x)&&x>=0&&x<=255)?Uint8Array.from(value.publicKey):null;if(!value.publicKey||value.publicKey.length!==32)throw new Error("Trusted Epiphany host identity public key must be 32 bytes.");const derived=createHash("sha256").update(Buffer.from("epiphany.host-incarnation.identity.v0\0")).update(value.publicKey).digest("hex");if(derived!==value.identityId)throw new Error("Trusted Epiphany host identity id does not match its public key.");return value;} +function loadCultNetRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error(`CultNet TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return {cultnet:requireCult("cultnet-ts"),msgpack:requireCult("@msgpack/msgpack")};} +function required(value,label){if(typeof value!=="string"||!value.trim())throw new Error(`${label} is required.`);return value;} diff --git a/tools/epiphany-operator-discord-outbox.mjs b/tools/epiphany-operator-discord-outbox.mjs new file mode 100644 index 0000000..62611a8 --- /dev/null +++ b/tools/epiphany-operator-discord-outbox.mjs @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { buildOperatorDefinitions,operatorRequestTuple } from "./epiphany-operator-command-documents.mjs"; + +export class EpiphanyOperatorDiscordOutbox { + constructor(cache,definition,encode){this.cache=cache;this.definition=definition;this.encode=encode;} + static async open(path,{cultcacheRuntime,encode}={}){const runtime=cultcacheRuntime??loadCultCacheRuntime(),definition=buildOperatorDefinitions(runtime.defineDocumentType).discordDelivery,cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();if(typeof encode!=="function")throw new Error("operator Discord outbox requires the canonical MessagePack encoder");await cache.pullAllBackingStores();return new EpiphanyOperatorDiscordOutbox(cache,definition,encode);} + get(requestId){return this.cache.get(this.definition,requestId);} + isExactDelivery(request){const value=this.get(request.requestId);return Boolean(value&&value.requestPayloadSha256===this.requestDigest(request)&&value.commandId===request.commandId&&value.discordGuildId===request.discordGuildId&&value.discordChannelId===request.discordChannelId&&value.discordInteractionId===request.sourceEventId&&value.targetRuntimeId===request.targetRuntimeId);} + async publishCompleted(request,receipt){const value=this.base(request,{status:"completed",disposition:receipt.result.disposition,failureCode:"",detail:receipt.result.detail,operatorStatus:receipt.result.operatorStatus,stateStatus:receipt.result.stateStatus,coordinatorAction:receipt.result.coordinatorAction,brakeStatus:receipt.result.brakeStatus,sealedResultPayloadSha256:receipt.resultPayloadSha256,executorSignatureSha256:`sha256-${createHash("sha256").update(receipt.executorSignature).digest("hex")}`,resultProviderIdentityId:receipt.providerIdentityId,recordedAt:receipt.completedAt});return this.put(value);} + async publishRefused(request,terminal){const value=this.base(request,{status:"refused",disposition:"",failureCode:terminal.failureCode,detail:terminal.failureCode,operatorStatus:"",stateStatus:"",coordinatorAction:"",brakeStatus:"",sealedResultPayloadSha256:"",executorSignatureSha256:"",resultProviderIdentityId:"",recordedAt:terminal.recordedAt});return this.put(value);} + requestDigest(request){return `sha256-${createHash("sha256").update(this.encode(operatorRequestTuple(request))).digest("hex")}`;} + base(request,result){return {schemaVersion:"bifrost.discord.epiphany_operator_delivery.v0",deliveryId:request.requestId,requestId:request.requestId,requestPayloadSha256:this.requestDigest(request),commandId:request.commandId,discordGuildId:request.discordGuildId,discordChannelId:request.discordChannelId,discordInteractionId:request.sourceEventId,targetRuntimeId:request.targetRuntimeId,...result,privateStateExposed:false};} + async put(value){const current=this.get(value.requestId);if(current&&stable(current)!==stable(value))throw new Error("operator Discord delivery identity collision");if(!current)await this.cache.put(this.definition,value.deliveryId,value);return current??value;} +} +function loadCultCacheRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultcache-ts","package.json");if(!existsSync(pkg))throw new Error(`CultCache TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return requireCult("cultcache-ts");} +function stable(v){return JSON.stringify(v,(_k,x)=>x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/epiphany-operator-ledger.mjs b/tools/epiphany-operator-ledger.mjs new file mode 100644 index 0000000..b1410e4 --- /dev/null +++ b/tools/epiphany-operator-ledger.mjs @@ -0,0 +1,20 @@ +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { buildOperatorDefinitions } from "./epiphany-operator-command-documents.mjs"; + +export class DurableOperatorLedger { + constructor(cache,definitions){this.cache=cache;this.definitions=definitions;} + static async open(path,{cultcacheRuntime}={}){const runtime=cultcacheRuntime??loadCultCacheRuntime(),definitions=buildOperatorDefinitions(runtime.defineDocumentType),cache=runtime.CultCache.builder().withDocumentType(definitions.request).withDocumentType(definitions.admission).withDocumentType(definitions.sealedResult).withDocumentType(definitions.terminal).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return new DurableOperatorLedger(cache,definitions);} + async admit(id,request,admission){if(id!==request.requestId||id!==admission.admissionId||request.commandId!==admission.packet.commandId)throw new Error("operator admission ledger key is not bound to request and admission identity");const currentRequest=this.cache.get(this.definitions.request,id),currentAdmission=this.cache.get(this.definitions.admission,id);if(currentRequest&&stable(currentRequest)!==stable(request)||currentAdmission&&stable(currentAdmission)!==stable(admission))throw new Error("operator request identity collision");if(!currentRequest)await this.cache.put(this.definitions.request,id,request);if(!currentAdmission)await this.cache.put(this.definitions.admission,id,admission);} + async complete(id,result,providerIdentityId,receipt){const admission=this.cache.get(this.definitions.admission,id),request=this.cache.get(this.definitions.request,id);if(!admission||!request)throw new Error("operator result has no durable admitted request");if(!receipt||receipt.commandId!==result.commandId||receipt.providerIdentityId!==providerIdentityId||stable(receipt.result)!==stable(result)||receipt.commandId!==admission.packet.commandId||receipt.packetSha256!==admission.packetSha256||receipt.targetRuntimeId!==admission.packet.targetRuntimeId||request.commandId!==receipt.commandId)throw new Error("operator completion is not bound to its exact durable admission");const current=this.cache.get(this.definitions.sealedResult,result.commandId);if(current&&stable(current)!==stable(receipt))throw new Error("operator result identity collision");if(!current)await this.cache.put(this.definitions.sealedResult,result.commandId,receipt);} + async terminalize(value){const current=this.cache.get(this.definitions.terminal,value.requestId);if(current&&stable(current)!==stable(value))throw new Error("operator terminal disposition identity collision");if(!current)await this.cache.put(this.definitions.terminal,value.requestId,value);} + getAdmission(id){return this.cache.get(this.definitions.admission,id);} + getRequest(id){return this.cache.get(this.definitions.request,id);} + getCompletion(commandId){return this.cache.get(this.definitions.sealedResult,commandId);} + getTerminal(requestId){return this.cache.get(this.definitions.terminal,requestId);} + isExactCompletion(requestId,commandId){const request=this.getRequest(requestId),admission=this.getAdmission(requestId),completion=this.getCompletion(commandId);return Boolean(request&&admission&&completion&&request.commandId===commandId&&admission.packet.commandId===commandId&&completion.commandId===commandId&&completion.packetSha256===admission.packetSha256&&completion.targetRuntimeId===admission.packet.targetRuntimeId);} +} + +function loadCultCacheRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultcache-ts","package.json");if(!existsSync(pkg))throw new Error(`CultCache TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return requireCult("cultcache-ts");} +function stable(v){return JSON.stringify(v,(_k,x)=>x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/epiphany-operator-worker.mjs b/tools/epiphany-operator-worker.mjs new file mode 100644 index 0000000..3749eb9 --- /dev/null +++ b/tools/epiphany-operator-worker.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { buildOperatorDefinitions } from "./epiphany-operator-command-documents.mjs"; +import { buildFeedbackDefinitions } from "./persona-feedback-documents.mjs"; +import { admitOperatorRequest,loadOperatorIdentity,OperatorCommandPermanentRefusal } from "./epiphany-operator-commands.mjs"; +import { EpiphanyOperatorCultNetRudpTransport,loadEpiphanyOperatorTrustAnchor } from "./epiphany-operator-cultnet-rudp.mjs"; +import { DurableOperatorLedger } from "./epiphany-operator-ledger.mjs"; +import { EpiphanyOperatorDiscordOutbox } from "./epiphany-operator-discord-outbox.mjs"; + +const {command,options}=parseArgs(process.argv.slice(2)); +if(command==="help"){ + process.stdout.write("epiphany-operator-worker --request-store --private-store --ledger-store --delivery-store --private-key --epiphany-trust-anchor --epiphany-rudp --owner-discord-id [--runtime-id ] [--interval-ms ] [--timeout-ms ]\n"); + process.exit(0); +} +await main().catch(error=>{process.stderr.write(`${error instanceof Error?error.message:String(error)}\n`);process.exitCode=1;}); + +async function main(){ + if(!["once","serve"].includes(command))throw new Error("operator worker command must be once or serve"); + const runtime=loadRuntime(); + const operatorDefs=buildOperatorDefinitions(runtime.defineDocumentType); + const feedbackDefs=buildFeedbackDefinitions(runtime.defineDocumentType); + const requestStore=requiredOption("request-store","BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE"); + const privateStore=requiredOption("private-store","BIFROST_PERSONA_FEEDBACK_PRIVATE_STORE"); + const ledgerStore=requiredOption("ledger-store","BIFROST_EPIPHANY_OPERATOR_LEDGER_STORE"); + const deliveryStore=requiredOption("delivery-store","BIFROST_EPIPHANY_OPERATOR_DISCORD_DELIVERY_STORE"); + const ownerDiscordId=requiredOption("owner-discord-id","BIFROST_EPIPHANY_OPERATOR_OWNER_DISCORD_ID"); + assertDistinctStores(requestStore,privateStore,ledgerStore,deliveryStore); + const identity=await loadOperatorIdentity(resolve(requiredOption("private-key","BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY"))); + const anchor=await loadEpiphanyOperatorTrustAnchor(resolve(requiredOption("epiphany-trust-anchor","BIFROST_EPIPHANY_EXECUTOR_TRUST_ANCHOR")),{decode:runtime.decode}); + const transport=new EpiphanyOperatorCultNetRudpTransport({endpoint:requiredOption("epiphany-rudp","BIFROST_EPIPHANY_OPERATOR_RUDP"),runtimeId:option("runtime-id","BIFROST_RUNTIME_ID")||"bifrost-yggdrasil",trustedEpiphanyIdentity:anchor,timeoutMs:integerOption("timeout-ms",5000,100,60000),cultnetRuntime:runtime.cultnet,msgpackRuntime:runtime.msgpack}); + const ledger=await DurableOperatorLedger.open(resolve(ledgerStore),{cultcacheRuntime:runtime.cultcache}); + const outbox=await EpiphanyOperatorDiscordOutbox.open(resolve(deliveryStore),{cultcacheRuntime:runtime.cultcache,encode:runtime.encode}); + const requests=runtime.CultCache.builder().withDocumentType(operatorDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(resolve(requestStore))).build(); + const privateCache=runtime.CultCache.builder().withDocumentType(feedbackDefs.actorLink).withGenericStore(new runtime.SingleFileMessagePackBackingStore(resolve(privateStore))).build(); + const interval=integerOption("interval-ms",500,100,60000); + do{ + const output=await processPending({requests,privateCache,operatorDefs,feedbackDefs,ledger,outbox,ownerDiscordId,identity,transport,encode:runtime.encode}); + process.stdout.write(`${JSON.stringify({schemaVersion:"bifrost.epiphany_operator_worker_pulse.v0",status:output.every(x=>["completed","refused"].includes(x.status))?"completed":"degraded",processed:output.length,results:output,privateStateExposed:false})}\n`); + if(command==="once")return; + await new Promise(done=>setTimeout(done,interval)); + }while(true); +} + +async function processPending({requests,privateCache,operatorDefs,feedbackDefs,ledger,outbox,ownerDiscordId,identity,transport,encode}){ + await requests.pullAllBackingStores();await privateCache.pullAllBackingStores(); + const pending=requests.getAll(operatorDefs.request).filter(request=>!outbox.isExactDelivery(request)).sort((a,b)=>a.issuedAt.localeCompare(b.issuedAt)); + const output=[]; + for(const request of pending){ + const terminal=ledger.getTerminal(request.requestId); + if(terminal){await outbox.publishRefused(request,terminal);output.push(summary(request,"refused","",terminal.failureCode));continue;} + try{ + const actorLink=privateCache.get(feedbackDefs.actorLink,`${request.discordGuildId}:${request.sourceActorDiscordId}`); + const result=await admitOperatorRequest({request,ownerDiscordId,actorLink,identity,encode,transport,ledger}); + await outbox.publishCompleted(request,result.receipt); + output.push(summary(request,"completed",result.result.disposition,result.result.detail)); + }catch(error){ + if(error instanceof OperatorCommandPermanentRefusal){ + const value={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:request.requestId,commandId:request.commandId,status:"refused",failureCode:error.code,recordedAt:new Date().toISOString(),retryable:false,privateStateExposed:false}; + await ledger.terminalize(value);await outbox.publishRefused(request,value); + } + output.push(summary(request,error instanceof OperatorCommandPermanentRefusal?"refused":"retryable-failure","",error instanceof OperatorCommandPermanentRefusal?error.code:"transport-or-ledger-unavailable")); + } + } + return output; +} + +function summary(request,status,disposition,detail){return {requestId:request.requestId,commandId:request.commandId,status,disposition,detail};} +function loadRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),cachePkg=resolve(cult,"packages","cultcache-ts","package.json"),netPkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(cachePkg)||!existsSync(netPkg))throw new Error("CultLib CultCache/CultNet TypeScript runtimes are unavailable");const cr=createRequire(cachePkg),nr=createRequire(netPkg),cultcache=cr("cultcache-ts"),msgpack=nr("@msgpack/msgpack");return {cultcache,cultnet:nr("cultnet-ts"),msgpack,encode:msgpack.encode,decode:msgpack.decode,CultCache:cultcache.CultCache,SingleFileMessagePackBackingStore:cultcache.SingleFileMessagePackBackingStore,defineDocumentType:cultcache.defineDocumentType};} +function parseArgs(values){const command=values.shift()??"help",options={},allowed=new Set(["request-store","private-store","ledger-store","delivery-store","private-key","epiphany-trust-anchor","epiphany-rudp","owner-discord-id","runtime-id","interval-ms","timeout-ms"]);while(values.length){const flag=values.shift();if(!flag?.startsWith("--")||!values.length)throw new Error(`invalid operator worker option ${flag??""}`);const key=flag.slice(2);if(!allowed.has(key))throw new Error(`unknown operator worker option --${key}`);if(Object.hasOwn(options,key))throw new Error(`duplicate operator worker option --${key}`);options[key]=values.shift();}return {command,options};} +function option(name,environment){return options[name]??process.env[environment];} +function requiredOption(name,environment){const value=option(name,environment);if(typeof value!=="string"||!value.trim())throw new Error(`operator worker requires --${name} or ${environment}`);return value;} +function integerOption(name,fallback,min,max){const raw=options[name],value=raw===undefined?fallback:Number(raw);if(!Number.isInteger(value)||valuemax)throw new Error(`operator worker --${name} must be ${min}..=${max}`);return value;} +function assertDistinctStores(...paths){const resolved=paths.map(path=>resolve(path).toLowerCase());if(new Set(resolved).size!==resolved.length)throw new Error("operator request, actor-link, immutable ledger, and Discord delivery stores must be physically distinct");} diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 8873f8d..63b54c4 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -168,6 +168,9 @@ const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`; const epiphanyOperatorRequestSchemaId = "voidbot.discord.epiphany_operator_request.v0"; const epiphanyOperatorAdmissionSchemaId = "bifrost.operator_command.delivery.v0"; const epiphanyOperatorResultSchemaId = "epiphany.operator_command.result.v0"; +const epiphanyOperatorSealedResultSchemaId = "epiphany.operator_command.sealed_result.v0"; +const epiphanyOperatorTerminalSchemaId = "bifrost.operator_command.terminal.v0"; +const epiphanyOperatorDiscordDeliverySchemaId = "bifrost.discord.epiphany_operator_delivery.v0"; const advertisementDefinition = defineDocumentType({ type: documentType, @@ -334,7 +337,8 @@ function buildAdvertisement(options) { endpoint("motion-surface", motionSurfaceCultMeshUri, "gamecult.eve.surface.v1", ["cultmesh", "governance", "motion"]), endpoint("motion-commands", motionCommandCultMeshUri, "bifrost.motion_command.v0", ["command", "cultmesh", "governance", "motion"]), endpoint("epiphany-operator-requests", `${locatedService}/commands/epiphany-operator`, epiphanyOperatorRequestSchemaId, ["command", "discord", "operator", "cultmesh"]), - endpoint("epiphany-operator-results", `${locatedService}/receipts/epiphany-operator`, epiphanyOperatorResultSchemaId, ["receipt", "epiphany", "operator", "cultmesh"]), + endpoint("epiphany-operator-results", `${locatedService}/receipts/epiphany-operator`, epiphanyOperatorSealedResultSchemaId, ["receipt", "epiphany", "operator", "cultnet", "rudp"]), + endpoint("epiphany-operator-discord-deliveries", `${locatedService}/deliveries/discord/epiphany-operator`, epiphanyOperatorDiscordDeliverySchemaId, ["delivery", "discord", "operator", "cultcache"]), ], routes: [ route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true), @@ -377,6 +381,9 @@ function buildAdvertisement(options) { schema(epiphanyOperatorRequestSchemaId, "voidbot.discord.epiphany_operator_request", "strict owner-authored Discord operator request inbox"), schema(epiphanyOperatorAdmissionSchemaId, "bifrost.operator_command.delivery", "Bifrost-signed expiring Epiphany operator admission"), schema(epiphanyOperatorResultSchemaId, "epiphany.operator_command.result", "Epiphany-origin command result returned across the authenticated transport boundary"), + schema(epiphanyOperatorSealedResultSchemaId, "epiphany.operator_command.sealed_result", "Epiphany host-signed receipt binding the exact application result to command, packet, runtime, and payload digest"), + schema(epiphanyOperatorTerminalSchemaId, "bifrost.operator_command.terminal", "Bifrost-owned immutable terminal refusal for a non-retryable operator request"), + schema(epiphanyOperatorDiscordDeliverySchemaId, "bifrost.discord.epiphany_operator_delivery", "Bifrost-owned immutable Discord result delivery bound to the original request and sealed Epiphany result or terminal refusal"), ], witnesses: [ witness(".bifrost/provider-store.cc", `${schemaId}; ${surfaceSchemaId}; ${interfaceBindingSchemaId}; ${discordPostCommandSchemaId}; ${discordPostReceiptSchemaId}`, "current", "typed provider advertisement, operator surface, Eve interface binding, and Bifrost Discord command request/receipt store"), @@ -389,6 +396,8 @@ function buildAdvertisement(options) { witness(".bifrost/bridge-receipts.cc", crossingReceiptSchemaId, "current", "canonical governed public crossing receipt history"), witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "future product Eve/CultUI surfaces not yet in the provider store"), witness(".bifrost/epiphany-operator-requests.cc", epiphanyOperatorRequestSchemaId, "configured", "dedicated VoidBot-writable Bifrost operator request inbox; never an Epiphany store"), + witness(".bifrost/epiphany-operator-ledger.cc", `${epiphanyOperatorAdmissionSchemaId}; ${epiphanyOperatorSealedResultSchemaId}; ${epiphanyOperatorTerminalSchemaId}`, "configured", "Bifrost-owned immutable admission, host-sealed application-result, and terminal-refusal ledger"), + witness(".bifrost/epiphany-operator-discord-deliveries.cc", epiphanyOperatorDiscordDeliverySchemaId, "configured", "Bifrost-writable and VoidBot-readable immutable operator result delivery outbox"), ], surfaces: [ surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-store.cc", "eve", [ @@ -500,7 +509,7 @@ function buildAdvertisement(options) { ]), boundary("epiphany-operator", "Bifrost admission; Epiphany execution", [ "admit exact owner-only status, sleep, wake, and directive requests", - "require an existing Bifrost actor link and Heimdall actor reference", + "require the exact configured Discord owner and persisted Bifrost actor-link mapping; Heimdall reference is provenance until signed claims are live", "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity", "relay only Epiphany-origin results bound to the admitted command", ], [ From dabf7aa320f89fbca6733797b06255405a092ece Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 15:09:26 +0100 Subject: [PATCH 008/155] Align Epiphany operator bridge v1 --- .env.local.example | 2 + README.md | 3 + docs/epiphany-operator-boundary.md | 48 +++++++++ .../ProviderAdvertisementTests.cs | 5 +- tests/epiphany-operator-command.test.mjs | 2 +- tests/epiphany-operator-cultnet-rudp.test.mjs | 2 +- .../epiphany-operator-discord-outbox.test.mjs | 9 +- tests/epiphany-operator-rust-interop.test.mjs | 2 +- tests/epiphany-operator-v1.test.mjs | 99 +++++++++++++++++++ tests/epiphany-operator-worker.test.mjs | 3 +- tools/epiphany-operator-command-documents.mjs | 31 +++--- tools/epiphany-operator-commands.mjs | 6 +- tools/epiphany-operator-cultnet-rudp.mjs | 11 ++- tools/epiphany-operator-discord-outbox.mjs | 8 +- tools/provider-advertisement.mjs | 26 ++--- 15 files changed, 216 insertions(+), 41 deletions(-) create mode 100644 docs/epiphany-operator-boundary.md create mode 100644 tests/epiphany-operator-v1.test.mjs diff --git a/.env.local.example b/.env.local.example index fad7427..86bdaf8 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,6 +2,8 @@ # The default compose.local.yaml is self-contained for local smoke testing. COMPOSE_PROJECT_NAME=bifrost-local +BIFROST_ROOT_VERSE=asgard +BIFROST_MACHINE_ID=yggdrasil # Epiphany operator admission. Endpoint and trust anchor are explicit; there is # no localhost or identity-string fallback. diff --git a/README.md b/README.md index 938e48f..a69f211 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,9 @@ For the full staged roadmap, including contribution points, revenue share, patro - dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true` - print the Eve provider advertisement with `node tools/provider-advertisement.mjs print` - export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc` +- inspect the typed six-command Epiphany operator authority map in + `docs/epiphany-operator-boundary.md`; provider advertisement commands require + explicit `BIFROST_ROOT_VERSE` and `BIFROST_MACHINE_ID` deployment identity - inspect the interactive Motion Verse surface at `/eve/governance/surface` while signed in as an active member - container smoke test with `docker compose -f compose.local.yaml up --build` - container smoke tests may hit `/healthz` and `/readyz` as product probes. diff --git a/docs/epiphany-operator-boundary.md b/docs/epiphany-operator-boundary.md new file mode 100644 index 0000000..83f0651 --- /dev/null +++ b/docs/epiphany-operator-boundary.md @@ -0,0 +1,48 @@ +# Epiphany operator boundary + +## Authority map + +- Owner: Bifrost alone authenticates a Discord operator request, resolves the + persisted Discord-to-Bifrost actor link, and signs an expiring Epiphany + admission. Epiphany alone decides and applies the named command. +- Inputs: one strict `voidbot.discord.epiphany_operator_request.v0`, the exact + configured Discord owner, a persisted Bifrost actor link, the deployment's + operator signing identity, and the configured Epiphany runtime/host trust + anchor. +- Outputs: one immutable `bifrost.operator_command.delivery.v1`, one verified + `epiphany.operator_command.sealed_result.v1`, and one bounded + `bifrost.discord.epiphany_operator_delivery.v1` projection. +- Derived state: Discord delivery fields are notification-only projections of + the signed result. Reviews contain at most ten candidate identity/status + summaries. Proposal bodies and private Mind state never cross this boundary. +- Forbidden writers: ordinary Discord conversation, Persona feedback, argv, + shell commands, `/queue-codex`, provider-advertisement metadata, and the + Discord outbox cannot create an Epiphany consequence. +- Shared path: Status, Sleep, Wake, Directive, Reviews, and Review all use the + same request admission, signature, RUDP execution, sealed-result verification, + durable ledger, and Discord projection path. +- Cut line: v0 admissions and sealed results exist only so already-durable + four-command work can drain or replay. Bifrost never mints v0, and v0 cannot + carry Reviews or Review. + +## Review command + +Review binds the exact current Mind candidate with `mindRequestId`, +`candidateId`, bare 64-hex `candidateSha256`, `expectedModelRevision`, bare +64-hex `expectedModelHash`, and one decision: `adopt`, `refuse`, or `hold`. +Bifrost does not infer a decision from conversation and cannot substitute a +candidate after signing. + +## Deployment identity + +Provider discovery reads `BIFROST_ROOT_VERSE` and `BIFROST_MACHINE_ID` from the +deployment environment. The canonical Yggdrasil example config supplies +`asgard` and `yggdrasil`; the provider code has no Starfire or machine fallback. + +## Verification layer + +The Rust-generated fixture owns cross-runtime schema versions, signing +purposes, command vocabulary, bounded review projection, admission bytes, and +sealed-result bytes. Bifrost tests consume that fixture, then run hostile +schema, identity, digest, binding, replay, and tamper cases against the same +ports used by the worker. diff --git a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs index 1a026ca..f7f8f91 100644 --- a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs +++ b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs @@ -23,7 +23,8 @@ public sealed class ProviderAdvertisementTests AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage"); AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github"); AssertEndpoint(root, "epiphany-operator-requests", "voidbot.discord.epiphany_operator_request.v0", "operator"); - AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.sealed_result.v0", "epiphany"); + AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.sealed_result.v1", "epiphany"); + Assert.Equal("asgard.yggdrasil.bifrost", root.GetProperty("locatedService").GetString()); AssertSchemaPurpose(root, "bifrost.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt"); AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost"); AssertWitness(root, ".bifrost/bridge-receipts.cc", "bifrost.crossing_receipt.v1", "current"); @@ -182,6 +183,8 @@ public sealed class ProviderAdvertisementTests process.StartInfo.Environment["BIFROST_SKIP_LOCAL_ENV"] = "true"; process.StartInfo.Environment["BIFROST_REDDIT_CLIENT_ID"] = string.Empty; process.StartInfo.Environment["BIFROST_REDDIT_REFRESH_TOKEN"] = string.Empty; + process.StartInfo.Environment["BIFROST_ROOT_VERSE"] = "asgard"; + process.StartInfo.Environment["BIFROST_MACHINE_ID"] = "yggdrasil"; process.Start(); var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tests/epiphany-operator-command.test.mjs b/tests/epiphany-operator-command.test.mjs index afb473d..a64ccaa 100644 --- a/tests/epiphany-operator-command.test.mjs +++ b/tests/epiphany-operator-command.test.mjs @@ -5,7 +5,7 @@ import {verifyPurpose} from "../tools/bifrost-signed-identity.mjs"; const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); const actorLink={guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1"}; function request(command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} -function result(admission){const value={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result:value,resultPayloadSha256:"sha256-result",commandId:value.commandId,packetSha256:value.packetSha256,targetRuntimeId:value.targetRuntimeId,completedAt:value.completedAt,providerIdentityId:"epiphany-host",privateStateExposed:false,executorSignature:new Uint8Array(64)};return {providerIdentityId:"epiphany-host",result:value,receipt};} +function result(admission){const value={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result:value,resultPayloadSha256:"sha256-result",commandId:value.commandId,packetSha256:value.packetSha256,targetRuntimeId:value.targetRuntimeId,completedAt:value.completedAt,providerIdentityId:"epiphany-host",privateStateExposed:false,executorSignature:new Uint8Array(64)};return {providerIdentityId:"epiphany-host",result:value,receipt};} const transport=(execute)=>({trustedIdentityId:"epiphany-host",execute,verifyReceipt:(_admission,receipt)=>receipt}); class TestLedger{constructor(){this.requests=new Map();this.admissions=new Map();this.completions=new Map();this.terminals=new Map();}admit(id,request,value){const oldRequest=this.requests.get(id),old=this.admissions.get(id);if(oldRequest&&JSON.stringify(oldRequest)!==JSON.stringify(request)||old&&JSON.stringify(old)!==JSON.stringify(value))throw new Error("operator request identity collision");this.requests.set(id,request);this.admissions.set(id,value);}complete(_id,result,_provider,receipt){this.completions.set(result.commandId,receipt??{...result,result,providerIdentityId:"epiphany-host"});}getRequest(id){return this.requests.get(id);}getAdmission(id){return this.admissions.get(id);}getCompletion(id){return this.completions.get(id);}terminalize(value){this.terminals.set(value.requestId,value);}getTerminal(id){return this.terminals.get(id);}} async function identity(){const d=await mkdtemp(resolve(tmpdir(),"bifrost-operator-"));return enrollOperatorIdentity(resolve(d,"operator.seed"));} diff --git a/tests/epiphany-operator-cultnet-rudp.test.mjs b/tests/epiphany-operator-cultnet-rudp.test.mjs index 705253d..710b856 100644 --- a/tests/epiphany-operator-cultnet-rudp.test.mjs +++ b/tests/epiphany-operator-cultnet-rudp.test.mjs @@ -9,7 +9,7 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; import { resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; -import { EPIPHANY_RESULT_SIGNING_PURPOSE,EpiphanyOperatorCultNetRudpTransport,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; +import { LEGACY_EPIPHANY_RESULT_SIGNING_PURPOSE as EPIPHANY_RESULT_SIGNING_PURPOSE,EpiphanyOperatorCultNetRudpTransport,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")); diff --git a/tests/epiphany-operator-discord-outbox.test.mjs b/tests/epiphany-operator-discord-outbox.test.mjs index 2c43d8d..5c11014 100644 --- a/tests/epiphany-operator-discord-outbox.test.mjs +++ b/tests/epiphany-operator-discord-outbox.test.mjs @@ -1,3 +1,6 @@ + + + import test from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; @@ -7,6 +10,6 @@ import { resolve } from "node:path"; import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; const requireCache=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),requireNet=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),{encode}=requireNet("@msgpack/msgpack"); -function request(id="request-1"){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:id,commandId:id,nonce:id,sourceEventId:id,sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:id,targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};} -function receipt(id="request-1"){return {schemaVersion:"epiphany.operator_command.sealed_result.v0",result:{disposition:"observed",detail:"bounded",operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged"},resultPayloadSha256:`sha256-${"1".repeat(64)}`,providerIdentityId:"executor",executorSignature:new Uint8Array(64),completedAt:"2026-07-19T12:00:01Z",commandId:id};} -test("Discord outbox publishes immutable request-bound completed and refused deliveries",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-outbox-")),path=resolve(root,"deliveries.cc"),outbox=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode}),r=request(),completed=await outbox.publishCompleted(r,receipt());assert.equal(completed.deliveryId,r.requestId);assert.match(completed.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.match(completed.executorSignatureSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(completed.operatorStatus,"sleeping");assert.equal(completed.brakeStatus,"engaged");assert.equal(outbox.isExactDelivery(r),true);assert.equal(outbox.isExactDelivery({...r,discordChannelId:"alien"}),false);assert.equal(completed.privateStateExposed,false);await outbox.publishCompleted(r,receipt());await assert.rejects(()=>outbox.publishCompleted(r,{...receipt(),result:{...receipt().result,disposition:"applied",detail:"changed"}}),/collision/);const refusedRequest=request("request-2"),refused=await outbox.publishRefused(refusedRequest,{failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:02Z"});assert.equal(refused.status,"refused");assert.equal(refused.failureCode,"foreign-owner");assert.equal(refused.sealedResultPayloadSha256,"");const reopened=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode});assert.equal(reopened.get(r.requestId).status,"completed");assert.equal(reopened.get(refusedRequest.requestId).status,"refused");}); +function request(id="request-1",command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:id,commandId:id,nonce:id,sourceEventId:id,sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:id,targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} +function receipt(id="request-1"){return {schemaVersion:"epiphany.operator_command.sealed_result.v1",result:{disposition:"observed",detail:"bounded",operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",reviews:[],reviewCandidateId:"candidate-1",reviewDecision:"hold"},resultPayloadSha256:`sha256-${"1".repeat(64)}`,providerIdentityId:"executor",executorSignature:new Uint8Array(64),completedAt:"2026-07-19T12:00:01Z",commandId:id};} +test("Discord outbox publishes immutable request-bound completed and refused deliveries",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-outbox-")),path=resolve(root,"deliveries.cc"),outbox=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode}),r=request("request-1",{kind:"review",mindRequestId:"mind-1",candidateId:"candidate-1",candidateSha256:"1".repeat(64),expectedModelRevision:41,expectedModelHash:"2".repeat(64),decision:"hold"}),completed=await outbox.publishCompleted(r,receipt());assert.equal(completed.deliveryId,r.requestId);assert.match(completed.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.match(completed.executorSignatureSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(completed.operatorStatus,"sleeping");assert.equal(completed.brakeStatus,"engaged");assert.equal(completed.schemaVersion,"bifrost.discord.epiphany_operator_delivery.v1");assert.equal(completed.commandId,r.commandId);assert.deepEqual(completed.reviews,[]);assert.equal(completed.reviewCandidateId,"candidate-1");assert.equal(completed.reviewDecision,"hold");assert.equal(outbox.isExactDelivery(r),true);assert.equal(outbox.isExactDelivery({...r,discordChannelId:"alien"}),false);assert.equal(completed.privateStateExposed,false);await outbox.publishCompleted(r,receipt());await assert.rejects(()=>outbox.publishCompleted({...r,command:{...r.command,candidateId:"alien"}},receipt()),/does not bind/);await assert.rejects(()=>outbox.publishCompleted(r,{...receipt(),result:{...receipt().result,disposition:"applied",detail:"changed"}}),/collision/);const refusedRequest=request("request-2"),refused=await outbox.publishRefused(refusedRequest,{failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:02Z"});assert.equal(refused.status,"refused");assert.equal(refused.failureCode,"foreign-owner");assert.equal(refused.sealedResultPayloadSha256,"");assert.deepEqual(refused.reviews,[]);assert.equal(refused.reviewCandidateId,"");assert.equal(refused.reviewDecision,"");const reopened=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode});assert.equal(reopened.get(r.requestId).status,"completed");assert.equal(reopened.get(refusedRequest.requestId).status,"refused");}); diff --git a/tests/epiphany-operator-rust-interop.test.mjs b/tests/epiphany-operator-rust-interop.test.mjs index b5eb5a1..04bfc02 100644 --- a/tests/epiphany-operator-rust-interop.test.mjs +++ b/tests/epiphany-operator-rust-interop.test.mjs @@ -11,4 +11,4 @@ import { loadEpiphanyOperatorTrustAnchor,verifySealedReceipt } from "../tools/ep import { verifyPurpose } from "../tools/bifrost-signed-identity.mjs"; const fixture=process.env.EPIPHANY_OPERATOR_RUST_FIXTURE??resolve("..","EpiphanyAgent",".epiphany-smoke","operator-command-interop-rust"),requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{decode,encode}=requireCult("@msgpack/msgpack"); -test("consumes and verifies the Rust-emitted operator admission and sealed receipt",{skip:!existsSync(resolve(fixture,"manifest.json"))&&"Rust interop fixture is not present in this standalone checkout"},async()=>{const manifest=JSON.parse(await readFile(resolve(fixture,"manifest.json"),"utf8")),admissionBytes=await readFile(resolve(fixture,manifest.admissionFile)),receiptBytes=await readFile(resolve(fixture,manifest.sealedResultFile));assert.equal(`sha256-${createHash("sha256").update(admissionBytes).digest("hex")}`,manifest.admissionSha256);assert.equal(`sha256-${createHash("sha256").update(receiptBytes).digest("hex")}`,manifest.sealedResultSha256);const admission=parseOperatorAdmission(decode(admissionBytes)),receipt=decode(receiptBytes),bifrostAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.bifrostRawTrustAnchorFile),{decode}),executorAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.executorRawTrustAnchorFile),{decode});assert.equal(verifyPurpose(bifrostAnchor.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(admission)),admission.providerSignature),true);assert.equal(verifySealedReceipt({receipt,admission,trustedIdentity:executorAnchor,encode}).commandId,admission.packet.commandId);assert.throws(()=>verifySealedReceipt({receipt:{...receipt,packetSha256:"sha256-hostile"},admission,trustedIdentity:executorAnchor,encode}),/not bound/);assert.equal(manifest.rudpConnectionId,0xe91f0001);assert.equal(manifest.privateStateExposed,false);}); +test("consumes and verifies the Rust-emitted operator admission, protocol, and sealed receipt",{skip:!existsSync(resolve(fixture,"manifest.json"))&&"Rust interop fixture is not present in this standalone checkout"},async()=>{const manifest=JSON.parse(await readFile(resolve(fixture,"manifest.json"),"utf8")),protocolBytes=await readFile(resolve(fixture,manifest.protocolFile)),protocol=JSON.parse(protocolBytes),admissionBytes=await readFile(resolve(fixture,manifest.admissionFile)),receiptBytes=await readFile(resolve(fixture,manifest.sealedResultFile));assert.equal(`sha256-${createHash("sha256").update(admissionBytes).digest("hex")}`,manifest.admissionSha256);assert.equal(`sha256-${createHash("sha256").update(receiptBytes).digest("hex")}`,manifest.sealedResultSha256);assert.equal(`sha256-${createHash("sha256").update(protocolBytes).digest("hex")}`,manifest.protocolSha256);assert.equal(protocol.admissionSchemaVersion,"bifrost.operator_command.delivery.v1");assert.equal(protocol.resultSchemaVersion,"epiphany.operator_command.result.v1");assert.equal(protocol.sealedResultSchemaVersion,"epiphany.operator_command.sealed_result.v1");assert.deepEqual(protocol.commands.map(command=>command.kind),["status","sleep","wake","directive","reviews","review"]);assert.deepEqual(protocol.reviewDecisions,["adopt","refuse","hold"]);assert.equal(protocol.boundedReviewResult.reviews.length,1);assert.equal(protocol.boundedReviewResult.privateStateExposed,false);const admission=parseOperatorAdmission(decode(admissionBytes)),receipt=decode(receiptBytes),bifrostAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.bifrostRawTrustAnchorFile),{decode}),executorAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.executorRawTrustAnchorFile),{decode});assert.equal(verifyPurpose(bifrostAnchor.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(admission)),admission.providerSignature),true);assert.equal(verifySealedReceipt({receipt,admission,trustedIdentity:executorAnchor,encode}).commandId,admission.packet.commandId);assert.throws(()=>verifySealedReceipt({receipt:{...receipt,packetSha256:"sha256-hostile"},admission,trustedIdentity:executorAnchor,encode}),/not bound/);assert.equal(manifest.rudpConnectionId,0xe91f0001);assert.equal(manifest.privateStateExposed,false);}); diff --git a/tests/epiphany-operator-v1.test.mjs b/tests/epiphany-operator-v1.test.mjs new file mode 100644 index 0000000..9410f45 --- /dev/null +++ b/tests/epiphany-operator-v1.test.mjs @@ -0,0 +1,99 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + commandTupleForInterop, + parseOperatorAdmission, + parseOperatorRequest, + parseOperatorResult, + wireResultTuple, +} from "../tools/epiphany-operator-command-documents.mjs"; + +const hex = "a".repeat(64); +const review = { + kind: "review", + mindRequestId: "mind-1", + candidateId: "candidate-1", + candidateSha256: hex, + expectedModelRevision: 41, + expectedModelHash: "b".repeat(64), + decision: "adopt", +}; + +function request(command) { + return { + schemaName: "voidbot.discord.epiphany_operator_request", + schemaVersion: "voidbot.discord.epiphany_operator_request.v0", + requestId: "request-1", + commandId: "command-1", + nonce: "nonce-1", + sourceEventId: "event-1", + sourceActorDiscordId: "owner", + discordGuildId: "guild", + discordChannelId: "ops", + discordMessageId: "message-1", + targetRuntimeId: "epiphany-yggdrasil", + issuedAt: "2026-07-19T12:00:00Z", + expiresAt: "2026-07-19T12:01:00Z", + producerId: "voidbot", + producerRuntimeId: "voidbot-yggdrasil", + authorityClass: "operator_request_only", + status: "pending", + command, + }; +} + +test("six-command v1 accepts bounded Reviews and exact Review identity only", () => { + assert.equal(parseOperatorRequest(request({ kind: "reviews" })).command.kind, "reviews"); + assert.deepEqual(commandTupleForInterop(parseOperatorRequest(request(review)).command), [ + "review", + ["mind-1", "candidate-1", hex, 41, "b".repeat(64), "adopt"], + ]); + for (const hostile of [ + { ...review, candidateSha256: `sha256-${hex}` }, + { ...review, decision: "approve" }, + { ...review, expectedModelRevision: -1 }, + { ...review, proposalText: "private" }, + ]) assert.throws(() => parseOperatorRequest(request(hostile))); +}); + +test("v0 admission is drain-only and cannot acquire review authority", () => { + const packet = { + commandId: "command-1", nonce: "nonce-1", sourceEventId: "event-1", + sourceActorId: "actor", discordGuildId: "guild", discordChannelId: "ops", + discordMessageId: "message-1", targetRuntimeId: "epiphany-yggdrasil", + issuedAt: "2026-07-19T12:00:00Z", expiresAt: "2026-07-19T12:01:00Z", + command: { kind: "reviews" }, + }; + const admission = { + schemaName: "bifrost.operator_command.delivery", + schemaVersion: "bifrost.operator_command.delivery.v0", + admissionId: "request-1", packet, packetSha256: `sha256-${hex}`, + sourceObserverId: "voidbot", sourceObserverRuntimeId: "voidbot-yggdrasil", + provider: "bifrost", bifrostAdmissionReceiptId: "request-1", + authority: "exact_operator_command_only", providerIdentityId: "bifrost", + providerSignature: new Uint8Array(64), + }; + assert.throws(() => parseOperatorAdmission(admission, { allowLegacy: true }), /cannot carry Mind review/); + assert.throws(() => parseOperatorAdmission(admission), /current admission schema/); +}); + +test("v1 result exposes only bounded review identities and status", () => { + const result = { + schemaVersion: "epiphany.operator_command.result.v1", resultId: "result-1", + commandId: "command-1", packetSha256: `sha256-${hex}`, + targetRuntimeId: "epiphany-yggdrasil", disposition: "observed", + consequenceKind: "mind-review-candidates", consequenceRef: "", + completedAt: "2026-07-19T12:00:01Z", privateStateExposed: false, + operatorStatus: "", stateStatus: "", coordinatorAction: "", brakeStatus: "", + detail: "1 pending Mind review candidate(s)", + reviews: [{ mindRequestId: "mind-1", candidateId: "candidate-1", + candidateSha256: hex, modelRevision: 41, + modelHash: "b".repeat(64), frontierItemId: "frontier-1", + requestedAt: "2026-07-19T11:59:00Z" }], + reviewCandidateId: "", reviewDecision: "", + }; + assert.equal(wireResultTuple(parseOperatorResult(result)).length, 18); + assert.throws(() => parseOperatorResult({ ...result, reviews: [...result.reviews, ...Array(10).fill(result.reviews[0])] }), /bounded/); + assert.throws(() => parseOperatorResult({ ...result, proposalText: "private" })); +}); diff --git a/tests/epiphany-operator-worker.test.mjs b/tests/epiphany-operator-worker.test.mjs index c5cd1de..3109b24 100644 --- a/tests/epiphany-operator-worker.test.mjs +++ b/tests/epiphany-operator-worker.test.mjs @@ -1,3 +1,4 @@ + import test from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; @@ -16,5 +17,5 @@ import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; const requireCache=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),requireNet=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),cultnet=requireNet("cultnet-ts"),msgpack=requireNet("@msgpack/msgpack"); -test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); +test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); function collect(child){return new Promise(resolveExit=>{let stdout="",stderr="";child.stdout.on("data",x=>stdout+=x);child.stderr.on("data",x=>stderr+=x);child.on("exit",code=>resolveExit({code,stdout,stderr}));});} diff --git a/tools/epiphany-operator-command-documents.mjs b/tools/epiphany-operator-command-documents.mjs index ec5bb8b..8d80e88 100644 --- a/tools/epiphany-operator-command-documents.mjs +++ b/tools/epiphany-operator-command-documents.mjs @@ -1,36 +1,43 @@ export const operatorRequestType="voidbot.discord.epiphany_operator_request"; export const operatorRequestSchema="voidbot.discord.epiphany_operator_request.v0"; export const operatorAdmissionType="bifrost.operator_command.delivery"; -export const operatorAdmissionSchema="bifrost.operator_command.delivery.v0"; +export const operatorAdmissionSchema="bifrost.operator_command.delivery.v1"; +export const legacyOperatorAdmissionSchema="bifrost.operator_command.delivery.v0"; export const operatorResultType="epiphany.operator_command.result"; -export const operatorResultSchema="epiphany.operator_command.result.v0"; +export const operatorResultSchema="epiphany.operator_command.result.v1"; +export const legacyOperatorResultSchema="epiphany.operator_command.result.v0"; export const operatorResultReceiptType="epiphany.operator_command.sealed_result"; -export const operatorResultReceiptSchema="epiphany.operator_command.sealed_result.v0"; +export const operatorResultReceiptSchema="epiphany.operator_command.sealed_result.v1"; +export const legacyOperatorResultReceiptSchema="epiphany.operator_command.sealed_result.v0"; export const operatorTerminalType="bifrost.operator_command.terminal"; export const operatorTerminalSchema="bifrost.operator_command.terminal.v0"; export const operatorDiscordDeliveryType="bifrost.discord.epiphany_operator_delivery"; -export const operatorDiscordDeliverySchema="bifrost.discord.epiphany_operator_delivery.v0"; +export const operatorDiscordDeliverySchema="bifrost.discord.epiphany_operator_delivery.v1"; -export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",parseOperatorAdmission),result:make(operatorResultType,operatorResultSchema,"resultId",parseOperatorResult),sealedResult:make(operatorResultReceiptType,operatorResultReceiptSchema,"commandId",parseOperatorResultReceipt),terminal:make(operatorTerminalType,operatorTerminalSchema,"requestId",parseOperatorTerminal),discordDelivery:make(operatorDiscordDeliveryType,operatorDiscordDeliverySchema,"deliveryId",parseOperatorDiscordDelivery)};} +export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",value=>parseOperatorAdmission(value,{allowLegacy:true})),result:make(operatorResultType,operatorResultSchema,"resultId",value=>parseOperatorResult(value,{allowLegacy:true})),sealedResult:make(operatorResultReceiptType,operatorResultReceiptSchema,"commandId",value=>parseOperatorResultReceipt(value,{allowLegacy:true})),terminal:make(operatorTerminalType,operatorTerminalSchema,"requestId",parseOperatorTerminal),discordDelivery:make(operatorDiscordDeliveryType,operatorDiscordDeliverySchema,"deliveryId",parseOperatorDiscordDelivery)};} export function parseOperatorRequest(value){const v=exactRecord(value,["schemaName","schemaVersion","requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","producerId","producerRuntimeId","authorityClass","status","command"],"operator request"); exact(v.schemaName,operatorRequestType,"schemaName");exact(v.schemaVersion,operatorRequestSchema,"schemaVersion"); for(const k of ["requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","producerRuntimeId"])text(v[k],k); exact(v.producerId,"voidbot","producerId");exact(v.authorityClass,"operator_request_only","authorityClass");exact(v.status,"pending","status"); const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>60000)throw new Error("operator request TTL must be positive and at most 60 seconds.");parseCommand(v.command);return v;} -export function parseOperatorAdmission(v){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");exact(v.schemaVersion,operatorAdmissionSchema,"schemaVersion");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");v.providerSignature=bytes(v.providerSignature,"providerSignature",64);parsePacket(v.packet);return v;} -export function parseOperatorResult(v){v=exactRecord(v,["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"],"operator result");exact(v.schemaVersion,operatorResultSchema,"schemaVersion");for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k);if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid.");if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state.");for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);date(v.completedAt,"completedAt");return v;} -export function parseOperatorResultReceipt(v){v=exactRecord(v,["schemaVersion","result","resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId","privateStateExposed","executorSignature"],"operator sealed result receipt");exact(v.schemaVersion,operatorResultReceiptSchema,"schemaVersion");parseOperatorResult(v.result);for(const k of ["resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId"])text(v[k],k);date(v.completedAt,"completedAt");if(v.privateStateExposed!==false)throw new Error("operator sealed result receipt must not expose private state.");v.executorSignature=bytes(v.executorSignature,"executorSignature",64);return v;} +export function parseOperatorAdmission(v,{allowLegacy=false}={}){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");if(v.schemaVersion!==operatorAdmissionSchema&&!(allowLegacy&&v.schemaVersion===legacyOperatorAdmissionSchema))throw new Error("schemaVersion must be the current admission schema.");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");v.providerSignature=bytes(v.providerSignature,"providerSignature",64);parsePacket(v.packet);if(v.schemaVersion===legacyOperatorAdmissionSchema&&["reviews","review"].includes(v.packet.command.kind))throw new Error("legacy v0 admission cannot carry Mind review commands.");return v;} +export function parseOperatorResult(v,{allowLegacy=false}={}){const legacy=v?.schemaVersion===legacyOperatorResultSchema;if(legacy&&!allowLegacy)throw new Error("legacy result is replay-only.");v=exactRecord(v,legacy?["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"]:["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail","reviews","reviewCandidateId","reviewDecision"],"operator result");if(!legacy)exact(v.schemaVersion,operatorResultSchema,"schemaVersion");for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k);if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid.");if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state.");for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"]){if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(v[k].length>512)throw new Error(`${k} exceeds 512 characters.`);}if(!legacy){for(const k of ["reviewCandidateId","reviewDecision"]){if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);}if(!Array.isArray(v.reviews)||v.reviews.length>10)throw new Error("operator reviews must be a bounded array.");v.reviews.forEach(parseReviewSummary);if(v.reviewDecision&&!['adopt','refuse','hold'].includes(v.reviewDecision))throw new Error("reviewDecision is invalid.");}date(v.completedAt,"completedAt");return v;} +export function parseOperatorResultReceipt(v,{allowLegacy=false}={}){v=exactRecord(v,["schemaVersion","result","resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId","privateStateExposed","executorSignature"],"operator sealed result receipt");const legacy=v.schemaVersion===legacyOperatorResultReceiptSchema;if(legacy&&!allowLegacy)throw new Error("legacy sealed result is replay-only.");if(!legacy)exact(v.schemaVersion,operatorResultReceiptSchema,"schemaVersion");parseOperatorResult(v.result,{allowLegacy});if(legacy!== (v.result.schemaVersion===legacyOperatorResultSchema))throw new Error("sealed result and embedded result versions differ.");for(const k of ["resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId"])text(v[k],k);date(v.completedAt,"completedAt");if(v.privateStateExposed!==false)throw new Error("operator sealed result receipt must not expose private state.");v.executorSignature=bytes(v.executorSignature,"executorSignature",64);return v;} export function parseOperatorTerminal(v){v=exactRecord(v,["schemaVersion","requestId","commandId","status","failureCode","recordedAt","retryable","privateStateExposed"],"operator terminal disposition");exact(v.schemaVersion,operatorTerminalSchema,"schemaVersion");for(const k of ["requestId","commandId","failureCode","recordedAt"])text(v[k],k);exact(v.status,"refused","status");date(v.recordedAt,"recordedAt");if(v.retryable!==false||v.privateStateExposed!==false)throw new Error("operator terminal disposition must be non-retryable and private-state-free.");return v;} -export function parseOperatorDiscordDelivery(v){v=exactRecord(v,["schemaVersion","deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId","recordedAt","privateStateExposed"],"operator Discord delivery");exact(v.schemaVersion,operatorDiscordDeliverySchema,"schemaVersion");for(const k of ["deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","recordedAt"])text(v[k],k);if(!/^sha256-[0-9a-f]{64}$/.test(v.requestPayloadSha256))throw new Error("operator Discord delivery request digest is invalid.");if(!["completed","refused"].includes(v.status))throw new Error("operator Discord delivery status is invalid.");for(const k of ["disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);for(const k of ["detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus"])if(v[k].length>512)throw new Error(`operator Discord delivery ${k} exceeds 512 characters.`);if(v.status==="completed"&&(!v.disposition||!/^sha256-[0-9a-f]{64}$/.test(v.sealedResultPayloadSha256)||!/^sha256-[0-9a-f]{64}$/.test(v.executorSignatureSha256)||!v.resultProviderIdentityId))throw new Error("completed operator Discord delivery lacks sealed-result binding.");if(v.status==="refused"&&!v.failureCode)throw new Error("refused operator Discord delivery lacks failure code.");date(v.recordedAt,"recordedAt");if(v.privateStateExposed!==false)throw new Error("operator Discord delivery must not expose private state.");return v;} +export function parseOperatorDiscordDelivery(v){v=exactRecord(v,["schemaVersion","deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","reviews","reviewCandidateId","reviewDecision","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId","recordedAt","privateStateExposed"],"operator Discord delivery");exact(v.schemaVersion,operatorDiscordDeliverySchema,"schemaVersion");for(const k of ["deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","recordedAt"])text(v[k],k);if(!/^sha256-[0-9a-f]{64}$/.test(v.requestPayloadSha256))throw new Error("operator Discord delivery request digest is invalid.");if(!["completed","refused"].includes(v.status))throw new Error("operator Discord delivery status is invalid.");for(const k of ["disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","reviewCandidateId","reviewDecision","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(!Array.isArray(v.reviews)||v.reviews.length>10)throw new Error("operator Discord delivery reviews must be bounded.");v.reviews.forEach(parseReviewSummary);if(v.reviewDecision&&!['adopt','refuse','hold'].includes(v.reviewDecision))throw new Error("operator Discord delivery reviewDecision is invalid.");for(const k of ["detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus"])if(v[k].length>512)throw new Error(`operator Discord delivery ${k} exceeds 512 characters.`);if(v.status==="completed"&&(!v.disposition||!/^sha256-[0-9a-f]{64}$/.test(v.sealedResultPayloadSha256)||!/^sha256-[0-9a-f]{64}$/.test(v.executorSignatureSha256)||!v.resultProviderIdentityId))throw new Error("completed operator Discord delivery lacks sealed-result binding.");if(v.status==="refused"&&!v.failureCode)throw new Error("refused operator Discord delivery lacks failure code.");date(v.recordedAt,"recordedAt");if(v.privateStateExposed!==false)throw new Error("operator Discord delivery must not expose private state.");return v;} export function operatorRequestTuple(v){return [v.schemaName,v.schemaVersion,v.requestId,v.commandId,v.nonce,v.sourceEventId,v.sourceActorDiscordId,v.discordGuildId,v.discordChannelId,v.discordMessageId,v.targetRuntimeId,v.issuedAt,v.expiresAt,v.producerId,v.producerRuntimeId,v.authorityClass,v.status,commandTuple(v.command)];} export function packetTuple(p){return [p.commandId,p.nonce,p.sourceEventId,p.sourceActorId,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.issuedAt,p.expiresAt,commandTuple(p.command)];} export function admissionTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];} // rmp-serde's compact serializer encodes the nested Rust wire-result struct as // this positional array inside the signed tuple. The separately hashed result // payload is encoded with named fields. -export function wireResultTuple(v){return [v.schemaVersion,v.resultId,v.commandId,v.packetSha256,v.targetRuntimeId,v.disposition,v.consequenceKind,v.consequenceRef,v.completedAt,v.privateStateExposed,v.operatorStatus,v.stateStatus,v.coordinatorAction,v.brakeStatus,v.detail];} +export function wireResultTuple(v){const tuple=[v.schemaVersion,v.resultId,v.commandId,v.packetSha256,v.targetRuntimeId,v.disposition,v.consequenceKind,v.consequenceRef,v.completedAt,v.privateStateExposed,v.operatorStatus,v.stateStatus,v.coordinatorAction,v.brakeStatus,v.detail];return v.schemaVersion===legacyOperatorResultSchema?tuple:[...tuple,v.reviews.map(reviewSummaryTuple),v.reviewCandidateId,v.reviewDecision];} export function resultReceiptTuple(v){return [v.schemaVersion,wireResultTuple(v.result),v.resultPayloadSha256,v.commandId,v.packetSha256,v.targetRuntimeId,v.completedAt,v.providerIdentityId,v.privateStateExposed];} function parsePacket(v){v=exactRecord(v,["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","command"],"operator packet");for(const k of ["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId"])text(v[k],k);date(v.issuedAt,"issuedAt");date(v.expiresAt,"expiresAt");parseCommand(v.command);return v;} -function parseCommand(v){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error("command must be an object.");if(v.kind==="status"||v.kind==="wake")return exactRecord(v,["kind"],"command");if(v.kind==="sleep"){exactRecord(v,["kind","reason"],"command");text(v.reason,"reason");if(v.reason.length>512)throw new Error("sleep reason exceeds 512 characters.");return v;}if(v.kind==="directive"){exactRecord(v,["kind","objective"],"command");text(v.objective,"objective");if(v.objective.length>4096)throw new Error("directive objective exceeds 4096 characters.");return v;}throw new Error("command kind is invalid.");} +function parseCommand(v){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error("command must be an object.");if(["status","wake","reviews"].includes(v.kind))return exactRecord(v,["kind"],"command");if(v.kind==="sleep"){exactRecord(v,["kind","reason"],"command");text(v.reason,"reason");if(v.reason.length>512)throw new Error("sleep reason exceeds 512 characters.");return v;}if(v.kind==="directive"){exactRecord(v,["kind","objective"],"command");text(v.objective,"objective");if(v.objective.length>4096)throw new Error("directive objective exceeds 4096 characters.");return v;}if(v.kind==="review"){exactRecord(v,["kind","mindRequestId","candidateId","candidateSha256","expectedModelRevision","expectedModelHash","decision"],"command");for(const k of ["mindRequestId","candidateId"]){text(v[k],k);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);}hex64(v.candidateSha256,"candidateSha256");hex64(v.expectedModelHash,"expectedModelHash");if(!Number.isSafeInteger(v.expectedModelRevision)||v.expectedModelRevision<0)throw new Error("expectedModelRevision must be an unsigned safe integer.");if(!["adopt","refuse","hold"].includes(v.decision))throw new Error("review decision is invalid.");return v;}throw new Error("command kind is invalid.");} // rmp-serde's compact enum form is the snake-case unit variant string, or a // two-element [variant, sole-field] tuple for these one-field struct variants. -function commandTuple(v){if(v.kind==="sleep")return ["sleep",v.reason];if(v.kind==="directive")return ["directive",v.objective];return v.kind;} +function commandTuple(v){if(v.kind==="sleep")return ["sleep",v.reason];if(v.kind==="directive")return ["directive",v.objective];if(v.kind==="review")return ["review",[v.mindRequestId,v.candidateId,v.candidateSha256,v.expectedModelRevision,v.expectedModelHash,v.decision]];return v.kind;} +export const commandTupleForInterop=commandTuple; +function parseReviewSummary(v){exactRecord(v,["mindRequestId","candidateId","candidateSha256","modelRevision","modelHash","frontierItemId","requestedAt"],"review summary");for(const k of ["mindRequestId","candidateId","frontierItemId"]){text(v[k],k);if(v[k].length>256)throw new Error(`review ${k} exceeds 256 characters.`);}hex64(v.candidateSha256,"candidateSha256");hex64(v.modelHash,"modelHash");if(!Number.isSafeInteger(v.modelRevision)||v.modelRevision<0)throw new Error("review modelRevision is invalid.");date(v.requestedAt,"requestedAt");return v;} +function reviewSummaryTuple(v){return [v.mindRequestId,v.candidateId,v.candidateSha256,v.modelRevision,v.modelHash,v.frontierItemId,v.requestedAt];} +function hex64(v,label){if(typeof v!=="string"||!/^[0-9a-fA-F]{64}$/.test(v))throw new Error(`${label} must be 64 hexadecimal characters.`);return v;} function exactRecord(v,keys,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);const actual=Object.keys(v).sort(),expected=[...keys].sort();if(actual.join("\0")!==expected.join("\0"))throw new Error(`${label} has unknown or missing fields.`);return v;} function text(v,label){if(typeof v!=="string"||!v.trim())throw new Error(`${label} is required.`);return v;} function exact(v,e,label){if(v!==e)throw new Error(`${label} must be ${e}.`);} diff --git a/tools/epiphany-operator-commands.mjs b/tools/epiphany-operator-commands.mjs index db934c9..6220e8e 100644 --- a/tools/epiphany-operator-commands.mjs +++ b/tools/epiphany-operator-commands.mjs @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { admissionTuple,operatorAdmissionSchema,parseOperatorAdmission,parseOperatorRequest,parseOperatorResult,parseOperatorResultReceipt,packetTuple } from "./epiphany-operator-command-documents.mjs"; import { enrollSignedIdentity,loadSignedIdentity,signPurpose,trustAnchor } from "./bifrost-signed-identity.mjs"; -export const OPERATOR_SIGNING_PURPOSE="bifrost.operator-command.delivery.v0"; +export const OPERATOR_SIGNING_PURPOSE="bifrost.operator-command.delivery.v1"; export const defaultOperatorIdentityPath=()=>process.env.BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY??(process.platform==="win32"?resolve(".bifrost","private","epiphany-operator-ed25519.seed"):"/var/lib/gamecult/bifrost/epiphany-operator/operator-ed25519.seed"); export class OperatorCommandPermanentRefusal extends Error { constructor(code,message){super(message);this.name="OperatorCommandPermanentRefusal";this.code=code;this.retryable=false;} } @@ -17,9 +17,9 @@ export async function admitOperatorRequest({request,ownerDiscordId,actorLink,ide if(existingAdmission){ await compareAdmission(ledger,request,existingAdmission); if(existingCompletion){ - parseOperatorResultReceipt(existingCompletion); + parseOperatorResultReceipt(existingCompletion,{allowLegacy:true}); if(existingCompletion.commandId!==existingAdmission.packet.commandId||existingCompletion.packetSha256!==existingAdmission.packetSha256||existingCompletion.targetRuntimeId!==existingAdmission.packet.targetRuntimeId)refuse("command-id-collision","operator completion is not bound to this exact durable admission"); - transport.verifyReceipt(existingAdmission,existingCompletion); + parseOperatorAdmission(existingAdmission,{allowLegacy:true});transport.verifyReceipt(existingAdmission,existingCompletion); return {admission:existingAdmission,result:existingCompletion.result,receipt:existingCompletion,resultProviderIdentityId:existingCompletion.providerIdentityId,recovered:true}; } return executeAdmission({request,admission:existingAdmission,transport,ledger,recovered:true}); diff --git a/tools/epiphany-operator-cultnet-rudp.mjs b/tools/epiphany-operator-cultnet-rudp.mjs index 095316a..dfb9445 100644 --- a/tools/epiphany-operator-cultnet-rudp.mjs +++ b/tools/epiphany-operator-cultnet-rudp.mjs @@ -6,6 +6,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { operatorAdmissionType, + legacyOperatorResultReceiptSchema, operatorResultReceiptType, parseOperatorAdmission, parseOperatorResultReceipt, @@ -13,7 +14,8 @@ import { } from "./epiphany-operator-command-documents.mjs"; import { verifyPurpose } from "./bifrost-signed-identity.mjs"; -export const EPIPHANY_RESULT_SIGNING_PURPOSE="epiphany.operator-command.sealed-result.v0"; +export const EPIPHANY_RESULT_SIGNING_PURPOSE="epiphany.operator-command.sealed-result.v1"; +export const LEGACY_EPIPHANY_RESULT_SIGNING_PURPOSE="epiphany.operator-command.sealed-result.v0"; const RUDP_CONNECTION_ID=0xe91f0001; export class EpiphanyOperatorCultNetRudpTransport { @@ -41,7 +43,7 @@ export class EpiphanyOperatorCultNetRudpTransport { } async #executeOne(admission){ - parseOperatorAdmission(admission); + parseOperatorAdmission(admission,{allowLegacy:true}); const socket=this.socketFactory("udp4"); await bindSocket(socket); const connection=this.connectionFactory({runtimeId:this.runtimeId,socket,mode:"client",remoteHost:this.endpoint.host,remotePort:this.endpoint.port,connectionId:RUDP_CONNECTION_ID,resendDelayMs:50,resendPollMs:10}); @@ -61,7 +63,7 @@ export class EpiphanyOperatorCultNetRudpTransport { export async function loadEpiphanyOperatorTrustAnchor(path,{decode}={}){const runtime=decode?{decode}:loadCultNetRuntime().msgpack,value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==6)throw new Error("Epiphany operator trust anchor must be the raw compact 6-field MessagePack crossing artifact.");return parseTrustAnchor({schemaVersion:value[0],identityId:value[1],publicKey:value[2],assurance:value[3],identityCreatedAt:value[4],sourceIdentityRecordSha256:value[5]});} export function verifySealedReceipt({receipt,admission,trustedIdentity,encode}){ - parseOperatorAdmission(admission);parseOperatorResultReceipt(receipt);const anchor=parseTrustAnchor(trustedIdentity); + parseOperatorAdmission(admission,{allowLegacy:true});parseOperatorResultReceipt(receipt,{allowLegacy:true});const anchor=parseTrustAnchor(trustedIdentity); if(receipt.providerIdentityId!==anchor.identityId)throw new Error("Epiphany sealed result used an untrusted host identity."); if(receipt.commandId!==admission.packet.commandId||receipt.packetSha256!==admission.packetSha256||receipt.targetRuntimeId!==admission.packet.targetRuntimeId)throw new Error("Epiphany sealed result receipt is not bound to the admitted command."); if(receipt.result.commandId!==receipt.commandId||receipt.result.packetSha256!==receipt.packetSha256||receipt.result.targetRuntimeId!==receipt.targetRuntimeId||receipt.result.completedAt!==receipt.completedAt)throw new Error("Epiphany sealed result receipt substituted its embedded application result."); @@ -73,7 +75,8 @@ export function verifySealedReceipt({receipt,admission,trustedIdentity,encode}){ if(completedx instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 63b54c4..9ef52f6 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -158,19 +158,18 @@ const surfaceSchemaId = "gamecult.eve.surface_state.v1"; const interfaceBindingDocumentType = "gamecult.eve.interface_binding"; const interfaceBindingSchemaId = "gamecult.eve.interface_binding.v1"; const verseId = "bifrost.local"; -const rootVerse = "asgard"; -const currentMachine = "starfire"; +const rootVerse = requiredDeploymentIdentity("BIFROST_ROOT_VERSE"); +const currentMachine = requiredDeploymentIdentity("BIFROST_MACHINE_ID"); const canonicalService = `${rootVerse}.bifrost`; const locatedService = `${rootVerse}.${currentMachine}.bifrost`; -const plannedLocatedService = `${rootVerse}.yggdrasil.bifrost`; const motionSurfaceCultMeshUri = `cultmesh://${locatedService}/eve/governance/surface`; const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`; const epiphanyOperatorRequestSchemaId = "voidbot.discord.epiphany_operator_request.v0"; -const epiphanyOperatorAdmissionSchemaId = "bifrost.operator_command.delivery.v0"; -const epiphanyOperatorResultSchemaId = "epiphany.operator_command.result.v0"; -const epiphanyOperatorSealedResultSchemaId = "epiphany.operator_command.sealed_result.v0"; +const epiphanyOperatorAdmissionSchemaId = "bifrost.operator_command.delivery.v1"; +const epiphanyOperatorResultSchemaId = "epiphany.operator_command.result.v1"; +const epiphanyOperatorSealedResultSchemaId = "epiphany.operator_command.sealed_result.v1"; const epiphanyOperatorTerminalSchemaId = "bifrost.operator_command.terminal.v0"; -const epiphanyOperatorDiscordDeliverySchemaId = "bifrost.discord.epiphany_operator_delivery.v0"; +const epiphanyOperatorDiscordDeliverySchemaId = "bifrost.discord.epiphany_operator_delivery.v1"; const advertisementDefinition = defineDocumentType({ type: documentType, @@ -323,7 +322,6 @@ function buildAdvertisement(options) { rootVerse, canonicalService, locatedService, - plannedLocatedService, cultMeshAddress: locatedService, endpoints: [ endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]), @@ -349,7 +347,7 @@ function buildAdvertisement(options) { presentationOwner: "Eve/CultUI", discoveryOwner: "Odin through CultMesh", stateOwner: "Bifrost typed state with CultCache .cc witnesses or export paths", - runtimeMigration: `currently ${locatedService}; planned move target ${plannedLocatedService}`, + runtimeMigration: `deployment identity ${locatedService}`, }, namespaces: [ namespace("gamecult.bifrost.service", "service registration, build/version, schema catalog, and command discovery"), @@ -971,7 +969,6 @@ function buildInterfaceBinding(surface, stats) { cultMeshAddress: `${locatedService}/eve/tui`, canonicalService, locatedService, - plannedLocatedService, endpoints: [ endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]), endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]), @@ -1186,7 +1183,6 @@ function parseAdvertisement(input, { requireCurrentContract = true } = {}) { rootVerse: optionalString(input.rootVerse, rootVerse), canonicalService: optionalString(input.canonicalService, canonicalService), locatedService: optionalString(input.locatedService, locatedService), - plannedLocatedService: typeof input.plannedLocatedService === "string" ? input.plannedLocatedService.trim() : "", cultMeshAddress: optionalString(input.cultMeshAddress, locatedService), endpoints: Array.isArray(input.endpoints) ? requireObjectArray(input.endpoints, "endpoints") : [], routes: Array.isArray(input.routes) ? requireObjectArray(input.routes, "routes") : [], @@ -1305,6 +1301,14 @@ function resolveOptionPath(path) { return resolve(process.cwd(), path); } +function requiredDeploymentIdentity(name) { + const value = process.env[name]; + if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(value)) { + throw new Error(`${name} must explicitly name this deployment using lowercase letters, digits, or hyphens.`); + } + return value; +} + function printJson(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } From 3824d42b6da4752aea36189931327b13b54367fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 15:18:13 +0100 Subject: [PATCH 009/155] Add typed operator identity enrollment --- tests/epiphany-operator-worker.test.mjs | 3 ++- tools/epiphany-operator-worker.mjs | 21 +++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/epiphany-operator-worker.test.mjs b/tests/epiphany-operator-worker.test.mjs index 3109b24..740510e 100644 --- a/tests/epiphany-operator-worker.test.mjs +++ b/tests/epiphany-operator-worker.test.mjs @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import { createSocket } from "node:dgram"; import { spawn } from "node:child_process"; import { createRequire } from "node:module"; -import { mkdtemp,writeFile } from "node:fs/promises"; +import { mkdtemp,readFile,stat,writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { buildOperatorDefinitions,resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; @@ -17,5 +17,6 @@ import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; const requireCache=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),requireNet=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),cultnet=requireNet("cultnet-ts"),msgpack=requireNet("@msgpack/msgpack"); +test("operator identity enrollment emits only a compact public trust anchor",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-enroll-")),key=resolve(root,"private","operator.seed"),anchor=resolve(root,"public","operator.msgpack"),run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"enroll-identity","--private-key",key,"--trust-anchor-out",anchor],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim()),decoded=msgpack.decode(await readFile(anchor));assert.equal(pulse.schemaVersion,"bifrost.epiphany_operator_identity_enrollment.v0");assert.equal(pulse.privateStateExposed,false);assert.equal(decoded[0],"epiphany.host_identity_trust_anchor.v0");assert.equal(decoded.length,6);if(process.platform!=="win32"){assert.equal((await stat(key)).mode&0o777,0o600);assert.equal((await stat(anchor)).mode&0o777,0o640);}}); test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); function collect(child){return new Promise(resolveExit=>{let stdout="",stderr="";child.stdout.on("data",x=>stdout+=x);child.stderr.on("data",x=>stderr+=x);child.on("exit",code=>resolveExit({code,stdout,stderr}));});} diff --git a/tools/epiphany-operator-worker.mjs b/tools/epiphany-operator-worker.mjs index 3749eb9..0e61f36 100644 --- a/tools/epiphany-operator-worker.mjs +++ b/tools/epiphany-operator-worker.mjs @@ -1,24 +1,26 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { existsSync } from "node:fs"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { buildOperatorDefinitions } from "./epiphany-operator-command-documents.mjs"; import { buildFeedbackDefinitions } from "./persona-feedback-documents.mjs"; -import { admitOperatorRequest,loadOperatorIdentity,OperatorCommandPermanentRefusal } from "./epiphany-operator-commands.mjs"; +import { admitOperatorRequest,enrollOperatorIdentity,loadOperatorIdentity,OperatorCommandPermanentRefusal,trustAnchor } from "./epiphany-operator-commands.mjs"; import { EpiphanyOperatorCultNetRudpTransport,loadEpiphanyOperatorTrustAnchor } from "./epiphany-operator-cultnet-rudp.mjs"; import { DurableOperatorLedger } from "./epiphany-operator-ledger.mjs"; import { EpiphanyOperatorDiscordOutbox } from "./epiphany-operator-discord-outbox.mjs"; const {command,options}=parseArgs(process.argv.slice(2)); if(command==="help"){ - process.stdout.write("epiphany-operator-worker --request-store --private-store --ledger-store --delivery-store --private-key --epiphany-trust-anchor --epiphany-rudp --owner-discord-id [--runtime-id ] [--interval-ms ] [--timeout-ms ]\n"); + process.stdout.write("epiphany-operator-worker --request-store --private-store --ledger-store --delivery-store --private-key --epiphany-trust-anchor --epiphany-rudp --owner-discord-id [--trust-anchor-out ] [--runtime-id ] [--interval-ms ] [--timeout-ms ]\n"); process.exit(0); } await main().catch(error=>{process.stderr.write(`${error instanceof Error?error.message:String(error)}\n`);process.exitCode=1;}); async function main(){ - if(!["once","serve"].includes(command))throw new Error("operator worker command must be once or serve"); const runtime=loadRuntime(); + if(command==="enroll-identity")return enrollIdentity(runtime); + if(!["once","serve"].includes(command))throw new Error("operator worker command must be once, serve, or enroll-identity"); const operatorDefs=buildOperatorDefinitions(runtime.defineDocumentType); const feedbackDefs=buildFeedbackDefinitions(runtime.defineDocumentType); const requestStore=requiredOption("request-store","BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE"); @@ -43,6 +45,17 @@ async function main(){ }while(true); } +async function enrollIdentity(runtime){ + const privatePath=resolve(requiredOption("private-key","BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY")); + const outputPath=resolve(requiredOption("trust-anchor-out","BIFROST_EPIPHANY_OPERATOR_TRUST_ANCHOR_OUT")); + if(privatePath===outputPath)throw new Error("operator private identity and public trust anchor must be physically distinct"); + const identity=await enrollOperatorIdentity(privatePath),anchor=trustAnchor(identity,new Date().toISOString()); + await mkdir(resolve(outputPath,".."),{recursive:true}); + await writeFile(outputPath,runtime.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]),{flag:"wx",mode:0o640}); + await chmod(outputPath,0o640); + process.stdout.write(`${JSON.stringify({schemaVersion:"bifrost.epiphany_operator_identity_enrollment.v0",identityId:identity.identityId,trustAnchor:outputPath,privateStateExposed:false})}\n`); +} + async function processPending({requests,privateCache,operatorDefs,feedbackDefs,ledger,outbox,ownerDiscordId,identity,transport,encode}){ await requests.pullAllBackingStores();await privateCache.pullAllBackingStores(); const pending=requests.getAll(operatorDefs.request).filter(request=>!outbox.isExactDelivery(request)).sort((a,b)=>a.issuedAt.localeCompare(b.issuedAt)); @@ -68,7 +81,7 @@ async function processPending({requests,privateCache,operatorDefs,feedbackDefs,l function summary(request,status,disposition,detail){return {requestId:request.requestId,commandId:request.commandId,status,disposition,detail};} function loadRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),cachePkg=resolve(cult,"packages","cultcache-ts","package.json"),netPkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(cachePkg)||!existsSync(netPkg))throw new Error("CultLib CultCache/CultNet TypeScript runtimes are unavailable");const cr=createRequire(cachePkg),nr=createRequire(netPkg),cultcache=cr("cultcache-ts"),msgpack=nr("@msgpack/msgpack");return {cultcache,cultnet:nr("cultnet-ts"),msgpack,encode:msgpack.encode,decode:msgpack.decode,CultCache:cultcache.CultCache,SingleFileMessagePackBackingStore:cultcache.SingleFileMessagePackBackingStore,defineDocumentType:cultcache.defineDocumentType};} -function parseArgs(values){const command=values.shift()??"help",options={},allowed=new Set(["request-store","private-store","ledger-store","delivery-store","private-key","epiphany-trust-anchor","epiphany-rudp","owner-discord-id","runtime-id","interval-ms","timeout-ms"]);while(values.length){const flag=values.shift();if(!flag?.startsWith("--")||!values.length)throw new Error(`invalid operator worker option ${flag??""}`);const key=flag.slice(2);if(!allowed.has(key))throw new Error(`unknown operator worker option --${key}`);if(Object.hasOwn(options,key))throw new Error(`duplicate operator worker option --${key}`);options[key]=values.shift();}return {command,options};} +function parseArgs(values){const command=values.shift()??"help",options={},allowed=new Set(["request-store","private-store","ledger-store","delivery-store","private-key","trust-anchor-out","epiphany-trust-anchor","epiphany-rudp","owner-discord-id","runtime-id","interval-ms","timeout-ms"]);while(values.length){const flag=values.shift();if(!flag?.startsWith("--")||!values.length)throw new Error(`invalid operator worker option ${flag??""}`);const key=flag.slice(2);if(!allowed.has(key))throw new Error(`unknown operator worker option --${key}`);if(Object.hasOwn(options,key))throw new Error(`duplicate operator worker option --${key}`);options[key]=values.shift();}return {command,options};} function option(name,environment){return options[name]??process.env[environment];} function requiredOption(name,environment){const value=option(name,environment);if(typeof value!=="string"||!value.trim())throw new Error(`operator worker requires --${name} or ${environment}`);return value;} function integerOption(name,fallback,min,max){const raw=options[name],value=raw===undefined?fallback:Number(raw);if(!Number.isInteger(value)||valuemax)throw new Error(`operator worker --${name} must be ${min}..=${max}`);return value;} From 225859bd7ec3339944841ebbfc67b421eb405727 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 16:01:37 +0100 Subject: [PATCH 010/155] Test operator bridge against configured CultLib --- tests/epiphany-operator-command.test.mjs | 2 +- tests/epiphany-operator-cultnet-rudp.test.mjs | 2 +- tests/epiphany-operator-discord-outbox.test.mjs | 2 +- tests/epiphany-operator-ledger.test.mjs | 2 +- tests/epiphany-operator-rust-interop.test.mjs | 2 +- tests/epiphany-operator-worker.test.mjs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/epiphany-operator-command.test.mjs b/tests/epiphany-operator-command.test.mjs index a64ccaa..29b77ef 100644 --- a/tests/epiphany-operator-command.test.mjs +++ b/tests/epiphany-operator-command.test.mjs @@ -2,7 +2,7 @@ import test from "node:test";import assert from "node:assert/strict";import {mkd import {admitOperatorRequest,enrollOperatorIdentity,OPERATOR_SIGNING_PURPOSE} from "../tools/epiphany-operator-commands.mjs"; import {admissionTuple,packetTuple,parseOperatorRequest} from "../tools/epiphany-operator-command-documents.mjs"; import {verifyPurpose} from "../tools/bifrost-signed-identity.mjs"; -const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); +const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); const actorLink={guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1"}; function request(command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} function result(admission){const value={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result:value,resultPayloadSha256:"sha256-result",commandId:value.commandId,packetSha256:value.packetSha256,targetRuntimeId:value.targetRuntimeId,completedAt:value.completedAt,providerIdentityId:"epiphany-host",privateStateExposed:false,executorSignature:new Uint8Array(64)};return {providerIdentityId:"epiphany-host",result:value,receipt};} diff --git a/tests/epiphany-operator-cultnet-rudp.test.mjs b/tests/epiphany-operator-cultnet-rudp.test.mjs index 710b856..ce01608 100644 --- a/tests/epiphany-operator-cultnet-rudp.test.mjs +++ b/tests/epiphany-operator-cultnet-rudp.test.mjs @@ -12,7 +12,7 @@ import { resultReceiptTuple } from "../tools/epiphany-operator-command-documents import { LEGACY_EPIPHANY_RESULT_SIGNING_PURPOSE as EPIPHANY_RESULT_SIGNING_PURPOSE,EpiphanyOperatorCultNetRudpTransport,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; -const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")); +const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")); const msgpack=requireCult("@msgpack/msgpack"),cultnet=requireCult("cultnet-ts"); function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} async function fixture(){const root=await mkdtemp(resolve(tmpdir(),"epiphany-result-")),identity=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(identity,"2026-07-19T11:00:00Z"),a=admission();const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:a.packet.commandId,packetSha256:a.packetSha256,targetRuntimeId:a.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};const receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:identity.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));return {a,identity,anchor,receipt};} diff --git a/tests/epiphany-operator-discord-outbox.test.mjs b/tests/epiphany-operator-discord-outbox.test.mjs index 5c11014..6cc74b3 100644 --- a/tests/epiphany-operator-discord-outbox.test.mjs +++ b/tests/epiphany-operator-discord-outbox.test.mjs @@ -9,7 +9,7 @@ import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; -const requireCache=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),requireNet=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),{encode}=requireNet("@msgpack/msgpack"); +const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCache=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),requireNet=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),{encode}=requireNet("@msgpack/msgpack"); function request(id="request-1",command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:id,commandId:id,nonce:id,sourceEventId:id,sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:id,targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} function receipt(id="request-1"){return {schemaVersion:"epiphany.operator_command.sealed_result.v1",result:{disposition:"observed",detail:"bounded",operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",reviews:[],reviewCandidateId:"candidate-1",reviewDecision:"hold"},resultPayloadSha256:`sha256-${"1".repeat(64)}`,providerIdentityId:"executor",executorSignature:new Uint8Array(64),completedAt:"2026-07-19T12:00:01Z",commandId:id};} test("Discord outbox publishes immutable request-bound completed and refused deliveries",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-outbox-")),path=resolve(root,"deliveries.cc"),outbox=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode}),r=request("request-1",{kind:"review",mindRequestId:"mind-1",candidateId:"candidate-1",candidateSha256:"1".repeat(64),expectedModelRevision:41,expectedModelHash:"2".repeat(64),decision:"hold"}),completed=await outbox.publishCompleted(r,receipt());assert.equal(completed.deliveryId,r.requestId);assert.match(completed.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.match(completed.executorSignatureSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(completed.operatorStatus,"sleeping");assert.equal(completed.brakeStatus,"engaged");assert.equal(completed.schemaVersion,"bifrost.discord.epiphany_operator_delivery.v1");assert.equal(completed.commandId,r.commandId);assert.deepEqual(completed.reviews,[]);assert.equal(completed.reviewCandidateId,"candidate-1");assert.equal(completed.reviewDecision,"hold");assert.equal(outbox.isExactDelivery(r),true);assert.equal(outbox.isExactDelivery({...r,discordChannelId:"alien"}),false);assert.equal(completed.privateStateExposed,false);await outbox.publishCompleted(r,receipt());await assert.rejects(()=>outbox.publishCompleted({...r,command:{...r.command,candidateId:"alien"}},receipt()),/does not bind/);await assert.rejects(()=>outbox.publishCompleted(r,{...receipt(),result:{...receipt().result,disposition:"applied",detail:"changed"}}),/collision/);const refusedRequest=request("request-2"),refused=await outbox.publishRefused(refusedRequest,{failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:02Z"});assert.equal(refused.status,"refused");assert.equal(refused.failureCode,"foreign-owner");assert.equal(refused.sealedResultPayloadSha256,"");assert.deepEqual(refused.reviews,[]);assert.equal(refused.reviewCandidateId,"");assert.equal(refused.reviewDecision,"");const reopened=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode});assert.equal(reopened.get(r.requestId).status,"completed");assert.equal(reopened.get(refusedRequest.requestId).status,"refused");}); diff --git a/tests/epiphany-operator-ledger.test.mjs b/tests/epiphany-operator-ledger.test.mjs index 252ad0f..1aed71f 100644 --- a/tests/epiphany-operator-ledger.test.mjs +++ b/tests/epiphany-operator-ledger.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; -const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),runtime=requireCult("cultcache-ts"); +const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),runtime=requireCult("cultcache-ts"); function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} function receipt(){const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:"command-1",packetSha256:"sha256-packet",targetRuntimeId:"epiphany-yggdrasil",disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};return {schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:"sha256-result",commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:"epiphany",privateStateExposed:false,executorSignature:new Uint8Array(64)};} test("durable operator ledger survives restart and refuses admission/result collisions",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-ledger-")),path=resolve(root,"ledger.cc"),first=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime}),a=admission(),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:a.admissionId,commandId:a.packet.commandId,nonce:a.packet.nonce,sourceEventId:a.packet.sourceEventId,sourceActorDiscordId:"owner",discordGuildId:a.packet.discordGuildId,discordChannelId:a.packet.discordChannelId,discordMessageId:a.packet.discordMessageId,targetRuntimeId:a.packet.targetRuntimeId,issuedAt:a.packet.issuedAt,expiresAt:a.packet.expiresAt,producerId:"voidbot",producerRuntimeId:a.sourceObserverRuntimeId,authorityClass:"operator_request_only",status:"pending",command:a.packet.command},r=receipt(),terminal={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:"rejected-1",commandId:"rejected-1",status:"refused",failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:00Z",retryable:false,privateStateExposed:false};await first.admit(a.admissionId,request,a);await first.complete(a.admissionId,r.result,r.providerIdentityId,r);await first.terminalize(terminal);const reopened=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime});assert.equal(reopened.getRequest(a.admissionId).sourceActorDiscordId,"owner");assert.equal(reopened.getAdmission(a.admissionId).packetSha256,a.packetSha256);assert.equal(reopened.getCompletion(r.commandId).result.resultId,r.result.resultId);assert.equal(reopened.getTerminal(terminal.requestId).failureCode,"foreign-owner");await reopened.admit(a.admissionId,request,a);await reopened.complete(a.admissionId,r.result,r.providerIdentityId,r);await reopened.terminalize(terminal);await assert.rejects(()=>reopened.admit(a.admissionId,{...request,sourceActorDiscordId:"alien"},a),/collision/);await assert.rejects(()=>reopened.admit(a.admissionId,request,{...a,packetSha256:"sha256-alien"}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,detail:"alien"},r.providerIdentityId,{...r,result:{...r.result,detail:"alien"}}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,packetSha256:"sha256-alien"},r.providerIdentityId,{...r,packetSha256:"sha256-alien",result:{...r.result,packetSha256:"sha256-alien"}}),/exact durable admission/);await assert.rejects(()=>reopened.terminalize({...terminal,failureCode:"alien"}),/collision/);}); diff --git a/tests/epiphany-operator-rust-interop.test.mjs b/tests/epiphany-operator-rust-interop.test.mjs index 04bfc02..e8f0a19 100644 --- a/tests/epiphany-operator-rust-interop.test.mjs +++ b/tests/epiphany-operator-rust-interop.test.mjs @@ -10,5 +10,5 @@ import { OPERATOR_SIGNING_PURPOSE } from "../tools/epiphany-operator-commands.mj import { loadEpiphanyOperatorTrustAnchor,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; import { verifyPurpose } from "../tools/bifrost-signed-identity.mjs"; -const fixture=process.env.EPIPHANY_OPERATOR_RUST_FIXTURE??resolve("..","EpiphanyAgent",".epiphany-smoke","operator-command-interop-rust"),requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{decode,encode}=requireCult("@msgpack/msgpack"); +const fixture=process.env.EPIPHANY_OPERATOR_RUST_FIXTURE??resolve("..","EpiphanyAgent",".epiphany-smoke","operator-command-interop-rust"),cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),{decode,encode}=requireCult("@msgpack/msgpack"); test("consumes and verifies the Rust-emitted operator admission, protocol, and sealed receipt",{skip:!existsSync(resolve(fixture,"manifest.json"))&&"Rust interop fixture is not present in this standalone checkout"},async()=>{const manifest=JSON.parse(await readFile(resolve(fixture,"manifest.json"),"utf8")),protocolBytes=await readFile(resolve(fixture,manifest.protocolFile)),protocol=JSON.parse(protocolBytes),admissionBytes=await readFile(resolve(fixture,manifest.admissionFile)),receiptBytes=await readFile(resolve(fixture,manifest.sealedResultFile));assert.equal(`sha256-${createHash("sha256").update(admissionBytes).digest("hex")}`,manifest.admissionSha256);assert.equal(`sha256-${createHash("sha256").update(receiptBytes).digest("hex")}`,manifest.sealedResultSha256);assert.equal(`sha256-${createHash("sha256").update(protocolBytes).digest("hex")}`,manifest.protocolSha256);assert.equal(protocol.admissionSchemaVersion,"bifrost.operator_command.delivery.v1");assert.equal(protocol.resultSchemaVersion,"epiphany.operator_command.result.v1");assert.equal(protocol.sealedResultSchemaVersion,"epiphany.operator_command.sealed_result.v1");assert.deepEqual(protocol.commands.map(command=>command.kind),["status","sleep","wake","directive","reviews","review"]);assert.deepEqual(protocol.reviewDecisions,["adopt","refuse","hold"]);assert.equal(protocol.boundedReviewResult.reviews.length,1);assert.equal(protocol.boundedReviewResult.privateStateExposed,false);const admission=parseOperatorAdmission(decode(admissionBytes)),receipt=decode(receiptBytes),bifrostAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.bifrostRawTrustAnchorFile),{decode}),executorAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.executorRawTrustAnchorFile),{decode});assert.equal(verifyPurpose(bifrostAnchor.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(admission)),admission.providerSignature),true);assert.equal(verifySealedReceipt({receipt,admission,trustedIdentity:executorAnchor,encode}).commandId,admission.packet.commandId);assert.throws(()=>verifySealedReceipt({receipt:{...receipt,packetSha256:"sha256-hostile"},admission,trustedIdentity:executorAnchor,encode}),/not bound/);assert.equal(manifest.rudpConnectionId,0xe91f0001);assert.equal(manifest.privateStateExposed,false);}); diff --git a/tests/epiphany-operator-worker.test.mjs b/tests/epiphany-operator-worker.test.mjs index 740510e..ad16be9 100644 --- a/tests/epiphany-operator-worker.test.mjs +++ b/tests/epiphany-operator-worker.test.mjs @@ -16,7 +16,7 @@ import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; -const requireCache=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),requireNet=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),cultnet=requireNet("cultnet-ts"),msgpack=requireNet("@msgpack/msgpack"); +const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCache=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),requireNet=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),cultnet=requireNet("cultnet-ts"),msgpack=requireNet("@msgpack/msgpack"); test("operator identity enrollment emits only a compact public trust anchor",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-enroll-")),key=resolve(root,"private","operator.seed"),anchor=resolve(root,"public","operator.msgpack"),run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"enroll-identity","--private-key",key,"--trust-anchor-out",anchor],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim()),decoded=msgpack.decode(await readFile(anchor));assert.equal(pulse.schemaVersion,"bifrost.epiphany_operator_identity_enrollment.v0");assert.equal(pulse.privateStateExposed,false);assert.equal(decoded[0],"epiphany.host_identity_trust_anchor.v0");assert.equal(decoded.length,6);if(process.platform!=="win32"){assert.equal((await stat(key)).mode&0o777,0o600);assert.equal((await stat(anchor)).mode&0o777,0o640);}}); test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); function collect(child){return new Promise(resolveExit=>{let stdout="",stderr="";child.stdout.on("data",x=>stdout+=x);child.stderr.on("data",x=>stderr+=x);child.on("exit",code=>resolveExit({code,stdout,stderr}));});} From 6491f449cf0dbfa952b409de90ccdf511669a60b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 18:43:49 +0100 Subject: [PATCH 011/155] Unify Epiphany operator request v1 --- docs/bridge.md | 4 ++-- docs/epiphany-operator-boundary.md | 2 +- tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs | 2 +- tests/epiphany-operator-command.test.mjs | 4 ++-- tests/epiphany-operator-discord-outbox.test.mjs | 2 +- tests/epiphany-operator-ledger.test.mjs | 2 +- tests/epiphany-operator-v1.test.mjs | 2 +- tests/epiphany-operator-worker.test.mjs | 2 +- tools/epiphany-operator-command-documents.mjs | 2 +- tools/provider-advertisement.mjs | 4 ++-- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/bridge.md b/docs/bridge.md index a5e4944..2d906fa 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,9 +196,9 @@ The command is a dry-run shape probe for public surfaces that are real enough to 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. -Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v0` document containing one of `status`, `sleep`, `wake`, or `directive`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the exact persisted guild/actor mapping from its private actor-link store. The actor link is the admitted mapping authority; its Heimdall reference remains provenance until a signed live Heimdall claim surface exists and is not represented as authorization. Bifrost derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v0` with a dedicated operator Ed25519 seed. The feedback seed is not reused. +Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v1` document containing one of `status`, `sleep`, `wake`, `directive`, `reviews`, or `review`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the exact persisted guild/actor mapping from its private actor-link store. The actor link is the admitted mapping authority; its Heimdall reference remains provenance until a signed live Heimdall claim surface exists and is not represented as authorization. Bifrost derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v1` with a dedicated operator Ed25519 seed. The feedback seed is not reused. -Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. One supervised worker serializes requests across the CultNet RUDP boundary and requires the trusted Epiphany executor anchor. Success requires a host-signed `epiphany.operator_command.sealed_result.v0` whose payload digest, command id, packet digest, target runtime, completion time, and executor identity verify exactly; a transport acknowledgement is not success. Admissions and sealed results are persisted in the typed Bifrost operator ledger. Permanent pre-admission refusals are separately terminalized there, while transport failures remain retryable. An expired request cannot mint a new admission, but an exact admission already durably recorded before expiry may be retransmitted to recover Epiphany's one idempotent result without depending on mutable actor-link or signer state. The worker publishes a separate immutable Discord-delivery projection bound to the original request digest and interaction id; it exposes only bounded result/refusal fields and sealed-result fingerprints, never raw signatures or private state. VoidBot reads that outbox and owns Discord response/checkpoint state. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this path. +Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. One supervised worker serializes requests across the CultNet RUDP boundary and requires the trusted Epiphany executor anchor. Success requires a host-signed `epiphany.operator_command.sealed_result.v1` whose payload digest, command id, packet digest, target runtime, completion time, and executor identity verify exactly; a transport acknowledgement is not success. Admissions and sealed results are persisted in the typed Bifrost operator ledger. Permanent pre-admission refusals are separately terminalized there, while transport failures remain retryable. An expired request cannot mint a new admission, but an exact admission already durably recorded before expiry may be retransmitted to recover Epiphany's one idempotent result without depending on mutable actor-link or signer state. The worker publishes a separate immutable Discord-delivery projection bound to the original request digest and interaction id; it exposes only bounded result/refusal fields and sealed-result fingerprints, never raw signatures or private state. VoidBot reads that outbox and owns Discord response/checkpoint state. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this path. Durable v0 admissions/results are replay-only and cannot acquire the review vocabulary. 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. diff --git a/docs/epiphany-operator-boundary.md b/docs/epiphany-operator-boundary.md index 83f0651..9c18847 100644 --- a/docs/epiphany-operator-boundary.md +++ b/docs/epiphany-operator-boundary.md @@ -5,7 +5,7 @@ - Owner: Bifrost alone authenticates a Discord operator request, resolves the persisted Discord-to-Bifrost actor link, and signs an expiring Epiphany admission. Epiphany alone decides and applies the named command. -- Inputs: one strict `voidbot.discord.epiphany_operator_request.v0`, the exact +- Inputs: one strict `voidbot.discord.epiphany_operator_request.v1`, the exact configured Discord owner, a persisted Bifrost actor link, the deployment's operator signing identity, and the configured Epiphany runtime/host trust anchor. diff --git a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs index f7f8f91..0188028 100644 --- a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs +++ b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs @@ -22,7 +22,7 @@ public sealed class ProviderAdvertisementTests AssertEndpoint(root, "crossing-receipts", "bifrost.crossing_receipt.v1", "bridge"); AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage"); AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github"); - AssertEndpoint(root, "epiphany-operator-requests", "voidbot.discord.epiphany_operator_request.v0", "operator"); + AssertEndpoint(root, "epiphany-operator-requests", "voidbot.discord.epiphany_operator_request.v1", "operator"); AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.sealed_result.v1", "epiphany"); Assert.Equal("asgard.yggdrasil.bifrost", root.GetProperty("locatedService").GetString()); AssertSchemaPurpose(root, "bifrost.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt"); diff --git a/tests/epiphany-operator-command.test.mjs b/tests/epiphany-operator-command.test.mjs index 29b77ef..109aef4 100644 --- a/tests/epiphany-operator-command.test.mjs +++ b/tests/epiphany-operator-command.test.mjs @@ -4,14 +4,14 @@ import {admissionTuple,packetTuple,parseOperatorRequest} from "../tools/epiphany import {verifyPurpose} from "../tools/bifrost-signed-identity.mjs"; const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); const actorLink={guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1"}; -function request(command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} +function request(command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v1",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} function result(admission){const value={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result:value,resultPayloadSha256:"sha256-result",commandId:value.commandId,packetSha256:value.packetSha256,targetRuntimeId:value.targetRuntimeId,completedAt:value.completedAt,providerIdentityId:"epiphany-host",privateStateExposed:false,executorSignature:new Uint8Array(64)};return {providerIdentityId:"epiphany-host",result:value,receipt};} const transport=(execute)=>({trustedIdentityId:"epiphany-host",execute,verifyReceipt:(_admission,receipt)=>receipt}); class TestLedger{constructor(){this.requests=new Map();this.admissions=new Map();this.completions=new Map();this.terminals=new Map();}admit(id,request,value){const oldRequest=this.requests.get(id),old=this.admissions.get(id);if(oldRequest&&JSON.stringify(oldRequest)!==JSON.stringify(request)||old&&JSON.stringify(old)!==JSON.stringify(value))throw new Error("operator request identity collision");this.requests.set(id,request);this.admissions.set(id,value);}complete(_id,result,_provider,receipt){this.completions.set(result.commandId,receipt??{...result,result,providerIdentityId:"epiphany-host"});}getRequest(id){return this.requests.get(id);}getAdmission(id){return this.admissions.get(id);}getCompletion(id){return this.completions.get(id);}terminalize(value){this.terminals.set(value.requestId,value);}getTerminal(id){return this.terminals.get(id);}} async function identity(){const d=await mkdtemp(resolve(tmpdir(),"bifrost-operator-"));return enrollOperatorIdentity(resolve(d,"operator.seed"));} test("signs the exact Rust packet and admission tuples",async()=>{const id=await identity(),out=await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a))});assert.equal(out.admission.packetSha256,`sha256-${(await import("node:crypto")).createHash("sha256").update(encode(packetTuple(out.admission.packet))).digest("hex")}`);assert.ok(verifyPurpose(id.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(out.admission)),out.admission.providerSignature));assert.equal(out.result.disposition,"observed");}); test("requires exact owner and admitted Bifrost actor link",async()=>{const id=await identity();for(const args of [{ownerDiscordId:"other",actorLink},{ownerDiscordId:"owner",actorLink:null}])await assert.rejects(()=>admitOperatorRequest({request:request(),identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a)),...args}));const provenanceOnly={...actorLink,heimdallActorRef:""};assert.equal((await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink:provenanceOnly,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a))})).result.disposition,"observed");}); -test("strict request, ttl, command bounds, and expiry fail closed",()=>{assert.throws(()=>parseOperatorRequest({...request(),extra:true}));assert.throws(()=>parseOperatorRequest({...request(),expiresAt:"2026-07-19T10:01:01Z"}));assert.throws(()=>parseOperatorRequest(request({kind:"argv",args:["whoami"]})));assert.throws(()=>parseOperatorRequest(request({kind:"sleep",reason:""})));}); +test("strict request, ttl, command bounds, and expiry fail closed",()=>{assert.throws(()=>parseOperatorRequest({...request(),schemaVersion:"voidbot.discord.epiphany_operator_request.v0"}));assert.throws(()=>parseOperatorRequest({...request(),extra:true}));assert.throws(()=>parseOperatorRequest({...request(),expiresAt:"2026-07-19T10:01:01Z"}));assert.throws(()=>parseOperatorRequest(request({kind:"argv",args:["whoami"]})));assert.throws(()=>parseOperatorRequest(request({kind:"sleep",reason:""})));}); test("does not synthesize success without an authenticated transport result",async()=>{const id=await identity(),base={request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger()};await assert.rejects(()=>admitOperatorRequest(base),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:transport(async a=>({...result(a),providerIdentityId:"impostor"}))}),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:transport(async a=>({...result(a),result:{...result(a).result,packetSha256:"sha256-wrong"}}))}),/not bound/);}); test("retransmits an exact durable admission after expiry without mutable actor or signer state",async()=>{const id=await identity(),ledger=new TestLedger(),r=request(),base={request:r,ownerDiscordId:"owner",actorLink,identity:id,encode,ledger};await assert.rejects(()=>admitOperatorRequest({...base,now:new Date("2026-07-19T10:00:01Z"),transport:transport(async()=>{throw new Error("lost result")})}),/lost result/);let calls=0;const recovered=await admitOperatorRequest({...base,ownerDiscordId:"rotated-owner",actorLink:null,identity:null,now:new Date("2026-07-19T10:02:00Z"),transport:transport(async a=>{calls++;return result(a)})});assert.equal(calls,1);assert.equal(recovered.recovered,true);const fresh={...r,requestId:"interaction-2",commandId:"interaction-2",nonce:"interaction-2",sourceEventId:"interaction-2",discordMessageId:"interaction-2"};await assert.rejects(()=>admitOperatorRequest({...base,request:fresh,ledger:new TestLedger(),now:new Date("2026-07-19T10:02:00Z"),transport:transport(async a=>result(a))}),error=>error.code==="expired-new-request");}); test("does not hide command-id collisions behind an existing completion",async()=>{const id=await identity(),ledger=new TestLedger(),base={ownerDiscordId:"owner",actorLink,identity:id,encode,ledger,now:new Date("2026-07-19T10:00:01Z"),transport:transport(async a=>result(a))};await admitOperatorRequest({...base,request:request()});const collision={...request(),requestId:"interaction-alien",nonce:"alien",sourceEventId:"alien",discordMessageId:"alien"};await assert.rejects(()=>admitOperatorRequest({...base,request:collision}),error=>error.code==="command-id-collision");}); diff --git a/tests/epiphany-operator-discord-outbox.test.mjs b/tests/epiphany-operator-discord-outbox.test.mjs index 6cc74b3..11b6202 100644 --- a/tests/epiphany-operator-discord-outbox.test.mjs +++ b/tests/epiphany-operator-discord-outbox.test.mjs @@ -10,6 +10,6 @@ import { resolve } from "node:path"; import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCache=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),requireNet=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),{encode}=requireNet("@msgpack/msgpack"); -function request(id="request-1",command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:id,commandId:id,nonce:id,sourceEventId:id,sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:id,targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} +function request(id="request-1",command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v1",requestId:id,commandId:id,nonce:id,sourceEventId:id,sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:id,targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} function receipt(id="request-1"){return {schemaVersion:"epiphany.operator_command.sealed_result.v1",result:{disposition:"observed",detail:"bounded",operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",reviews:[],reviewCandidateId:"candidate-1",reviewDecision:"hold"},resultPayloadSha256:`sha256-${"1".repeat(64)}`,providerIdentityId:"executor",executorSignature:new Uint8Array(64),completedAt:"2026-07-19T12:00:01Z",commandId:id};} test("Discord outbox publishes immutable request-bound completed and refused deliveries",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-outbox-")),path=resolve(root,"deliveries.cc"),outbox=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode}),r=request("request-1",{kind:"review",mindRequestId:"mind-1",candidateId:"candidate-1",candidateSha256:"1".repeat(64),expectedModelRevision:41,expectedModelHash:"2".repeat(64),decision:"hold"}),completed=await outbox.publishCompleted(r,receipt());assert.equal(completed.deliveryId,r.requestId);assert.match(completed.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.match(completed.executorSignatureSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(completed.operatorStatus,"sleeping");assert.equal(completed.brakeStatus,"engaged");assert.equal(completed.schemaVersion,"bifrost.discord.epiphany_operator_delivery.v1");assert.equal(completed.commandId,r.commandId);assert.deepEqual(completed.reviews,[]);assert.equal(completed.reviewCandidateId,"candidate-1");assert.equal(completed.reviewDecision,"hold");assert.equal(outbox.isExactDelivery(r),true);assert.equal(outbox.isExactDelivery({...r,discordChannelId:"alien"}),false);assert.equal(completed.privateStateExposed,false);await outbox.publishCompleted(r,receipt());await assert.rejects(()=>outbox.publishCompleted({...r,command:{...r.command,candidateId:"alien"}},receipt()),/does not bind/);await assert.rejects(()=>outbox.publishCompleted(r,{...receipt(),result:{...receipt().result,disposition:"applied",detail:"changed"}}),/collision/);const refusedRequest=request("request-2"),refused=await outbox.publishRefused(refusedRequest,{failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:02Z"});assert.equal(refused.status,"refused");assert.equal(refused.failureCode,"foreign-owner");assert.equal(refused.sealedResultPayloadSha256,"");assert.deepEqual(refused.reviews,[]);assert.equal(refused.reviewCandidateId,"");assert.equal(refused.reviewDecision,"");const reopened=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode});assert.equal(reopened.get(r.requestId).status,"completed");assert.equal(reopened.get(refusedRequest.requestId).status,"refused");}); diff --git a/tests/epiphany-operator-ledger.test.mjs b/tests/epiphany-operator-ledger.test.mjs index 1aed71f..be7b739 100644 --- a/tests/epiphany-operator-ledger.test.mjs +++ b/tests/epiphany-operator-ledger.test.mjs @@ -9,4 +9,4 @@ import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),runtime=requireCult("cultcache-ts"); function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} function receipt(){const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:"command-1",packetSha256:"sha256-packet",targetRuntimeId:"epiphany-yggdrasil",disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};return {schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:"sha256-result",commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:"epiphany",privateStateExposed:false,executorSignature:new Uint8Array(64)};} -test("durable operator ledger survives restart and refuses admission/result collisions",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-ledger-")),path=resolve(root,"ledger.cc"),first=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime}),a=admission(),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:a.admissionId,commandId:a.packet.commandId,nonce:a.packet.nonce,sourceEventId:a.packet.sourceEventId,sourceActorDiscordId:"owner",discordGuildId:a.packet.discordGuildId,discordChannelId:a.packet.discordChannelId,discordMessageId:a.packet.discordMessageId,targetRuntimeId:a.packet.targetRuntimeId,issuedAt:a.packet.issuedAt,expiresAt:a.packet.expiresAt,producerId:"voidbot",producerRuntimeId:a.sourceObserverRuntimeId,authorityClass:"operator_request_only",status:"pending",command:a.packet.command},r=receipt(),terminal={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:"rejected-1",commandId:"rejected-1",status:"refused",failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:00Z",retryable:false,privateStateExposed:false};await first.admit(a.admissionId,request,a);await first.complete(a.admissionId,r.result,r.providerIdentityId,r);await first.terminalize(terminal);const reopened=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime});assert.equal(reopened.getRequest(a.admissionId).sourceActorDiscordId,"owner");assert.equal(reopened.getAdmission(a.admissionId).packetSha256,a.packetSha256);assert.equal(reopened.getCompletion(r.commandId).result.resultId,r.result.resultId);assert.equal(reopened.getTerminal(terminal.requestId).failureCode,"foreign-owner");await reopened.admit(a.admissionId,request,a);await reopened.complete(a.admissionId,r.result,r.providerIdentityId,r);await reopened.terminalize(terminal);await assert.rejects(()=>reopened.admit(a.admissionId,{...request,sourceActorDiscordId:"alien"},a),/collision/);await assert.rejects(()=>reopened.admit(a.admissionId,request,{...a,packetSha256:"sha256-alien"}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,detail:"alien"},r.providerIdentityId,{...r,result:{...r.result,detail:"alien"}}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,packetSha256:"sha256-alien"},r.providerIdentityId,{...r,packetSha256:"sha256-alien",result:{...r.result,packetSha256:"sha256-alien"}}),/exact durable admission/);await assert.rejects(()=>reopened.terminalize({...terminal,failureCode:"alien"}),/collision/);}); +test("durable operator ledger survives restart and refuses admission/result collisions",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-ledger-")),path=resolve(root,"ledger.cc"),first=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime}),a=admission(),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v1",requestId:a.admissionId,commandId:a.packet.commandId,nonce:a.packet.nonce,sourceEventId:a.packet.sourceEventId,sourceActorDiscordId:"owner",discordGuildId:a.packet.discordGuildId,discordChannelId:a.packet.discordChannelId,discordMessageId:a.packet.discordMessageId,targetRuntimeId:a.packet.targetRuntimeId,issuedAt:a.packet.issuedAt,expiresAt:a.packet.expiresAt,producerId:"voidbot",producerRuntimeId:a.sourceObserverRuntimeId,authorityClass:"operator_request_only",status:"pending",command:a.packet.command},r=receipt(),terminal={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:"rejected-1",commandId:"rejected-1",status:"refused",failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:00Z",retryable:false,privateStateExposed:false};await first.admit(a.admissionId,request,a);await first.complete(a.admissionId,r.result,r.providerIdentityId,r);await first.terminalize(terminal);const reopened=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime});assert.equal(reopened.getRequest(a.admissionId).sourceActorDiscordId,"owner");assert.equal(reopened.getAdmission(a.admissionId).packetSha256,a.packetSha256);assert.equal(reopened.getCompletion(r.commandId).result.resultId,r.result.resultId);assert.equal(reopened.getTerminal(terminal.requestId).failureCode,"foreign-owner");await reopened.admit(a.admissionId,request,a);await reopened.complete(a.admissionId,r.result,r.providerIdentityId,r);await reopened.terminalize(terminal);await assert.rejects(()=>reopened.admit(a.admissionId,{...request,sourceActorDiscordId:"alien"},a),/collision/);await assert.rejects(()=>reopened.admit(a.admissionId,request,{...a,packetSha256:"sha256-alien"}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,detail:"alien"},r.providerIdentityId,{...r,result:{...r.result,detail:"alien"}}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,packetSha256:"sha256-alien"},r.providerIdentityId,{...r,packetSha256:"sha256-alien",result:{...r.result,packetSha256:"sha256-alien"}}),/exact durable admission/);await assert.rejects(()=>reopened.terminalize({...terminal,failureCode:"alien"}),/collision/);}); diff --git a/tests/epiphany-operator-v1.test.mjs b/tests/epiphany-operator-v1.test.mjs index 9410f45..f803c24 100644 --- a/tests/epiphany-operator-v1.test.mjs +++ b/tests/epiphany-operator-v1.test.mjs @@ -23,7 +23,7 @@ const review = { function request(command) { return { schemaName: "voidbot.discord.epiphany_operator_request", - schemaVersion: "voidbot.discord.epiphany_operator_request.v0", + schemaVersion: "voidbot.discord.epiphany_operator_request.v1", requestId: "request-1", commandId: "command-1", nonce: "nonce-1", diff --git a/tests/epiphany-operator-worker.test.mjs b/tests/epiphany-operator-worker.test.mjs index ad16be9..8ed7a79 100644 --- a/tests/epiphany-operator-worker.test.mjs +++ b/tests/epiphany-operator-worker.test.mjs @@ -18,5 +18,5 @@ import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discor const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCache=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),requireNet=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),cultnet=requireNet("cultnet-ts"),msgpack=requireNet("@msgpack/msgpack"); test("operator identity enrollment emits only a compact public trust anchor",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-enroll-")),key=resolve(root,"private","operator.seed"),anchor=resolve(root,"public","operator.msgpack"),run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"enroll-identity","--private-key",key,"--trust-anchor-out",anchor],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim()),decoded=msgpack.decode(await readFile(anchor));assert.equal(pulse.schemaVersion,"bifrost.epiphany_operator_identity_enrollment.v0");assert.equal(pulse.privateStateExposed,false);assert.equal(decoded[0],"epiphany.host_identity_trust_anchor.v0");assert.equal(decoded.length,6);if(process.platform!=="win32"){assert.equal((await stat(key)).mode&0o777,0o600);assert.equal((await stat(anchor)).mode&0o777,0o640);}}); -test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); +test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v1",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); function collect(child){return new Promise(resolveExit=>{let stdout="",stderr="";child.stdout.on("data",x=>stdout+=x);child.stderr.on("data",x=>stderr+=x);child.on("exit",code=>resolveExit({code,stdout,stderr}));});} diff --git a/tools/epiphany-operator-command-documents.mjs b/tools/epiphany-operator-command-documents.mjs index 8d80e88..638cd2d 100644 --- a/tools/epiphany-operator-command-documents.mjs +++ b/tools/epiphany-operator-command-documents.mjs @@ -1,5 +1,5 @@ export const operatorRequestType="voidbot.discord.epiphany_operator_request"; -export const operatorRequestSchema="voidbot.discord.epiphany_operator_request.v0"; +export const operatorRequestSchema="voidbot.discord.epiphany_operator_request.v1"; export const operatorAdmissionType="bifrost.operator_command.delivery"; export const operatorAdmissionSchema="bifrost.operator_command.delivery.v1"; export const legacyOperatorAdmissionSchema="bifrost.operator_command.delivery.v0"; diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 9ef52f6..5dd4465 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -164,7 +164,7 @@ const canonicalService = `${rootVerse}.bifrost`; const locatedService = `${rootVerse}.${currentMachine}.bifrost`; const motionSurfaceCultMeshUri = `cultmesh://${locatedService}/eve/governance/surface`; const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`; -const epiphanyOperatorRequestSchemaId = "voidbot.discord.epiphany_operator_request.v0"; +const epiphanyOperatorRequestSchemaId = "voidbot.discord.epiphany_operator_request.v1"; const epiphanyOperatorAdmissionSchemaId = "bifrost.operator_command.delivery.v1"; const epiphanyOperatorResultSchemaId = "epiphany.operator_command.result.v1"; const epiphanyOperatorSealedResultSchemaId = "epiphany.operator_command.sealed_result.v1"; @@ -506,7 +506,7 @@ function buildAdvertisement(options) { "does not touch secrets in this advertisement", ]), boundary("epiphany-operator", "Bifrost admission; Epiphany execution", [ - "admit exact owner-only status, sleep, wake, and directive requests", + "admit exact owner-only status, sleep, wake, directive, reviews, and review requests", "require the exact configured Discord owner and persisted Bifrost actor-link mapping; Heimdall reference is provenance until signed claims are live", "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity", "relay only Epiphany-origin results bound to the admitted command", From 671168fefc9a8b00dfd08f850416a8c22493526a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 21:41:50 +0100 Subject: [PATCH 012/155] Sign Persona feedback provider health --- docs/bridge.md | 2 + tests/persona-feedback-cli.test.mjs | 5 +- tests/persona-feedback-idunn-health.test.mjs | 37 ++++++++++ tools/bifrost-feedback-identity.mjs | 15 +++++ tools/persona-feedback-idunn-health.mjs | 71 +++++++++++++------- tools/persona-feedback.mjs | 13 ++-- 6 files changed, 111 insertions(+), 32 deletions(-) create mode 100644 tests/persona-feedback-idunn-health.test.mjs diff --git a/docs/bridge.md b/docs/bridge.md index 2d906fa..a44e7b4 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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. diff --git a/tests/persona-feedback-cli.test.mjs b/tests/persona-feedback-cli.test.mjs index 33d17be..09a1bfc 100644 --- a/tests/persona-feedback-cli.test.mjs +++ b/tests/persona-feedback-cli.test.mjs @@ -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); diff --git a/tests/persona-feedback-idunn-health.test.mjs b/tests/persona-feedback-idunn-health.test.mjs new file mode 100644 index 0000000..fb090ab --- /dev/null +++ b/tests/persona-feedback-idunn-health.test.mjs @@ -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;} diff --git a/tools/bifrost-feedback-identity.mjs b/tools/bifrost-feedback-identity.mjs index 72b7258..1dbab9e 100644 --- a/tools/bifrost-feedback-identity.mjs +++ b/tools/bifrost-feedback-identity.mjs @@ -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;} diff --git a/tools/persona-feedback-idunn-health.mjs b/tools/persona-feedback-idunn-health.mjs index 577e339..2c056ff 100644 --- a/tools/persona-feedback-idunn-health.mjs +++ b/tools/persona-feedback-idunn-health.mjs @@ -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);} diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index 04c5b99..f60e0b6 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -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 Date: Sun, 19 Jul 2026 22:54:44 +0100 Subject: [PATCH 013/155] Add exact Epiphany Status v2 transport --- tests/epiphany-operator-status-v2.test.mjs | 28 +++++++++++ tools/epiphany-operator-command-documents.mjs | 47 +++++++++++++++++-- tools/epiphany-operator-cultnet-rudp.mjs | 1 + tools/epiphany-operator-discord-outbox.mjs | 4 +- 4 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 tests/epiphany-operator-status-v2.test.mjs diff --git a/tests/epiphany-operator-status-v2.test.mjs b/tests/epiphany-operator-status-v2.test.mjs new file mode 100644 index 0000000..7da0cac --- /dev/null +++ b/tests/epiphany-operator-status-v2.test.mjs @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile,mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createRequire } from "node:module"; +import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; +import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; +import { EPIPHANY_RESULT_SIGNING_PURPOSE,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; +import { parseOperatorResult,parseOperatorStatusV2,resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; + +const requireCult=createRequire(resolve("..","CultLib","packages","cultnet-ts","package.json")); +const msgpack=requireCult("@msgpack/msgpack"); +const fixtureRoot=process.env.EPIPHANY_OPERATOR_RUST_FIXTURE??resolve("..","EpiphanyAgent",".epiphany-smoke","operator-command-interop-rust"); +async function golden(){const fixture=JSON.parse(await readFile(resolve(fixtureRoot,"protocol.json"),"utf8")).operatorStatusV2MigrationFixture;if(fixture)return fixture;const unavailable=(daemonId,healthContract)=>({daemonId,healthContract,availability:"unavailable",unavailableReason:"required-provider-record-missing",state:null,reasonCode:null,providerObservedAtUnixMillis:null,evaluatedAtUnixMillis:null,expiresAtUnixMillis:null,releaseId:null,releaseWitnessSha256:null,sourceCommit:null,deploymentId:null,projectionSha256:null});return {schemaVersion:"epiphany.operator.status.v2",releaseId:"release-fixture",releaseWitnessSha256:`sha256-${"a".repeat(64)}`,sourceCommit:"b".repeat(40),deploymentId:"deployment-fixture",coordinatorSnapshot:"coordinator-snapshot/sleeping",coordinatorState:"coordinator-state/ready",coordinatorAction:"coordinator-action/none",brakeStatus:"engaged",residentStatus:"resident-self/idle",pressureCount:0,pendingReviewCount:0,providerSetStatus:"incomplete",providers:[unavailable("yggdrasil-epiphany","epiphany.cultnet-rudp-runtime-health"),unavailable("yggdrasil-bifrost-persona-feedback","bifrost.cultnet-rudp-persona-feedback-health")],privateStateExposed:false};} +function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v1",admissionId:"admission-status",packet:{commandId:"command-status",nonce:"nonce",sourceEventId:"event",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:`sha256-${"1".repeat(64)}`,sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} +async function sealed(statusV2){ + const a=admission(),root=await mkdtemp(resolve(tmpdir(),"bifrost-status-v2-")),identity=await enrollOperatorIdentity(resolve(root,"executor.seed")); + const result={schemaVersion:"epiphany.operator_command.status_result.v2",resultId:"result-status",commandId:a.packet.commandId,packetSha256:a.packetSha256,targetRuntimeId:a.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"none",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:"",statusV2}; + const receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:identity.identityId,privateStateExposed:false,executorSignature:new Uint8Array()}; + receipt.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));return {a,identity,receipt}; +} + +test("exactly parses the Rust Status-v2 golden and preserves partial providers",async()=>{const value=await golden();assert.deepEqual(parseOperatorStatusV2(structuredClone(value)),value);const partial=structuredClone(value),p=partial.providers[0];Object.assign(p,{availability:"authenticated-current",unavailableReason:"",state:"healthy",reasonCode:"ok",providerObservedAtUnixMillis:1,evaluatedAtUnixMillis:2,expiresAtUnixMillis:3,releaseId:value.releaseId,releaseWitnessSha256:value.releaseWitnessSha256,sourceCommit:value.sourceCommit,deploymentId:value.deploymentId,projectionSha256:`sha256-${"3".repeat(64)}`});assert.equal(parseOperatorStatusV2(partial).providerSetStatus,"incomplete");}); +test("rejects extra, missing, private, overlong, reordered, and false-complete Status-v2 shapes",async()=>{const value=await golden(),cases=[{...value,privateKey:"x"},(()=>{const x=structuredClone(value);delete x.residentStatus;return x;})(),{...value,privateStateExposed:true},{...value,residentStatus:"x".repeat(257)},{...value,providers:[...value.providers].reverse()},{...value,providerSetStatus:"complete-authenticated"}];for(const hostile of cases)assert.throws(()=>parseOperatorStatusV2(hostile));}); +test("verifies the signed Status-v2 receipt and rejects nested mutation",async()=>{const built=await sealed(await golden()),trusted=trustAnchor(built.identity,"2026-07-19T11:00:00Z");assert.equal(verifySealedReceipt({receipt:built.receipt,admission:built.a,trustedIdentity:trusted,encode:msgpack.encode}),built.receipt);const mutated=structuredClone(built.receipt);mutated.result.statusV2.pressureCount++;assert.throws(()=>verifySealedReceipt({receipt:mutated,admission:built.a,trustedIdentity:trusted,encode:msgpack.encode}),/digest|signature/);}); +test("non-Status v1 remains exact and cannot smuggle statusV2",()=>{const result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"r",commandId:"c",packetSha256:"p",targetRuntimeId:"t",disposition:"observed",consequenceKind:"snapshot",consequenceRef:"",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"",stateStatus:"",coordinatorAction:"",brakeStatus:"",detail:"",reviews:[],reviewCandidateId:"",reviewDecision:"",statusV2:{}};assert.throws(()=>parseOperatorResult(result),/unknown or missing/);}); diff --git a/tools/epiphany-operator-command-documents.mjs b/tools/epiphany-operator-command-documents.mjs index 638cd2d..beccc8f 100644 --- a/tools/epiphany-operator-command-documents.mjs +++ b/tools/epiphany-operator-command-documents.mjs @@ -5,6 +5,7 @@ export const operatorAdmissionSchema="bifrost.operator_command.delivery.v1"; export const legacyOperatorAdmissionSchema="bifrost.operator_command.delivery.v0"; export const operatorResultType="epiphany.operator_command.result"; export const operatorResultSchema="epiphany.operator_command.result.v1"; +export const operatorStatusResultSchema="epiphany.operator_command.status_result.v2"; export const legacyOperatorResultSchema="epiphany.operator_command.result.v0"; export const operatorResultReceiptType="epiphany.operator_command.sealed_result"; export const operatorResultReceiptSchema="epiphany.operator_command.sealed_result.v1"; @@ -17,17 +18,30 @@ export const operatorDiscordDeliverySchema="bifrost.discord.epiphany_operator_de export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",value=>parseOperatorAdmission(value,{allowLegacy:true})),result:make(operatorResultType,operatorResultSchema,"resultId",value=>parseOperatorResult(value,{allowLegacy:true})),sealedResult:make(operatorResultReceiptType,operatorResultReceiptSchema,"commandId",value=>parseOperatorResultReceipt(value,{allowLegacy:true})),terminal:make(operatorTerminalType,operatorTerminalSchema,"requestId",parseOperatorTerminal),discordDelivery:make(operatorDiscordDeliveryType,operatorDiscordDeliverySchema,"deliveryId",parseOperatorDiscordDelivery)};} export function parseOperatorRequest(value){const v=exactRecord(value,["schemaName","schemaVersion","requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","producerId","producerRuntimeId","authorityClass","status","command"],"operator request"); exact(v.schemaName,operatorRequestType,"schemaName");exact(v.schemaVersion,operatorRequestSchema,"schemaVersion"); for(const k of ["requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","producerRuntimeId"])text(v[k],k); exact(v.producerId,"voidbot","producerId");exact(v.authorityClass,"operator_request_only","authorityClass");exact(v.status,"pending","status"); const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>60000)throw new Error("operator request TTL must be positive and at most 60 seconds.");parseCommand(v.command);return v;} export function parseOperatorAdmission(v,{allowLegacy=false}={}){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");if(v.schemaVersion!==operatorAdmissionSchema&&!(allowLegacy&&v.schemaVersion===legacyOperatorAdmissionSchema))throw new Error("schemaVersion must be the current admission schema.");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");v.providerSignature=bytes(v.providerSignature,"providerSignature",64);parsePacket(v.packet);if(v.schemaVersion===legacyOperatorAdmissionSchema&&["reviews","review"].includes(v.packet.command.kind))throw new Error("legacy v0 admission cannot carry Mind review commands.");return v;} -export function parseOperatorResult(v,{allowLegacy=false}={}){const legacy=v?.schemaVersion===legacyOperatorResultSchema;if(legacy&&!allowLegacy)throw new Error("legacy result is replay-only.");v=exactRecord(v,legacy?["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"]:["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail","reviews","reviewCandidateId","reviewDecision"],"operator result");if(!legacy)exact(v.schemaVersion,operatorResultSchema,"schemaVersion");for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k);if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid.");if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state.");for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"]){if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(v[k].length>512)throw new Error(`${k} exceeds 512 characters.`);}if(!legacy){for(const k of ["reviewCandidateId","reviewDecision"]){if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);}if(!Array.isArray(v.reviews)||v.reviews.length>10)throw new Error("operator reviews must be a bounded array.");v.reviews.forEach(parseReviewSummary);if(v.reviewDecision&&!['adopt','refuse','hold'].includes(v.reviewDecision))throw new Error("reviewDecision is invalid.");}date(v.completedAt,"completedAt");return v;} +export function parseOperatorResult(v,{allowLegacy=false}={}){ + const legacy=v?.schemaVersion===legacyOperatorResultSchema,status=v?.schemaVersion===operatorStatusResultSchema; + if(legacy&&!allowLegacy)throw new Error("legacy result is replay-only."); + const base=["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"],current=[...base,"reviews","reviewCandidateId","reviewDecision"]; + v=exactRecord(v,legacy?base:status?[...current,"statusV2"]:current,"operator result"); + if(!legacy&&!status)exact(v.schemaVersion,operatorResultSchema,"schemaVersion"); + for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k); + if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid."); + if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state."); + for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"]){if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(v[k].length>512)throw new Error(`${k} exceeds 512 characters.`);} + if(!legacy){for(const k of ["reviewCandidateId","reviewDecision"]){if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);}if(!Array.isArray(v.reviews)||v.reviews.length>10)throw new Error("operator reviews must be a bounded array.");v.reviews.forEach(parseReviewSummary);if(v.reviewDecision&&!['adopt','refuse','hold'].includes(v.reviewDecision))throw new Error("reviewDecision is invalid.");} + if(status)parseOperatorStatusV2(v.statusV2); + date(v.completedAt,"completedAt");return v; +} export function parseOperatorResultReceipt(v,{allowLegacy=false}={}){v=exactRecord(v,["schemaVersion","result","resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId","privateStateExposed","executorSignature"],"operator sealed result receipt");const legacy=v.schemaVersion===legacyOperatorResultReceiptSchema;if(legacy&&!allowLegacy)throw new Error("legacy sealed result is replay-only.");if(!legacy)exact(v.schemaVersion,operatorResultReceiptSchema,"schemaVersion");parseOperatorResult(v.result,{allowLegacy});if(legacy!== (v.result.schemaVersion===legacyOperatorResultSchema))throw new Error("sealed result and embedded result versions differ.");for(const k of ["resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId"])text(v[k],k);date(v.completedAt,"completedAt");if(v.privateStateExposed!==false)throw new Error("operator sealed result receipt must not expose private state.");v.executorSignature=bytes(v.executorSignature,"executorSignature",64);return v;} export function parseOperatorTerminal(v){v=exactRecord(v,["schemaVersion","requestId","commandId","status","failureCode","recordedAt","retryable","privateStateExposed"],"operator terminal disposition");exact(v.schemaVersion,operatorTerminalSchema,"schemaVersion");for(const k of ["requestId","commandId","failureCode","recordedAt"])text(v[k],k);exact(v.status,"refused","status");date(v.recordedAt,"recordedAt");if(v.retryable!==false||v.privateStateExposed!==false)throw new Error("operator terminal disposition must be non-retryable and private-state-free.");return v;} -export function parseOperatorDiscordDelivery(v){v=exactRecord(v,["schemaVersion","deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","reviews","reviewCandidateId","reviewDecision","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId","recordedAt","privateStateExposed"],"operator Discord delivery");exact(v.schemaVersion,operatorDiscordDeliverySchema,"schemaVersion");for(const k of ["deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","recordedAt"])text(v[k],k);if(!/^sha256-[0-9a-f]{64}$/.test(v.requestPayloadSha256))throw new Error("operator Discord delivery request digest is invalid.");if(!["completed","refused"].includes(v.status))throw new Error("operator Discord delivery status is invalid.");for(const k of ["disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","reviewCandidateId","reviewDecision","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(!Array.isArray(v.reviews)||v.reviews.length>10)throw new Error("operator Discord delivery reviews must be bounded.");v.reviews.forEach(parseReviewSummary);if(v.reviewDecision&&!['adopt','refuse','hold'].includes(v.reviewDecision))throw new Error("operator Discord delivery reviewDecision is invalid.");for(const k of ["detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus"])if(v[k].length>512)throw new Error(`operator Discord delivery ${k} exceeds 512 characters.`);if(v.status==="completed"&&(!v.disposition||!/^sha256-[0-9a-f]{64}$/.test(v.sealedResultPayloadSha256)||!/^sha256-[0-9a-f]{64}$/.test(v.executorSignatureSha256)||!v.resultProviderIdentityId))throw new Error("completed operator Discord delivery lacks sealed-result binding.");if(v.status==="refused"&&!v.failureCode)throw new Error("refused operator Discord delivery lacks failure code.");date(v.recordedAt,"recordedAt");if(v.privateStateExposed!==false)throw new Error("operator Discord delivery must not expose private state.");return v;} +export function parseOperatorDiscordDelivery(v){v=exactRecord(v,["schemaVersion","deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","reviews","reviewCandidateId","reviewDecision","statusV2","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId","recordedAt","privateStateExposed"],"operator Discord delivery");exact(v.schemaVersion,operatorDiscordDeliverySchema,"schemaVersion");for(const k of ["deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","recordedAt"])text(v[k],k);if(!/^sha256-[0-9a-f]{64}$/.test(v.requestPayloadSha256))throw new Error("operator Discord delivery request digest is invalid.");if(!["completed","refused"].includes(v.status))throw new Error("operator Discord delivery status is invalid.");for(const k of ["disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","reviewCandidateId","reviewDecision","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(!Array.isArray(v.reviews)||v.reviews.length>10)throw new Error("operator Discord delivery reviews must be bounded.");v.reviews.forEach(parseReviewSummary);if(v.reviewDecision&&!['adopt','refuse','hold'].includes(v.reviewDecision))throw new Error("operator Discord delivery reviewDecision is invalid.");for(const k of ["detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus"])if(v[k].length>512)throw new Error(`operator Discord delivery ${k} exceeds 512 characters.`);if(v.status==="completed"&&(!v.disposition||!/^sha256-[0-9a-f]{64}$/.test(v.sealedResultPayloadSha256)||!/^sha256-[0-9a-f]{64}$/.test(v.executorSignatureSha256)||!v.resultProviderIdentityId))throw new Error("completed operator Discord delivery lacks sealed-result binding.");if(v.status==="refused"&&!v.failureCode)throw new Error("refused operator Discord delivery lacks failure code.");if(v.statusV2!==null)parseOperatorStatusV2(v.statusV2);if(v.status!=="completed"&&v.statusV2!==null)throw new Error("refused delivery cannot carry status v2.");date(v.recordedAt,"recordedAt");if(v.privateStateExposed!==false)throw new Error("operator Discord delivery must not expose private state.");return v;} export function operatorRequestTuple(v){return [v.schemaName,v.schemaVersion,v.requestId,v.commandId,v.nonce,v.sourceEventId,v.sourceActorDiscordId,v.discordGuildId,v.discordChannelId,v.discordMessageId,v.targetRuntimeId,v.issuedAt,v.expiresAt,v.producerId,v.producerRuntimeId,v.authorityClass,v.status,commandTuple(v.command)];} export function packetTuple(p){return [p.commandId,p.nonce,p.sourceEventId,p.sourceActorId,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.issuedAt,p.expiresAt,commandTuple(p.command)];} export function admissionTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];} // rmp-serde's compact serializer encodes the nested Rust wire-result struct as // this positional array inside the signed tuple. The separately hashed result // payload is encoded with named fields. -export function wireResultTuple(v){const tuple=[v.schemaVersion,v.resultId,v.commandId,v.packetSha256,v.targetRuntimeId,v.disposition,v.consequenceKind,v.consequenceRef,v.completedAt,v.privateStateExposed,v.operatorStatus,v.stateStatus,v.coordinatorAction,v.brakeStatus,v.detail];return v.schemaVersion===legacyOperatorResultSchema?tuple:[...tuple,v.reviews.map(reviewSummaryTuple),v.reviewCandidateId,v.reviewDecision];} +export function wireResultTuple(v){const tuple=[v.schemaVersion,v.resultId,v.commandId,v.packetSha256,v.targetRuntimeId,v.disposition,v.consequenceKind,v.consequenceRef,v.completedAt,v.privateStateExposed,v.operatorStatus,v.stateStatus,v.coordinatorAction,v.brakeStatus,v.detail];if(v.schemaVersion===legacyOperatorResultSchema)return tuple;const current=[...tuple,v.reviews.map(reviewSummaryTuple),v.reviewCandidateId,v.reviewDecision];return v.schemaVersion===operatorStatusResultSchema?[...current,statusV2Tuple(v.statusV2)]:current;} export function resultReceiptTuple(v){return [v.schemaVersion,wireResultTuple(v.result),v.resultPayloadSha256,v.commandId,v.packetSha256,v.targetRuntimeId,v.completedAt,v.providerIdentityId,v.privateStateExposed];} function parsePacket(v){v=exactRecord(v,["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","command"],"operator packet");for(const k of ["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId"])text(v[k],k);date(v.issuedAt,"issuedAt");date(v.expiresAt,"expiresAt");parseCommand(v.command);return v;} function parseCommand(v){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error("command must be an object.");if(["status","wake","reviews"].includes(v.kind))return exactRecord(v,["kind"],"command");if(v.kind==="sleep"){exactRecord(v,["kind","reason"],"command");text(v.reason,"reason");if(v.reason.length>512)throw new Error("sleep reason exceeds 512 characters.");return v;}if(v.kind==="directive"){exactRecord(v,["kind","objective"],"command");text(v.objective,"objective");if(v.objective.length>4096)throw new Error("directive objective exceeds 4096 characters.");return v;}if(v.kind==="review"){exactRecord(v,["kind","mindRequestId","candidateId","candidateSha256","expectedModelRevision","expectedModelHash","decision"],"command");for(const k of ["mindRequestId","candidateId"]){text(v[k],k);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);}hex64(v.candidateSha256,"candidateSha256");hex64(v.expectedModelHash,"expectedModelHash");if(!Number.isSafeInteger(v.expectedModelRevision)||v.expectedModelRevision<0)throw new Error("expectedModelRevision must be an unsigned safe integer.");if(!["adopt","refuse","hold"].includes(v.decision))throw new Error("review decision is invalid.");return v;}throw new Error("command kind is invalid.");} @@ -37,6 +51,33 @@ function commandTuple(v){if(v.kind==="sleep")return ["sleep",v.reason];if(v.kind export const commandTupleForInterop=commandTuple; function parseReviewSummary(v){exactRecord(v,["mindRequestId","candidateId","candidateSha256","modelRevision","modelHash","frontierItemId","requestedAt"],"review summary");for(const k of ["mindRequestId","candidateId","frontierItemId"]){text(v[k],k);if(v[k].length>256)throw new Error(`review ${k} exceeds 256 characters.`);}hex64(v.candidateSha256,"candidateSha256");hex64(v.modelHash,"modelHash");if(!Number.isSafeInteger(v.modelRevision)||v.modelRevision<0)throw new Error("review modelRevision is invalid.");date(v.requestedAt,"requestedAt");return v;} function reviewSummaryTuple(v){return [v.mindRequestId,v.candidateId,v.candidateSha256,v.modelRevision,v.modelHash,v.frontierItemId,v.requestedAt];} +export function parseOperatorStatusV2(v){ + v=exactRecord(v,["schemaVersion","releaseId","releaseWitnessSha256","sourceCommit","deploymentId","coordinatorSnapshot","coordinatorState","coordinatorAction","brakeStatus","residentStatus","pressureCount","pendingReviewCount","providerSetStatus","providers","privateStateExposed"],"operator status v2"); + exact(v.schemaVersion,"epiphany.operator.status.v2","status schemaVersion"); + for(const k of ["releaseId","deploymentId","coordinatorSnapshot","coordinatorState","coordinatorAction","brakeStatus","residentStatus"]){text(v[k],k);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);} + digest(v.releaseWitnessSha256,"releaseWitnessSha256");if(!/^[0-9a-f]{40}$/.test(v.sourceCommit))throw new Error("sourceCommit must be a lowercase 40-character commit."); + for(const k of ["pressureCount","pendingReviewCount"])if(!Number.isSafeInteger(v[k])||v[k]<0||v[k]>1_000_000)throw new Error(`${k} is invalid.`); + if(!["complete-authenticated","incomplete"].includes(v.providerSetStatus))throw new Error("providerSetStatus is invalid."); + if(!Array.isArray(v.providers)||v.providers.length!==2)throw new Error("operator status v2 requires exactly two providers."); + const policies=[["yggdrasil-epiphany","epiphany.cultnet-rudp-runtime-health",true],["yggdrasil-bifrost-persona-feedback","bifrost.cultnet-rudp-persona-feedback-health",false]]; + v.providers.forEach((p,i)=>parseStatusProvider(p,...policies[i])); + if(v.providers.every(p=>p.availability==="authenticated-current")!==(v.providerSetStatus==="complete-authenticated"))throw new Error("providerSetStatus does not match provider availability."); + if(v.privateStateExposed!==false)throw new Error("operator status v2 must not expose private state.");return v; +} +function parseStatusProvider(v,daemonId,contract,hasLineage){ + v=exactRecord(v,["daemonId","healthContract","availability","unavailableReason","state","reasonCode","providerObservedAtUnixMillis","evaluatedAtUnixMillis","expiresAtUnixMillis","releaseId","releaseWitnessSha256","sourceCommit","deploymentId","projectionSha256"],"operator status provider");exact(v.daemonId,daemonId,"daemonId");exact(v.healthContract,contract,"healthContract"); + if(!["authenticated-current","unavailable"].includes(v.availability))throw new Error("provider availability is invalid.");if(typeof v.unavailableReason!=="string"||v.unavailableReason.length>256)throw new Error("unavailableReason is invalid."); + for(const k of ["state","reasonCode","releaseId","sourceCommit","deploymentId"])if(v[k]!==null&&(typeof v[k]!=="string"||!v[k]||v[k].length>256))throw new Error(`${k} is invalid.`); + for(const k of ["providerObservedAtUnixMillis","evaluatedAtUnixMillis","expiresAtUnixMillis"])if(v[k]!==null&&(!Number.isSafeInteger(v[k])||v[k]<0))throw new Error(`${k} is invalid.`); + for(const k of ["releaseWitnessSha256","projectionSha256"])if(v[k]!==null)digest(v[k],k);if(v.sourceCommit!==null&&!/^[0-9a-f]{40}$/.test(v.sourceCommit))throw new Error("provider sourceCommit is invalid."); + const observed=["state","reasonCode","providerObservedAtUnixMillis","evaluatedAtUnixMillis","expiresAtUnixMillis","releaseId","releaseWitnessSha256","sourceCommit","deploymentId","projectionSha256"]; + if(v.availability==="unavailable"){if(!v.unavailableReason||observed.some(k=>v[k]!==null))throw new Error("unavailable provider must carry only its reason.");} + else{if(v.unavailableReason||["state","reasonCode","providerObservedAtUnixMillis","evaluatedAtUnixMillis","expiresAtUnixMillis","projectionSha256"].some(k=>v[k]===null))throw new Error("authenticated provider lacks current bounded state.");if(hasLineage){if(["releaseId","releaseWitnessSha256","sourceCommit","deploymentId"].some(k=>v[k]===null))throw new Error("Epiphany provider lacks release lineage.");}else if(["releaseId","releaseWitnessSha256","sourceCommit","deploymentId"].some(k=>v[k]!==null))throw new Error("Bifrost provider must not claim Epiphany release lineage.");} + return v; +} +function statusProviderTuple(v){return [v.daemonId,v.healthContract,v.availability,v.unavailableReason,v.state,v.reasonCode,v.providerObservedAtUnixMillis,v.evaluatedAtUnixMillis,v.expiresAtUnixMillis,v.releaseId,v.releaseWitnessSha256,v.sourceCommit,v.deploymentId,v.projectionSha256];} +function statusV2Tuple(v){return [v.schemaVersion,v.releaseId,v.releaseWitnessSha256,v.sourceCommit,v.deploymentId,v.coordinatorSnapshot,v.coordinatorState,v.coordinatorAction,v.brakeStatus,v.residentStatus,v.pressureCount,v.pendingReviewCount,v.providerSetStatus,v.providers.map(statusProviderTuple),v.privateStateExposed];} +function digest(v,label){if(typeof v!=="string"||!/^sha256-[0-9a-f]{64}$/.test(v))throw new Error(`${label} must be a sha256 digest.`);return v;} function hex64(v,label){if(typeof v!=="string"||!/^[0-9a-fA-F]{64}$/.test(v))throw new Error(`${label} must be 64 hexadecimal characters.`);return v;} function exactRecord(v,keys,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);const actual=Object.keys(v).sort(),expected=[...keys].sort();if(actual.join("\0")!==expected.join("\0"))throw new Error(`${label} has unknown or missing fields.`);return v;} function text(v,label){if(typeof v!=="string"||!v.trim())throw new Error(`${label} is required.`);return v;} diff --git a/tools/epiphany-operator-cultnet-rudp.mjs b/tools/epiphany-operator-cultnet-rudp.mjs index dfb9445..a6c6248 100644 --- a/tools/epiphany-operator-cultnet-rudp.mjs +++ b/tools/epiphany-operator-cultnet-rudp.mjs @@ -64,6 +64,7 @@ export async function loadEpiphanyOperatorTrustAnchor(path,{decode}={}){const ru export function verifySealedReceipt({receipt,admission,trustedIdentity,encode}){ parseOperatorAdmission(admission,{allowLegacy:true});parseOperatorResultReceipt(receipt,{allowLegacy:true});const anchor=parseTrustAnchor(trustedIdentity); + if(receipt.result.schemaVersion==="epiphany.operator_command.status_result.v2"&&admission.packet.command.kind!=="status")throw new Error("Epiphany Status-v2 result is not bound to a Status command."); if(receipt.providerIdentityId!==anchor.identityId)throw new Error("Epiphany sealed result used an untrusted host identity."); if(receipt.commandId!==admission.packet.commandId||receipt.packetSha256!==admission.packetSha256||receipt.targetRuntimeId!==admission.packet.targetRuntimeId)throw new Error("Epiphany sealed result receipt is not bound to the admitted command."); if(receipt.result.commandId!==receipt.commandId||receipt.result.packetSha256!==receipt.packetSha256||receipt.result.targetRuntimeId!==receipt.targetRuntimeId||receipt.result.completedAt!==receipt.completedAt)throw new Error("Epiphany sealed result receipt substituted its embedded application result."); diff --git a/tools/epiphany-operator-discord-outbox.mjs b/tools/epiphany-operator-discord-outbox.mjs index 4a8ef6d..a3b2bb9 100644 --- a/tools/epiphany-operator-discord-outbox.mjs +++ b/tools/epiphany-operator-discord-outbox.mjs @@ -10,8 +10,8 @@ export class EpiphanyOperatorDiscordOutbox { static async open(path,{cultcacheRuntime,encode}={}){const runtime=cultcacheRuntime??loadCultCacheRuntime(),definition=buildOperatorDefinitions(runtime.defineDocumentType).discordDelivery,cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();if(typeof encode!=="function")throw new Error("operator Discord outbox requires the canonical MessagePack encoder");await cache.pullAllBackingStores();return new EpiphanyOperatorDiscordOutbox(cache,definition,encode);} get(requestId){return this.cache.get(this.definition,requestId);} isExactDelivery(request){const value=this.get(request.requestId);return Boolean(value&&value.requestPayloadSha256===this.requestDigest(request)&&value.commandId===request.commandId&&value.discordGuildId===request.discordGuildId&&value.discordChannelId===request.discordChannelId&&value.discordInteractionId===request.sourceEventId&&value.targetRuntimeId===request.targetRuntimeId);} - async publishCompleted(request,receipt){validateReviewProjection(request,receipt.result);const value=this.base(request,{status:"completed",disposition:receipt.result.disposition,failureCode:"",detail:receipt.result.detail,operatorStatus:receipt.result.operatorStatus,stateStatus:receipt.result.stateStatus,coordinatorAction:receipt.result.coordinatorAction,brakeStatus:receipt.result.brakeStatus,reviews:receipt.result.reviews??[],reviewCandidateId:receipt.result.reviewCandidateId??"",reviewDecision:receipt.result.reviewDecision??"",sealedResultPayloadSha256:receipt.resultPayloadSha256,executorSignatureSha256:`sha256-${createHash("sha256").update(receipt.executorSignature).digest("hex")}`,resultProviderIdentityId:receipt.providerIdentityId,recordedAt:receipt.completedAt});return this.put(value);} - async publishRefused(request,terminal){const value=this.base(request,{status:"refused",disposition:"",failureCode:terminal.failureCode,detail:terminal.failureCode,operatorStatus:"",stateStatus:"",coordinatorAction:"",brakeStatus:"",reviews:[],reviewCandidateId:"",reviewDecision:"",sealedResultPayloadSha256:"",executorSignatureSha256:"",resultProviderIdentityId:"",recordedAt:terminal.recordedAt});return this.put(value);} + async publishCompleted(request,receipt){validateReviewProjection(request,receipt.result);const value=this.base(request,{status:"completed",disposition:receipt.result.disposition,failureCode:"",detail:receipt.result.detail,operatorStatus:receipt.result.operatorStatus,stateStatus:receipt.result.stateStatus,coordinatorAction:receipt.result.coordinatorAction,brakeStatus:receipt.result.brakeStatus,reviews:receipt.result.reviews??[],reviewCandidateId:receipt.result.reviewCandidateId??"",reviewDecision:receipt.result.reviewDecision??"",statusV2:receipt.result.statusV2??null,sealedResultPayloadSha256:receipt.resultPayloadSha256,executorSignatureSha256:`sha256-${createHash("sha256").update(receipt.executorSignature).digest("hex")}`,resultProviderIdentityId:receipt.providerIdentityId,recordedAt:receipt.completedAt});return this.put(value);} + async publishRefused(request,terminal){const value=this.base(request,{status:"refused",disposition:"",failureCode:terminal.failureCode,detail:terminal.failureCode,operatorStatus:"",stateStatus:"",coordinatorAction:"",brakeStatus:"",reviews:[],reviewCandidateId:"",reviewDecision:"",statusV2:null,sealedResultPayloadSha256:"",executorSignatureSha256:"",resultProviderIdentityId:"",recordedAt:terminal.recordedAt});return this.put(value);} requestDigest(request){return `sha256-${createHash("sha256").update(this.encode(operatorRequestTuple(request))).digest("hex")}`;} base(request,result){return {schemaVersion:"bifrost.discord.epiphany_operator_delivery.v1",deliveryId:request.requestId,requestId:request.requestId,requestPayloadSha256:this.requestDigest(request),commandId:request.commandId,discordGuildId:request.discordGuildId,discordChannelId:request.discordChannelId,discordInteractionId:request.sourceEventId,targetRuntimeId:request.targetRuntimeId,...result,privateStateExposed:false};} async put(value){const current=this.get(value.requestId);if(current&&stable(current)!==stable(value))throw new Error("operator Discord delivery identity collision");if(!current)await this.cache.put(this.definition,value.deliveryId,value);return current??value;} From 548dd9397f32a7329a8668255bd8ee7eab725f90 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 00:34:04 +0100 Subject: [PATCH 014/155] Own native Persona Discord transport --- docs/bridge.md | 14 ++- ...rsona-discord-crossing-rust-smoke.test.mjs | 25 ++++ tests/persona-discord-delivery.test.mjs | 32 ++++++ tests/persona-feedback-cli.test.mjs | 66 +++++++++-- tools/bifrost-discord-persona-ingress.mjs | 107 ++++++++++++++++++ tools/bifrost-signed-identity.mjs | 5 + tools/persona-discord-delivery-documents.mjs | 42 +++++++ tools/persona-discord-delivery.mjs | 51 +++++++++ tools/persona-discord-permit.mjs | 36 ++++++ tools/persona-feedback-documents.mjs | 12 +- tools/persona-feedback.mjs | 64 +++++++++-- 11 files changed, 430 insertions(+), 24 deletions(-) create mode 100644 tests/persona-discord-crossing-rust-smoke.test.mjs create mode 100644 tests/persona-discord-delivery.test.mjs create mode 100644 tools/bifrost-discord-persona-ingress.mjs create mode 100644 tools/persona-discord-delivery-documents.mjs create mode 100644 tools/persona-discord-delivery.mjs create mode 100644 tools/persona-discord-permit.mjs diff --git a/docs/bridge.md b/docs/bridge.md index a44e7b4..a11c5d4 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -194,7 +194,17 @@ The command is a dry-run shape probe for public surfaces that are real enough to ### 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's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an operator-command or actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. + +Persona replies cross back through two explicit CultMesh surfaces. Epiphany is +the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost +reads that surface, authenticates the dedicated Persona-mouth signer, journals +external execution before touching Discord, and is the sole writer of +`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but +never receives the Discord token or write access to Bifrost private state. +`unknown` is terminal for automatic transport retry: an external post may have +happened, so restart recovery must not manufacture a duplicate. Failed and +unknown outcomes remain Persona pressure for explicit resolution. Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v1` document containing one of `status`, `sleep`, `wake`, `directive`, `reviews`, or `review`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the exact persisted guild/actor mapping from its private actor-link store. The actor link is the admitted mapping authority; its Heimdall reference remains provenance until a signed live Heimdall claim surface exists and is not represented as authorization. Bifrost derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v1` with a dedicated operator Ed25519 seed. The feedback seed is not reused. @@ -208,7 +218,7 @@ The public trust-anchor artifact is a Rust-compatible six-element MessagePack tu 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`. +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=bifrost-discord`; 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. diff --git a/tests/persona-discord-crossing-rust-smoke.test.mjs b/tests/persona-discord-crossing-rust-smoke.test.mjs new file mode 100644 index 0000000..19e2a6f --- /dev/null +++ b/tests/persona-discord-crossing-rust-smoke.test.mjs @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {spawnSync} from "node:child_process"; +import {chmod,mkdtemp,writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; +import {createRequire} from "node:module"; +import {enrollDomainIdentity} from "../tools/bifrost-signed-identity.mjs"; +import {receiptIdentityDomain} from "../tools/persona-discord-delivery-documents.mjs"; +import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; + +const epiphany=resolve(import.meta.dirname,"..","..","EpiphanyAgent"),cult=resolve(import.meta.dirname,"..","..","CultLib"); +function runtime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} + +test("Bifrost consumes a Rust-authored request store through its read-only crossing directory",async()=>{ + const root=await mkdtemp(resolve(tmpdir(),"rust-bifrost-crossing-")),requestDir=resolve(root,"request"),receiptDir=resolve(root,"receipt"),requestStore=resolve(requestDir,"requests.cc"),anchor=resolve(root,"mouth-anchor.msgpack"),identity=resolve(root,"mouth.cc"),deliveryKey=resolve(root,"delivery.seed"),bridge=resolve(root,"bridge.mjs"); + const cargo=spawnSync("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-crossing-fixture","--","--request-store",requestStore,"--identity-store",identity,"--request-anchor",anchor],{cwd:epiphany,encoding:"utf8",timeout:180000,windowsHide:true}); + assert.equal(cargo.status,0,cargo.stderr||cargo.stdout); + await chmod(requestStore,0o444); + await enrollDomainIdentity(deliveryKey,"delivery",receiptIdentityDomain); + await writeFile(bridge,`console.log(JSON.stringify({action:"discord-post",ok:true,channelId:"123",messageId:"rust-smoke-message",transport:"smoke",crossingReceiptId:"rust-smoke-receipt",url:"https://discord.invalid/rust-smoke"}))`); + const r=runtime(),results=await processPersonaDiscordDeliveries({runtime:r,requestStorePath:requestStore,receiptStorePath:resolve(receiptDir,"receipts.cc"),executionStorePath:resolve(root,"private","executions.cc"),epiphanyTrustAnchorPath:anchor,privateKeyPath:deliveryKey,targetAdmitted:()=>true,requestPermit:async({requestPayloadSha256})=>({permitId:`permit:${requestPayloadSha256}`,expiresAt:new Date(Date.now()+5000).toISOString()}),bridgeCliPath:bridge,bifrostRoot:root}); + assert.equal(results[0]?.status,"completed"); + assert.equal(results[0]?.requestId,"rust-crossing-smoke-1"); +}); diff --git a/tests/persona-discord-delivery.test.mjs b/tests/persona-discord-delivery.test.mjs new file mode 100644 index 0000000..ad619b6 --- /dev/null +++ b/tests/persona-discord-delivery.test.mjs @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {createHash} from "node:crypto"; +import {createRequire} from "node:module"; +import {mkdtemp,writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; +import {buildPersonaDiscordDeliveryDefinitions,executionSchema,parseReceipt,parseRequest,receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestIdentityDomain,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs"; +import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; +import {enrollDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "../tools/bifrost-signed-identity.mjs"; + +const signature=()=>new Uint8Array(64); +const request=()=>({schemaVersion:"epiphany.persona_discord_delivery_request.v0",requestId:"request-1",effectDocumentId:"effect-1",targetRuntimeId:"epiphany-yggdrasil",personaAgentId:"epiphany.Persona",channelId:"123",replyToMessageId:"456",content:"hello",contentSha256:`sha256-${"a".repeat(64)}`,issuedAt:"2026-07-21T12:00:00Z",expiresAt:"2026-07-21T12:01:00Z",privateStateExposed:false,signerIdentityId:"signer",signerSignature:signature()}); + +test("Persona delivery request has one exact signature tuple",()=>{const value=parseRequest(request());assert.deepEqual(requestSigningTuple(value),["epiphany.persona_discord_delivery_request.v0","request-1","effect-1","epiphany-yggdrasil","epiphany.Persona","123","456","hello",`sha256-${"a".repeat(64)}`,"2026-07-21T12:00:00Z","2026-07-21T12:01:00Z",false,"signer"]);}); + +test("Persona delivery receipt terminal states cannot counterfeit delivery",()=>{const base={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"request-1",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"unknown",channelId:"123",replyToMessageId:"456",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.equal(receiptSigningTuple(parseReceipt(base)).length,14);assert.throws(()=>parseReceipt({...base,messageId:"counterfeit"}),/may not claim delivery evidence/);assert.doesNotThrow(()=>parseReceipt({...base,status:"failed"}));assert.doesNotThrow(()=>parseReceipt({...base,status:"completed",messageId:"789",transport:"bifrost.discord-post",crossingReceiptId:"crossing-1",receiptUrl:"https://discord.com/channels/g/123/789"}));}); + +test("Persona delivery documents refuse privacy and identity substitutions",()=>{assert.throws(()=>parseRequest({...request(),privateStateExposed:true}),/must be false/);const receipt={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"receipt-2",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"failed",channelId:"123",replyToMessageId:"",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.throws(()=>parseReceipt(receipt),/must equal requestId/);}); + +test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");}); + +test("running journal recovers as terminal unknown without a second Discord post",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-crash-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"anchor.msgpack");await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const digest=`sha256-${createHash("sha256").update(runtime.encode(requestSigningTuple(value))).digest("hex")}`,executions=await open(runtime,defs.execution,executionStore);await executions.put(defs.execution,value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:value.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:"crash fixture"});const results=await processPersonaDiscordDeliveries({runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:resolve(root,"must-not-run.mjs"),bifrostRoot:root});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");}); + +test("permit intent survives network failure and restart reuses its exact request tuple",async()=>{const f=await deliveryFixture("permit-retry-");const attempts=[];let results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async value=>{attempts.push({...value});throw new Error("permit network unavailable");}});assert.equal(results[0].status,"permit_pending");const executions=await open(f.runtime,f.defs.execution,f.executionStore),pending=executions.get(f.defs.execution,f.value.requestId);assert.equal(pending.status,"permit_pending");assert.equal(pending.permitNonce,attempts[0].nonce);assert.equal(pending.permitIssuedAt,attempts[0].issuedAt);assert.equal(pending.permitExpiresAt,attempts[0].expiresAt);results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async value=>{attempts.push({...value});return {permitId:"permit-after-restart"};}});assert.equal(results[0].status,"completed");assert.equal(attempts.length,2);for(const key of ["requestId","requestPayloadSha256","targetRuntimeId","nonce","issuedAt","expiresAt"])assert.equal(attempts[1][key],attempts[0][key]);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(f.counterPath,"utf8")),"1");}); + +test("expired permit intent restarts as terminal unknown without requesting or posting",async()=>{const f=await deliveryFixture("permit-expired-"),digest=`sha256-${createHash("sha256").update(f.runtime.encode(requestSigningTuple(f.value))).digest("hex")}`,executions=await open(f.runtime,f.defs.execution,f.executionStore),issuedAt=new Date(Date.now()-6000).toISOString(),expiresAt=new Date(Date.now()-1000).toISOString();await executions.put(f.defs.execution,f.value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:f.value.requestId,requestPayloadSha256:digest,status:"permit_pending",recordedAt:issuedAt,permitNonce:"persisted-nonce",permitIssuedAt:issuedAt,permitExpiresAt:expiresAt,detail:"restart fixture"});let permitCalls=0;const results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async()=>{permitCalls++;throw new Error("must not request");},bridgeCliPath:resolve(f.root,"must-not-post.mjs")});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");assert.equal(permitCalls,0);await assert.rejects(import("node:fs/promises").then(fs=>fs.readFile(f.counterPath,"utf8")));}); + +function loadRuntime(){const cult=resolve(import.meta.dirname,"..","..","CultLib"),cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} +async function open(runtime,definition,path){const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return cache;} +function requestAnchor(identity){return ["gamecult.service_trust_anchor.v1","epiphany-persona-mouth:epiphany-yggdrasil:v0","epiphany-persona-mouth","epiphany-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",requestSigningPurpose,"epiphany.persona_discord_delivery_request.v0","root",Date.now(),null,false];} +async function deliveryFixture(prefix){const root=await mkdtemp(resolve(tmpdir(),prefix)),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgeCliPath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain);await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgeCliPath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);return {root,runtime,defs,value,executionStore,counterPath,options:{runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath,bifrostRoot:root}};} diff --git a/tests/persona-feedback-cli.test.mjs b/tests/persona-feedback-cli.test.mjs index 09a1bfc..3e6c10c 100644 --- a/tests/persona-feedback-cli.test.mjs +++ b/tests/persona-feedback-cli.test.mjs @@ -7,6 +7,7 @@ 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"; +import { conversationFromDiscordMessage, discordGatewayReady } from "../tools/bifrost-discord-persona-ingress.mjs"; const root=resolve(import.meta.dirname,".."), cult=resolve(root,"..","CultLib"); const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")); @@ -18,7 +19,7 @@ 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"),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); + cli("bind-target",store,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","bifrost-discord-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"); @@ -35,7 +36,7 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async( 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"); + assert.equal(signed.sourceObserverId,"bifrost-discord");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); @@ -54,22 +55,71 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async( 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); + assert.equal(readiness.schemaVersion,"bifrost.persona_feedback.readiness.v0");assert.equal(readiness.status,"ready");assert.equal(readiness.bindingCount,1);assert.equal(readiness.pendingFailedCount,0);assert.equal(readiness.privateStateExposed,false); + const staged=cli("status",store,"--observation-store",observationStore,"--private-key",key,"--epiphany-persona-mouth-trust-anchor",resolve(dir,"missing-mouth-anchor.msgpack"),"--epiphany-persona-permit-trust-anchor",resolve(dir,"missing-permit-anchor.msgpack"),"--epiphany-persona-permit-rudp","rudp://127.0.0.1:9","--persona-delivery-private-key",resolve(dir,"missing-delivery.seed")); + assert.equal(staged.ingressReady,true);assert.deepEqual(staged.ingressReasons,[]);assert.equal(staged.personaDelivery.ready,false);assert.equal(staged.status,"degraded"); 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(observationStore).pullAll()).map(entry=>entry.type))],["bifrost.discord.persona_conversation_event"]); + assert.equal((await new Store(store).pullAll()).some(entry=>entry.type==="bifrost.discord.persona_conversation_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 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","bifrost-discord-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}));}); +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","bifrost-discord-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"},{producerId:"voidbot"}])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"};} +test("native Discord ingress admits only direct human address",()=>{ + const base={id:"m1",guild_id:"g",channel_id:"c",timestamp:"2026-07-21T20:00:00.000Z",author:{id:"human",username:"Human",bot:false},content:"<@42> hello Epiphany",mentions:[{id:"42"}]}; + const admitted=conversationFromDiscordMessage(base,"42");assert.equal(admitted.content,"hello Epiphany");assert.equal(admitted.addressingMode,"text");assert.match(admitted.payloadHash,/^[a-f0-9]{64}$/); + assert.equal(conversationFromDiscordMessage({...base,author:{id:"bot",bot:true}},"42"),null); + assert.equal(conversationFromDiscordMessage({...base,mentions:[],content:"ambient chatter"},"42"),null); + assert.equal(conversationFromDiscordMessage({...base,mentions:[{id:"42"}],content:"<@42> "},"42"),null); + assert.equal(conversationFromDiscordMessage({...base,content:`<@42> ${"x".repeat(1201)}`},"42"),null); + assert.equal(conversationFromDiscordMessage({...base,content:`<@42> ${"x".repeat(1200)}`},"42").content.length,1200); + assert.equal(Buffer.byteLength(conversationFromDiscordMessage({...base,content:`<@42> ${"😀".repeat(300)}`},"42").content,"utf8"),1200); + assert.equal(conversationFromDiscordMessage({...base,content:`<@42> ${"😀".repeat(301)}`},"42"),null); + assert.equal(conversationFromDiscordMessage({...base,timestamp:"not-a-time"},"42"),null); + const reply=conversationFromDiscordMessage({...base,mentions:[],content:"following up",referenced_message:{author:{id:"42"}}},"42");assert.equal(reply.addressingMode,"reply"); + const commandShaped=conversationFromDiscordMessage({...base,content:"<@42> /epiphany wake"},"42");assert.equal(commandShaped.content,"/epiphany wake");assert.equal(commandShaped.addressingMode,"text"); +}); + +test("Discord gateway health fails closed while disconnected or heartbeat-stale",()=>{ + const now=1_000_000; + assert.equal(discordGatewayReady({connected:false,sessionId:"s",lastHeartbeatAckAtMillis:now},now),false); + assert.equal(discordGatewayReady({connected:true,sessionId:"",lastHeartbeatAckAtMillis:now},now),false); + assert.equal(discordGatewayReady({connected:true,sessionId:"s",lastHeartbeatAckAtMillis:0},now),false); + assert.equal(discordGatewayReady({connected:true,sessionId:"s",lastHeartbeatAckAtMillis:now-120001},now),false); + assert.equal(discordGatewayReady({connected:true,sessionId:"s",lastHeartbeatAckAtMillis:now-120000},now),true); +}); + +test("serve drains a persisted observation left by a failed prior process",async()=>{ + const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-recovery-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc"),delivery=resolve(dir,"delivery.cc"),key=resolve(dir,"identity.seed"),anchor=resolve(dir,"anchor.msgpack"); + cli("bind-target",store,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","bifrost-discord-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",delivery); + await putEvent(observations,event("crash-recovery","human")); + const failed=raw("process",store,"--observation-store",observations,"--event-id","crash-recovery","--private-key",key,"--delivery-store",delivery);assert.notEqual(failed.status,0); + cliNoStore("enroll-identity","--private-key",key,"--trust-anchor-out",anchor); + const recovered=cli("serve",store,"--observation-store",observations,"--once","true","--private-key",key,"--delivery-store",delivery,"--interval-ms","1");assert.equal(recovered.scanned,1);assert.equal(recovered.results[0].ok,true); + const n=await CultMesh.createNode(store,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery]});await n.cache?.pullAllBackingStores?.();assert.ok((await n.get(defs.receipt,"crash-recovery"))); +}); + +test("serve durably accounts for failed pending observations and clears them after admission",async()=>{ + const dir=await mkdtemp(resolve(tmpdir(),"bifrost-feedback-failure-")),store=resolve(dir,"provider.cc"),observations=resolve(dir,"observations.cc"),delivery=resolve(dir,"delivery.cc"),key=resolve(dir,"identity.seed"),anchor=resolve(dir,"anchor.msgpack"); + cli("bind-target",store,"--guild-id","g","--channel-id","c","--persona-id","epiphany","--repo","GameCult/Epiphany","--runtime-id","epiphany-yggdrasil","--producer-runtime-id","bifrost-discord-yggdrasil","--source-visibility","organization","--data-classification","organization_feedback","--delivery-store",delivery); + await putEvent(observations,event("retry-evidence","human")); + const failed=cli("serve",store,"--observation-store",observations,"--once","true","--private-key",key,"--delivery-store",delivery,"--interval-ms","1"); + assert.equal(failed.results[0].ok,false);assert.equal(failed.readiness.status,"degraded");assert.equal(failed.readiness.pendingFailedCount,1);assert.match(failed.readiness.reasons[0],/identity|pendingFailures/); + let n=await CultMesh.createNode(store,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});await n.cache?.pullAllBackingStores?.(); + const evidence=(await n.get(defs.pendingFailure,"retry-evidence"))?.value??await n.get(defs.pendingFailure,"retry-evidence");assert.equal(evidence.attemptCount,1);assert.equal(evidence.privateStateExposed,false);assert.ok(Buffer.byteLength(evidence.lastError,"utf8")<=512); + cliNoStore("enroll-identity","--private-key",key,"--trust-anchor-out",anchor); + const recovered=cli("serve",store,"--observation-store",observations,"--once","true","--private-key",key,"--delivery-store",delivery,"--interval-ms","1");assert.equal(recovered.results[0].ok,true);assert.equal(recovered.readiness.pendingFailedCount,0);assert.equal(recovered.readiness.status,"ready"); + n=await CultMesh.createNode(store,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});await n.cache?.pullAllBackingStores?.();assert.equal(await n.get(defs.pendingFailure,"retry-evidence"),undefined); +}); + +function event(id,author,targetRuntimeId="epiphany-yggdrasil"){const content=`feedback ${id}`;return {schemaName:"bifrost.discord.persona_conversation_event",schemaVersion:"bifrost.discord.persona_conversation_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:"bifrost-discord",producerRuntimeId:"bifrost-discord-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"});} diff --git a/tools/bifrost-discord-persona-ingress.mjs b/tools/bifrost-discord-persona-ingress.mjs new file mode 100644 index 0000000..3c08602 --- /dev/null +++ b/tools/bifrost-discord-persona-ingress.mjs @@ -0,0 +1,107 @@ +import { createHash } from "node:crypto"; + +const GATEWAY_VERSION = 10; +const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15); + +export function conversationFromDiscordMessage(message, botUserId) { + if (!message || typeof message !== "object" || message.author?.bot === true) return null; + const guildId = text(message.guild_id); + const channelId = text(message.channel_id); + const messageId = text(message.id); + const authorId = text(message.author?.id); + const observedAt = Date.parse(String(message.timestamp ?? "")); + if (!guildId || !channelId || !messageId || !authorId || !botUserId || !Number.isFinite(observedAt)) return null; + const mentioned = Array.isArray(message.mentions) + && message.mentions.some((mention) => String(mention?.id ?? "") === botUserId); + const replied = String(message.referenced_message?.author?.id ?? "") === botUserId; + if (!mentioned && !replied) return null; + const content = String(message.content ?? "") + .replace(new RegExp(`<@!?${escapeRegex(botUserId)}>`, "g"), " ") + .replace(/\s+/g, " ") + .trim(); + if (!content || Buffer.byteLength(content, "utf8") > 1200) return null; + return { + guildId, + channelId, + messageId, + authorId, + authorName: text(message.member?.nick) || text(message.author?.global_name) || text(message.author?.username) || authorId, + content, + addressingMode: mentioned ? "text" : "reply", + observedAt: new Date(observedAt).toISOString(), + payloadHash: createHash("sha256").update(content, "utf8").digest("hex"), + }; +} + +export function discordGatewayReady(state, nowMillis = Date.now(), maxAckAgeMillis = 120000) { + return state?.connected === true + && text(state.sessionId) !== "" + && Number.isFinite(state.lastHeartbeatAckAtMillis) + && state.lastHeartbeatAckAtMillis > 0 + && nowMillis >= state.lastHeartbeatAckAtMillis + && nowMillis - state.lastHeartbeatAckAtMillis <= maxAckAgeMillis; +} + +export async function serveDiscordPersonaIngress({ token, runtimeId, onConversation, onReady = () => {}, onDisconnect = () => {}, onState = () => {} }) { + if (!text(token)) throw new Error("Bifrost Discord Persona ingress requires a bot token."); + if (!text(runtimeId)) throw new Error("Bifrost Discord Persona ingress requires a runtime id."); + if (typeof onConversation !== "function") throw new Error("Bifrost Discord Persona ingress requires a conversation sink."); + onState({connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"discovering"}); + const gateway = await discordJson("https://discord.com/api/v10/gateway/bot", token); + if (!text(gateway?.url)) throw new Error("Discord gateway discovery returned no URL."); + onState({connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"discovered"}); + let sequence = null, sessionId = "", resumeUrl = gateway.url, botUserId = "", stopped = false; + while (!stopped) { + try { + const result = await gatewaySession({ token, runtimeId, url: resumeUrl, sequence, sessionId, botUserId, onConversation, onReady, onState }); + sequence = result.sequence; sessionId = result.sessionId; resumeUrl = result.resumeUrl; botUserId = result.botUserId; + stopped = result.stopped; + } catch (error) { + if (error?.fatal === true) throw error; + onState({connected:false,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"disconnected"}); + onDisconnect(error); + await delay(2000); + } + } +} + +async function gatewaySession(state) { + const socket = new WebSocket(`${state.url}/?v=${GATEWAY_VERSION}&encoding=json`); + let sequence=state.sequence,sessionId=state.sessionId,resumeUrl=state.url,botUserId=state.botUserId,heartbeatTimer=null,heartbeatAck=true,stopped=false; + await new Promise((resolve, reject) => { + const cleanup=()=>{if(heartbeatTimer)clearInterval(heartbeatTimer);}; + socket.addEventListener("error",()=>{cleanup();reject(new Error("Discord gateway socket failed."));},{once:true}); + socket.addEventListener("close",(event)=>{cleanup();state.onState({connected:false,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"disconnected"});if([4004,4013,4014].includes(event.code)){stopped=true;const error=new Error(`Discord permanently rejected the Bifrost gateway session (close ${event.code}).`);error.fatal=true;reject(error);return;}resolve();},{once:true}); + socket.addEventListener("message",async event=>{ + try { + const frame=JSON.parse(String(event.data)); + if(Number.isInteger(frame.s))sequence=frame.s; + if(frame.op===10){ + const interval=Number(frame.d?.heartbeat_interval);if(!Number.isFinite(interval)||interval<1000)throw new Error("Discord gateway supplied an invalid heartbeat interval."); + const heartbeat=()=>{if(!heartbeatAck){socket.close(4000,"heartbeat timeout");return;}heartbeatAck=false;socket.send(JSON.stringify({op:1,d:sequence}));}; + heartbeatTimer=setInterval(heartbeat,interval);heartbeat(); + if(sessionId&&sequence!==null)socket.send(JSON.stringify({op:6,d:{token:state.token,session_id:sessionId,seq:sequence}})); + else socket.send(JSON.stringify({op:2,d:{token:state.token,intents:GATEWAY_INTENTS,properties:{os:process.platform,browser:"bifrost",device:"bifrost"}}})); + } else if(frame.op===11){heartbeatAck=true;state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:Date.now(),phase:"heartbeat-acknowledged"});} + else if(frame.op===7)socket.close(4000,"server reconnect"); + else if(frame.op===9){sessionId="";sequence=null;socket.close(4000,"invalid session");} + else if(frame.op===0&&frame.t==="READY"){ + sessionId=text(frame.d?.session_id);resumeUrl=text(frame.d?.resume_gateway_url)||state.url;botUserId=text(frame.d?.user?.id);if(!sessionId||!botUserId)throw new Error("Discord READY omitted session or bot identity.");state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"ready-awaiting-heartbeat-ack"});await state.onReady({runtimeId:state.runtimeId,botUserId,sessionId}); + } else if(frame.op===0&&frame.t==="RESUMED"){state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"resumed-awaiting-heartbeat-ack"});await state.onReady({runtimeId:state.runtimeId,botUserId,sessionId});} + else if(frame.op===0&&frame.t==="MESSAGE_CREATE"){ + const conversation=conversationFromDiscordMessage(frame.d,botUserId);if(conversation)await state.onConversation(conversation); + } + } catch(error){socket.close(4000,"ingress failure");reject(error);} + }); + }); + return {sequence,sessionId,resumeUrl,botUserId,stopped}; +} + +async function discordJson(url, token) { + const response=await fetch(url,{headers:{authorization:`Bot ${token}`},redirect:"error",signal:AbortSignal.timeout(15000)}); + if(!response.ok)throw new Error(`Discord gateway discovery failed with HTTP ${response.status}.`); + return response.json(); +} +function text(value){return typeof value==="string"?value.trim():"";} +function escapeRegex(value){return value.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");} +function delay(ms){return new Promise(resolve=>setTimeout(resolve,ms));} diff --git a/tools/bifrost-signed-identity.mjs b/tools/bifrost-signed-identity.mjs index 2603003..70bfeeb 100644 --- a/tools/bifrost-signed-identity.mjs +++ b/tools/bifrost-signed-identity.mjs @@ -7,8 +7,13 @@ const SIGNATURE_DOMAIN=Buffer.from("epiphany.host-incarnation.signature.v0\0","u export async function enrollSignedIdentity(path,label="Bifrost identity") { await mkdir(dirname(path),{recursive:true}); const {privateKey}=generateKeyPairSync("ed25519"),seed=Buffer.from(privateKey.export({format:"jwk"}).d,"base64url"),handle=await open(path,"wx",0o600); try{await handle.writeFile(seed);await handle.sync();}finally{await handle.close();} await chmod(path,0o600); return loadSignedIdentity(path,label); } export async function loadSignedIdentity(path,label="Bifrost identity") { const info=await stat(path); if(!info.isFile())throw new Error(`${label} must be a regular file.`); if(process.platform!=="win32"&&(info.mode&0o077)!==0)throw new Error(`${label} private key must have mode 0600.`); if(process.platform!=="win32"&&typeof process.geteuid==="function"&&info.uid!==process.geteuid())throw new Error(`${label} private key must be owned by the service user.`); const seed=await readFile(path);if(seed.length!==32)throw new Error(`${label} seed must be exactly 32 bytes.`); const key=createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),seed]),format:"der",type:"pkcs8"}),publicKey=createPublicKey(key).export({format:"der",type:"spki"}).subarray(-32),identityId=createHash("sha256").update(ID_DOMAIN).update(publicKey).digest("hex"); return {identityId,publicKey,key}; } +export async function enrollDomainIdentity(path,label,identityDomain){await enrollSignedIdentity(path,label);return loadDomainIdentity(path,label,identityDomain);} +export async function loadDomainIdentity(path,label,identityDomain){const identity=await loadSignedIdentity(path,label);return {...identity,identityId:createHash("sha256").update(Buffer.from(identityDomain,"utf8")).update(identity.publicKey).digest("hex")};} export function signPurpose(identity,purpose,payload){return ed25519Sign(null,domainMessage(purpose,payload),identity.key);} export function verifyPurpose(publicKey,purpose,payload,signature){const key=createPublicKey({key:Buffer.concat([Buffer.from("302a300506032b6570032100","hex"),Buffer.from(publicKey)]),format:"der",type:"spki"});return ed25519Verify(null,domainMessage(purpose,payload),key,signature);} +export function signDomainPurpose(identity,signatureDomain,purpose,payload){return ed25519Sign(null,domainMessageWith(signatureDomain,purpose,payload),identity.key);} +export function verifyDomainPurpose(publicKey,signatureDomain,purpose,payload,signature){const key=createPublicKey({key:Buffer.concat([Buffer.from("302a300506032b6570032100","hex"),Buffer.from(publicKey)]),format:"der",type:"spki"});return ed25519Verify(null,domainMessageWith(signatureDomain,purpose,payload),key,signature);} 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")}`};} function domainMessage(purpose,payload){const p=Buffer.from(purpose,"utf8");return Buffer.concat([SIGNATURE_DOMAIN,u64(p.length),p,u64(payload.length),payload]);} +function domainMessageWith(signatureDomain,purpose,payload){const p=Buffer.from(purpose,"utf8");return Buffer.concat([Buffer.from(signatureDomain,"utf8"),u64(p.length),p,u64(payload.length),payload]);} function u64(value){const b=Buffer.alloc(8);b.writeBigUInt64BE(BigInt(value));return b;} diff --git a/tools/persona-discord-delivery-documents.mjs b/tools/persona-discord-delivery-documents.mjs new file mode 100644 index 0000000..4787e92 --- /dev/null +++ b/tools/persona-discord-delivery-documents.mjs @@ -0,0 +1,42 @@ +export const requestType = "epiphany.persona_discord_delivery_request"; +export const requestSchema = "epiphany.persona_discord_delivery_request.v0"; +export const requestSigningPurpose = "epiphany.persona-discord.delivery-request.v0"; +export const requestIdentityDomain = "epiphany.persona-discord-delivery-request.identity.v0\0"; +export const requestSignatureDomain = "epiphany.persona-discord-delivery-request.signature.v0\0"; +export const receiptType = "bifrost.persona_discord_delivery_receipt"; +export const receiptSchema = "bifrost.persona_discord_delivery_receipt.v0"; +export const receiptSigningPurpose = "bifrost.persona-discord.delivery-receipt.v0"; +export const receiptIdentityDomain = "bifrost.persona-discord-delivery-receipt.identity.v0\0"; +export const receiptSignatureDomain = "bifrost.persona-discord-delivery-receipt.signature.v0\0"; +export const executionType = "bifrost.persona_discord_delivery_execution"; +export const executionSchema = "bifrost.persona_discord_delivery_execution.v0"; + +export function buildPersonaDiscordDeliveryDefinitions(defineDocumentType) { + const make=(type,schema,name,parse,members)=>defineDocumentType({type,schemaName:type,schemaId:schema,schemaVersion:schema,contentHash:schema,global:false,name,schema:{parse},members}); + return { + request:make(requestType,requestSchema,"requestId",parseRequest,requestMembers), + receipt:make(receiptType,receiptSchema,"receiptId",parseReceipt,receiptMembers), + execution:make(executionType,executionSchema,"requestId",parseExecution), + }; +} + +const requestMembers = ["schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="signerSignature"?"byte[]":"string",isName:memberName==="requestId"})); +const receiptMembers = ["schemaVersion","receiptId","requestId","requestPayloadSha256","status","channelId","replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl","completedAt","providerIdentityId","privateStateExposed","providerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="providerSignature"?"byte[]":"string",isName:memberName==="receiptId"})); + +export function requestSigningTuple(v){return [v.schemaVersion,v.requestId,v.effectDocumentId,v.targetRuntimeId,v.personaAgentId,v.channelId,v.replyToMessageId,v.content,v.contentSha256,v.issuedAt,v.expiresAt,v.privateStateExposed,v.signerIdentityId];} +export function receiptSigningTuple(v){return [v.schemaVersion,v.receiptId,v.requestId,v.requestPayloadSha256,v.status,v.channelId,v.replyToMessageId,v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl,v.completedAt,v.providerIdentityId,v.privateStateExposed];} + +export function parseRequest(value){const v=typed(value,requestMembers,"Persona Discord delivery request");closed(v,["schemaName","schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"],"request");exact(v.schemaVersion,requestSchema,"request schema");for(const key of ["requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","contentSha256","issuedAt","expiresAt","signerIdentityId"])text(v[key],key);content(v.content);optionalText(v.replyToMessageId,"replyToMessageId");if(v.privateStateExposed!==false)throw new Error("request privateStateExposed must be false.");bytes64(v.signerSignature,"signerSignature");sha(v.contentSha256,"contentSha256");const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>120000)throw new Error("request lifetime must be positive and at most 120 seconds.");return v;} +export function parseReceipt(value){const v=record(value,"Persona Discord delivery receipt");exact(v.schemaVersion,receiptSchema,"receipt schema");for(const key of ["receiptId","requestId","requestPayloadSha256","status","channelId","completedAt","providerIdentityId"])text(v[key],key);for(const key of ["replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl"])optionalText(v[key],key);if(v.receiptId!==v.requestId)throw new Error("receiptId must equal requestId.");if(!["completed","failed","unknown"].includes(v.status))throw new Error("receipt status is invalid.");if(v.status==="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(x=>!x))throw new Error("completed receipt lacks exact delivery evidence.");if(v.status!=="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(Boolean))throw new Error("non-completed receipt may not claim delivery evidence.");if(v.privateStateExposed!==false)throw new Error("receipt privateStateExposed must be false.");bytes64(v.providerSignature,"providerSignature");sha(v.requestPayloadSha256,"requestPayloadSha256");date(v.completedAt,"completedAt");return v;} +export function parseExecution(value){const v=record(value,"Persona Discord execution journal");exact(v.schemaVersion,executionSchema,"execution schema");for(const key of ["requestId","requestPayloadSha256","status","recordedAt"])text(v[key],key);if(!["permit_pending","running","completed","failed","unknown"].includes(v.status))throw new Error("execution status is invalid.");if(v.status==="permit_pending"){text(v.permitNonce,"permitNonce");date(v.permitIssuedAt,"permitIssuedAt");date(v.permitExpiresAt,"permitExpiresAt");if(Date.parse(v.permitExpiresAt)<=Date.parse(v.permitIssuedAt)||Date.parse(v.permitExpiresAt)-Date.parse(v.permitIssuedAt)>5000)throw new Error("permit intent lifetime is invalid");}return v;} +function record(v,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);return v;} +function typed(v,members,label){if(Array.isArray(v)){if(v.length!==members.length)throw new Error(`${label} tuple has the wrong arity.`);return Object.fromEntries(members.map(member=>[member.memberName,member.typeName==="byte[]"&&Array.isArray(v[member.slot])?Uint8Array.from(v[member.slot]):v[member.slot]]));}return record(v,label);} +function text(v,label){bounded(v,label,256);} +function bounded(v,label,maxBytes){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>maxBytes||/\p{Cc}/u.test(v))throw new Error(`${label} is empty, oversized, or contains control characters.`);} +function content(v){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>1900||v.includes("\0"))throw new Error("content is empty, exceeds 1900 UTF-8 bytes, or contains NUL.");} +function optionalText(v,label){if(typeof v!=="string")throw new Error(`${label} must be a string.`);} +function exact(v,w,label){if(v!==w)throw new Error(`${label} must be ${w}.`);} +function bytes64(v,label){if(!(v instanceof Uint8Array)||v.length!==64)throw new Error(`${label} must be 64 bytes.`);} +function sha(v,label){if(!/^sha256-[0-9a-f]{64}$/.test(v))throw new Error(`${label} must be lowercase sha256.`);} +function date(v,label){const parsed=Date.parse(v);if(Number.isNaN(parsed))throw new Error(`${label} must be RFC3339.`);return parsed;} +function closed(v,keys,label){const allowed=new Set(keys);for(const key of Object.keys(v))if(!allowed.has(key))throw new Error(`${label} contains unknown field ${key}.`);} diff --git a/tools/persona-discord-delivery.mjs b/tools/persona-discord-delivery.mjs new file mode 100644 index 0000000..30bcc92 --- /dev/null +++ b/tools/persona-discord-delivery.mjs @@ -0,0 +1,51 @@ +import {createHash,randomBytes} from "node:crypto"; +import {spawnSync} from "node:child_process"; +import {access,readFile,writeFile,mkdir} from "node:fs/promises"; +import {constants} from "node:fs"; +import {dirname,resolve} from "node:path"; +import {enrollDomainIdentity,loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs"; +import {buildPersonaDiscordDeliveryDefinitions,executionSchema,receiptIdentityDomain,receiptSchema,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "./persona-discord-delivery-documents.mjs"; +import {requestPersonaDiscordPermit} from "./persona-discord-permit.mjs"; +import {permitRequestIdentityDomain,permitRequestPurpose,permitRequestSchema} from "./persona-discord-permit.mjs"; + +export async function enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath,trustAnchorOut}){const identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} +export async function exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath,trustAnchorOut}){const identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery-permit-request:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",permitRequestPurpose,permitRequestSchema,"root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} + +export async function processPersonaDiscordDeliveries(options){ + const {runtime,requestStorePath,receiptStorePath,executionStorePath,epiphanyTrustAnchorPath,privateKeyPath,targetAdmitted}=options,defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType); + requireSeparate([requestStorePath,receiptStorePath,executionStorePath]); + const requests=await open(runtime,defs.request,requestStorePath),receipts=await open(runtime,defs.receipt,receiptStorePath),executions=await open(runtime,defs.execution,executionStorePath),anchor=await readAnchor(runtime,epiphanyTrustAnchorPath),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain); + const results=[]; + for(const request of requests.cache.getAll(defs.request).sort((a,b)=>String(a.issuedAt).localeCompare(String(b.issuedAt)))){ + const unsigned=runtime.encode(requestSigningTuple(request)),digest=`sha256-${sha(unsigned)}`,existingReceipt=unwrap(receipts.cache.get(defs.receipt,request.requestId)),existingExecution=unwrap(executions.cache.get(defs.execution,request.requestId)); + if(existingReceipt){if(existingReceipt.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its terminal receipt.`);continue;} + if(existingExecution){if(existingExecution.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its execution journal.`);if(existingExecution.status==="running"){results.push(await terminal("unknown",request,digest,"delivery outcome was indeterminate after service interruption",{runtime,defs,receipts,executions,identity}));continue;}if(existingExecution.status!=="permit_pending")continue;} + let failure=""; + try{validateRequest(request,unsigned,anchor,targetAdmitted);}catch(error){failure=error instanceof Error?error.message:String(error);} + if(failure){results.push(await terminal("failed",request,digest,failure,{runtime,defs,receipts,executions,identity}));continue;} + if(existingExecution&&Date.parse(existingExecution.permitExpiresAt)now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");} +function post(request,options){const args=[options.bridgeCliPath,"discord-post","--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;} +async function terminal(status,request,digest,detail,c){const now=new Date().toISOString(),posted=c.posted??{},receipt={schemaName:"bifrost.persona_discord_delivery_receipt",schemaVersion:receiptSchema,receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status,channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:status==="completed"?posted.messageId:"",transport:status==="completed"?posted.transport:"",crossingReceiptId:status==="completed"?posted.crossingReceiptId:"",receiptUrl:status==="completed"?posted.url:"",completedAt:now,providerIdentityId:c.identity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(c.identity,receiptSignatureDomain,receiptSigningPurpose,c.runtime.encode(receiptSigningTuple(receipt))));await c.receipts.cache.put(c.defs.receipt,receipt.receiptId,receipt);await c.executions.cache.put(c.defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status,recordedAt:now,detail});return receipt;} +async function open(runtime,definition,path){await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return {cache};} +async function readAnchor(runtime,path){const value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==13)throw new Error("Epiphany Persona mouth trust anchor must be the exact service-contract tuple");const [schemaVersion,trustAnchorId,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,bindingAuthority,boundAt,expiresAt,privateStateExposed]=value,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||typeof trustAnchorId!=="string"||serviceId!=="epiphany-persona-mouth"||runtimeId!=="epiphany-yggdrasil"||typeof identityId!=="string"||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==requestSigningPurpose||signedSchema!=="epiphany.persona_discord_delivery_request.v0"||bindingAuthority!=="root"||!Number.isSafeInteger(boundAt)||boundAt<=0||boundAt>now||(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<=boundAt||expiresAt<=now))||privateStateExposed!==false)throw new Error("Epiphany Persona mouth service trust anchor is invalid");return {identityId,publicKey};} +function requireSeparate(paths){const keys=paths.map(p=>resolve(p).toLowerCase());if(new Set(keys).size!==keys.length)throw new Error("Persona Discord crossing and private execution stores must be distinct.");} +function sha(value){return createHash("sha256").update(value).digest("hex");} +function unwrap(value){return Array.isArray(value)?value[0]:value?.value??value;} diff --git a/tools/persona-discord-permit.mjs b/tools/persona-discord-permit.mjs new file mode 100644 index 0000000..ac9c7ef --- /dev/null +++ b/tools/persona-discord-permit.mjs @@ -0,0 +1,36 @@ +import {createHash,randomBytes} from "node:crypto"; +import {createSocket} from "node:dgram"; +import {createRequire} from "node:module"; +import {existsSync} from "node:fs"; +import {readFile} from "node:fs/promises"; +import {resolve} from "node:path"; +import {loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs"; + +export const permitRequestSchema="bifrost.persona_discord_delivery_permit_request.v0"; +export const permitSchema="epiphany.persona_discord_delivery_permit.v0"; +export const permitRequestPurpose="bifrost.persona-discord.delivery-permit-request.v0"; +export const permitPurpose="epiphany.persona-discord.delivery-permit.v0"; +export const permitRequestIdentityDomain="bifrost.persona-discord-delivery-permit-request.identity.v0\0"; +export const permitRequestSignatureDomain="bifrost.persona-discord-delivery-permit-request.signature.v0\0"; +export const permitIdentityDomain="epiphany.persona-discord-delivery-permit.identity.v0\0"; +export const permitSignatureDomain="epiphany.persona-discord-delivery-permit.signature.v0\0"; +const connectionId=0xe91f0002; + +export const permitRequestTuple=v=>[v.schemaVersion,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.issuedAt,v.expiresAt,v.requesterIdentityId,v.privateStateExposed]; +export const permitTuple=v=>[v.schemaVersion,v.permitId,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.requesterIdentityId,v.brakeStateDocumentId,v.brakeStateDocumentSha256,v.brakeObservedAt,v.issuedAt,v.expiresAt,v.providerIdentityId,v.privateStateExposed]; + +export async function requestPersonaDiscordPermit({endpoint,requestId,requestPayloadSha256,targetRuntimeId,nonce,issuedAt,expiresAt,privateKeyPath,epiphanyPermitAnchorPath,timeoutMs=5000,runtime:provided,socketFactory=createSocket,connectionFactory}){ + const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath),now=Date.now(); + const request={schemaVersion:permitRequestSchema,requestId,requestPayloadSha256,targetRuntimeId,nonce:nonce??randomBytes(16).toString("hex"),issuedAt:issuedAt??new Date(now).toISOString(),expiresAt:expiresAt??new Date(now+5000).toISOString(),requesterIdentityId:identity.identityId,privateStateExposed:false,requesterSignature:new Uint8Array()}; + request.requesterSignature=new Uint8Array(signDomainPurpose(identity,permitRequestSignatureDomain,permitRequestPurpose,runtime.msgpack.encode(permitRequestTuple(request)))); + const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");await bind(socket);const connection=(connectionFactory??(o=>new runtime.cultnet.CultNetRudpSocketTransportConnection(o)))({runtimeId:"bifrost-discord-yggdrasil",socket,mode:"client",remoteHost:host,remotePort:port,connectionId,resendDelayMs:50,resendPollMs:10}); + try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(request),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();} +} + +export function validatePermit(v,request,anchor,encode){if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expiresissued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),v.providerSignature))throw new Error("Epiphany permit signature is invalid");return v;} +async function readAnchor(runtime,path){const a=runtime.msgpack.decode(await readFile(path));if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,publicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!=="epiphany-yggdrasil"||!(publicKey instanceof Uint8Array)||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};} +function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=m.decode(d?.payload);if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p?.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})} +async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}} +function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})} +function parseEndpoint(v){const u=new URL(v);if(u.protocol!=="rudp:"||!u.hostname||!u.port)throw new Error("permit endpoint must be rudp://host:port");return {host:u.hostname,port:Number(u.port)}} +function loadRuntime(){const root=resolve(import.meta.dirname,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(root,"..","CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error("CultNet runtime unavailable");const req=createRequire(pkg);return {cultnet:req("cultnet-ts"),msgpack:req("@msgpack/msgpack")};} diff --git a/tools/persona-feedback-documents.mjs b/tools/persona-feedback-documents.mjs index a0f4b11..b183c90 100644 --- a/tools/persona-feedback-documents.mjs +++ b/tools/persona-feedback-documents.mjs @@ -1,5 +1,5 @@ -export const feedbackEventType = "voidbot.discord.persona_feedback_event"; -export const feedbackEventSchema = "voidbot.discord.persona_feedback_event.v0"; +export const feedbackEventType = "bifrost.discord.persona_conversation_event"; +export const feedbackEventSchema = "bifrost.discord.persona_conversation_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"; @@ -8,6 +8,8 @@ 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"; +export const feedbackPendingFailureType = "bifrost.persona_feedback.pending_failure"; +export const feedbackPendingFailureSchema = "bifrost.persona_feedback.pending_failure.v0"; // Kept as explicit constructors because CultCache document metadata is part of the wire contract. export function buildFeedbackDefinitions(defineDocumentType) { @@ -18,6 +20,7 @@ export function buildFeedbackDefinitions(defineDocumentType) { actorLink: make(actorLinkType, actorLinkSchema, "linkId", parseActorLink), receipt: make(feedbackReceiptType, feedbackReceiptSchema, "receiptId", parseReceipt), delivery: make(feedbackDeliveryType, feedbackDeliverySchema, "admissionId", parseDelivery), + pendingFailure: make(feedbackPendingFailureType, feedbackPendingFailureSchema, "failureId", parsePendingFailure), }; } @@ -25,7 +28,7 @@ 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.producerId, "bifrost-discord", "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."); @@ -35,7 +38,8 @@ export function parseEvent(value) { 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; } +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,"bifrost-discord","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; } +export function parsePendingFailure(value) { const v=record(value,"pending failure"); exact(v.schemaVersion,feedbackPendingFailureSchema,"pending failure schema"); for(const k of ["failureId","eventId","lastError","lastAttemptAt"])text(v[k],k); if(v.failureId!==v.eventId)throw new Error("failureId must equal eventId."); if(!Number.isSafeInteger(v.attemptCount)||v.attemptCount<1||v.attemptCount>1_000_000)throw new Error("attemptCount is invalid."); if(Buffer.byteLength(v.lastError,"utf8")>512)throw new Error("lastError exceeds 512 UTF-8 bytes."); if(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(v.lastAttemptAt)||Number.isNaN(Date.parse(v.lastAttemptAt)))throw new Error("lastAttemptAt must be RFC3339."); if(v.privateStateExposed!==false)throw new Error("privateStateExposed 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}.`);} diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index f60e0b6..608adfa 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -4,15 +4,20 @@ 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 { buildFeedbackDefinitions, actorLinkSchema, feedbackBindingSchema, feedbackDeliverySchema, feedbackReceiptSchema, feedbackPendingFailureSchema } from "./persona-feedback-documents.mjs"; import { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrostFeedbackIdentity, providerHealthTrustAnchor, signFeedbackAdmission, trustAnchor } from "./bifrost-feedback-identity.mjs"; import { createPersonaFeedbackHealthPublisher, publishPersonaFeedbackHealth } from "./persona-feedback-idunn-health.mjs"; +import { discordGatewayReady, serveDiscordPersonaIngress } from "./bifrost-discord-persona-ingress.mjs"; +import { enrollPersonaDiscordDeliveryIdentity, exportPersonaDiscordPermitRequestAnchor, inspectPersonaDiscordDeliveryReadiness, processPersonaDiscordDeliveries } from "./persona-discord-delivery.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 observationStorePath=resolve(options["observation-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-conversation-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 personaDeliveryRequestStorePath=resolve(options["persona-delivery-request-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-requests.cc"):"/srv/bifrost/persona-discord-crossing/requests.cc")); +const personaDeliveryReceiptStorePath=resolve(options["persona-delivery-receipt-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-receipts.cc"):"/srv/bifrost/persona-discord-crossing/receipts.cc")); +const personaDeliveryExecutionStorePath=resolve(options["persona-delivery-execution-store"]||(process.platform==="win32"?resolve(root,".bifrost","private","persona-discord-executions.cc"):"/var/lib/gamecult/bifrost/persona-feedback/persona-discord-executions.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;} @@ -22,16 +27,19 @@ async function main(){ if(command==="link-actor")return linkActor(); if(command==="process")return processEvent(requireOption("event-id")); if(command==="serve")return serve(); + if(command==="serve-discord")return serveDiscord(); if(command==="export")return exportSnapshot(); if(command==="enroll-identity")return enrollIdentity(); + if(command==="enroll-persona-delivery-identity")return enrollPersonaDeliveryIdentity(); + if(command==="export-persona-permit-request-anchor")return exportPersonaPermitRequestAnchor(); 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|export-provider-health-anchor"); + throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|status|export|enroll-identity|enroll-persona-delivery-identity|export-provider-health-anchor"); } -async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery]});} +async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});} 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 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"]||"bifrost-discord",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){ @@ -45,7 +53,7 @@ async function processEvent(id,emit=true){ 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()}; + const admission={schemaName:"bifrost.persona_feedback.delivery",schemaVersion:feedbackDeliverySchema,admissionId:id,packet,packetSha256:`sha256-${shaBytes(runtime.encode(packetTuple(packet)))}`,sourceObserverId:"bifrost-discord",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}); @@ -53,16 +61,51 @@ 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.");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 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 results=await drainPendingObservations(),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:results.length,results,readiness:readinessState});return;}await new Promise(done=>setTimeout(done,interval));}while(true);} + +async function drainPendingObservations(){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);await privateNodeValue.cache.pullAllBackingStores();await privateNodeValue.cache.delete(defs.pendingFailure,event.eventId);results.push({eventId:event.eventId,ok:true,error:""});}catch(error){const message=boundedError(error),previous=unwrap(await privateNodeValue.get(defs.pendingFailure,event.eventId)),failure={schemaName:"bifrost.persona_feedback.pending_failure",schemaVersion:feedbackPendingFailureSchema,failureId:event.eventId,eventId:event.eventId,attemptCount:Math.min((previous?.attemptCount??0)+1,1_000_000),lastError:message,lastAttemptAt:new Date().toISOString(),privateStateExposed:false};await privateNodeValue.put(defs.pendingFailure,event.eventId,failure);results.push({eventId:event.eventId,ok:false,error:message});}}await privateNodeValue.flush?.();return results;} + +async function serveDiscord(){ + const token=process.env.BIFROST_DISCORD_BOT_TOKEN; + const producerRuntimeId=options["producer-runtime-id"]||process.env.BIFROST_DISCORD_RUNTIME_ID||"bifrost-discord-yggdrasil"; + const healthEndpoint=options["idunn-rudp-health"]||process.env.BIFROST_IDUNN_RUDP_HEALTH;if(!healthEndpoint)throw new Error("Bifrost Discord Persona ingress requires --idunn-rudp-health or BIFROST_IDUNN_RUDP_HEALTH."); + const healthPublisher=createPersonaFeedbackHealthPublisher(await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()))); + let gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"starting"}; + const publishHealth=async()=>{const value=await readiness(),gatewayReady=discordGatewayReady(gatewayState),active=value.status==="ready"&&gatewayReady;await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:active?"active":"degraded",detail:active?"ready":`discord-${gatewayState.phase||"disconnected"}`,publisher:healthPublisher,publishUnsignedDiagnostic:true});return {...value,gatewayReady,gatewayState};}; + const initial=await readiness();if(!initial.ingressReady)throw new Error(`Bifrost Discord Persona ingress is not ready: ${initial.ingressReasons.join("; ")}`); + const recovered=await drainPendingObservations();if(recovered.some(value=>!value.ok))console.error(`Bifrost Persona ingress startup recovery retained ${recovered.filter(value=>!value.ok).length} failed observation(s) for reconnect retry.`); + await drainPersonaDeliveries().catch(error=>console.error(`Bifrost Persona delivery is staged but not yet ready: ${error instanceof Error?error.message:String(error)}`)); + await publishHealth(); + const healthTimer=setInterval(()=>publishHealth().catch(error=>console.error(`Bifrost Persona-feedback health publication failed: ${error instanceof Error?error.message:String(error)}`)),10000); + let observationBusy=false;const observationTimer=setInterval(async()=>{if(observationBusy)return;observationBusy=true;try{const retried=await drainPendingObservations();if(retried.some(value=>!value.ok))console.error(`Bifrost Persona ingress retained ${retried.filter(value=>!value.ok).length} failed pending observation(s).`);}catch(error){console.error(`Bifrost Persona observation reconciliation failed: ${error instanceof Error?error.message:String(error)}`);}finally{observationBusy=false;}},positiveInteger(options["observation-retry-interval-ms"]||"5000","observation-retry-interval-ms")); + let deliveryBusy=false;const deliveryTimer=setInterval(async()=>{if(deliveryBusy)return;deliveryBusy=true;try{await drainPersonaDeliveries();}catch(error){console.error(`Bifrost Persona delivery poll failed: ${error instanceof Error?error.message:String(error)}`);}finally{deliveryBusy=false;}},positiveInteger(options["persona-delivery-interval-ms"]||"500","persona-delivery-interval-ms")); + try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);} +} + +async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding);return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});} + +async function admitDiscordConversation(conversation,producerRuntimeId){ + const privateNodeValue=await privateNode();await privateNodeValue.cache?.pullAllBackingStores?.(); + const bindings=privateNodeValue.cache.getAll(defs.binding).filter(binding=>binding.guildId===conversation.guildId&&binding.channelId===conversation.channelId&&binding.producerId==="bifrost-discord"&&binding.producerRuntimeId===producerRuntimeId); + if(bindings.length===0)return; + if(bindings.length!==1)throw new Error("Discord channel has multiple Bifrost Persona targets; addressed conversation is ambiguous."); + const binding=bindings[0],eventId=`discord:${conversation.guildId}:${conversation.channelId}:${conversation.messageId}`,event={schemaName:"bifrost.discord.persona_conversation_event",schemaVersion:"bifrost.discord.persona_conversation_event.v0",eventId,observedAt:conversation.observedAt,guildId:conversation.guildId,channelId:conversation.channelId,messageId:conversation.messageId,authorId:conversation.authorId,authorName:conversation.authorName,addressingMode:conversation.addressingMode,targetPersonaId:binding.targetPersonaId,targetRepoName:binding.targetRepoName,targetRuntimeId:binding.targetRuntimeId,content:conversation.content,payloadHash:conversation.payloadHash,producerId:"bifrost-discord",producerRuntimeId,authorityClass:"feedback_only",status:"pending"}; + const observations=await observationNode();await observations.cache?.pullAllBackingStores?.();const existing=unwrap(await observations.get(defs.event,eventId)); + if(existing&&stable(existing)!==stable(event))throw new Error("Discord conversation event identity collision."); + if(!existing){await observations.put(defs.event,eventId,event);await observations.flush?.();} + await processEvent(eventId,false); +} 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 enrollPersonaDeliveryIdentity(){print(await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} +async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} 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 readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.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).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.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){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"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 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"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} +function bindingId(o){return `feedback:${o["guild-id"]}:${o["channel-id"]}:${o["runtime-id"]}:${o.repo}:${o["persona-id"]}:${o["producer-id"]||"bifrost-discord"}:${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];} @@ -72,6 +115,7 @@ function requireOption(name){const value=options[name];if(!value)throw new Error 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 boundedError(error){const value=error instanceof Error?error.message:String(error),bytes=Buffer.from(value,"utf8");if(bytes.length<=512)return value;let end=512;while(end>0&&(bytes[end]&0xc0)===0x80)end--;return bytes.subarray(0,end).toString("utf8");} 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;}}} From 5f519cb5caff57f544d70bdffcf20a4b28ea706c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 00:59:30 +0100 Subject: [PATCH 015/155] Keep Discord phase out of provider health --- tools/persona-feedback.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index 608adfa..3f578af 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -71,7 +71,7 @@ async function serveDiscord(){ const healthEndpoint=options["idunn-rudp-health"]||process.env.BIFROST_IDUNN_RUDP_HEALTH;if(!healthEndpoint)throw new Error("Bifrost Discord Persona ingress requires --idunn-rudp-health or BIFROST_IDUNN_RUDP_HEALTH."); const healthPublisher=createPersonaFeedbackHealthPublisher(await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()))); let gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"starting"}; - const publishHealth=async()=>{const value=await readiness(),gatewayReady=discordGatewayReady(gatewayState),active=value.status==="ready"&&gatewayReady;await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:active?"active":"degraded",detail:active?"ready":`discord-${gatewayState.phase||"disconnected"}`,publisher:healthPublisher,publishUnsignedDiagnostic:true});return {...value,gatewayReady,gatewayState};}; + const publishHealth=async()=>{const value=await readiness(),gatewayReady=discordGatewayReady(gatewayState),active=value.status==="ready"&&gatewayReady;await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:active?"active":"degraded",detail:active?"ready":"readiness-degraded",publisher:healthPublisher,publishUnsignedDiagnostic:true});return {...value,gatewayReady,gatewayState};}; const initial=await readiness();if(!initial.ingressReady)throw new Error(`Bifrost Discord Persona ingress is not ready: ${initial.ingressReasons.join("; ")}`); const recovered=await drainPendingObservations();if(recovered.some(value=>!value.ok))console.error(`Bifrost Persona ingress startup recovery retained ${recovered.filter(value=>!value.ok).length} failed observation(s) for reconnect retry.`); await drainPersonaDeliveries().catch(error=>console.error(`Bifrost Persona delivery is staged but not yet ready: ${error instanceof Error?error.message:String(error)}`)); From 8b115b08b427e5a5ba3772c76d04b4c878b97bf4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:01:07 +0100 Subject: [PATCH 016/155] Encode provider health signatures canonically --- tests/persona-feedback-idunn-health.test.mjs | 6 +++--- tools/persona-feedback-idunn-health.mjs | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/persona-feedback-idunn-health.test.mjs b/tests/persona-feedback-idunn-health.test.mjs index fb090ab..b335418 100644 --- a/tests/persona-feedback-idunn-health.test.mjs +++ b/tests/persona-feedback-idunn-health.test.mjs @@ -13,13 +13,13 @@ const idDomain=Buffer.from("gamecult.provider-health.identity.v1\0"),signatureDo 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(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.ok(Array.isArray(record[15]));assert.equal(record[15].length,64);assert.equal(record[16],false); + const canonical=[...record];canonical[15]=[];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(); + 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]=[]; 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); diff --git a/tools/persona-feedback-idunn-health.mjs b/tools/persona-feedback-idunn-health.mjs index 2c056ff..90328c7 100644 --- a/tools/persona-feedback-idunn-health.mjs +++ b/tools/persona-feedback-idunn-health.mjs @@ -25,8 +25,11 @@ export function createSignedPersonaFeedbackHealth(input){ 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); + // Rust's canonical positional contract represents Vec as a MessagePack + // integer array. Uint8Array lowers to bin8 and is therefore a different + // signed payload even though both decode to bytes in JavaScript. + 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",[],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]=Array.from(proof.signature); return {record,payload:encode(record),providerIdentityId:provider.identityId,observedAtUnixMillis:observed}; } From d74b61f4b787c7a7dbe6ba40acb420225cf3182f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:07:17 +0100 Subject: [PATCH 017/155] Encode provider health timestamps as canonical u64 --- tests/persona-feedback-idunn-health.test.mjs | 16 ++++++++-------- tools/persona-feedback-idunn-health.mjs | 10 +++++++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/persona-feedback-idunn-health.test.mjs b/tests/persona-feedback-idunn-health.test.mjs index b335418..d816654 100644 --- a/tests/persona-feedback-idunn-health.test.mjs +++ b/tests/persona-feedback-idunn-health.test.mjs @@ -12,18 +12,18 @@ const root=resolve(import.meta.dirname,".."),cult=resolve(root,"..","CultLib"),r 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.ok(Array.isArray(record[15]));assert.equal(record[15].length,64);assert.equal(record[16],false); - const canonical=[...record];canonical[15]=[];assert.equal(verifyProvider(identity,encode(canonical),record[15]),true); + 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,{useBigInt64:true}); + 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,1773950400123n]);assert.deepEqual(record.slice(10,14),[null,null,null,null]);assert.equal(record[14],"ed25519");assert.ok(Array.isArray(record[15]));assert.equal(record[15].length,64);assert.equal(record[16],false); + const canonical=[...record];canonical[15]=[];assert.equal(verifyProvider(identity,encode(canonical,{useBigInt64:true}),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]=[]; - 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); + 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,{useBigInt64:true}),canonical=[...record];canonical[15]=[]; + const canonicalPayload=encode(canonical,{useBigInt64:true});assert.equal(verifyProvider(identity,canonicalPayload,record[15]),true);assert.equal(verifyProvider(other,canonicalPayload,record[15]),false); + const mutated=[...canonical];mutated[4]="active";assert.equal(verifyProvider(identity,encode(mutated,{useBigInt64:true}),record[15]),false); + const oldDomainSignature=signFeedbackAdmission(identity,canonicalPayload);assert.equal(verifyProvider(identity,canonicalPayload,oldDomainSignature),false); + const unsignedDiagnostic=[record[1],record[4],record[5],new Date(Number(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()=>{ diff --git a/tools/persona-feedback-idunn-health.mjs b/tools/persona-feedback-idunn-health.mjs index 90328c7..7986b22 100644 --- a/tools/persona-feedback-idunn-health.mjs +++ b/tools/persona-feedback-idunn-health.mjs @@ -28,11 +28,15 @@ export function createSignedPersonaFeedbackHealth(input){ // Rust's canonical positional contract represents Vec as a MessagePack // integer array. Uint8Array lowers to bin8 and is therefore a different // signed payload even though both decode to bytes in JavaScript. - 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",[],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]=Array.from(proof.signature); - return {record,payload:encode(record),providerIdentityId:provider.identityId,observedAtUnixMillis:observed}; + // Safe JavaScript numbers above u32 lower as float64. Rust's u64 lowers as + // uint64, so retain the timestamp as BigInt on this signed wire surface. + const provider=providerHealthIdentity(input.identity),record=[schema,input.daemonId,input.healthContract,sourceRuntimeId,input.state,input.detail,provider.identityId,input.publisherIncarnationId,input.publisherSequence,BigInt(observed),null,null,null,null,"ed25519",[],false]; + const proof=signProviderHealth(input.identity,canonicalHealthEncode(record));if(proof.identityId!==provider.identityId)throw new Error("Provider-health signer identity derivation disagrees with the packet.");record[15]=Array.from(proof.signature); + return {record,payload:canonicalHealthEncode(record),providerIdentityId:provider.identityId,observedAtUnixMillis:observed}; } +function canonicalHealthEncode(value){return encode(value,{useBigInt64:true});} + 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."); From a7647a1660b3b925f5824e5d1e9e1864b9b1ca15 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:15:35 +0100 Subject: [PATCH 018/155] Follow portable CultNet provider signature bytes --- tests/persona-feedback-idunn-health.test.mjs | 6 +++--- tools/persona-feedback-idunn-health.mjs | 9 ++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/persona-feedback-idunn-health.test.mjs b/tests/persona-feedback-idunn-health.test.mjs index d816654..d84c847 100644 --- a/tests/persona-feedback-idunn-health.test.mjs +++ b/tests/persona-feedback-idunn-health.test.mjs @@ -13,13 +13,13 @@ const idDomain=Buffer.from("gamecult.provider-health.identity.v1\0"),signatureDo 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,{useBigInt64:true}); - 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,1773950400123n]);assert.deepEqual(record.slice(10,14),[null,null,null,null]);assert.equal(record[14],"ed25519");assert.ok(Array.isArray(record[15]));assert.equal(record[15].length,64);assert.equal(record[16],false); - const canonical=[...record];canonical[15]=[];assert.equal(verifyProvider(identity,encode(canonical,{useBigInt64:true}),record[15]),true); + 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,1773950400123n]);assert.deepEqual(record.slice(10,14),[null,null,null,null]);assert.ok(record[15] instanceof Uint8Array);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,{useBigInt64:true}),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,{useBigInt64:true}),canonical=[...record];canonical[15]=[]; + 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,{useBigInt64:true}),canonical=[...record];canonical[15]=new Uint8Array(); const canonicalPayload=encode(canonical,{useBigInt64:true});assert.equal(verifyProvider(identity,canonicalPayload,record[15]),true);assert.equal(verifyProvider(other,canonicalPayload,record[15]),false); const mutated=[...canonical];mutated[4]="active";assert.equal(verifyProvider(identity,encode(mutated,{useBigInt64:true}),record[15]),false); const oldDomainSignature=signFeedbackAdmission(identity,canonicalPayload);assert.equal(verifyProvider(identity,canonicalPayload,oldDomainSignature),false); diff --git a/tools/persona-feedback-idunn-health.mjs b/tools/persona-feedback-idunn-health.mjs index 7986b22..8230de3 100644 --- a/tools/persona-feedback-idunn-health.mjs +++ b/tools/persona-feedback-idunn-health.mjs @@ -25,13 +25,12 @@ export function createSignedPersonaFeedbackHealth(input){ 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."); - // Rust's canonical positional contract represents Vec as a MessagePack - // integer array. Uint8Array lowers to bin8 and is therefore a different - // signed payload even though both decode to bytes in JavaScript. + // The shared CultNet contract marks signatures as portable bytes, so both + // Rust and JavaScript lower this field as MessagePack binary. // Safe JavaScript numbers above u32 lower as float64. Rust's u64 lowers as // uint64, so retain the timestamp as BigInt on this signed wire surface. - const provider=providerHealthIdentity(input.identity),record=[schema,input.daemonId,input.healthContract,sourceRuntimeId,input.state,input.detail,provider.identityId,input.publisherIncarnationId,input.publisherSequence,BigInt(observed),null,null,null,null,"ed25519",[],false]; - const proof=signProviderHealth(input.identity,canonicalHealthEncode(record));if(proof.identityId!==provider.identityId)throw new Error("Provider-health signer identity derivation disagrees with the packet.");record[15]=Array.from(proof.signature); + const provider=providerHealthIdentity(input.identity),record=[schema,input.daemonId,input.healthContract,sourceRuntimeId,input.state,input.detail,provider.identityId,input.publisherIncarnationId,input.publisherSequence,BigInt(observed),null,null,null,null,"ed25519",new Uint8Array(),false]; + const proof=signProviderHealth(input.identity,canonicalHealthEncode(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:canonicalHealthEncode(record),providerIdentityId:provider.identityId,observedAtUnixMillis:observed}; } From da537ad8dced1dc4d22495bbf226e8241280357c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:49:25 +0100 Subject: [PATCH 019/155] Use canonical daemon health publisher role --- tests/persona-feedback-idunn-health.test.mjs | 3 ++- tools/persona-feedback-idunn-health.mjs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/persona-feedback-idunn-health.test.mjs b/tests/persona-feedback-idunn-health.test.mjs index d84c847..96d15f0 100644 --- a/tests/persona-feedback-idunn-health.test.mjs +++ b/tests/persona-feedback-idunn-health.test.mjs @@ -6,12 +6,13 @@ 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"; +import { createPersonaFeedbackHealthPublisher, createSignedPersonaFeedbackHealth, signedHealthSourceRole } 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()=>{ + assert.equal(signedHealthSourceRole,"daemon-health-publisher"); 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,{useBigInt64:true}); 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,1773950400123n]);assert.deepEqual(record.slice(10,14),[null,null,null,null]);assert.ok(record[15] instanceof Uint8Array);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,{useBigInt64:true}),record[15]),true); diff --git a/tools/persona-feedback-idunn-health.mjs b/tools/persona-feedback-idunn-health.mjs index 8230de3..d7be54f 100644 --- a/tools/persona-feedback-idunn-health.mjs +++ b/tools/persona-feedback-idunn-health.mjs @@ -8,6 +8,7 @@ const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=re 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"; +export const signedHealthSourceRole="daemon-health-publisher"; const states=new Set(["active","warming","degraded","failed"]),reasons=new Set(["ready","starting","readiness-degraded","unavailable"]); export function createPersonaFeedbackHealthPublisher(identity,publisherIncarnationId=randomUUID()){ @@ -43,7 +44,7 @@ export async function publishPersonaFeedbackHealth(input){ 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"); + 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,signedHealthSourceRole); 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"); From da716c95f21259565b8333e62677690ab4bbbcf7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:53:44 +0100 Subject: [PATCH 020/155] Prove signed health transport identity --- tests/persona-feedback-idunn-health.test.mjs | 3 ++- tools/persona-feedback-idunn-health.mjs | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/persona-feedback-idunn-health.test.mjs b/tests/persona-feedback-idunn-health.test.mjs index 96d15f0..360a15a 100644 --- a/tests/persona-feedback-idunn-health.test.mjs +++ b/tests/persona-feedback-idunn-health.test.mjs @@ -6,7 +6,7 @@ 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, signedHealthSourceRole } from "../tools/persona-feedback-idunn-health.mjs"; +import { createPersonaFeedbackHealthPublisher, createSignedHealthRawPut, createSignedPersonaFeedbackHealth, signedHealthSourceRole } 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"); @@ -15,6 +15,7 @@ test("Persona-feedback emits the exact canonical signed daemon-health tuple",asy assert.equal(signedHealthSourceRole,"daemon-health-publisher"); 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,{useBigInt64:true}); 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,1773950400123n]);assert.deepEqual(record.slice(10,14),[null,null,null,null]);assert.ok(record[15] instanceof Uint8Array);assert.equal(record[14],"ed25519");assert.equal(record[15].length,64);assert.equal(record[16],false); + const put=createSignedHealthRawPut("yggdrasil-bifrost-persona-feedback",signed);assert.equal(put.document.recordKey,"yggdrasil-bifrost-persona-feedback");assert.equal(put.document.sourceRuntimeId,"bifrost-persona-feedback");assert.equal(put.document.sourceRole,"daemon-health-publisher");assert.equal(put.document.schemaId,"idunn.signed_daemon_health");assert.deepEqual(put.document.payload,signed.payload); const canonical=[...record];canonical[15]=new Uint8Array();assert.equal(verifyProvider(identity,encode(canonical,{useBigInt64:true}),record[15]),true); assert.equal(signed.providerIdentityId,createHash("sha256").update(idDomain).update(identity.publicKey).digest("hex"));assert.notEqual(signed.providerIdentityId,identity.identityId); }); diff --git a/tools/persona-feedback-idunn-health.mjs b/tools/persona-feedback-idunn-health.mjs index d7be54f..d341d18 100644 --- a/tools/persona-feedback-idunn-health.mjs +++ b/tools/persona-feedback-idunn-health.mjs @@ -35,6 +35,12 @@ export function createSignedPersonaFeedbackHealth(input){ return {record,payload:canonicalHealthEncode(record),providerIdentityId:provider.identityId,observedAtUnixMillis:observed}; } +export function createSignedHealthRawPut(daemonId,signed){ + requireText(daemonId,"daemon id"); + const incarnation=signed.record[7],sequence=signed.record[8],storedAt=new Date(signed.observedAtUnixMillis).toISOString(); + return rawPut(`bifrost-feedback-signed-health:${incarnation}:${sequence}`,"idunn.signed_daemon_health",daemonId,storedAt,signed.payload,signedHealthSourceRole); +} + function canonicalHealthEncode(value){return encode(value,{useBigInt64:true});} export async function publishPersonaFeedbackHealth(input){ @@ -44,7 +50,7 @@ export async function publishPersonaFeedbackHealth(input){ 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,signedHealthSourceRole); + const incarnation=signed.record[7],sequence=signed.record[8],storedAt=new Date(signed.observedAtUnixMillis).toISOString(),signedMessage=createSignedHealthRawPut(input.daemonId,signed); 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"); From b8deaa9a8c4053d62539ea2f1749bd51c46e55b6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 20:10:23 +0100 Subject: [PATCH 021/155] persona: add typed cross-host delivery nerve --- ...rsona-discord-crossing-rust-smoke.test.mjs | 6 ++-- tests/persona-discord-delivery.test.mjs | 10 +++--- ...rsona-discord-rudp-cross-language.test.mjs | 27 ++++++++++++++++ tools/persona-discord-delivery-documents.mjs | 3 +- tools/persona-discord-delivery.mjs | 6 ++-- tools/persona-discord-permit.mjs | 4 +-- tools/persona-discord-rudp.mjs | 31 +++++++++++++++++++ tools/persona-feedback.mjs | 11 +++++-- 8 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 tests/persona-discord-rudp-cross-language.test.mjs create mode 100644 tools/persona-discord-rudp.mjs diff --git a/tests/persona-discord-crossing-rust-smoke.test.mjs b/tests/persona-discord-crossing-rust-smoke.test.mjs index 19e2a6f..d48dce5 100644 --- a/tests/persona-discord-crossing-rust-smoke.test.mjs +++ b/tests/persona-discord-crossing-rust-smoke.test.mjs @@ -9,17 +9,17 @@ import {enrollDomainIdentity} from "../tools/bifrost-signed-identity.mjs"; import {receiptIdentityDomain} from "../tools/persona-discord-delivery-documents.mjs"; import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; -const epiphany=resolve(import.meta.dirname,"..","..","EpiphanyAgent"),cult=resolve(import.meta.dirname,"..","..","CultLib"); +const epiphany=resolve(import.meta.dirname,"..","..","Epiphany"),cult=resolve(import.meta.dirname,"..","..","CultLib"); function runtime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} test("Bifrost consumes a Rust-authored request store through its read-only crossing directory",async()=>{ const root=await mkdtemp(resolve(tmpdir(),"rust-bifrost-crossing-")),requestDir=resolve(root,"request"),receiptDir=resolve(root,"receipt"),requestStore=resolve(requestDir,"requests.cc"),anchor=resolve(root,"mouth-anchor.msgpack"),identity=resolve(root,"mouth.cc"),deliveryKey=resolve(root,"delivery.seed"),bridge=resolve(root,"bridge.mjs"); - const cargo=spawnSync("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-crossing-fixture","--","--request-store",requestStore,"--identity-store",identity,"--request-anchor",anchor],{cwd:epiphany,encoding:"utf8",timeout:180000,windowsHide:true}); + const cargo=spawnSync("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-crossing-fixture","--","--request-store",requestStore,"--identity-store",identity,"--request-anchor",anchor],{cwd:epiphany,encoding:"utf8",timeout:180000,windowsHide:true,env:{...process.env,CARGO_TARGET_DIR:"C:\\Users\\Meta\\.cargo-target-codex"}}); assert.equal(cargo.status,0,cargo.stderr||cargo.stdout); await chmod(requestStore,0o444); await enrollDomainIdentity(deliveryKey,"delivery",receiptIdentityDomain); await writeFile(bridge,`console.log(JSON.stringify({action:"discord-post",ok:true,channelId:"123",messageId:"rust-smoke-message",transport:"smoke",crossingReceiptId:"rust-smoke-receipt",url:"https://discord.invalid/rust-smoke"}))`); - const r=runtime(),results=await processPersonaDiscordDeliveries({runtime:r,requestStorePath:requestStore,receiptStorePath:resolve(receiptDir,"receipts.cc"),executionStorePath:resolve(root,"private","executions.cc"),epiphanyTrustAnchorPath:anchor,privateKeyPath:deliveryKey,targetAdmitted:()=>true,requestPermit:async({requestPayloadSha256})=>({permitId:`permit:${requestPayloadSha256}`,expiresAt:new Date(Date.now()+5000).toISOString()}),bridgeCliPath:bridge,bifrostRoot:root}); + const r=runtime(),results=await processPersonaDiscordDeliveries({runtime:r,requestStorePath:requestStore,receiptStorePath:resolve(receiptDir,"receipts.cc"),executionStorePath:resolve(root,"private","executions.cc"),epiphanyTrustAnchorPath:anchor,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,requestPermit:async({requestPayloadSha256})=>({permitId:`permit:${requestPayloadSha256}`,expiresAt:new Date(Date.now()+5000).toISOString()}),bridgeCliPath:bridge,bifrostRoot:root}); assert.equal(results[0]?.status,"completed"); assert.equal(results[0]?.requestId,"rust-crossing-smoke-1"); }); diff --git a/tests/persona-discord-delivery.test.mjs b/tests/persona-discord-delivery.test.mjs index ad619b6..44461f3 100644 --- a/tests/persona-discord-delivery.test.mjs +++ b/tests/persona-discord-delivery.test.mjs @@ -5,7 +5,7 @@ import {createRequire} from "node:module"; import {mkdtemp,writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {resolve} from "node:path"; -import {buildPersonaDiscordDeliveryDefinitions,executionSchema,parseReceipt,parseRequest,receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestIdentityDomain,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs"; +import {buildPersonaDiscordDeliveryDefinitions,executionSchema,parseReceipt,parseRequest,receiptDocumentTuple,receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestIdentityDomain,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs"; import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; import {enrollDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "../tools/bifrost-signed-identity.mjs"; @@ -14,13 +14,13 @@ const request=()=>({schemaVersion:"epiphany.persona_discord_delivery_request.v0" test("Persona delivery request has one exact signature tuple",()=>{const value=parseRequest(request());assert.deepEqual(requestSigningTuple(value),["epiphany.persona_discord_delivery_request.v0","request-1","effect-1","epiphany-yggdrasil","epiphany.Persona","123","456","hello",`sha256-${"a".repeat(64)}`,"2026-07-21T12:00:00Z","2026-07-21T12:01:00Z",false,"signer"]);}); -test("Persona delivery receipt terminal states cannot counterfeit delivery",()=>{const base={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"request-1",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"unknown",channelId:"123",replyToMessageId:"456",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.equal(receiptSigningTuple(parseReceipt(base)).length,14);assert.throws(()=>parseReceipt({...base,messageId:"counterfeit"}),/may not claim delivery evidence/);assert.doesNotThrow(()=>parseReceipt({...base,status:"failed"}));assert.doesNotThrow(()=>parseReceipt({...base,status:"completed",messageId:"789",transport:"bifrost.discord-post",crossingReceiptId:"crossing-1",receiptUrl:"https://discord.com/channels/g/123/789"}));}); +test("Persona delivery receipt terminal states cannot counterfeit delivery",()=>{const base={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"request-1",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"unknown",channelId:"123",replyToMessageId:"456",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.equal(receiptSigningTuple(parseReceipt(base)).length,14);assert.equal(receiptDocumentTuple(base).length,15);assert.deepEqual(parseReceipt(receiptDocumentTuple(base)),base);assert.throws(()=>parseReceipt({...base,messageId:"counterfeit"}),/may not claim delivery evidence/);assert.doesNotThrow(()=>parseReceipt({...base,status:"failed"}));assert.doesNotThrow(()=>parseReceipt({...base,status:"completed",messageId:"789",transport:"bifrost.discord-post",crossingReceiptId:"crossing-1",receiptUrl:"https://discord.com/channels/g/123/789"}));}); test("Persona delivery documents refuse privacy and identity substitutions",()=>{assert.throws(()=>parseRequest({...request(),privateStateExposed:true}),/must be false/);const receipt={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"receipt-2",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"failed",channelId:"123",replyToMessageId:"",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.throws(()=>parseReceipt(receipt),/must equal requestId/);}); -test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");}); +test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");}); -test("running journal recovers as terminal unknown without a second Discord post",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-crash-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"anchor.msgpack");await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const digest=`sha256-${createHash("sha256").update(runtime.encode(requestSigningTuple(value))).digest("hex")}`,executions=await open(runtime,defs.execution,executionStore);await executions.put(defs.execution,value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:value.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:"crash fixture"});const results=await processPersonaDiscordDeliveries({runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:resolve(root,"must-not-run.mjs"),bifrostRoot:root});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");}); +test("running journal recovers as terminal unknown without a second Discord post",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-crash-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"anchor.msgpack");await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const digest=`sha256-${createHash("sha256").update(runtime.encode(requestSigningTuple(value))).digest("hex")}`,executions=await open(runtime,defs.execution,executionStore);await executions.put(defs.execution,value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:value.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:"crash fixture"});const results=await processPersonaDiscordDeliveries({runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:resolve(root,"must-not-run.mjs"),bifrostRoot:root});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");}); test("permit intent survives network failure and restart reuses its exact request tuple",async()=>{const f=await deliveryFixture("permit-retry-");const attempts=[];let results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async value=>{attempts.push({...value});throw new Error("permit network unavailable");}});assert.equal(results[0].status,"permit_pending");const executions=await open(f.runtime,f.defs.execution,f.executionStore),pending=executions.get(f.defs.execution,f.value.requestId);assert.equal(pending.status,"permit_pending");assert.equal(pending.permitNonce,attempts[0].nonce);assert.equal(pending.permitIssuedAt,attempts[0].issuedAt);assert.equal(pending.permitExpiresAt,attempts[0].expiresAt);results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async value=>{attempts.push({...value});return {permitId:"permit-after-restart"};}});assert.equal(results[0].status,"completed");assert.equal(attempts.length,2);for(const key of ["requestId","requestPayloadSha256","targetRuntimeId","nonce","issuedAt","expiresAt"])assert.equal(attempts[1][key],attempts[0][key]);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(f.counterPath,"utf8")),"1");}); @@ -29,4 +29,4 @@ test("expired permit intent restarts as terminal unknown without requesting or p function loadRuntime(){const cult=resolve(import.meta.dirname,"..","..","CultLib"),cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} async function open(runtime,definition,path){const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return cache;} function requestAnchor(identity){return ["gamecult.service_trust_anchor.v1","epiphany-persona-mouth:epiphany-yggdrasil:v0","epiphany-persona-mouth","epiphany-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",requestSigningPurpose,"epiphany.persona_discord_delivery_request.v0","root",Date.now(),null,false];} -async function deliveryFixture(prefix){const root=await mkdtemp(resolve(tmpdir(),prefix)),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgeCliPath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain);await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgeCliPath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);return {root,runtime,defs,value,executionStore,counterPath,options:{runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath,bifrostRoot:root}};} +async function deliveryFixture(prefix){const root=await mkdtemp(resolve(tmpdir(),prefix)),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgeCliPath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain);await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgeCliPath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);return {root,runtime,defs,value,executionStore,counterPath,options:{runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath,bifrostRoot:root}};} diff --git a/tests/persona-discord-rudp-cross-language.test.mjs b/tests/persona-discord-rudp-cross-language.test.mjs new file mode 100644 index 0000000..d52e542 --- /dev/null +++ b/tests/persona-discord-rudp-cross-language.test.mjs @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {createHash} from "node:crypto"; +import {createRequire} from "node:module"; +import {mkdtemp,readFile,writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; +import {enrollDomainIdentity,signDomainPurpose} from "../tools/bifrost-signed-identity.mjs"; +import {receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs"; +import {startPersonaDiscordDeliveryRudpServer} from "../tools/persona-discord-rudp.mjs"; + +const epiphany=resolve(import.meta.dirname,"..","..","Epiphany"); + +test("Rust Persona mouth and Node Bifrost exchange one signed request and terminal receipt over CultNet RUDP",async()=>{ + const root=await mkdtemp(resolve(tmpdir(),"persona-rudp-cross-language-")),runtime=loadRuntime(),deliveryIdentity=await enrollDomainIdentity(resolve(root,"delivery.seed"),"delivery identity",receiptIdentityDomain),receiptAnchor=resolve(root,"receipt-anchor.msgpack"),requestAnchor=resolve(root,"request-anchor.msgpack"),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),identityStore=resolve(root,"mouth.cc"); + await writeFile(receiptAnchor,runtime.msgpack.encode(["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",deliveryIdentity.identityId,new Uint8Array(deliveryIdentity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false])); + let admitted; + const server=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:"rudp://127.0.0.1:0",onRequest:async request=>{admitted=request;const digest=`sha256-${createHash("sha256").update(runtime.msgpack.encode(requestSigningTuple(request))).digest("hex")}`,receipt={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status:"completed",channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:"node-rudp-message",transport:"bifrost.discord-post",crossingReceiptId:"node-rudp-crossing",receiptUrl:`https://discord.com/channels/g/${request.channelId}/node-rudp-message`,completedAt:new Date().toISOString(),providerIdentityId:deliveryIdentity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(deliveryIdentity,receiptSignatureDomain,receiptSigningPurpose,runtime.msgpack.encode(receiptSigningTuple(receipt))));return receipt;}}); + try{ + const output=await run("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-rudp-client-fixture","--","--request-store",requestStore,"--receipt-store",receiptStore,"--identity-store",identityStore,"--request-anchor",requestAnchor,"--receipt-anchor",receiptAnchor,"--endpoint",`127.0.0.1:${server.address.port}`],{cwd:epiphany,env:{...process.env,CARGO_TARGET_DIR:"C:\\Users\\Meta\\.cargo-target-codex"}}); + assert.equal(output.code,0,`${output.stderr}\nserver diagnostics: ${JSON.stringify(server.diagnostics)}`);const result=JSON.parse(output.stdout.trim());assert.equal(result.status,"completed");assert.equal(result.messageId,"node-rudp-message");assert.equal(admitted.targetRuntimeId,"epiphany-starfire");assert.ok((await readFile(requestStore)).length>0);assert.ok((await readFile(receiptStore)).length>0); + }finally{server.close();} +}); + +function loadRuntime(){const cult=resolve(import.meta.dirname,"..","..","CultLib"),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cultnet=nr("cultnet-ts"),msgpack=nr("@msgpack/msgpack");return {cultnet,msgpack};} +function run(command,args,options){return new Promise((resolveRun,reject)=>{const child=spawn(command,args,{...options,stdio:["ignore","pipe","pipe"]});let stdout="",stderr="";child.stdout.on("data",chunk=>stdout+=chunk);child.stderr.on("data",chunk=>stderr+=chunk);child.once("error",reject);child.once("close",code=>resolveRun({code,stdout,stderr}));});} diff --git a/tools/persona-discord-delivery-documents.mjs b/tools/persona-discord-delivery-documents.mjs index 4787e92..a99ff3b 100644 --- a/tools/persona-discord-delivery-documents.mjs +++ b/tools/persona-discord-delivery-documents.mjs @@ -25,9 +25,10 @@ const receiptMembers = ["schemaVersion","receiptId","requestId","requestPayloadS export function requestSigningTuple(v){return [v.schemaVersion,v.requestId,v.effectDocumentId,v.targetRuntimeId,v.personaAgentId,v.channelId,v.replyToMessageId,v.content,v.contentSha256,v.issuedAt,v.expiresAt,v.privateStateExposed,v.signerIdentityId];} export function receiptSigningTuple(v){return [v.schemaVersion,v.receiptId,v.requestId,v.requestPayloadSha256,v.status,v.channelId,v.replyToMessageId,v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl,v.completedAt,v.providerIdentityId,v.privateStateExposed];} +export function receiptDocumentTuple(v){return [...receiptSigningTuple(v),v.providerSignature];} export function parseRequest(value){const v=typed(value,requestMembers,"Persona Discord delivery request");closed(v,["schemaName","schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"],"request");exact(v.schemaVersion,requestSchema,"request schema");for(const key of ["requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","contentSha256","issuedAt","expiresAt","signerIdentityId"])text(v[key],key);content(v.content);optionalText(v.replyToMessageId,"replyToMessageId");if(v.privateStateExposed!==false)throw new Error("request privateStateExposed must be false.");bytes64(v.signerSignature,"signerSignature");sha(v.contentSha256,"contentSha256");const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>120000)throw new Error("request lifetime must be positive and at most 120 seconds.");return v;} -export function parseReceipt(value){const v=record(value,"Persona Discord delivery receipt");exact(v.schemaVersion,receiptSchema,"receipt schema");for(const key of ["receiptId","requestId","requestPayloadSha256","status","channelId","completedAt","providerIdentityId"])text(v[key],key);for(const key of ["replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl"])optionalText(v[key],key);if(v.receiptId!==v.requestId)throw new Error("receiptId must equal requestId.");if(!["completed","failed","unknown"].includes(v.status))throw new Error("receipt status is invalid.");if(v.status==="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(x=>!x))throw new Error("completed receipt lacks exact delivery evidence.");if(v.status!=="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(Boolean))throw new Error("non-completed receipt may not claim delivery evidence.");if(v.privateStateExposed!==false)throw new Error("receipt privateStateExposed must be false.");bytes64(v.providerSignature,"providerSignature");sha(v.requestPayloadSha256,"requestPayloadSha256");date(v.completedAt,"completedAt");return v;} +export function parseReceipt(value){const v=typed(value,receiptMembers,"Persona Discord delivery receipt");exact(v.schemaVersion,receiptSchema,"receipt schema");for(const key of ["receiptId","requestId","requestPayloadSha256","status","channelId","completedAt","providerIdentityId"])text(v[key],key);for(const key of ["replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl"])optionalText(v[key],key);if(v.receiptId!==v.requestId)throw new Error("receiptId must equal requestId.");if(!["completed","failed","unknown"].includes(v.status))throw new Error("receipt status is invalid.");if(v.status==="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(x=>!x))throw new Error("completed receipt lacks exact delivery evidence.");if(v.status!=="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(Boolean))throw new Error("non-completed receipt may not claim delivery evidence.");if(v.privateStateExposed!==false)throw new Error("receipt privateStateExposed must be false.");bytes64(v.providerSignature,"providerSignature");sha(v.requestPayloadSha256,"requestPayloadSha256");date(v.completedAt,"completedAt");return v;} export function parseExecution(value){const v=record(value,"Persona Discord execution journal");exact(v.schemaVersion,executionSchema,"execution schema");for(const key of ["requestId","requestPayloadSha256","status","recordedAt"])text(v[key],key);if(!["permit_pending","running","completed","failed","unknown"].includes(v.status))throw new Error("execution status is invalid.");if(v.status==="permit_pending"){text(v.permitNonce,"permitNonce");date(v.permitIssuedAt,"permitIssuedAt");date(v.permitExpiresAt,"permitExpiresAt");if(Date.parse(v.permitExpiresAt)<=Date.parse(v.permitIssuedAt)||Date.parse(v.permitExpiresAt)-Date.parse(v.permitIssuedAt)>5000)throw new Error("permit intent lifetime is invalid");}return v;} function record(v,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);return v;} function typed(v,members,label){if(Array.isArray(v)){if(v.length!==members.length)throw new Error(`${label} tuple has the wrong arity.`);return Object.fromEntries(members.map(member=>[member.memberName,member.typeName==="byte[]"&&Array.isArray(v[member.slot])?Uint8Array.from(v[member.slot]):v[member.slot]]));}return record(v,label);} diff --git a/tools/persona-discord-delivery.mjs b/tools/persona-discord-delivery.mjs index 30bcc92..daa5bd6 100644 --- a/tools/persona-discord-delivery.mjs +++ b/tools/persona-discord-delivery.mjs @@ -14,7 +14,7 @@ export async function exportPersonaDiscordPermitRequestAnchor({runtime,privateKe export async function processPersonaDiscordDeliveries(options){ const {runtime,requestStorePath,receiptStorePath,executionStorePath,epiphanyTrustAnchorPath,privateKeyPath,targetAdmitted}=options,defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType); requireSeparate([requestStorePath,receiptStorePath,executionStorePath]); - const requests=await open(runtime,defs.request,requestStorePath),receipts=await open(runtime,defs.receipt,receiptStorePath),executions=await open(runtime,defs.execution,executionStorePath),anchor=await readAnchor(runtime,epiphanyTrustAnchorPath),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain); + const requests=await open(runtime,defs.request,requestStorePath),receipts=await open(runtime,defs.receipt,receiptStorePath),executions=await open(runtime,defs.execution,executionStorePath),anchor=await readAnchor(runtime,epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain); const results=[]; for(const request of requests.cache.getAll(defs.request).sort((a,b)=>String(a.issuedAt).localeCompare(String(b.issuedAt)))){ const unsigned=runtime.encode(requestSigningTuple(request)),digest=`sha256-${sha(unsigned)}`,existingReceipt=unwrap(receipts.cache.get(defs.receipt,request.requestId)),existingExecution=unwrap(executions.cache.get(defs.execution,request.requestId)); @@ -39,13 +39,13 @@ export async function processPersonaDiscordDeliveries(options){ return results; } -export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await access(options.requestStorePath,constants.R_OK);await access(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}} +export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await access(options.requestStorePath,constants.R_OK);await access(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}} function validateRequest(request,unsigned,anchor,targetAdmitted){if(request.signerIdentityId!==anchor.identityId)throw new Error("request signer is not the pinned Epiphany Persona mouth identity");if(!verifyDomainPurpose(anchor.publicKey,requestSignatureDomain,requestSigningPurpose,unsigned,request.signerSignature))throw new Error("request signature is invalid");if(`sha256-${sha(Buffer.from(request.content,"utf8"))}`!==request.contentSha256)throw new Error("request content digest is invalid");const now=Date.now(),issued=Date.parse(request.issuedAt),expires=Date.parse(request.expiresAt);if(issued>now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");} function post(request,options){const args=[options.bridgeCliPath,"discord-post","--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;} async function terminal(status,request,digest,detail,c){const now=new Date().toISOString(),posted=c.posted??{},receipt={schemaName:"bifrost.persona_discord_delivery_receipt",schemaVersion:receiptSchema,receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status,channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:status==="completed"?posted.messageId:"",transport:status==="completed"?posted.transport:"",crossingReceiptId:status==="completed"?posted.crossingReceiptId:"",receiptUrl:status==="completed"?posted.url:"",completedAt:now,providerIdentityId:c.identity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(c.identity,receiptSignatureDomain,receiptSigningPurpose,c.runtime.encode(receiptSigningTuple(receipt))));await c.receipts.cache.put(c.defs.receipt,receipt.receiptId,receipt);await c.executions.cache.put(c.defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status,recordedAt:now,detail});return receipt;} async function open(runtime,definition,path){await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return {cache};} -async function readAnchor(runtime,path){const value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==13)throw new Error("Epiphany Persona mouth trust anchor must be the exact service-contract tuple");const [schemaVersion,trustAnchorId,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,bindingAuthority,boundAt,expiresAt,privateStateExposed]=value,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||typeof trustAnchorId!=="string"||serviceId!=="epiphany-persona-mouth"||runtimeId!=="epiphany-yggdrasil"||typeof identityId!=="string"||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==requestSigningPurpose||signedSchema!=="epiphany.persona_discord_delivery_request.v0"||bindingAuthority!=="root"||!Number.isSafeInteger(boundAt)||boundAt<=0||boundAt>now||(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<=boundAt||expiresAt<=now))||privateStateExposed!==false)throw new Error("Epiphany Persona mouth service trust anchor is invalid");return {identityId,publicKey};} +async function readAnchor(runtime,path,expectedRuntimeId){if(typeof expectedRuntimeId!=="string"||!expectedRuntimeId)throw new Error("Epiphany runtime identity is required for Persona mouth trust");const value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==13)throw new Error("Epiphany Persona mouth trust anchor must be the exact service-contract tuple");const [schemaVersion,trustAnchorId,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,bindingAuthority,boundAt,expiresAt,privateStateExposed]=value,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||typeof trustAnchorId!=="string"||serviceId!=="epiphany-persona-mouth"||runtimeId!==expectedRuntimeId||typeof identityId!=="string"||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==requestSigningPurpose||signedSchema!=="epiphany.persona_discord_delivery_request.v0"||bindingAuthority!=="root"||!Number.isSafeInteger(boundAt)||boundAt<=0||boundAt>now||(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<=boundAt||expiresAt<=now))||privateStateExposed!==false)throw new Error("Epiphany Persona mouth service trust anchor is invalid");return {identityId,publicKey};} function requireSeparate(paths){const keys=paths.map(p=>resolve(p).toLowerCase());if(new Set(keys).size!==keys.length)throw new Error("Persona Discord crossing and private execution stores must be distinct.");} function sha(value){return createHash("sha256").update(value).digest("hex");} function unwrap(value){return Array.isArray(value)?value[0]:value?.value??value;} diff --git a/tools/persona-discord-permit.mjs b/tools/persona-discord-permit.mjs index ac9c7ef..defa797 100644 --- a/tools/persona-discord-permit.mjs +++ b/tools/persona-discord-permit.mjs @@ -20,7 +20,7 @@ export const permitRequestTuple=v=>[v.schemaVersion,v.requestId,v.requestPayload export const permitTuple=v=>[v.schemaVersion,v.permitId,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.requesterIdentityId,v.brakeStateDocumentId,v.brakeStateDocumentSha256,v.brakeObservedAt,v.issuedAt,v.expiresAt,v.providerIdentityId,v.privateStateExposed]; export async function requestPersonaDiscordPermit({endpoint,requestId,requestPayloadSha256,targetRuntimeId,nonce,issuedAt,expiresAt,privateKeyPath,epiphanyPermitAnchorPath,timeoutMs=5000,runtime:provided,socketFactory=createSocket,connectionFactory}){ - const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath),now=Date.now(); + const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath,targetRuntimeId),now=Date.now(); const request={schemaVersion:permitRequestSchema,requestId,requestPayloadSha256,targetRuntimeId,nonce:nonce??randomBytes(16).toString("hex"),issuedAt:issuedAt??new Date(now).toISOString(),expiresAt:expiresAt??new Date(now+5000).toISOString(),requesterIdentityId:identity.identityId,privateStateExposed:false,requesterSignature:new Uint8Array()}; request.requesterSignature=new Uint8Array(signDomainPurpose(identity,permitRequestSignatureDomain,permitRequestPurpose,runtime.msgpack.encode(permitRequestTuple(request)))); const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");await bind(socket);const connection=(connectionFactory??(o=>new runtime.cultnet.CultNetRudpSocketTransportConnection(o)))({runtimeId:"bifrost-discord-yggdrasil",socket,mode:"client",remoteHost:host,remotePort:port,connectionId,resendDelayMs:50,resendPollMs:10}); @@ -28,7 +28,7 @@ export async function requestPersonaDiscordPermit({endpoint,requestId,requestPay } export function validatePermit(v,request,anchor,encode){if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expiresissued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),v.providerSignature))throw new Error("Epiphany permit signature is invalid");return v;} -async function readAnchor(runtime,path){const a=runtime.msgpack.decode(await readFile(path));if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,publicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!=="epiphany-yggdrasil"||!(publicKey instanceof Uint8Array)||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};} +async function readAnchor(runtime,path,expectedRuntimeId){const a=runtime.msgpack.decode(await readFile(path));if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,publicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!==expectedRuntimeId||!(publicKey instanceof Uint8Array)||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};} function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=m.decode(d?.payload);if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p?.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})} async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}} function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})} diff --git a/tools/persona-discord-rudp.mjs b/tools/persona-discord-rudp.mjs new file mode 100644 index 0000000..d3ebfe6 --- /dev/null +++ b/tools/persona-discord-rudp.mjs @@ -0,0 +1,31 @@ +import {createSocket} from "node:dgram"; +import {parseRequest,receiptDocumentTuple} from "./persona-discord-delivery-documents.mjs"; + +export const personaDiscordDeliveryConnectionId=0xe91f0003; + +export async function startPersonaDiscordDeliveryRudpServer({runtime,endpoint,runtimeId="bifrost-discord-yggdrasil",onRequest,socketFactory=createSocket,connectionFactory}){ + const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4"); + await bind(socket,host,port); + const connection=(connectionFactory??(options=>new runtime.cultnet.CultNetRudpSocketTransportConnection(options)))({runtimeId,socket,mode:"server",connectionId:personaDiscordDeliveryConnectionId,initialSequence:100,resendDelayMs:50,resendPollMs:10}); + let busy=false;const diagnostics={datagrams:0,frames:0,responses:0,errors:[]};socket.on("message",()=>diagnostics.datagrams++); + const frame=async value=>{ + diagnostics.frames++; + if(busy)return; + busy=true; + try{ + if(value.channelId!=="schema")throw new Error("Persona delivery request arrived on a foreign channel"); + const message=runtime.cultnet.parseCultNetMessage(runtime.msgpack.decode(value.payload),"cultnet.schema.v0"),document=message.document; + if(message.schemaVersion!=="cultnet.document_put_raw.v0"||document.schemaId!=="epiphany.persona_discord_delivery_request.v0"||document.payloadEncoding!=="messagepack"||document.sourceRole!=="epiphany-persona-mouth"||document.sourceRuntimeId!=="epiphany-starfire"||!Array.isArray(document.tags)||document.tags.length!==1||document.tags[0]!=="cultnet.transport.rudp.v0")throw new Error("Persona delivery request envelope substituted authority"); + const request=parseRequest(runtime.msgpack.decode(document.payload)); + if(document.recordKey!==request?.requestId||document.sourceAgentId!==request?.signerIdentityId||request?.targetRuntimeId!=="epiphany-starfire")throw new Error("Persona delivery request envelope is not bound to its signed payload"); + const receipt=await onRequest(request); + const response={schemaVersion:"cultnet.document_put_raw.v0",messageId:`persona-delivery-receipt-${receipt.requestId}`,document:{schemaId:"bifrost.persona_discord_delivery_receipt.v0",recordKey:receipt.requestId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(receiptDocumentTuple(receipt)),sourceRuntimeId:runtimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}}; + connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(response,"cultnet.schema.v0")));diagnostics.responses++; + }catch(error){const message=error instanceof Error?error.message:String(error);diagnostics.errors.push(message);console.error(`Bifrost Persona delivery RUDP rejected frame: ${message}`);}finally{busy=false;} + }; + connection.on("frame",frame); + return {address:socket.address(),diagnostics,close(){connection.off?.("frame",frame);connection.close();}}; +} + +function parseEndpoint(value){const url=new URL(value);if(url.protocol!=="rudp:"||!url.hostname||!url.port)throw new Error("Persona delivery listener must be rudp://host:port");return {host:url.hostname,port:Number(url.port)};} +function bind(socket,host,port){return new Promise((resolve,reject)=>{socket.once("error",reject);socket.once("listening",resolve);socket.bind(port,host);});} diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index 3f578af..0edd8f9 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -9,6 +9,8 @@ import { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrost import { createPersonaFeedbackHealthPublisher, publishPersonaFeedbackHealth } from "./persona-feedback-idunn-health.mjs"; import { discordGatewayReady, serveDiscordPersonaIngress } from "./bifrost-discord-persona-ingress.mjs"; import { enrollPersonaDiscordDeliveryIdentity, exportPersonaDiscordPermitRequestAnchor, inspectPersonaDiscordDeliveryReadiness, processPersonaDiscordDeliveries } from "./persona-discord-delivery.mjs"; +import { startPersonaDiscordDeliveryRudpServer } from "./persona-discord-rudp.mjs"; +import { buildPersonaDiscordDeliveryDefinitions, parseRequest as parsePersonaDiscordDeliveryRequest } from "./persona-discord-delivery-documents.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); @@ -76,13 +78,16 @@ async function serveDiscord(){ const recovered=await drainPendingObservations();if(recovered.some(value=>!value.ok))console.error(`Bifrost Persona ingress startup recovery retained ${recovered.filter(value=>!value.ok).length} failed observation(s) for reconnect retry.`); await drainPersonaDeliveries().catch(error=>console.error(`Bifrost Persona delivery is staged but not yet ready: ${error instanceof Error?error.message:String(error)}`)); await publishHealth(); + const deliveryRudp=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:requireOption("persona-delivery-rudp-listen"),onRequest:acceptPersonaDeliveryRequest}); const healthTimer=setInterval(()=>publishHealth().catch(error=>console.error(`Bifrost Persona-feedback health publication failed: ${error instanceof Error?error.message:String(error)}`)),10000); let observationBusy=false;const observationTimer=setInterval(async()=>{if(observationBusy)return;observationBusy=true;try{const retried=await drainPendingObservations();if(retried.some(value=>!value.ok))console.error(`Bifrost Persona ingress retained ${retried.filter(value=>!value.ok).length} failed pending observation(s).`);}catch(error){console.error(`Bifrost Persona observation reconciliation failed: ${error instanceof Error?error.message:String(error)}`);}finally{observationBusy=false;}},positiveInteger(options["observation-retry-interval-ms"]||"5000","observation-retry-interval-ms")); let deliveryBusy=false;const deliveryTimer=setInterval(async()=>{if(deliveryBusy)return;deliveryBusy=true;try{await drainPersonaDeliveries();}catch(error){console.error(`Bifrost Persona delivery poll failed: ${error instanceof Error?error.message:String(error)}`);}finally{deliveryBusy=false;}},positiveInteger(options["persona-delivery-interval-ms"]||"500","persona-delivery-interval-ms")); - try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);} + try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{deliveryRudp.close();gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);} } -async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding);return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});} +async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding),epiphanyRuntimeId=requireOption("epiphany-runtime-id");return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId,epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.targetRuntimeId===epiphanyRuntimeId&&request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});} + +async function acceptPersonaDeliveryRequest(request){const deliveryDefs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requests=runtime.CultCache.builder().withDocumentType(deliveryDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryRequestStorePath)).build();await requests.pullAllBackingStores();const parsed=parsePersonaDiscordDeliveryRequest(request),existing=unwrap(requests.get(deliveryDefs.request,parsed.requestId));if(existing&&stable(existing)!==stable(parsed))throw new Error(`Persona delivery request ${parsed.requestId} conflicts with durable endpoint state.`);if(!existing)await requests.put(deliveryDefs.request,parsed.requestId,parsed);await drainPersonaDeliveries();const receipts=runtime.CultCache.builder().withDocumentType(deliveryDefs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryReceiptStorePath)).build();await receipts.pullAllBackingStores();const receipt=unwrap(receipts.get(deliveryDefs.receipt,parsed.requestId));if(!receipt)throw new Error(`Persona delivery request ${parsed.requestId} has no terminal Bifrost receipt yet.`);return receipt;} async function admitDiscordConversation(conversation,producerRuntimeId){ const privateNodeValue=await privateNode();await privateNodeValue.cache?.pullAllBackingStores?.(); @@ -101,7 +106,7 @@ async function enrollPersonaDeliveryIdentity(){print(await enrollPersonaDiscordD async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} 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 ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.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).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.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){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"ready":"degraded",reasons,privateStateExposed:false};} +async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.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).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.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){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"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"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} From dd79b8b991d3bacbdee775f3a34c633305337d9a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 20:17:22 +0100 Subject: [PATCH 022/155] persona: separate delivery listener daemon --- tools/persona-feedback.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index 0edd8f9..6187c2e 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -30,13 +30,14 @@ async function main(){ if(command==="process")return processEvent(requireOption("event-id")); if(command==="serve")return serve(); if(command==="serve-discord")return serveDiscord(); + if(command==="serve-persona-delivery")return servePersonaDelivery(); if(command==="export")return exportSnapshot(); if(command==="enroll-identity")return enrollIdentity(); if(command==="enroll-persona-delivery-identity")return enrollPersonaDeliveryIdentity(); if(command==="export-persona-permit-request-anchor")return exportPersonaPermitRequestAnchor(); 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|serve-discord|status|export|enroll-identity|enroll-persona-delivery-identity|export-provider-health-anchor"); + throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|serve-persona-delivery|status|export|enroll-identity|enroll-persona-delivery-identity|export-provider-health-anchor"); } async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});} async function observationNode(){return runtime.CultMesh.createNode(observationStorePath,{documents:[defs.event]});} @@ -78,13 +79,14 @@ async function serveDiscord(){ const recovered=await drainPendingObservations();if(recovered.some(value=>!value.ok))console.error(`Bifrost Persona ingress startup recovery retained ${recovered.filter(value=>!value.ok).length} failed observation(s) for reconnect retry.`); await drainPersonaDeliveries().catch(error=>console.error(`Bifrost Persona delivery is staged but not yet ready: ${error instanceof Error?error.message:String(error)}`)); await publishHealth(); - const deliveryRudp=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:requireOption("persona-delivery-rudp-listen"),onRequest:acceptPersonaDeliveryRequest}); const healthTimer=setInterval(()=>publishHealth().catch(error=>console.error(`Bifrost Persona-feedback health publication failed: ${error instanceof Error?error.message:String(error)}`)),10000); let observationBusy=false;const observationTimer=setInterval(async()=>{if(observationBusy)return;observationBusy=true;try{const retried=await drainPendingObservations();if(retried.some(value=>!value.ok))console.error(`Bifrost Persona ingress retained ${retried.filter(value=>!value.ok).length} failed pending observation(s).`);}catch(error){console.error(`Bifrost Persona observation reconciliation failed: ${error instanceof Error?error.message:String(error)}`);}finally{observationBusy=false;}},positiveInteger(options["observation-retry-interval-ms"]||"5000","observation-retry-interval-ms")); let deliveryBusy=false;const deliveryTimer=setInterval(async()=>{if(deliveryBusy)return;deliveryBusy=true;try{await drainPersonaDeliveries();}catch(error){console.error(`Bifrost Persona delivery poll failed: ${error instanceof Error?error.message:String(error)}`);}finally{deliveryBusy=false;}},positiveInteger(options["persona-delivery-interval-ms"]||"500","persona-delivery-interval-ms")); - try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{deliveryRudp.close();gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);} + try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);} } +async function servePersonaDelivery(){const readinessState=await readiness();if(!readinessState.personaDelivery.ready)throw new Error(`Bifrost Persona delivery is not ready: ${readinessState.personaDelivery.reasons.join("; ")}`);const server=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:requireOption("persona-delivery-rudp-listen"),onRequest:acceptPersonaDeliveryRequest});print({schemaVersion:"bifrost.persona_discord_delivery_listener.v0",status:"ready",runtimeId:"bifrost-discord-yggdrasil",host:server.address.address,port:server.address.port,requestSignerIdentityId:readinessState.personaDelivery.requestSignerIdentityId,receiptSignerIdentityId:readinessState.personaDelivery.receiptSignerIdentityId,privateStateExposed:false});await new Promise(()=>{});} + async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding),epiphanyRuntimeId=requireOption("epiphany-runtime-id");return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId,epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.targetRuntimeId===epiphanyRuntimeId&&request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});} async function acceptPersonaDeliveryRequest(request){const deliveryDefs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requests=runtime.CultCache.builder().withDocumentType(deliveryDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryRequestStorePath)).build();await requests.pullAllBackingStores();const parsed=parsePersonaDiscordDeliveryRequest(request),existing=unwrap(requests.get(deliveryDefs.request,parsed.requestId));if(existing&&stable(existing)!==stable(parsed))throw new Error(`Persona delivery request ${parsed.requestId} conflicts with durable endpoint state.`);if(!existing)await requests.put(deliveryDefs.request,parsed.requestId,parsed);await drainPersonaDeliveries();const receipts=runtime.CultCache.builder().withDocumentType(deliveryDefs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryReceiptStorePath)).build();await receipts.pullAllBackingStores();const receipt=unwrap(receipts.get(deliveryDefs.receipt,parsed.requestId));if(!receipt)throw new Error(`Persona delivery request ${parsed.requestId} has no terminal Bifrost receipt yet.`);return receipt;} @@ -106,7 +108,7 @@ async function enrollPersonaDeliveryIdentity(){print(await enrollPersonaDiscordD async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} 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 ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.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).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.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){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"ready":"degraded",reasons,privateStateExposed:false};} +async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.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).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.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){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||command==="serve-persona-delivery"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"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"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} From 34eb1e59e635d16721f8913d6cfeaf3c68b05071 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 20:22:22 +0100 Subject: [PATCH 023/155] persona: split receipt and permit requester identities --- tests/persona-discord-delivery.test.mjs | 4 +++- tools/persona-discord-delivery.mjs | 6 +++--- tools/persona-feedback.mjs | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/persona-discord-delivery.test.mjs b/tests/persona-discord-delivery.test.mjs index 44461f3..7ffe531 100644 --- a/tests/persona-discord-delivery.test.mjs +++ b/tests/persona-discord-delivery.test.mjs @@ -6,7 +6,7 @@ import {mkdtemp,writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {resolve} from "node:path"; import {buildPersonaDiscordDeliveryDefinitions,executionSchema,parseReceipt,parseRequest,receiptDocumentTuple,receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestIdentityDomain,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs"; -import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; +import {enrollPersonaDiscordDeliveryIdentity,exportPersonaDiscordPermitRequestAnchor,processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; import {enrollDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "../tools/bifrost-signed-identity.mjs"; const signature=()=>new Uint8Array(64); @@ -18,6 +18,8 @@ test("Persona delivery receipt terminal states cannot counterfeit delivery",()=> test("Persona delivery documents refuse privacy and identity substitutions",()=>{assert.throws(()=>parseRequest({...request(),privateStateExposed:true}),/must be false/);const receipt={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"receipt-2",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"failed",channelId:"123",replyToMessageId:"",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.throws(()=>parseReceipt(receipt),/must equal requestId/);}); +test("receipt signer and permit requester are distinct domain identities",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-identities-")),runtime=loadRuntime(),receiptKey=resolve(root,"receipt.seed"),permitKey=resolve(root,"permit.seed"),receiptAnchor=resolve(root,"receipt-anchor.msgpack"),permitAnchor=resolve(root,"permit-anchor.msgpack"),receipt=await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:receiptKey,trustAnchorOut:receiptAnchor}),permit=await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:permitKey,trustAnchorOut:permitAnchor});assert.notEqual(receiptKey,permitKey);assert.notEqual(receipt.identityId,permit.identityId);}); + test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");}); test("running journal recovers as terminal unknown without a second Discord post",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-crash-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"anchor.msgpack");await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const digest=`sha256-${createHash("sha256").update(runtime.encode(requestSigningTuple(value))).digest("hex")}`,executions=await open(runtime,defs.execution,executionStore);await executions.put(defs.execution,value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:value.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:"crash fixture"});const results=await processPersonaDiscordDeliveries({runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:resolve(root,"must-not-run.mjs"),bifrostRoot:root});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");}); diff --git a/tools/persona-discord-delivery.mjs b/tools/persona-discord-delivery.mjs index daa5bd6..e11b669 100644 --- a/tools/persona-discord-delivery.mjs +++ b/tools/persona-discord-delivery.mjs @@ -9,7 +9,7 @@ import {requestPersonaDiscordPermit} from "./persona-discord-permit.mjs"; import {permitRequestIdentityDomain,permitRequestPurpose,permitRequestSchema} from "./persona-discord-permit.mjs"; export async function enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath,trustAnchorOut}){const identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} -export async function exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath,trustAnchorOut}){const identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery-permit-request:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",permitRequestPurpose,permitRequestSchema,"root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} +export async function exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath,trustAnchorOut}){let identity;try{await access(privateKeyPath,constants.F_OK);identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);}const anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery-permit-request:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",permitRequestPurpose,permitRequestSchema,"root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} export async function processPersonaDiscordDeliveries(options){ const {runtime,requestStorePath,receiptStorePath,executionStorePath,epiphanyTrustAnchorPath,privateKeyPath,targetAdmitted}=options,defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType); @@ -27,7 +27,7 @@ export async function processPersonaDiscordDeliveries(options){ const permitIssuedAt=existingExecution?.permitIssuedAt??new Date().toISOString(),permitExpiresAt=existingExecution?.permitExpiresAt??new Date(Date.parse(permitIssuedAt)+5000).toISOString(),nonce=existingExecution?.permitNonce??randomBytes(16).toString("hex"); if(!existingExecution)await executions.cache.put(defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status:"permit_pending",recordedAt:permitIssuedAt,permitNonce:nonce,permitIssuedAt,permitExpiresAt,detail:"permit request intent persisted before network"}); let permit; - try{permit=await (options.requestPermit??requestPersonaDiscordPermit)({endpoint:options.epiphanyPermitEndpoint,requestId:request.requestId,requestPayloadSha256:digest,targetRuntimeId:request.targetRuntimeId,nonce,issuedAt:permitIssuedAt,expiresAt:permitExpiresAt,privateKeyPath,epiphanyPermitAnchorPath:options.epiphanyPermitAnchorPath,runtime:options.permitRuntime,timeoutMs:options.permitTimeoutMs});} + try{permit=await (options.requestPermit??requestPersonaDiscordPermit)({endpoint:options.epiphanyPermitEndpoint,requestId:request.requestId,requestPayloadSha256:digest,targetRuntimeId:request.targetRuntimeId,nonce,issuedAt:permitIssuedAt,expiresAt:permitExpiresAt,privateKeyPath:options.permitRequesterPrivateKeyPath,epiphanyPermitAnchorPath:options.epiphanyPermitAnchorPath,runtime:options.permitRuntime,timeoutMs:options.permitTimeoutMs});} catch(error){results.push({requestId:request.requestId,status:"permit_pending",detail:error instanceof Error?error.message:String(error)});continue;} await executions.cache.put(defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:`permit ${permit.permitId} durably consumed before external delivery`}); if(Date.parse(permit.expiresAt)<=Date.now()){results.push(await terminal("failed",request,digest,"Epiphany brake permit expired after durable running journal and before Discord actuation",{runtime,defs,receipts,executions,identity}));continue;} @@ -39,7 +39,7 @@ export async function processPersonaDiscordDeliveries(options){ return results; } -export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await access(options.requestStorePath,constants.R_OK);await access(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}} +export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath,options.privateKeyPath,options.permitRequesterPrivateKeyPath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),permitRequester=await loadDomainIdentity(options.permitRequesterPrivateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await access(options.requestStorePath,constants.R_OK);await access(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,permitRequesterIdentityId:permitRequester.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}} function validateRequest(request,unsigned,anchor,targetAdmitted){if(request.signerIdentityId!==anchor.identityId)throw new Error("request signer is not the pinned Epiphany Persona mouth identity");if(!verifyDomainPurpose(anchor.publicKey,requestSignatureDomain,requestSigningPurpose,unsigned,request.signerSignature))throw new Error("request signature is invalid");if(`sha256-${sha(Buffer.from(request.content,"utf8"))}`!==request.contentSha256)throw new Error("request content digest is invalid");const now=Date.now(),issued=Date.parse(request.issuedAt),expires=Date.parse(request.expiresAt);if(issued>now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");} function post(request,options){const args=[options.bridgeCliPath,"discord-post","--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;} diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index 6187c2e..4bb508c 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -87,7 +87,7 @@ async function serveDiscord(){ async function servePersonaDelivery(){const readinessState=await readiness();if(!readinessState.personaDelivery.ready)throw new Error(`Bifrost Persona delivery is not ready: ${readinessState.personaDelivery.reasons.join("; ")}`);const server=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:requireOption("persona-delivery-rudp-listen"),onRequest:acceptPersonaDeliveryRequest});print({schemaVersion:"bifrost.persona_discord_delivery_listener.v0",status:"ready",runtimeId:"bifrost-discord-yggdrasil",host:server.address.address,port:server.address.port,requestSignerIdentityId:readinessState.personaDelivery.requestSignerIdentityId,receiptSignerIdentityId:readinessState.personaDelivery.receiptSignerIdentityId,privateStateExposed:false});await new Promise(()=>{});} -async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding),epiphanyRuntimeId=requireOption("epiphany-runtime-id");return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId,epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.targetRuntimeId===epiphanyRuntimeId&&request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});} +async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding),epiphanyRuntimeId=requireOption("epiphany-runtime-id");return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId,epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.targetRuntimeId===epiphanyRuntimeId&&request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});} async function acceptPersonaDeliveryRequest(request){const deliveryDefs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requests=runtime.CultCache.builder().withDocumentType(deliveryDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryRequestStorePath)).build();await requests.pullAllBackingStores();const parsed=parsePersonaDiscordDeliveryRequest(request),existing=unwrap(requests.get(deliveryDefs.request,parsed.requestId));if(existing&&stable(existing)!==stable(parsed))throw new Error(`Persona delivery request ${parsed.requestId} conflicts with durable endpoint state.`);if(!existing)await requests.put(deliveryDefs.request,parsed.requestId,parsed);await drainPersonaDeliveries();const receipts=runtime.CultCache.builder().withDocumentType(deliveryDefs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryReceiptStorePath)).build();await receipts.pullAllBackingStores();const receipt=unwrap(receipts.get(deliveryDefs.receipt,parsed.requestId));if(!receipt)throw new Error(`Persona delivery request ${parsed.requestId} has no terminal Bifrost receipt yet.`);return receipt;} @@ -105,10 +105,10 @@ async function admitDiscordConversation(conversation,producerRuntimeId){ 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 enrollPersonaDeliveryIdentity(){print(await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} -async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} +async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("permit-request-private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} 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 ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.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).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.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){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||command==="serve-persona-delivery"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"ready":"degraded",reasons,privateStateExposed:false};} +async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.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).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.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){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||command==="serve-persona-delivery"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"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"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} From 3f4dcae605de341cb3f5da74a115db761a3517e6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 20:30:21 +0100 Subject: [PATCH 024/155] persona: keep staged readiness fixture complete --- tests/persona-feedback-cli.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/persona-feedback-cli.test.mjs b/tests/persona-feedback-cli.test.mjs index 3e6c10c..3333802 100644 --- a/tests/persona-feedback-cli.test.mjs +++ b/tests/persona-feedback-cli.test.mjs @@ -56,7 +56,7 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async( 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.pendingFailedCount,0);assert.equal(readiness.privateStateExposed,false); - const staged=cli("status",store,"--observation-store",observationStore,"--private-key",key,"--epiphany-persona-mouth-trust-anchor",resolve(dir,"missing-mouth-anchor.msgpack"),"--epiphany-persona-permit-trust-anchor",resolve(dir,"missing-permit-anchor.msgpack"),"--epiphany-persona-permit-rudp","rudp://127.0.0.1:9","--persona-delivery-private-key",resolve(dir,"missing-delivery.seed")); + const staged=cli("status",store,"--observation-store",observationStore,"--private-key",key,"--epiphany-persona-mouth-trust-anchor",resolve(dir,"missing-mouth-anchor.msgpack"),"--epiphany-runtime-id","epiphany-yggdrasil","--epiphany-persona-permit-trust-anchor",resolve(dir,"missing-permit-anchor.msgpack"),"--epiphany-persona-permit-rudp","rudp://127.0.0.1:9","--persona-delivery-private-key",resolve(dir,"missing-delivery.seed"),"--persona-permit-request-private-key",resolve(dir,"missing-permit-request.seed")); assert.equal(staged.ingressReady,true);assert.deepEqual(staged.ingressReasons,[]);assert.equal(staged.personaDelivery.ready,false);assert.equal(staged.status,"degraded"); const afterStatus=await Promise.all([store,observationStore,deliveryStore,key].map(path=>readFile(path)));assert.deepEqual(afterStatus,beforeStatus); const Store=cr("cultcache-ts").SingleFileMessagePackBackingStore; From fe845db18ed923f5d7a31c7605b4830e06167177 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 20:39:53 +0100 Subject: [PATCH 025/155] persona: admit first boot of typed delivery stores --- tests/persona-discord-delivery.test.mjs | 4 +++- tools/persona-discord-delivery.mjs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/persona-discord-delivery.test.mjs b/tests/persona-discord-delivery.test.mjs index 7ffe531..a41fdb9 100644 --- a/tests/persona-discord-delivery.test.mjs +++ b/tests/persona-discord-delivery.test.mjs @@ -6,7 +6,7 @@ import {mkdtemp,writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {resolve} from "node:path"; import {buildPersonaDiscordDeliveryDefinitions,executionSchema,parseReceipt,parseRequest,receiptDocumentTuple,receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestIdentityDomain,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs"; -import {enrollPersonaDiscordDeliveryIdentity,exportPersonaDiscordPermitRequestAnchor,processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; +import {enrollPersonaDiscordDeliveryIdentity,exportPersonaDiscordPermitRequestAnchor,inspectPersonaDiscordDeliveryReadiness,processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; import {enrollDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "../tools/bifrost-signed-identity.mjs"; const signature=()=>new Uint8Array(64); @@ -20,6 +20,8 @@ test("Persona delivery documents refuse privacy and identity substitutions",()=> test("receipt signer and permit requester are distinct domain identities",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-identities-")),runtime=loadRuntime(),receiptKey=resolve(root,"receipt.seed"),permitKey=resolve(root,"permit.seed"),receiptAnchor=resolve(root,"receipt-anchor.msgpack"),permitAnchor=resolve(root,"permit-anchor.msgpack"),receipt=await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:receiptKey,trustAnchorOut:receiptAnchor}),permit=await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:permitKey,trustAnchorOut:permitAnchor});assert.notEqual(receiptKey,permitKey);assert.notEqual(receipt.identityId,permit.identityId);}); +test("delivery listener admits absent typed stores when their owner directory is writable",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-readiness-")),runtime=loadRuntime(),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),mouthAnchor=resolve(root,"mouth-anchor.msgpack"),permitAnchor=resolve(root,"permit-anchor.msgpack"),receiptKey=resolve(root,"receipt.seed"),permitKey=resolve(root,"permit.seed");await writeFile(mouthAnchor,runtime.encode(requestAnchor(requestIdentity)));await writeFile(permitAnchor,new Uint8Array([1]));await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:receiptKey,trustAnchorOut:resolve(root,"receipt-anchor.msgpack")});await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:permitKey,trustAnchorOut:resolve(root,"permit-request-anchor.msgpack")});const readiness=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:resolve(root,"requests.cc"),receiptStorePath:resolve(root,"receipts.cc"),executionStorePath:resolve(root,"executions.cc"),epiphanyTrustAnchorPath:mouthAnchor,epiphanyRuntimeId:"epiphany-yggdrasil",epiphanyPermitAnchorPath:permitAnchor,epiphanyPermitEndpoint:"rudp://127.0.0.1:9",privateKeyPath:receiptKey,permitRequesterPrivateKeyPath:permitKey});assert.equal(readiness.ready,true);assert.deepEqual(readiness.reasons,[]);}); + test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");}); test("running journal recovers as terminal unknown without a second Discord post",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-crash-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"anchor.msgpack");await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const digest=`sha256-${createHash("sha256").update(runtime.encode(requestSigningTuple(value))).digest("hex")}`,executions=await open(runtime,defs.execution,executionStore);await executions.put(defs.execution,value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:value.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:"crash fixture"});const results=await processPersonaDiscordDeliveries({runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:resolve(root,"must-not-run.mjs"),bifrostRoot:root});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");}); diff --git a/tools/persona-discord-delivery.mjs b/tools/persona-discord-delivery.mjs index e11b669..ef2a0ec 100644 --- a/tools/persona-discord-delivery.mjs +++ b/tools/persona-discord-delivery.mjs @@ -39,12 +39,13 @@ export async function processPersonaDiscordDeliveries(options){ return results; } -export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath,options.privateKeyPath,options.permitRequesterPrivateKeyPath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),permitRequester=await loadDomainIdentity(options.permitRequesterPrivateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await access(options.requestStorePath,constants.R_OK);await access(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,permitRequesterIdentityId:permitRequester.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}} +export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath,options.privateKeyPath,options.permitRequesterPrivateKeyPath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),permitRequester=await loadDomainIdentity(options.permitRequesterPrivateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await accessStoreOrWritableParent(options.requestStorePath,constants.R_OK);await accessStoreOrWritableParent(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,permitRequesterIdentityId:permitRequester.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}} function validateRequest(request,unsigned,anchor,targetAdmitted){if(request.signerIdentityId!==anchor.identityId)throw new Error("request signer is not the pinned Epiphany Persona mouth identity");if(!verifyDomainPurpose(anchor.publicKey,requestSignatureDomain,requestSigningPurpose,unsigned,request.signerSignature))throw new Error("request signature is invalid");if(`sha256-${sha(Buffer.from(request.content,"utf8"))}`!==request.contentSha256)throw new Error("request content digest is invalid");const now=Date.now(),issued=Date.parse(request.issuedAt),expires=Date.parse(request.expiresAt);if(issued>now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");} function post(request,options){const args=[options.bridgeCliPath,"discord-post","--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;} async function terminal(status,request,digest,detail,c){const now=new Date().toISOString(),posted=c.posted??{},receipt={schemaName:"bifrost.persona_discord_delivery_receipt",schemaVersion:receiptSchema,receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status,channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:status==="completed"?posted.messageId:"",transport:status==="completed"?posted.transport:"",crossingReceiptId:status==="completed"?posted.crossingReceiptId:"",receiptUrl:status==="completed"?posted.url:"",completedAt:now,providerIdentityId:c.identity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(c.identity,receiptSignatureDomain,receiptSigningPurpose,c.runtime.encode(receiptSigningTuple(receipt))));await c.receipts.cache.put(c.defs.receipt,receipt.receiptId,receipt);await c.executions.cache.put(c.defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status,recordedAt:now,detail});return receipt;} async function open(runtime,definition,path){await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return {cache};} +async function accessStoreOrWritableParent(path,mode){try{await access(path,mode);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(path),constants.W_OK);}} async function readAnchor(runtime,path,expectedRuntimeId){if(typeof expectedRuntimeId!=="string"||!expectedRuntimeId)throw new Error("Epiphany runtime identity is required for Persona mouth trust");const value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==13)throw new Error("Epiphany Persona mouth trust anchor must be the exact service-contract tuple");const [schemaVersion,trustAnchorId,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,bindingAuthority,boundAt,expiresAt,privateStateExposed]=value,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||typeof trustAnchorId!=="string"||serviceId!=="epiphany-persona-mouth"||runtimeId!==expectedRuntimeId||typeof identityId!=="string"||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==requestSigningPurpose||signedSchema!=="epiphany.persona_discord_delivery_request.v0"||bindingAuthority!=="root"||!Number.isSafeInteger(boundAt)||boundAt<=0||boundAt>now||(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<=boundAt||expiresAt<=now))||privateStateExposed!==false)throw new Error("Epiphany Persona mouth service trust anchor is invalid");return {identityId,publicKey};} function requireSeparate(paths){const keys=paths.map(p=>resolve(p).toLowerCase());if(new Set(keys).size!==keys.length)throw new Error("Persona Discord crossing and private execution stores must be distinct.");} function sha(value){return createHash("sha256").update(value).digest("hex");} From 573bbd3968efcf38ce411c4ac934927fc8bdab11 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 20:55:35 +0100 Subject: [PATCH 026/155] persona: normalize Rust permit byte tuples --- tests/persona-discord-permit.test.mjs | 13 +++++++++++++ tools/persona-discord-permit.mjs | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 tests/persona-discord-permit.test.mjs diff --git a/tests/persona-discord-permit.test.mjs b/tests/persona-discord-permit.test.mjs new file mode 100644 index 0000000..8fe4afa --- /dev/null +++ b/tests/persona-discord-permit.test.mjs @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { enrollDomainIdentity, signDomainPurpose } from "../tools/bifrost-signed-identity.mjs"; +import { permitIdentityDomain, permitPurpose, permitSchema, permitSignatureDomain, permitTuple, parseEpiphanyPermitAnchorTuple, validatePermit } from "../tools/persona-discord-permit.mjs"; + +const requireCultNet=createRequire(resolve(import.meta.dirname,"..","..","CultLib","packages","cultnet-ts","package.json")); +const {encode}=requireCultNet("@msgpack/msgpack"); + +test("Rust-shaped permit anchors and signatures retain byte authority",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-permit-rust-shape-")),identity=await enrollDomainIdentity(resolve(root,"permit.seed"),"permit identity",permitIdentityDomain),now=Date.now(),anchor=parseEpiphanyPermitAnchorTuple(["gamecult.service_trust_anchor.v1","anchor","epiphany-persona-discord-permit","epiphany-starfire",identity.identityId,Array.from(identity.publicKey),"ed25519",permitPurpose,permitSchema,"root",now-1000,null,false],"epiphany-starfire",now),request={requestId:"request-1",requestPayloadSha256:`sha256-${"a".repeat(64)}`,targetRuntimeId:"epiphany-starfire",nonce:"nonce-1",requesterIdentityId:"requester"},permit={schemaVersion:permitSchema,permitId:`permit:${request.requestPayloadSha256}`,requestId:request.requestId,requestPayloadSha256:request.requestPayloadSha256,targetRuntimeId:request.targetRuntimeId,nonce:request.nonce,requesterIdentityId:request.requesterIdentityId,brakeStateDocumentId:"brake",brakeStateDocumentSha256:`sha256-${"b".repeat(64)}`,brakeObservedAt:new Date(now-100).toISOString(),issuedAt:new Date(now).toISOString(),expiresAt:new Date(now+4000).toISOString(),providerIdentityId:identity.identityId,privateStateExposed:false,providerSignature:[]};permit.providerSignature=Array.from(signDomainPurpose(identity,permitSignatureDomain,permitPurpose,encode(permitTuple(permit))));const parsed=validatePermit(permit,request,anchor,encode);assert.ok(parsed.providerSignature instanceof Uint8Array);assert.equal(parsed.providerSignature.length,64);}); diff --git a/tools/persona-discord-permit.mjs b/tools/persona-discord-permit.mjs index defa797..4a7165b 100644 --- a/tools/persona-discord-permit.mjs +++ b/tools/persona-discord-permit.mjs @@ -27,8 +27,9 @@ export async function requestPersonaDiscordPermit({endpoint,requestId,requestPay try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(request),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();} } -export function validatePermit(v,request,anchor,encode){if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expiresissued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),v.providerSignature))throw new Error("Epiphany permit signature is invalid");return v;} -async function readAnchor(runtime,path,expectedRuntimeId){const a=runtime.msgpack.decode(await readFile(path));if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,publicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!==expectedRuntimeId||!(publicKey instanceof Uint8Array)||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};} +export function validatePermit(v,request,anchor,encode){const providerSignature=v?.providerSignature instanceof Uint8Array?v.providerSignature:Array.isArray(v?.providerSignature)?Uint8Array.from(v.providerSignature):null;if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!providerSignature||providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expiresissued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),providerSignature))throw new Error("Epiphany permit signature is invalid");return {...v,providerSignature};} +export function parseEpiphanyPermitAnchorTuple(a,expectedRuntimeId,now=Date.now()){if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null;if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!==expectedRuntimeId||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};} +async function readAnchor(runtime,path,expectedRuntimeId){return parseEpiphanyPermitAnchorTuple(runtime.msgpack.decode(await readFile(path)),expectedRuntimeId);} function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=m.decode(d?.payload);if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p?.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})} async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}} function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})} From 514c2a6cb986f3fdda356ff99319839dc5a513c5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 21:15:57 +0100 Subject: [PATCH 027/155] persona: align permit bodies with Rust wire tuples --- tests/persona-discord-permit.test.mjs | 4 +++- tools/persona-discord-permit.mjs | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/persona-discord-permit.test.mjs b/tests/persona-discord-permit.test.mjs index 8fe4afa..fbba89a 100644 --- a/tests/persona-discord-permit.test.mjs +++ b/tests/persona-discord-permit.test.mjs @@ -5,9 +5,11 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { enrollDomainIdentity, signDomainPurpose } from "../tools/bifrost-signed-identity.mjs"; -import { permitIdentityDomain, permitPurpose, permitSchema, permitSignatureDomain, permitTuple, parseEpiphanyPermitAnchorTuple, validatePermit } from "../tools/persona-discord-permit.mjs"; +import { permitIdentityDomain, permitPurpose, permitRequestSchema, permitRequestWireTuple, permitSchema, permitSignatureDomain, permitTuple, parseEpiphanyPermitAnchorTuple, parsePermitWireTuple, validatePermit } from "../tools/persona-discord-permit.mjs"; const requireCultNet=createRequire(resolve(import.meta.dirname,"..","..","CultLib","packages","cultnet-ts","package.json")); const {encode}=requireCultNet("@msgpack/msgpack"); test("Rust-shaped permit anchors and signatures retain byte authority",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-permit-rust-shape-")),identity=await enrollDomainIdentity(resolve(root,"permit.seed"),"permit identity",permitIdentityDomain),now=Date.now(),anchor=parseEpiphanyPermitAnchorTuple(["gamecult.service_trust_anchor.v1","anchor","epiphany-persona-discord-permit","epiphany-starfire",identity.identityId,Array.from(identity.publicKey),"ed25519",permitPurpose,permitSchema,"root",now-1000,null,false],"epiphany-starfire",now),request={requestId:"request-1",requestPayloadSha256:`sha256-${"a".repeat(64)}`,targetRuntimeId:"epiphany-starfire",nonce:"nonce-1",requesterIdentityId:"requester"},permit={schemaVersion:permitSchema,permitId:`permit:${request.requestPayloadSha256}`,requestId:request.requestId,requestPayloadSha256:request.requestPayloadSha256,targetRuntimeId:request.targetRuntimeId,nonce:request.nonce,requesterIdentityId:request.requesterIdentityId,brakeStateDocumentId:"brake",brakeStateDocumentSha256:`sha256-${"b".repeat(64)}`,brakeObservedAt:new Date(now-100).toISOString(),issuedAt:new Date(now).toISOString(),expiresAt:new Date(now+4000).toISOString(),providerIdentityId:identity.identityId,privateStateExposed:false,providerSignature:[]};permit.providerSignature=Array.from(signDomainPurpose(identity,permitSignatureDomain,permitPurpose,encode(permitTuple(permit))));const parsed=validatePermit(permit,request,anchor,encode);assert.ok(parsed.providerSignature instanceof Uint8Array);assert.equal(parsed.providerSignature.length,64);}); + +test("Bifrost and Rust exchange exact positional permit bodies",()=>{const request={schemaVersion:permitRequestSchema,requestId:"request-1",requestPayloadSha256:`sha256-${"a".repeat(64)}`,targetRuntimeId:"epiphany-starfire",nonce:"nonce-1",issuedAt:"2026-08-08T20:00:00.000Z",expiresAt:"2026-08-08T20:00:05.000Z",requesterIdentityId:"requester",privateStateExposed:false,requesterSignature:Uint8Array.from({length:64},(_,i)=>i)};const requestWire=permitRequestWireTuple(request);assert.equal(requestWire.length,10);assert.deepEqual(requestWire.slice(0,9),[request.schemaVersion,request.requestId,request.requestPayloadSha256,request.targetRuntimeId,request.nonce,request.issuedAt,request.expiresAt,request.requesterIdentityId,false]);assert.deepEqual(requestWire[9],request.requesterSignature);const permitWire=[permitSchema,`permit:${request.requestPayloadSha256}`,request.requestId,request.requestPayloadSha256,request.targetRuntimeId,request.nonce,request.requesterIdentityId,"brake",`sha256-${"b".repeat(64)}`,"2026-08-08T20:00:00.000Z","2026-08-08T20:00:00.000Z","2026-08-08T20:00:04.000Z","provider",false,Array.from({length:64},(_,i)=>63-i)];const permit=parsePermitWireTuple(permitWire);assert.equal(permit.providerIdentityId,"provider");assert.deepEqual(permit.providerSignature,permitWire[14]);}); diff --git a/tools/persona-discord-permit.mjs b/tools/persona-discord-permit.mjs index 4a7165b..9d14e77 100644 --- a/tools/persona-discord-permit.mjs +++ b/tools/persona-discord-permit.mjs @@ -18,19 +18,21 @@ const connectionId=0xe91f0002; export const permitRequestTuple=v=>[v.schemaVersion,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.issuedAt,v.expiresAt,v.requesterIdentityId,v.privateStateExposed]; export const permitTuple=v=>[v.schemaVersion,v.permitId,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.requesterIdentityId,v.brakeStateDocumentId,v.brakeStateDocumentSha256,v.brakeObservedAt,v.issuedAt,v.expiresAt,v.providerIdentityId,v.privateStateExposed]; +export const permitRequestWireTuple=v=>[...permitRequestTuple(v),v.requesterSignature]; +export function parsePermitWireTuple(v){if(!Array.isArray(v)||v.length!==15)throw new Error("Epiphany permit must be an exact Rust wire tuple");const [schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature]=v;return {schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature};} export async function requestPersonaDiscordPermit({endpoint,requestId,requestPayloadSha256,targetRuntimeId,nonce,issuedAt,expiresAt,privateKeyPath,epiphanyPermitAnchorPath,timeoutMs=5000,runtime:provided,socketFactory=createSocket,connectionFactory}){ const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath,targetRuntimeId),now=Date.now(); const request={schemaVersion:permitRequestSchema,requestId,requestPayloadSha256,targetRuntimeId,nonce:nonce??randomBytes(16).toString("hex"),issuedAt:issuedAt??new Date(now).toISOString(),expiresAt:expiresAt??new Date(now+5000).toISOString(),requesterIdentityId:identity.identityId,privateStateExposed:false,requesterSignature:new Uint8Array()}; request.requesterSignature=new Uint8Array(signDomainPurpose(identity,permitRequestSignatureDomain,permitRequestPurpose,runtime.msgpack.encode(permitRequestTuple(request)))); const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");await bind(socket);const connection=(connectionFactory??(o=>new runtime.cultnet.CultNetRudpSocketTransportConnection(o)))({runtimeId:"bifrost-discord-yggdrasil",socket,mode:"client",remoteHost:host,remotePort:port,connectionId,resendDelayMs:50,resendPollMs:10}); - try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(request),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();} + try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(permitRequestWireTuple(request)),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();} } -export function validatePermit(v,request,anchor,encode){const providerSignature=v?.providerSignature instanceof Uint8Array?v.providerSignature:Array.isArray(v?.providerSignature)?Uint8Array.from(v.providerSignature):null;if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!providerSignature||providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expiresissued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),providerSignature))throw new Error("Epiphany permit signature is invalid");return {...v,providerSignature};} +export function validatePermit(value,request,anchor,encode){const v=Array.isArray(value)?parsePermitWireTuple(value):value,providerSignature=v?.providerSignature instanceof Uint8Array?v.providerSignature:Array.isArray(v?.providerSignature)?Uint8Array.from(v.providerSignature):null;if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!providerSignature||providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expiresissued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),providerSignature))throw new Error("Epiphany permit signature is invalid");return {...v,providerSignature};} export function parseEpiphanyPermitAnchorTuple(a,expectedRuntimeId,now=Date.now()){if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null;if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!==expectedRuntimeId||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};} async function readAnchor(runtime,path,expectedRuntimeId){return parseEpiphanyPermitAnchorTuple(runtime.msgpack.decode(await readFile(path)),expectedRuntimeId);} -function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=m.decode(d?.payload);if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p?.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})} +function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=parsePermitWireTuple(m.decode(d?.payload));if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})} async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}} function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})} function parseEndpoint(v){const u=new URL(v);if(u.protocol!=="rudp:"||!u.hostname||!u.port)throw new Error("permit endpoint must be rudp://host:port");return {host:u.hostname,port:Number(u.port)}} From cb3239aaac963995ad012e648f9a455463a54ea2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 21:26:13 +0100 Subject: [PATCH 028/155] persona: receipt Discord bridge commands --- tests/persona-discord-delivery.test.mjs | 2 +- tools/persona-discord-delivery.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/persona-discord-delivery.test.mjs b/tests/persona-discord-delivery.test.mjs index a41fdb9..53e73e8 100644 --- a/tests/persona-discord-delivery.test.mjs +++ b/tests/persona-discord-delivery.test.mjs @@ -22,7 +22,7 @@ test("receipt signer and permit requester are distinct domain identities",async( test("delivery listener admits absent typed stores when their owner directory is writable",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-readiness-")),runtime=loadRuntime(),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),mouthAnchor=resolve(root,"mouth-anchor.msgpack"),permitAnchor=resolve(root,"permit-anchor.msgpack"),receiptKey=resolve(root,"receipt.seed"),permitKey=resolve(root,"permit.seed");await writeFile(mouthAnchor,runtime.encode(requestAnchor(requestIdentity)));await writeFile(permitAnchor,new Uint8Array([1]));await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:receiptKey,trustAnchorOut:resolve(root,"receipt-anchor.msgpack")});await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:permitKey,trustAnchorOut:resolve(root,"permit-request-anchor.msgpack")});const readiness=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:resolve(root,"requests.cc"),receiptStorePath:resolve(root,"receipts.cc"),executionStorePath:resolve(root,"executions.cc"),epiphanyTrustAnchorPath:mouthAnchor,epiphanyRuntimeId:"epiphany-yggdrasil",epiphanyPermitAnchorPath:permitAnchor,epiphanyPermitEndpoint:"rudp://127.0.0.1:9",privateKeyPath:receiptKey,permitRequesterPrivateKeyPath:permitKey});assert.equal(readiness.ready,true);assert.deepEqual(readiness.reasons,[]);}); -test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");}); +test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)},i=process.argv.indexOf('--cultmesh-command-id');if(i<0||process.argv[i+1]!=='request-1')process.exit(2);let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");}); test("running journal recovers as terminal unknown without a second Discord post",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-crash-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"anchor.msgpack");await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const digest=`sha256-${createHash("sha256").update(runtime.encode(requestSigningTuple(value))).digest("hex")}`,executions=await open(runtime,defs.execution,executionStore);await executions.put(defs.execution,value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:value.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:"crash fixture"});const results=await processPersonaDiscordDeliveries({runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:resolve(root,"must-not-run.mjs"),bifrostRoot:root});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");}); diff --git a/tools/persona-discord-delivery.mjs b/tools/persona-discord-delivery.mjs index ef2a0ec..832bf66 100644 --- a/tools/persona-discord-delivery.mjs +++ b/tools/persona-discord-delivery.mjs @@ -42,7 +42,7 @@ export async function processPersonaDiscordDeliveries(options){ export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath,options.privateKeyPath,options.permitRequesterPrivateKeyPath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),permitRequester=await loadDomainIdentity(options.permitRequesterPrivateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await accessStoreOrWritableParent(options.requestStorePath,constants.R_OK);await accessStoreOrWritableParent(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,permitRequesterIdentityId:permitRequester.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}} function validateRequest(request,unsigned,anchor,targetAdmitted){if(request.signerIdentityId!==anchor.identityId)throw new Error("request signer is not the pinned Epiphany Persona mouth identity");if(!verifyDomainPurpose(anchor.publicKey,requestSignatureDomain,requestSigningPurpose,unsigned,request.signerSignature))throw new Error("request signature is invalid");if(`sha256-${sha(Buffer.from(request.content,"utf8"))}`!==request.contentSha256)throw new Error("request content digest is invalid");const now=Date.now(),issued=Date.parse(request.issuedAt),expires=Date.parse(request.expiresAt);if(issued>now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");} -function post(request,options){const args=[options.bridgeCliPath,"discord-post","--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;} +function post(request,options){const args=[options.bridgeCliPath,"discord-post","--cultmesh-command-id",request.requestId,"--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;} async function terminal(status,request,digest,detail,c){const now=new Date().toISOString(),posted=c.posted??{},receipt={schemaName:"bifrost.persona_discord_delivery_receipt",schemaVersion:receiptSchema,receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status,channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:status==="completed"?posted.messageId:"",transport:status==="completed"?posted.transport:"",crossingReceiptId:status==="completed"?posted.crossingReceiptId:"",receiptUrl:status==="completed"?posted.url:"",completedAt:now,providerIdentityId:c.identity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(c.identity,receiptSignatureDomain,receiptSigningPurpose,c.runtime.encode(receiptSigningTuple(receipt))));await c.receipts.cache.put(c.defs.receipt,receipt.receiptId,receipt);await c.executions.cache.put(c.defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status,recordedAt:now,detail});return receipt;} async function open(runtime,definition,path){await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return {cache};} async function accessStoreOrWritableParent(path,mode){try{await access(path,mode);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(path),constants.W_OK);}} From 8b9ab65b3f87d5a74ee4caec6b3a171aeaa002a8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 06:16:39 +0200 Subject: [PATCH 029/155] Delete Epiphany operator bridge --- .env.local.example | 11 --- README.md | 5 +- docs/bridge.md | 6 +- docs/epiphany-operator-boundary.md | 48 --------- docs/verse-service-contract.md | 4 - .../ProviderAdvertisementTests.cs | 4 - tests/epiphany-operator-command.test.mjs | 17 ---- tests/epiphany-operator-cultnet-rudp.test.mjs | 24 ----- .../epiphany-operator-discord-outbox.test.mjs | 15 --- tests/epiphany-operator-ledger.test.mjs | 12 --- tests/epiphany-operator-rust-interop.test.mjs | 14 --- tests/epiphany-operator-status-v2.test.mjs | 28 ------ tests/epiphany-operator-v1.test.mjs | 99 ------------------- tests/epiphany-operator-worker.test.mjs | 22 ----- tools/epiphany-operator-command-documents.mjs | 86 ---------------- tools/epiphany-operator-commands.mjs | 59 ----------- tools/epiphany-operator-cultnet-rudp.mjs | 90 ----------------- tools/epiphany-operator-discord-outbox.mjs | 21 ---- tools/epiphany-operator-ledger.mjs | 20 ---- tools/epiphany-operator-worker.mjs | 88 ----------------- tools/provider-advertisement.mjs | 29 ------ 21 files changed, 3 insertions(+), 699 deletions(-) delete mode 100644 docs/epiphany-operator-boundary.md delete mode 100644 tests/epiphany-operator-command.test.mjs delete mode 100644 tests/epiphany-operator-cultnet-rudp.test.mjs delete mode 100644 tests/epiphany-operator-discord-outbox.test.mjs delete mode 100644 tests/epiphany-operator-ledger.test.mjs delete mode 100644 tests/epiphany-operator-rust-interop.test.mjs delete mode 100644 tests/epiphany-operator-status-v2.test.mjs delete mode 100644 tests/epiphany-operator-v1.test.mjs delete mode 100644 tests/epiphany-operator-worker.test.mjs delete mode 100644 tools/epiphany-operator-command-documents.mjs delete mode 100644 tools/epiphany-operator-commands.mjs delete mode 100644 tools/epiphany-operator-cultnet-rudp.mjs delete mode 100644 tools/epiphany-operator-discord-outbox.mjs delete mode 100644 tools/epiphany-operator-ledger.mjs delete mode 100644 tools/epiphany-operator-worker.mjs diff --git a/.env.local.example b/.env.local.example index 86bdaf8..8a63878 100644 --- a/.env.local.example +++ b/.env.local.example @@ -5,15 +5,4 @@ COMPOSE_PROJECT_NAME=bifrost-local BIFROST_ROOT_VERSE=asgard BIFROST_MACHINE_ID=yggdrasil -# Epiphany operator admission. Endpoint and trust anchor are explicit; there is -# no localhost or identity-string fallback. -BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE=.bifrost/epiphany-operator-requests.cc -BIFROST_EPIPHANY_OPERATOR_LEDGER_STORE=.bifrost/epiphany-operator-ledger.cc -BIFROST_EPIPHANY_OPERATOR_DISCORD_DELIVERY_STORE=.bifrost/epiphany-operator-discord-deliveries.cc -BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY=.bifrost/private/epiphany-operator-ed25519.seed -BIFROST_EPIPHANY_OPERATOR_RUDP=rudp://yggdrasil:49100 -BIFROST_EPIPHANY_EXECUTOR_TRUST_ANCHOR=.bifrost/trust/epiphany-operator-executor.cc -BIFROST_EPIPHANY_OPERATOR_OWNER_DISCORD_ID= -BIFROST_EPIPHANY_OPERATOR_TARGET_RUNTIME_ID=epiphany-yggdrasil BIFROST_PERSONA_FEEDBACK_PRIVATE_STORE=.bifrost/persona-feedback-private.cc -BIFROST_EPIPHANY_OPERATOR_CULTMESH_URI= diff --git a/README.md b/README.md index a69f211..e0f9f0f 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,8 @@ For the full staged roadmap, including contribution points, revenue share, patro - dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true` - print the Eve provider advertisement with `node tools/provider-advertisement.mjs print` - export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc` -- inspect the typed six-command Epiphany operator authority map in - `docs/epiphany-operator-boundary.md`; provider advertisement commands require - explicit `BIFROST_ROOT_VERSE` and `BIFROST_MACHINE_ID` deployment identity +- provider advertisement commands require explicit `BIFROST_ROOT_VERSE` and + `BIFROST_MACHINE_ID` deployment identity - inspect the interactive Motion Verse surface at `/eve/governance/surface` while signed in as an active member - container smoke test with `docker compose -f compose.local.yaml up --build` - container smoke tests may hit `/healthz` and `/readyz` as product probes. diff --git a/docs/bridge.md b/docs/bridge.md index a11c5d4..683d7d2 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -194,7 +194,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to ### Organizational Persona Feedback Ingress -Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an operator-command or actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. +Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. Persona replies cross back through two explicit CultMesh surfaces. Epiphany is the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost @@ -206,10 +206,6 @@ never receives the Discord token or write access to Bifrost private state. happened, so restart recovery must not manufacture a duplicate. Failed and unknown outcomes remain Persona pressure for explicit resolution. -Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v1` document containing one of `status`, `sleep`, `wake`, `directive`, `reviews`, or `review`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the exact persisted guild/actor mapping from its private actor-link store. The actor link is the admitted mapping authority; its Heimdall reference remains provenance until a signed live Heimdall claim surface exists and is not represented as authorization. Bifrost derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v1` with a dedicated operator Ed25519 seed. The feedback seed is not reused. - -Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. One supervised worker serializes requests across the CultNet RUDP boundary and requires the trusted Epiphany executor anchor. Success requires a host-signed `epiphany.operator_command.sealed_result.v1` whose payload digest, command id, packet digest, target runtime, completion time, and executor identity verify exactly; a transport acknowledgement is not success. Admissions and sealed results are persisted in the typed Bifrost operator ledger. Permanent pre-admission refusals are separately terminalized there, while transport failures remain retryable. An expired request cannot mint a new admission, but an exact admission already durably recorded before expiry may be retransmitted to recover Epiphany's one idempotent result without depending on mutable actor-link or signer state. The worker publishes a separate immutable Discord-delivery projection bound to the original request digest and interaction id; it exposes only bounded result/refusal fields and sealed-result fingerprints, never raw signatures or private state. VoidBot reads that outbox and owns Discord response/checkpoint state. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this path. Durable v0 admissions/results are replay-only and cannot acquire the review vocabulary. - 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. diff --git a/docs/epiphany-operator-boundary.md b/docs/epiphany-operator-boundary.md deleted file mode 100644 index 9c18847..0000000 --- a/docs/epiphany-operator-boundary.md +++ /dev/null @@ -1,48 +0,0 @@ -# Epiphany operator boundary - -## Authority map - -- Owner: Bifrost alone authenticates a Discord operator request, resolves the - persisted Discord-to-Bifrost actor link, and signs an expiring Epiphany - admission. Epiphany alone decides and applies the named command. -- Inputs: one strict `voidbot.discord.epiphany_operator_request.v1`, the exact - configured Discord owner, a persisted Bifrost actor link, the deployment's - operator signing identity, and the configured Epiphany runtime/host trust - anchor. -- Outputs: one immutable `bifrost.operator_command.delivery.v1`, one verified - `epiphany.operator_command.sealed_result.v1`, and one bounded - `bifrost.discord.epiphany_operator_delivery.v1` projection. -- Derived state: Discord delivery fields are notification-only projections of - the signed result. Reviews contain at most ten candidate identity/status - summaries. Proposal bodies and private Mind state never cross this boundary. -- Forbidden writers: ordinary Discord conversation, Persona feedback, argv, - shell commands, `/queue-codex`, provider-advertisement metadata, and the - Discord outbox cannot create an Epiphany consequence. -- Shared path: Status, Sleep, Wake, Directive, Reviews, and Review all use the - same request admission, signature, RUDP execution, sealed-result verification, - durable ledger, and Discord projection path. -- Cut line: v0 admissions and sealed results exist only so already-durable - four-command work can drain or replay. Bifrost never mints v0, and v0 cannot - carry Reviews or Review. - -## Review command - -Review binds the exact current Mind candidate with `mindRequestId`, -`candidateId`, bare 64-hex `candidateSha256`, `expectedModelRevision`, bare -64-hex `expectedModelHash`, and one decision: `adopt`, `refuse`, or `hold`. -Bifrost does not infer a decision from conversation and cannot substitute a -candidate after signing. - -## Deployment identity - -Provider discovery reads `BIFROST_ROOT_VERSE` and `BIFROST_MACHINE_ID` from the -deployment environment. The canonical Yggdrasil example config supplies -`asgard` and `yggdrasil`; the provider code has no Starfire or machine fallback. - -## Verification layer - -The Rust-generated fixture owns cross-runtime schema versions, signing -purposes, command vocabulary, bounded review projection, admission bytes, and -sealed-result bytes. Bifrost tests consume that fixture, then run hostile -schema, identity, digest, binding, replay, and tamper cases against the same -ports used by the worker. diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index a8a504a..6e7d16e 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -55,10 +55,6 @@ Required witness path for the hosted service: Bifrost publishes under these CultMesh namespaces: -The operator bridge additionally advertises `commands/epiphany-operator` and `receipts/epiphany-operator`. The command endpoint consumes strict VoidBot inbox documents and emits dedicated Bifrost-signed admissions. Bifrost sends each admission as a typed `cultnet.document_put_raw.v0` document over reliable ordered UDP and waits for the correlated application receipt; transport acknowledgement alone is never success. The receipt endpoint exposes only `epiphany.operator_command.sealed_result.v0`: an exact, bounded result whose payload digest and command, packet, runtime, completion time, and executor identity are covered by the trusted Epiphany host's Ed25519 signature. Missing, late, malformed, foreign-key, replay-colliding, or incorrectly bound results fail closed. - -One supervised `tools/epiphany-operator-worker.mjs serve` process owns this route. It reads the dedicated VoidBot request inbox and Bifrost's persisted actor-link mapping, serializes commands through one RUDP client authority, and persists admissions, sealed results, and non-retryable terminal refusals in `.bifrost/epiphany-operator-ledger.cc`. It then publishes one immutable `bifrost.discord.epiphany_operator_delivery.v0` document per request to `.bifrost/epiphany-operator-discord-deliveries.cc`; Bifrost owns publication and VoidBot owns correlation plus the ephemeral Discord response. The delivery binds the original request digest and interaction coordinates to either the verified sealed-result payload/signature/provider digests or a terminal refusal code, and never contains raw signature material or private state. The exact configured Discord owner plus that persisted actor mapping are the current admission authority; any Heimdall reference on the link is provenance, not a substituted capability claim. The worker requires an explicit raw MessagePack Epiphany executor trust anchor and an explicit `rudp://` endpoint. There is no shell, argv, direct Epiphany-store, localhost, identity-string, or transport-ack fallback. - - `gamecult.bifrost.service` for service registration, health, build/version, schema catalog, and command capability discovery. - `gamecult.bifrost.governance` for motions, topics, comments, votes, diff --git a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs index 0188028..3962ea6 100644 --- a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs +++ b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs @@ -22,8 +22,6 @@ public sealed class ProviderAdvertisementTests AssertEndpoint(root, "crossing-receipts", "bifrost.crossing_receipt.v1", "bridge"); AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage"); AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github"); - AssertEndpoint(root, "epiphany-operator-requests", "voidbot.discord.epiphany_operator_request.v1", "operator"); - AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.sealed_result.v1", "epiphany"); Assert.Equal("asgard.yggdrasil.bifrost", root.GetProperty("locatedService").GetString()); AssertSchemaPurpose(root, "bifrost.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt"); AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost"); @@ -36,8 +34,6 @@ public sealed class ProviderAdvertisementTests AssertBoundaryForbiddenAuthority(root, "bridge", "surface-specific receipts cannot decide crossing completion"); AssertBoundaryCommand(root, "patron", "consume Heimdall-signed Patreon and PayPal support facts"); AssertBoundaryForbiddenAuthority(root, "patron", "does not store Patreon or PayPal provider tokens"); - AssertBoundaryCommand(root, "epiphany-operator", "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity"); - AssertBoundaryForbiddenAuthority(root, "epiphany-operator", "does not synthesize successful Epiphany results"); } [Fact] diff --git a/tests/epiphany-operator-command.test.mjs b/tests/epiphany-operator-command.test.mjs deleted file mode 100644 index 109aef4..0000000 --- a/tests/epiphany-operator-command.test.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import test from "node:test";import assert from "node:assert/strict";import {mkdtemp} from "node:fs/promises";import {tmpdir} from "node:os";import {resolve} from "node:path";import {createRequire} from "node:module"; -import {admitOperatorRequest,enrollOperatorIdentity,OPERATOR_SIGNING_PURPOSE} from "../tools/epiphany-operator-commands.mjs"; -import {admissionTuple,packetTuple,parseOperatorRequest} from "../tools/epiphany-operator-command-documents.mjs"; -import {verifyPurpose} from "../tools/bifrost-signed-identity.mjs"; -const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); -const actorLink={guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1"}; -function request(command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v1",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} -function result(admission){const value={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result:value,resultPayloadSha256:"sha256-result",commandId:value.commandId,packetSha256:value.packetSha256,targetRuntimeId:value.targetRuntimeId,completedAt:value.completedAt,providerIdentityId:"epiphany-host",privateStateExposed:false,executorSignature:new Uint8Array(64)};return {providerIdentityId:"epiphany-host",result:value,receipt};} -const transport=(execute)=>({trustedIdentityId:"epiphany-host",execute,verifyReceipt:(_admission,receipt)=>receipt}); -class TestLedger{constructor(){this.requests=new Map();this.admissions=new Map();this.completions=new Map();this.terminals=new Map();}admit(id,request,value){const oldRequest=this.requests.get(id),old=this.admissions.get(id);if(oldRequest&&JSON.stringify(oldRequest)!==JSON.stringify(request)||old&&JSON.stringify(old)!==JSON.stringify(value))throw new Error("operator request identity collision");this.requests.set(id,request);this.admissions.set(id,value);}complete(_id,result,_provider,receipt){this.completions.set(result.commandId,receipt??{...result,result,providerIdentityId:"epiphany-host"});}getRequest(id){return this.requests.get(id);}getAdmission(id){return this.admissions.get(id);}getCompletion(id){return this.completions.get(id);}terminalize(value){this.terminals.set(value.requestId,value);}getTerminal(id){return this.terminals.get(id);}} -async function identity(){const d=await mkdtemp(resolve(tmpdir(),"bifrost-operator-"));return enrollOperatorIdentity(resolve(d,"operator.seed"));} -test("signs the exact Rust packet and admission tuples",async()=>{const id=await identity(),out=await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a))});assert.equal(out.admission.packetSha256,`sha256-${(await import("node:crypto")).createHash("sha256").update(encode(packetTuple(out.admission.packet))).digest("hex")}`);assert.ok(verifyPurpose(id.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(out.admission)),out.admission.providerSignature));assert.equal(out.result.disposition,"observed");}); -test("requires exact owner and admitted Bifrost actor link",async()=>{const id=await identity();for(const args of [{ownerDiscordId:"other",actorLink},{ownerDiscordId:"owner",actorLink:null}])await assert.rejects(()=>admitOperatorRequest({request:request(),identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a)),...args}));const provenanceOnly={...actorLink,heimdallActorRef:""};assert.equal((await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink:provenanceOnly,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a))})).result.disposition,"observed");}); -test("strict request, ttl, command bounds, and expiry fail closed",()=>{assert.throws(()=>parseOperatorRequest({...request(),schemaVersion:"voidbot.discord.epiphany_operator_request.v0"}));assert.throws(()=>parseOperatorRequest({...request(),extra:true}));assert.throws(()=>parseOperatorRequest({...request(),expiresAt:"2026-07-19T10:01:01Z"}));assert.throws(()=>parseOperatorRequest(request({kind:"argv",args:["whoami"]})));assert.throws(()=>parseOperatorRequest(request({kind:"sleep",reason:""})));}); -test("does not synthesize success without an authenticated transport result",async()=>{const id=await identity(),base={request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger()};await assert.rejects(()=>admitOperatorRequest(base),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:transport(async a=>({...result(a),providerIdentityId:"impostor"}))}),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:transport(async a=>({...result(a),result:{...result(a).result,packetSha256:"sha256-wrong"}}))}),/not bound/);}); -test("retransmits an exact durable admission after expiry without mutable actor or signer state",async()=>{const id=await identity(),ledger=new TestLedger(),r=request(),base={request:r,ownerDiscordId:"owner",actorLink,identity:id,encode,ledger};await assert.rejects(()=>admitOperatorRequest({...base,now:new Date("2026-07-19T10:00:01Z"),transport:transport(async()=>{throw new Error("lost result")})}),/lost result/);let calls=0;const recovered=await admitOperatorRequest({...base,ownerDiscordId:"rotated-owner",actorLink:null,identity:null,now:new Date("2026-07-19T10:02:00Z"),transport:transport(async a=>{calls++;return result(a)})});assert.equal(calls,1);assert.equal(recovered.recovered,true);const fresh={...r,requestId:"interaction-2",commandId:"interaction-2",nonce:"interaction-2",sourceEventId:"interaction-2",discordMessageId:"interaction-2"};await assert.rejects(()=>admitOperatorRequest({...base,request:fresh,ledger:new TestLedger(),now:new Date("2026-07-19T10:02:00Z"),transport:transport(async a=>result(a))}),error=>error.code==="expired-new-request");}); -test("does not hide command-id collisions behind an existing completion",async()=>{const id=await identity(),ledger=new TestLedger(),base={ownerDiscordId:"owner",actorLink,identity:id,encode,ledger,now:new Date("2026-07-19T10:00:01Z"),transport:transport(async a=>result(a))};await admitOperatorRequest({...base,request:request()});const collision={...request(),requestId:"interaction-alien",nonce:"alien",sourceEventId:"alien",discordMessageId:"alien"};await assert.rejects(()=>admitOperatorRequest({...base,request:collision}),error=>error.code==="command-id-collision");}); diff --git a/tests/epiphany-operator-cultnet-rudp.test.mjs b/tests/epiphany-operator-cultnet-rudp.test.mjs deleted file mode 100644 index ce01608..0000000 --- a/tests/epiphany-operator-cultnet-rudp.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { EventEmitter } from "node:events"; -import { createSocket } from "node:dgram"; -import { createRequire } from "node:module"; -import { resolve } from "node:path"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; -import { resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; -import { LEGACY_EPIPHANY_RESULT_SIGNING_PURPOSE as EPIPHANY_RESULT_SIGNING_PURPOSE,EpiphanyOperatorCultNetRudpTransport,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; -import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; - -const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")); -const msgpack=requireCult("@msgpack/msgpack"),cultnet=requireCult("cultnet-ts"); -function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} -async function fixture(){const root=await mkdtemp(resolve(tmpdir(),"epiphany-result-")),identity=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(identity,"2026-07-19T11:00:00Z"),a=admission();const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:a.packet.commandId,packetSha256:a.packetSha256,targetRuntimeId:a.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};const receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:identity.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));return {a,identity,anchor,receipt};} - -test("verifies the exact host-sealed Epiphany receipt",async()=>{const {a,anchor,receipt}=await fixture();assert.equal(verifySealedReceipt({receipt,admission:a,trustedIdentity:anchor,encode:msgpack.encode}),receipt);}); -test("refuses wrong key, signature, digest, binding, predating completion, and schema",async()=>{const {a,anchor,receipt}=await fixture(),other=await fixture();const cases=[{...receipt,providerIdentityId:other.identity.identityId},{...receipt,executorSignature:new Uint8Array(64)},{...receipt,resultPayloadSha256:"sha256-wrong"},{...receipt,packetSha256:"sha256-wrong"},{...receipt,completedAt:"2026-07-19T11:59:59Z",result:{...receipt.result,completedAt:"2026-07-19T11:59:59Z"}},{...receipt,schemaVersion:"epiphany.operator_command.sealed_result.v9"}];for(const value of cases)assert.throws(()=>verifySealedReceipt({receipt:value,admission:a,trustedIdentity:anchor,encode:msgpack.encode}));}); -test("accepts a correctly signed post-expiry receipt for durable exact recovery",async()=>{const {a,anchor,identity,receipt}=await fixture(),completedAt="2026-07-19T12:02:00Z",result={...receipt.result,completedAt},recovered={...receipt,result,completedAt,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,executorSignature:new Uint8Array()};recovered.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(recovered))));assert.equal(verifySealedReceipt({receipt:recovered,admission:a,trustedIdentity:anchor,encode:msgpack.encode}),recovered);}); -test("RUDP adapter requires a correlated typed application result and times out otherwise",async()=>{const {a,anchor,receipt}=await fixture();class FakeConnection extends EventEmitter{connected=false;connect(){this.connected=true;}send(){if(this.reply)setImmediate(()=>this.emit("frame",this.reply));}close(){this.closed=true;}}const fakeSocket=()=>{const s=new EventEmitter();s.bind=()=>setImmediate(()=>s.emit("listening"));return s;};const make=(reply,timeoutMs=100)=>{const connection=new FakeConnection();connection.reply=reply;return {connection,transport:new EpiphanyOperatorCultNetRudpTransport({endpoint:"rudp://127.0.0.1:49100",trustedEpiphanyIdentity:anchor,timeoutMs,cultnetRuntime:cultnet,msgpackRuntime:msgpack,socketFactory:fakeSocket,connectionFactory:()=>connection})};};const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:"operator-result-command-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:a.packet.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:a.packet.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}};const ok=make({channelId:"schema",payload:msgpack.encode(message)});assert.equal((await ok.transport.execute(a)).result.resultId,"result-1");const absent=make(undefined);await assert.rejects(()=>absent.transport.execute(a),/Timed out waiting for the correlated/);const wrong=make({channelId:"schema",payload:msgpack.encode({...message,document:{...message.document,recordKey:"alien"}})});await assert.rejects(()=>wrong.transport.execute(a),/substituted result authority/);}); -test("real CultNet RUDP socket path waits for the sealed application document",async()=>{const {a,anchor,receipt}=await fixture(),socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:a.packet.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const request=msgpack.decode(frame.payload);assert.equal(request.document.schemaId,"bifrost.operator_command.delivery");const response={schemaVersion:"cultnet.document_put_raw.v0",messageId:"operator-result-command-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:a.packet.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:a.packet.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}};server.send("schema",msgpack.encode(cultnet.encodeCultNetMessageForWire(response,"cultnet.schema.v0")));});try{const port=socket.address().port,transport=new EpiphanyOperatorCultNetRudpTransport({endpoint:`rudp://127.0.0.1:${port}`,trustedEpiphanyIdentity:anchor,timeoutMs:1000,cultnetRuntime:cultnet,msgpackRuntime:msgpack});const output=await transport.execute(a);assert.equal(output.receipt.providerIdentityId,anchor.identityId);assert.equal(output.result.commandId,a.packet.commandId);}finally{server.close();}}); diff --git a/tests/epiphany-operator-discord-outbox.test.mjs b/tests/epiphany-operator-discord-outbox.test.mjs deleted file mode 100644 index 11b6202..0000000 --- a/tests/epiphany-operator-discord-outbox.test.mjs +++ /dev/null @@ -1,15 +0,0 @@ - - - -import test from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; - -const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCache=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),requireNet=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),{encode}=requireNet("@msgpack/msgpack"); -function request(id="request-1",command={kind:"status"}){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v1",requestId:id,commandId:id,nonce:id,sourceEventId:id,sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:id,targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} -function receipt(id="request-1"){return {schemaVersion:"epiphany.operator_command.sealed_result.v1",result:{disposition:"observed",detail:"bounded",operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",reviews:[],reviewCandidateId:"candidate-1",reviewDecision:"hold"},resultPayloadSha256:`sha256-${"1".repeat(64)}`,providerIdentityId:"executor",executorSignature:new Uint8Array(64),completedAt:"2026-07-19T12:00:01Z",commandId:id};} -test("Discord outbox publishes immutable request-bound completed and refused deliveries",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-outbox-")),path=resolve(root,"deliveries.cc"),outbox=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode}),r=request("request-1",{kind:"review",mindRequestId:"mind-1",candidateId:"candidate-1",candidateSha256:"1".repeat(64),expectedModelRevision:41,expectedModelHash:"2".repeat(64),decision:"hold"}),completed=await outbox.publishCompleted(r,receipt());assert.equal(completed.deliveryId,r.requestId);assert.match(completed.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.match(completed.executorSignatureSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(completed.operatorStatus,"sleeping");assert.equal(completed.brakeStatus,"engaged");assert.equal(completed.schemaVersion,"bifrost.discord.epiphany_operator_delivery.v1");assert.equal(completed.commandId,r.commandId);assert.deepEqual(completed.reviews,[]);assert.equal(completed.reviewCandidateId,"candidate-1");assert.equal(completed.reviewDecision,"hold");assert.equal(outbox.isExactDelivery(r),true);assert.equal(outbox.isExactDelivery({...r,discordChannelId:"alien"}),false);assert.equal(completed.privateStateExposed,false);await outbox.publishCompleted(r,receipt());await assert.rejects(()=>outbox.publishCompleted({...r,command:{...r.command,candidateId:"alien"}},receipt()),/does not bind/);await assert.rejects(()=>outbox.publishCompleted(r,{...receipt(),result:{...receipt().result,disposition:"applied",detail:"changed"}}),/collision/);const refusedRequest=request("request-2"),refused=await outbox.publishRefused(refusedRequest,{failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:02Z"});assert.equal(refused.status,"refused");assert.equal(refused.failureCode,"foreign-owner");assert.equal(refused.sealedResultPayloadSha256,"");assert.deepEqual(refused.reviews,[]);assert.equal(refused.reviewCandidateId,"");assert.equal(refused.reviewDecision,"");const reopened=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode});assert.equal(reopened.get(r.requestId).status,"completed");assert.equal(reopened.get(refusedRequest.requestId).status,"refused");}); diff --git a/tests/epiphany-operator-ledger.test.mjs b/tests/epiphany-operator-ledger.test.mjs deleted file mode 100644 index be7b739..0000000 --- a/tests/epiphany-operator-ledger.test.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; - -const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),runtime=requireCult("cultcache-ts"); -function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} -function receipt(){const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:"command-1",packetSha256:"sha256-packet",targetRuntimeId:"epiphany-yggdrasil",disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};return {schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:"sha256-result",commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:"epiphany",privateStateExposed:false,executorSignature:new Uint8Array(64)};} -test("durable operator ledger survives restart and refuses admission/result collisions",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-ledger-")),path=resolve(root,"ledger.cc"),first=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime}),a=admission(),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v1",requestId:a.admissionId,commandId:a.packet.commandId,nonce:a.packet.nonce,sourceEventId:a.packet.sourceEventId,sourceActorDiscordId:"owner",discordGuildId:a.packet.discordGuildId,discordChannelId:a.packet.discordChannelId,discordMessageId:a.packet.discordMessageId,targetRuntimeId:a.packet.targetRuntimeId,issuedAt:a.packet.issuedAt,expiresAt:a.packet.expiresAt,producerId:"voidbot",producerRuntimeId:a.sourceObserverRuntimeId,authorityClass:"operator_request_only",status:"pending",command:a.packet.command},r=receipt(),terminal={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:"rejected-1",commandId:"rejected-1",status:"refused",failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:00Z",retryable:false,privateStateExposed:false};await first.admit(a.admissionId,request,a);await first.complete(a.admissionId,r.result,r.providerIdentityId,r);await first.terminalize(terminal);const reopened=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime});assert.equal(reopened.getRequest(a.admissionId).sourceActorDiscordId,"owner");assert.equal(reopened.getAdmission(a.admissionId).packetSha256,a.packetSha256);assert.equal(reopened.getCompletion(r.commandId).result.resultId,r.result.resultId);assert.equal(reopened.getTerminal(terminal.requestId).failureCode,"foreign-owner");await reopened.admit(a.admissionId,request,a);await reopened.complete(a.admissionId,r.result,r.providerIdentityId,r);await reopened.terminalize(terminal);await assert.rejects(()=>reopened.admit(a.admissionId,{...request,sourceActorDiscordId:"alien"},a),/collision/);await assert.rejects(()=>reopened.admit(a.admissionId,request,{...a,packetSha256:"sha256-alien"}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,detail:"alien"},r.providerIdentityId,{...r,result:{...r.result,detail:"alien"}}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,packetSha256:"sha256-alien"},r.providerIdentityId,{...r,packetSha256:"sha256-alien",result:{...r.result,packetSha256:"sha256-alien"}}),/exact durable admission/);await assert.rejects(()=>reopened.terminalize({...terminal,failureCode:"alien"}),/collision/);}); diff --git a/tests/epiphany-operator-rust-interop.test.mjs b/tests/epiphany-operator-rust-interop.test.mjs deleted file mode 100644 index e8f0a19..0000000 --- a/tests/epiphany-operator-rust-interop.test.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { admissionTuple,parseOperatorAdmission } from "../tools/epiphany-operator-command-documents.mjs"; -import { OPERATOR_SIGNING_PURPOSE } from "../tools/epiphany-operator-commands.mjs"; -import { loadEpiphanyOperatorTrustAnchor,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; -import { verifyPurpose } from "../tools/bifrost-signed-identity.mjs"; - -const fixture=process.env.EPIPHANY_OPERATOR_RUST_FIXTURE??resolve("..","EpiphanyAgent",".epiphany-smoke","operator-command-interop-rust"),cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCult=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),{decode,encode}=requireCult("@msgpack/msgpack"); -test("consumes and verifies the Rust-emitted operator admission, protocol, and sealed receipt",{skip:!existsSync(resolve(fixture,"manifest.json"))&&"Rust interop fixture is not present in this standalone checkout"},async()=>{const manifest=JSON.parse(await readFile(resolve(fixture,"manifest.json"),"utf8")),protocolBytes=await readFile(resolve(fixture,manifest.protocolFile)),protocol=JSON.parse(protocolBytes),admissionBytes=await readFile(resolve(fixture,manifest.admissionFile)),receiptBytes=await readFile(resolve(fixture,manifest.sealedResultFile));assert.equal(`sha256-${createHash("sha256").update(admissionBytes).digest("hex")}`,manifest.admissionSha256);assert.equal(`sha256-${createHash("sha256").update(receiptBytes).digest("hex")}`,manifest.sealedResultSha256);assert.equal(`sha256-${createHash("sha256").update(protocolBytes).digest("hex")}`,manifest.protocolSha256);assert.equal(protocol.admissionSchemaVersion,"bifrost.operator_command.delivery.v1");assert.equal(protocol.resultSchemaVersion,"epiphany.operator_command.result.v1");assert.equal(protocol.sealedResultSchemaVersion,"epiphany.operator_command.sealed_result.v1");assert.deepEqual(protocol.commands.map(command=>command.kind),["status","sleep","wake","directive","reviews","review"]);assert.deepEqual(protocol.reviewDecisions,["adopt","refuse","hold"]);assert.equal(protocol.boundedReviewResult.reviews.length,1);assert.equal(protocol.boundedReviewResult.privateStateExposed,false);const admission=parseOperatorAdmission(decode(admissionBytes)),receipt=decode(receiptBytes),bifrostAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.bifrostRawTrustAnchorFile),{decode}),executorAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.executorRawTrustAnchorFile),{decode});assert.equal(verifyPurpose(bifrostAnchor.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(admission)),admission.providerSignature),true);assert.equal(verifySealedReceipt({receipt,admission,trustedIdentity:executorAnchor,encode}).commandId,admission.packet.commandId);assert.throws(()=>verifySealedReceipt({receipt:{...receipt,packetSha256:"sha256-hostile"},admission,trustedIdentity:executorAnchor,encode}),/not bound/);assert.equal(manifest.rudpConnectionId,0xe91f0001);assert.equal(manifest.privateStateExposed,false);}); diff --git a/tests/epiphany-operator-status-v2.test.mjs b/tests/epiphany-operator-status-v2.test.mjs deleted file mode 100644 index 7da0cac..0000000 --- a/tests/epiphany-operator-status-v2.test.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { readFile,mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import { createRequire } from "node:module"; -import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; -import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; -import { EPIPHANY_RESULT_SIGNING_PURPOSE,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; -import { parseOperatorResult,parseOperatorStatusV2,resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; - -const requireCult=createRequire(resolve("..","CultLib","packages","cultnet-ts","package.json")); -const msgpack=requireCult("@msgpack/msgpack"); -const fixtureRoot=process.env.EPIPHANY_OPERATOR_RUST_FIXTURE??resolve("..","EpiphanyAgent",".epiphany-smoke","operator-command-interop-rust"); -async function golden(){const fixture=JSON.parse(await readFile(resolve(fixtureRoot,"protocol.json"),"utf8")).operatorStatusV2MigrationFixture;if(fixture)return fixture;const unavailable=(daemonId,healthContract)=>({daemonId,healthContract,availability:"unavailable",unavailableReason:"required-provider-record-missing",state:null,reasonCode:null,providerObservedAtUnixMillis:null,evaluatedAtUnixMillis:null,expiresAtUnixMillis:null,releaseId:null,releaseWitnessSha256:null,sourceCommit:null,deploymentId:null,projectionSha256:null});return {schemaVersion:"epiphany.operator.status.v2",releaseId:"release-fixture",releaseWitnessSha256:`sha256-${"a".repeat(64)}`,sourceCommit:"b".repeat(40),deploymentId:"deployment-fixture",coordinatorSnapshot:"coordinator-snapshot/sleeping",coordinatorState:"coordinator-state/ready",coordinatorAction:"coordinator-action/none",brakeStatus:"engaged",residentStatus:"resident-self/idle",pressureCount:0,pendingReviewCount:0,providerSetStatus:"incomplete",providers:[unavailable("yggdrasil-epiphany","epiphany.cultnet-rudp-runtime-health"),unavailable("yggdrasil-bifrost-persona-feedback","bifrost.cultnet-rudp-persona-feedback-health")],privateStateExposed:false};} -function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v1",admissionId:"admission-status",packet:{commandId:"command-status",nonce:"nonce",sourceEventId:"event",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:`sha256-${"1".repeat(64)}`,sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} -async function sealed(statusV2){ - const a=admission(),root=await mkdtemp(resolve(tmpdir(),"bifrost-status-v2-")),identity=await enrollOperatorIdentity(resolve(root,"executor.seed")); - const result={schemaVersion:"epiphany.operator_command.status_result.v2",resultId:"result-status",commandId:a.packet.commandId,packetSha256:a.packetSha256,targetRuntimeId:a.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"none",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:"",statusV2}; - const receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:identity.identityId,privateStateExposed:false,executorSignature:new Uint8Array()}; - receipt.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));return {a,identity,receipt}; -} - -test("exactly parses the Rust Status-v2 golden and preserves partial providers",async()=>{const value=await golden();assert.deepEqual(parseOperatorStatusV2(structuredClone(value)),value);const partial=structuredClone(value),p=partial.providers[0];Object.assign(p,{availability:"authenticated-current",unavailableReason:"",state:"healthy",reasonCode:"ok",providerObservedAtUnixMillis:1,evaluatedAtUnixMillis:2,expiresAtUnixMillis:3,releaseId:value.releaseId,releaseWitnessSha256:value.releaseWitnessSha256,sourceCommit:value.sourceCommit,deploymentId:value.deploymentId,projectionSha256:`sha256-${"3".repeat(64)}`});assert.equal(parseOperatorStatusV2(partial).providerSetStatus,"incomplete");}); -test("rejects extra, missing, private, overlong, reordered, and false-complete Status-v2 shapes",async()=>{const value=await golden(),cases=[{...value,privateKey:"x"},(()=>{const x=structuredClone(value);delete x.residentStatus;return x;})(),{...value,privateStateExposed:true},{...value,residentStatus:"x".repeat(257)},{...value,providers:[...value.providers].reverse()},{...value,providerSetStatus:"complete-authenticated"}];for(const hostile of cases)assert.throws(()=>parseOperatorStatusV2(hostile));}); -test("verifies the signed Status-v2 receipt and rejects nested mutation",async()=>{const built=await sealed(await golden()),trusted=trustAnchor(built.identity,"2026-07-19T11:00:00Z");assert.equal(verifySealedReceipt({receipt:built.receipt,admission:built.a,trustedIdentity:trusted,encode:msgpack.encode}),built.receipt);const mutated=structuredClone(built.receipt);mutated.result.statusV2.pressureCount++;assert.throws(()=>verifySealedReceipt({receipt:mutated,admission:built.a,trustedIdentity:trusted,encode:msgpack.encode}),/digest|signature/);}); -test("non-Status v1 remains exact and cannot smuggle statusV2",()=>{const result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"r",commandId:"c",packetSha256:"p",targetRuntimeId:"t",disposition:"observed",consequenceKind:"snapshot",consequenceRef:"",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"",stateStatus:"",coordinatorAction:"",brakeStatus:"",detail:"",reviews:[],reviewCandidateId:"",reviewDecision:"",statusV2:{}};assert.throws(()=>parseOperatorResult(result),/unknown or missing/);}); diff --git a/tests/epiphany-operator-v1.test.mjs b/tests/epiphany-operator-v1.test.mjs deleted file mode 100644 index f803c24..0000000 --- a/tests/epiphany-operator-v1.test.mjs +++ /dev/null @@ -1,99 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - commandTupleForInterop, - parseOperatorAdmission, - parseOperatorRequest, - parseOperatorResult, - wireResultTuple, -} from "../tools/epiphany-operator-command-documents.mjs"; - -const hex = "a".repeat(64); -const review = { - kind: "review", - mindRequestId: "mind-1", - candidateId: "candidate-1", - candidateSha256: hex, - expectedModelRevision: 41, - expectedModelHash: "b".repeat(64), - decision: "adopt", -}; - -function request(command) { - return { - schemaName: "voidbot.discord.epiphany_operator_request", - schemaVersion: "voidbot.discord.epiphany_operator_request.v1", - requestId: "request-1", - commandId: "command-1", - nonce: "nonce-1", - sourceEventId: "event-1", - sourceActorDiscordId: "owner", - discordGuildId: "guild", - discordChannelId: "ops", - discordMessageId: "message-1", - targetRuntimeId: "epiphany-yggdrasil", - issuedAt: "2026-07-19T12:00:00Z", - expiresAt: "2026-07-19T12:01:00Z", - producerId: "voidbot", - producerRuntimeId: "voidbot-yggdrasil", - authorityClass: "operator_request_only", - status: "pending", - command, - }; -} - -test("six-command v1 accepts bounded Reviews and exact Review identity only", () => { - assert.equal(parseOperatorRequest(request({ kind: "reviews" })).command.kind, "reviews"); - assert.deepEqual(commandTupleForInterop(parseOperatorRequest(request(review)).command), [ - "review", - ["mind-1", "candidate-1", hex, 41, "b".repeat(64), "adopt"], - ]); - for (const hostile of [ - { ...review, candidateSha256: `sha256-${hex}` }, - { ...review, decision: "approve" }, - { ...review, expectedModelRevision: -1 }, - { ...review, proposalText: "private" }, - ]) assert.throws(() => parseOperatorRequest(request(hostile))); -}); - -test("v0 admission is drain-only and cannot acquire review authority", () => { - const packet = { - commandId: "command-1", nonce: "nonce-1", sourceEventId: "event-1", - sourceActorId: "actor", discordGuildId: "guild", discordChannelId: "ops", - discordMessageId: "message-1", targetRuntimeId: "epiphany-yggdrasil", - issuedAt: "2026-07-19T12:00:00Z", expiresAt: "2026-07-19T12:01:00Z", - command: { kind: "reviews" }, - }; - const admission = { - schemaName: "bifrost.operator_command.delivery", - schemaVersion: "bifrost.operator_command.delivery.v0", - admissionId: "request-1", packet, packetSha256: `sha256-${hex}`, - sourceObserverId: "voidbot", sourceObserverRuntimeId: "voidbot-yggdrasil", - provider: "bifrost", bifrostAdmissionReceiptId: "request-1", - authority: "exact_operator_command_only", providerIdentityId: "bifrost", - providerSignature: new Uint8Array(64), - }; - assert.throws(() => parseOperatorAdmission(admission, { allowLegacy: true }), /cannot carry Mind review/); - assert.throws(() => parseOperatorAdmission(admission), /current admission schema/); -}); - -test("v1 result exposes only bounded review identities and status", () => { - const result = { - schemaVersion: "epiphany.operator_command.result.v1", resultId: "result-1", - commandId: "command-1", packetSha256: `sha256-${hex}`, - targetRuntimeId: "epiphany-yggdrasil", disposition: "observed", - consequenceKind: "mind-review-candidates", consequenceRef: "", - completedAt: "2026-07-19T12:00:01Z", privateStateExposed: false, - operatorStatus: "", stateStatus: "", coordinatorAction: "", brakeStatus: "", - detail: "1 pending Mind review candidate(s)", - reviews: [{ mindRequestId: "mind-1", candidateId: "candidate-1", - candidateSha256: hex, modelRevision: 41, - modelHash: "b".repeat(64), frontierItemId: "frontier-1", - requestedAt: "2026-07-19T11:59:00Z" }], - reviewCandidateId: "", reviewDecision: "", - }; - assert.equal(wireResultTuple(parseOperatorResult(result)).length, 18); - assert.throws(() => parseOperatorResult({ ...result, reviews: [...result.reviews, ...Array(10).fill(result.reviews[0])] }), /bounded/); - assert.throws(() => parseOperatorResult({ ...result, proposalText: "private" })); -}); diff --git a/tests/epiphany-operator-worker.test.mjs b/tests/epiphany-operator-worker.test.mjs deleted file mode 100644 index 8ed7a79..0000000 --- a/tests/epiphany-operator-worker.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ - -import test from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { createSocket } from "node:dgram"; -import { spawn } from "node:child_process"; -import { createRequire } from "node:module"; -import { mkdtemp,readFile,stat,writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import { buildOperatorDefinitions,resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; -import { buildFeedbackDefinitions } from "../tools/persona-feedback-documents.mjs"; -import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; -import { EPIPHANY_RESULT_SIGNING_PURPOSE } from "../tools/epiphany-operator-cultnet-rudp.mjs"; -import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; -import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; -import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; - -const cultLibRoot=process.env.VOIDBOT_CULTLIB_ROOT||"E:/Projects/CultLib",requireCache=createRequire(resolve(cultLibRoot,"packages/cultcache-ts/package.json")),requireNet=createRequire(resolve(cultLibRoot,"packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),cultnet=requireNet("cultnet-ts"),msgpack=requireNet("@msgpack/msgpack"); -test("operator identity enrollment emits only a compact public trust anchor",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-enroll-")),key=resolve(root,"private","operator.seed"),anchor=resolve(root,"public","operator.msgpack"),run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"enroll-identity","--private-key",key,"--trust-anchor-out",anchor],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim()),decoded=msgpack.decode(await readFile(anchor));assert.equal(pulse.schemaVersion,"bifrost.epiphany_operator_identity_enrollment.v0");assert.equal(pulse.privateStateExposed,false);assert.equal(decoded[0],"epiphany.host_identity_trust_anchor.v0");assert.equal(decoded.length,6);if(process.platform!=="win32"){assert.equal((await stat(key)).mode&0o777,0o600);assert.equal((await stat(anchor)).mode&0o777,0o640);}}); -test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v1",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); -function collect(child){return new Promise(resolveExit=>{let stdout="",stderr="";child.stdout.on("data",x=>stdout+=x);child.stderr.on("data",x=>stderr+=x);child.on("exit",code=>resolveExit({code,stdout,stderr}));});} diff --git a/tools/epiphany-operator-command-documents.mjs b/tools/epiphany-operator-command-documents.mjs deleted file mode 100644 index beccc8f..0000000 --- a/tools/epiphany-operator-command-documents.mjs +++ /dev/null @@ -1,86 +0,0 @@ -export const operatorRequestType="voidbot.discord.epiphany_operator_request"; -export const operatorRequestSchema="voidbot.discord.epiphany_operator_request.v1"; -export const operatorAdmissionType="bifrost.operator_command.delivery"; -export const operatorAdmissionSchema="bifrost.operator_command.delivery.v1"; -export const legacyOperatorAdmissionSchema="bifrost.operator_command.delivery.v0"; -export const operatorResultType="epiphany.operator_command.result"; -export const operatorResultSchema="epiphany.operator_command.result.v1"; -export const operatorStatusResultSchema="epiphany.operator_command.status_result.v2"; -export const legacyOperatorResultSchema="epiphany.operator_command.result.v0"; -export const operatorResultReceiptType="epiphany.operator_command.sealed_result"; -export const operatorResultReceiptSchema="epiphany.operator_command.sealed_result.v1"; -export const legacyOperatorResultReceiptSchema="epiphany.operator_command.sealed_result.v0"; -export const operatorTerminalType="bifrost.operator_command.terminal"; -export const operatorTerminalSchema="bifrost.operator_command.terminal.v0"; -export const operatorDiscordDeliveryType="bifrost.discord.epiphany_operator_delivery"; -export const operatorDiscordDeliverySchema="bifrost.discord.epiphany_operator_delivery.v1"; - -export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",value=>parseOperatorAdmission(value,{allowLegacy:true})),result:make(operatorResultType,operatorResultSchema,"resultId",value=>parseOperatorResult(value,{allowLegacy:true})),sealedResult:make(operatorResultReceiptType,operatorResultReceiptSchema,"commandId",value=>parseOperatorResultReceipt(value,{allowLegacy:true})),terminal:make(operatorTerminalType,operatorTerminalSchema,"requestId",parseOperatorTerminal),discordDelivery:make(operatorDiscordDeliveryType,operatorDiscordDeliverySchema,"deliveryId",parseOperatorDiscordDelivery)};} -export function parseOperatorRequest(value){const v=exactRecord(value,["schemaName","schemaVersion","requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","producerId","producerRuntimeId","authorityClass","status","command"],"operator request"); exact(v.schemaName,operatorRequestType,"schemaName");exact(v.schemaVersion,operatorRequestSchema,"schemaVersion"); for(const k of ["requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","producerRuntimeId"])text(v[k],k); exact(v.producerId,"voidbot","producerId");exact(v.authorityClass,"operator_request_only","authorityClass");exact(v.status,"pending","status"); const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>60000)throw new Error("operator request TTL must be positive and at most 60 seconds.");parseCommand(v.command);return v;} -export function parseOperatorAdmission(v,{allowLegacy=false}={}){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");if(v.schemaVersion!==operatorAdmissionSchema&&!(allowLegacy&&v.schemaVersion===legacyOperatorAdmissionSchema))throw new Error("schemaVersion must be the current admission schema.");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");v.providerSignature=bytes(v.providerSignature,"providerSignature",64);parsePacket(v.packet);if(v.schemaVersion===legacyOperatorAdmissionSchema&&["reviews","review"].includes(v.packet.command.kind))throw new Error("legacy v0 admission cannot carry Mind review commands.");return v;} -export function parseOperatorResult(v,{allowLegacy=false}={}){ - const legacy=v?.schemaVersion===legacyOperatorResultSchema,status=v?.schemaVersion===operatorStatusResultSchema; - if(legacy&&!allowLegacy)throw new Error("legacy result is replay-only."); - const base=["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"],current=[...base,"reviews","reviewCandidateId","reviewDecision"]; - v=exactRecord(v,legacy?base:status?[...current,"statusV2"]:current,"operator result"); - if(!legacy&&!status)exact(v.schemaVersion,operatorResultSchema,"schemaVersion"); - for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k); - if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid."); - if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state."); - for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"]){if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(v[k].length>512)throw new Error(`${k} exceeds 512 characters.`);} - if(!legacy){for(const k of ["reviewCandidateId","reviewDecision"]){if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);}if(!Array.isArray(v.reviews)||v.reviews.length>10)throw new Error("operator reviews must be a bounded array.");v.reviews.forEach(parseReviewSummary);if(v.reviewDecision&&!['adopt','refuse','hold'].includes(v.reviewDecision))throw new Error("reviewDecision is invalid.");} - if(status)parseOperatorStatusV2(v.statusV2); - date(v.completedAt,"completedAt");return v; -} -export function parseOperatorResultReceipt(v,{allowLegacy=false}={}){v=exactRecord(v,["schemaVersion","result","resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId","privateStateExposed","executorSignature"],"operator sealed result receipt");const legacy=v.schemaVersion===legacyOperatorResultReceiptSchema;if(legacy&&!allowLegacy)throw new Error("legacy sealed result is replay-only.");if(!legacy)exact(v.schemaVersion,operatorResultReceiptSchema,"schemaVersion");parseOperatorResult(v.result,{allowLegacy});if(legacy!== (v.result.schemaVersion===legacyOperatorResultSchema))throw new Error("sealed result and embedded result versions differ.");for(const k of ["resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId"])text(v[k],k);date(v.completedAt,"completedAt");if(v.privateStateExposed!==false)throw new Error("operator sealed result receipt must not expose private state.");v.executorSignature=bytes(v.executorSignature,"executorSignature",64);return v;} -export function parseOperatorTerminal(v){v=exactRecord(v,["schemaVersion","requestId","commandId","status","failureCode","recordedAt","retryable","privateStateExposed"],"operator terminal disposition");exact(v.schemaVersion,operatorTerminalSchema,"schemaVersion");for(const k of ["requestId","commandId","failureCode","recordedAt"])text(v[k],k);exact(v.status,"refused","status");date(v.recordedAt,"recordedAt");if(v.retryable!==false||v.privateStateExposed!==false)throw new Error("operator terminal disposition must be non-retryable and private-state-free.");return v;} -export function parseOperatorDiscordDelivery(v){v=exactRecord(v,["schemaVersion","deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","reviews","reviewCandidateId","reviewDecision","statusV2","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId","recordedAt","privateStateExposed"],"operator Discord delivery");exact(v.schemaVersion,operatorDiscordDeliverySchema,"schemaVersion");for(const k of ["deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","recordedAt"])text(v[k],k);if(!/^sha256-[0-9a-f]{64}$/.test(v.requestPayloadSha256))throw new Error("operator Discord delivery request digest is invalid.");if(!["completed","refused"].includes(v.status))throw new Error("operator Discord delivery status is invalid.");for(const k of ["disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","reviewCandidateId","reviewDecision","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);if(!Array.isArray(v.reviews)||v.reviews.length>10)throw new Error("operator Discord delivery reviews must be bounded.");v.reviews.forEach(parseReviewSummary);if(v.reviewDecision&&!['adopt','refuse','hold'].includes(v.reviewDecision))throw new Error("operator Discord delivery reviewDecision is invalid.");for(const k of ["detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus"])if(v[k].length>512)throw new Error(`operator Discord delivery ${k} exceeds 512 characters.`);if(v.status==="completed"&&(!v.disposition||!/^sha256-[0-9a-f]{64}$/.test(v.sealedResultPayloadSha256)||!/^sha256-[0-9a-f]{64}$/.test(v.executorSignatureSha256)||!v.resultProviderIdentityId))throw new Error("completed operator Discord delivery lacks sealed-result binding.");if(v.status==="refused"&&!v.failureCode)throw new Error("refused operator Discord delivery lacks failure code.");if(v.statusV2!==null)parseOperatorStatusV2(v.statusV2);if(v.status!=="completed"&&v.statusV2!==null)throw new Error("refused delivery cannot carry status v2.");date(v.recordedAt,"recordedAt");if(v.privateStateExposed!==false)throw new Error("operator Discord delivery must not expose private state.");return v;} -export function operatorRequestTuple(v){return [v.schemaName,v.schemaVersion,v.requestId,v.commandId,v.nonce,v.sourceEventId,v.sourceActorDiscordId,v.discordGuildId,v.discordChannelId,v.discordMessageId,v.targetRuntimeId,v.issuedAt,v.expiresAt,v.producerId,v.producerRuntimeId,v.authorityClass,v.status,commandTuple(v.command)];} -export function packetTuple(p){return [p.commandId,p.nonce,p.sourceEventId,p.sourceActorId,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.issuedAt,p.expiresAt,commandTuple(p.command)];} -export function admissionTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];} -// rmp-serde's compact serializer encodes the nested Rust wire-result struct as -// this positional array inside the signed tuple. The separately hashed result -// payload is encoded with named fields. -export function wireResultTuple(v){const tuple=[v.schemaVersion,v.resultId,v.commandId,v.packetSha256,v.targetRuntimeId,v.disposition,v.consequenceKind,v.consequenceRef,v.completedAt,v.privateStateExposed,v.operatorStatus,v.stateStatus,v.coordinatorAction,v.brakeStatus,v.detail];if(v.schemaVersion===legacyOperatorResultSchema)return tuple;const current=[...tuple,v.reviews.map(reviewSummaryTuple),v.reviewCandidateId,v.reviewDecision];return v.schemaVersion===operatorStatusResultSchema?[...current,statusV2Tuple(v.statusV2)]:current;} -export function resultReceiptTuple(v){return [v.schemaVersion,wireResultTuple(v.result),v.resultPayloadSha256,v.commandId,v.packetSha256,v.targetRuntimeId,v.completedAt,v.providerIdentityId,v.privateStateExposed];} -function parsePacket(v){v=exactRecord(v,["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","command"],"operator packet");for(const k of ["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId"])text(v[k],k);date(v.issuedAt,"issuedAt");date(v.expiresAt,"expiresAt");parseCommand(v.command);return v;} -function parseCommand(v){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error("command must be an object.");if(["status","wake","reviews"].includes(v.kind))return exactRecord(v,["kind"],"command");if(v.kind==="sleep"){exactRecord(v,["kind","reason"],"command");text(v.reason,"reason");if(v.reason.length>512)throw new Error("sleep reason exceeds 512 characters.");return v;}if(v.kind==="directive"){exactRecord(v,["kind","objective"],"command");text(v.objective,"objective");if(v.objective.length>4096)throw new Error("directive objective exceeds 4096 characters.");return v;}if(v.kind==="review"){exactRecord(v,["kind","mindRequestId","candidateId","candidateSha256","expectedModelRevision","expectedModelHash","decision"],"command");for(const k of ["mindRequestId","candidateId"]){text(v[k],k);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);}hex64(v.candidateSha256,"candidateSha256");hex64(v.expectedModelHash,"expectedModelHash");if(!Number.isSafeInteger(v.expectedModelRevision)||v.expectedModelRevision<0)throw new Error("expectedModelRevision must be an unsigned safe integer.");if(!["adopt","refuse","hold"].includes(v.decision))throw new Error("review decision is invalid.");return v;}throw new Error("command kind is invalid.");} -// rmp-serde's compact enum form is the snake-case unit variant string, or a -// two-element [variant, sole-field] tuple for these one-field struct variants. -function commandTuple(v){if(v.kind==="sleep")return ["sleep",v.reason];if(v.kind==="directive")return ["directive",v.objective];if(v.kind==="review")return ["review",[v.mindRequestId,v.candidateId,v.candidateSha256,v.expectedModelRevision,v.expectedModelHash,v.decision]];return v.kind;} -export const commandTupleForInterop=commandTuple; -function parseReviewSummary(v){exactRecord(v,["mindRequestId","candidateId","candidateSha256","modelRevision","modelHash","frontierItemId","requestedAt"],"review summary");for(const k of ["mindRequestId","candidateId","frontierItemId"]){text(v[k],k);if(v[k].length>256)throw new Error(`review ${k} exceeds 256 characters.`);}hex64(v.candidateSha256,"candidateSha256");hex64(v.modelHash,"modelHash");if(!Number.isSafeInteger(v.modelRevision)||v.modelRevision<0)throw new Error("review modelRevision is invalid.");date(v.requestedAt,"requestedAt");return v;} -function reviewSummaryTuple(v){return [v.mindRequestId,v.candidateId,v.candidateSha256,v.modelRevision,v.modelHash,v.frontierItemId,v.requestedAt];} -export function parseOperatorStatusV2(v){ - v=exactRecord(v,["schemaVersion","releaseId","releaseWitnessSha256","sourceCommit","deploymentId","coordinatorSnapshot","coordinatorState","coordinatorAction","brakeStatus","residentStatus","pressureCount","pendingReviewCount","providerSetStatus","providers","privateStateExposed"],"operator status v2"); - exact(v.schemaVersion,"epiphany.operator.status.v2","status schemaVersion"); - for(const k of ["releaseId","deploymentId","coordinatorSnapshot","coordinatorState","coordinatorAction","brakeStatus","residentStatus"]){text(v[k],k);if(v[k].length>256)throw new Error(`${k} exceeds 256 characters.`);} - digest(v.releaseWitnessSha256,"releaseWitnessSha256");if(!/^[0-9a-f]{40}$/.test(v.sourceCommit))throw new Error("sourceCommit must be a lowercase 40-character commit."); - for(const k of ["pressureCount","pendingReviewCount"])if(!Number.isSafeInteger(v[k])||v[k]<0||v[k]>1_000_000)throw new Error(`${k} is invalid.`); - if(!["complete-authenticated","incomplete"].includes(v.providerSetStatus))throw new Error("providerSetStatus is invalid."); - if(!Array.isArray(v.providers)||v.providers.length!==2)throw new Error("operator status v2 requires exactly two providers."); - const policies=[["yggdrasil-epiphany","epiphany.cultnet-rudp-runtime-health",true],["yggdrasil-bifrost-persona-feedback","bifrost.cultnet-rudp-persona-feedback-health",false]]; - v.providers.forEach((p,i)=>parseStatusProvider(p,...policies[i])); - if(v.providers.every(p=>p.availability==="authenticated-current")!==(v.providerSetStatus==="complete-authenticated"))throw new Error("providerSetStatus does not match provider availability."); - if(v.privateStateExposed!==false)throw new Error("operator status v2 must not expose private state.");return v; -} -function parseStatusProvider(v,daemonId,contract,hasLineage){ - v=exactRecord(v,["daemonId","healthContract","availability","unavailableReason","state","reasonCode","providerObservedAtUnixMillis","evaluatedAtUnixMillis","expiresAtUnixMillis","releaseId","releaseWitnessSha256","sourceCommit","deploymentId","projectionSha256"],"operator status provider");exact(v.daemonId,daemonId,"daemonId");exact(v.healthContract,contract,"healthContract"); - if(!["authenticated-current","unavailable"].includes(v.availability))throw new Error("provider availability is invalid.");if(typeof v.unavailableReason!=="string"||v.unavailableReason.length>256)throw new Error("unavailableReason is invalid."); - for(const k of ["state","reasonCode","releaseId","sourceCommit","deploymentId"])if(v[k]!==null&&(typeof v[k]!=="string"||!v[k]||v[k].length>256))throw new Error(`${k} is invalid.`); - for(const k of ["providerObservedAtUnixMillis","evaluatedAtUnixMillis","expiresAtUnixMillis"])if(v[k]!==null&&(!Number.isSafeInteger(v[k])||v[k]<0))throw new Error(`${k} is invalid.`); - for(const k of ["releaseWitnessSha256","projectionSha256"])if(v[k]!==null)digest(v[k],k);if(v.sourceCommit!==null&&!/^[0-9a-f]{40}$/.test(v.sourceCommit))throw new Error("provider sourceCommit is invalid."); - const observed=["state","reasonCode","providerObservedAtUnixMillis","evaluatedAtUnixMillis","expiresAtUnixMillis","releaseId","releaseWitnessSha256","sourceCommit","deploymentId","projectionSha256"]; - if(v.availability==="unavailable"){if(!v.unavailableReason||observed.some(k=>v[k]!==null))throw new Error("unavailable provider must carry only its reason.");} - else{if(v.unavailableReason||["state","reasonCode","providerObservedAtUnixMillis","evaluatedAtUnixMillis","expiresAtUnixMillis","projectionSha256"].some(k=>v[k]===null))throw new Error("authenticated provider lacks current bounded state.");if(hasLineage){if(["releaseId","releaseWitnessSha256","sourceCommit","deploymentId"].some(k=>v[k]===null))throw new Error("Epiphany provider lacks release lineage.");}else if(["releaseId","releaseWitnessSha256","sourceCommit","deploymentId"].some(k=>v[k]!==null))throw new Error("Bifrost provider must not claim Epiphany release lineage.");} - return v; -} -function statusProviderTuple(v){return [v.daemonId,v.healthContract,v.availability,v.unavailableReason,v.state,v.reasonCode,v.providerObservedAtUnixMillis,v.evaluatedAtUnixMillis,v.expiresAtUnixMillis,v.releaseId,v.releaseWitnessSha256,v.sourceCommit,v.deploymentId,v.projectionSha256];} -function statusV2Tuple(v){return [v.schemaVersion,v.releaseId,v.releaseWitnessSha256,v.sourceCommit,v.deploymentId,v.coordinatorSnapshot,v.coordinatorState,v.coordinatorAction,v.brakeStatus,v.residentStatus,v.pressureCount,v.pendingReviewCount,v.providerSetStatus,v.providers.map(statusProviderTuple),v.privateStateExposed];} -function digest(v,label){if(typeof v!=="string"||!/^sha256-[0-9a-f]{64}$/.test(v))throw new Error(`${label} must be a sha256 digest.`);return v;} -function hex64(v,label){if(typeof v!=="string"||!/^[0-9a-fA-F]{64}$/.test(v))throw new Error(`${label} must be 64 hexadecimal characters.`);return v;} -function exactRecord(v,keys,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);const actual=Object.keys(v).sort(),expected=[...keys].sort();if(actual.join("\0")!==expected.join("\0"))throw new Error(`${label} has unknown or missing fields.`);return v;} -function text(v,label){if(typeof v!=="string"||!v.trim())throw new Error(`${label} is required.`);return v;} -function exact(v,e,label){if(v!==e)throw new Error(`${label} must be ${e}.`);} -function date(v,label){text(v,label);if(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(v))throw new Error(`${label} must be RFC3339.`);const n=Date.parse(v);if(!Number.isFinite(n))throw new Error(`${label} must be RFC3339.`);return n;} -function bytes(v,label,length){const normalized=v instanceof Uint8Array?v:Array.isArray(v)&&v.every(x=>Number.isInteger(x)&&x>=0&&x<=255)?Uint8Array.from(v):null;if(!normalized||normalized.length!==length)throw new Error(`${label} must be ${length} bytes.`);return normalized;} diff --git a/tools/epiphany-operator-commands.mjs b/tools/epiphany-operator-commands.mjs deleted file mode 100644 index 6220e8e..0000000 --- a/tools/epiphany-operator-commands.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import { createHash } from "node:crypto"; -import { resolve } from "node:path"; -import { admissionTuple,operatorAdmissionSchema,parseOperatorAdmission,parseOperatorRequest,parseOperatorResult,parseOperatorResultReceipt,packetTuple } from "./epiphany-operator-command-documents.mjs"; -import { enrollSignedIdentity,loadSignedIdentity,signPurpose,trustAnchor } from "./bifrost-signed-identity.mjs"; - -export const OPERATOR_SIGNING_PURPOSE="bifrost.operator-command.delivery.v1"; -export const defaultOperatorIdentityPath=()=>process.env.BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY??(process.platform==="win32"?resolve(".bifrost","private","epiphany-operator-ed25519.seed"):"/var/lib/gamecult/bifrost/epiphany-operator/operator-ed25519.seed"); - -export class OperatorCommandPermanentRefusal extends Error { constructor(code,message){super(message);this.name="OperatorCommandPermanentRefusal";this.code=code;this.retryable=false;} } - -export async function admitOperatorRequest({request,ownerDiscordId,actorLink,identity,encode,transport,now=new Date(),ledger}){ - requireDurableLedger(ledger); - try{parseOperatorRequest(request);}catch(error){throw new OperatorCommandPermanentRefusal("invalid-request",error instanceof Error?error.message:String(error));} - const existingRequest=await ledger.getRequest(request.requestId),existingAdmission=await ledger.getAdmission(request.requestId),existingCompletion=await ledger.getCompletion(request.commandId); - if(Boolean(existingRequest)!==Boolean(existingAdmission))throw new Error("operator durable request/admission pair is incomplete"); - requireAuthenticatedTransport(transport); - if(existingAdmission){ - await compareAdmission(ledger,request,existingAdmission); - if(existingCompletion){ - parseOperatorResultReceipt(existingCompletion,{allowLegacy:true}); - if(existingCompletion.commandId!==existingAdmission.packet.commandId||existingCompletion.packetSha256!==existingAdmission.packetSha256||existingCompletion.targetRuntimeId!==existingAdmission.packet.targetRuntimeId)refuse("command-id-collision","operator completion is not bound to this exact durable admission"); - parseOperatorAdmission(existingAdmission,{allowLegacy:true});transport.verifyReceipt(existingAdmission,existingCompletion); - return {admission:existingAdmission,result:existingCompletion.result,receipt:existingCompletion,resultProviderIdentityId:existingCompletion.providerIdentityId,recovered:true}; - } - return executeAdmission({request,admission:existingAdmission,transport,ledger,recovered:true}); - } - if(existingCompletion)refuse("command-id-collision","operator command id already belongs to a different durable admission"); - let admission; - try{ - if(request.sourceActorDiscordId!==ownerDiscordId)refuse("foreign-owner","Epiphany operator command requires the exact configured Discord owner."); - // This persisted Bifrost actor-link is the admitted mapping authority. Its - // Heimdall reference is provenance only until a signed live claim exists. - if(!actorLink||actorLink.guildId!==request.discordGuildId||actorLink.discordAuthorId!==request.sourceActorDiscordId||!actorLink.bifrostActorId?.trim())refuse("missing-actor-link","Epiphany operator command requires the exact admitted Bifrost actor link."); - const at=now instanceof Date?now:new Date(now);if(!Number.isFinite(at.getTime())||Date.parse(request.issuedAt)>at.getTime()||Date.parse(request.expiresAt)60000)throw new Error("Epiphany operator result timeout must be 100..=60000 milliseconds."); - this.timeoutMs=timeoutMs; - const loaded=(!cultnetRuntime||!msgpackRuntime)?loadCultNetRuntime():{}; - this.cultnet=cultnetRuntime??loaded.cultnet; - this.msgpack=msgpackRuntime??loaded.msgpack; - this.socketFactory=socketFactory; - this.connectionFactory=connectionFactory??(options=>new this.cultnet.CultNetRudpSocketTransportConnection(options)); - this.tail=Promise.resolve(); - } - - get trustedIdentityId(){return this.trustedIdentity.identityId;} - verifyReceipt(admission,receipt){return verifySealedReceipt({receipt,admission,trustedIdentity:this.trustedIdentity,encode:this.msgpack.encode});} - - execute(admission){ - const work=this.tail.then(()=>this.#executeOne(admission)); - this.tail=work.catch(()=>undefined); - return work; - } - - async #executeOne(admission){ - parseOperatorAdmission(admission,{allowLegacy:true}); - const socket=this.socketFactory("udp4"); - await bindSocket(socket); - const connection=this.connectionFactory({runtimeId:this.runtimeId,socket,mode:"client",remoteHost:this.endpoint.host,remotePort:this.endpoint.port,connectionId:RUDP_CONNECTION_ID,resendDelayMs:50,resendPollMs:10}); - try{ - connection.connect(); - await waitForConnected(connection,this.timeoutMs); - const response=waitForReceipt(connection,this.msgpack,this.timeoutMs,admission); - const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`operator-request-${admission.admissionId}`,document:{schemaId:operatorAdmissionType,recordKey:admission.admissionId,storedAt:admission.packet.issuedAt,payloadEncoding:"messagepack",payload:this.msgpack.encode(admission),sourceRuntimeId:admission.sourceObserverRuntimeId,sourceAgentId:admission.providerIdentityId,sourceRole:"bifrost-operator-admission",tags:["cultnet.transport.rudp.v0"]}}; - connection.send("schema",this.msgpack.encode(this.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0"))); - const receipt=await response; - this.verifyReceipt(admission,receipt); - return {providerIdentityId:receipt.providerIdentityId,result:receipt.result,receipt}; - }finally{connection.close();} - } -} - -export async function loadEpiphanyOperatorTrustAnchor(path,{decode}={}){const runtime=decode?{decode}:loadCultNetRuntime().msgpack,value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==6)throw new Error("Epiphany operator trust anchor must be the raw compact 6-field MessagePack crossing artifact.");return parseTrustAnchor({schemaVersion:value[0],identityId:value[1],publicKey:value[2],assurance:value[3],identityCreatedAt:value[4],sourceIdentityRecordSha256:value[5]});} - -export function verifySealedReceipt({receipt,admission,trustedIdentity,encode}){ - parseOperatorAdmission(admission,{allowLegacy:true});parseOperatorResultReceipt(receipt,{allowLegacy:true});const anchor=parseTrustAnchor(trustedIdentity); - if(receipt.result.schemaVersion==="epiphany.operator_command.status_result.v2"&&admission.packet.command.kind!=="status")throw new Error("Epiphany Status-v2 result is not bound to a Status command."); - if(receipt.providerIdentityId!==anchor.identityId)throw new Error("Epiphany sealed result used an untrusted host identity."); - if(receipt.commandId!==admission.packet.commandId||receipt.packetSha256!==admission.packetSha256||receipt.targetRuntimeId!==admission.packet.targetRuntimeId)throw new Error("Epiphany sealed result receipt is not bound to the admitted command."); - if(receipt.result.commandId!==receipt.commandId||receipt.result.packetSha256!==receipt.packetSha256||receipt.result.targetRuntimeId!==receipt.targetRuntimeId||receipt.result.completedAt!==receipt.completedAt)throw new Error("Epiphany sealed result receipt substituted its embedded application result."); - // Expiry gates first admission, not exact crash recovery. Epiphany may seal - // an already-admitted command after its lease while recovering the one - // idempotent consequence; Bifrost's durable ledger owns whether such a - // post-expiry retransmission was authorized. - const completed=Date.parse(receipt.completedAt),issued=Date.parse(admission.packet.issuedAt); - if(completed{const finish=(fn,value)=>{clearTimeout(timer);connection.off?.("frame",onFrame);connection.off?.("error",onError);fn(value);};const onError=error=>finish(reject,error);const onFrame=frame=>{try{if(frame.channelId!=="schema")throw new Error("Epiphany operator response arrived on a foreign CultNet channel.");const message=msgpack.decode(frame.payload);if(!message||message.schemaVersion!=="cultnet.document_put_raw.v0")throw new Error("Epiphany operator response is not a CultNet raw typed document.");const d=message.document;if(!d||d.schemaId!==operatorResultReceiptType||d.recordKey!==admission.packet.commandId||d.payloadEncoding!=="messagepack"||d.sourceRole!=="epiphany-operator-command-executor"||d.sourceRuntimeId!==admission.packet.targetRuntimeId)throw new Error("Epiphany operator response envelope substituted result authority.");const receipt=msgpack.decode(d.payload);if(d.sourceAgentId!==receipt?.providerIdentityId)throw new Error("Epiphany operator response envelope substituted executor identity.");finish(resolveReceipt,receipt);}catch(error){finish(reject,error);}};const timer=setTimeout(()=>finish(reject,new Error("Timed out waiting for the correlated Epiphany application result.")),timeoutMs);connection.on("frame",onFrame);connection.on("error",onError);});} -async function waitForConnected(connection,timeoutMs){const started=Date.now();while(!connection.connected){if(Date.now()-started>=timeoutMs)throw new Error("Timed out establishing the Epiphany CultNet RUDP session.");await new Promise(done=>setTimeout(done,5));}} -function bindSocket(socket){return new Promise((resolveBind,reject)=>{const fail=error=>{socket.off("listening",ready);reject(error)};const ready=()=>{socket.off("error",fail);resolveBind()};socket.once("error",fail);socket.once("listening",ready);socket.bind(0,"0.0.0.0");});} -function parseEndpoint(value){required(value,"Epiphany operator RUDP endpoint");const url=new URL(value);if(url.protocol!=="rudp:"||!url.hostname||!url.port||(url.pathname&&url.pathname!=="/"))throw new Error("Epiphany operator endpoint must be rudp://host:port.");const port=Number(url.port);if(!Number.isInteger(port)||port<1||port>65535)throw new Error("Epiphany operator endpoint port is invalid.");return {host:url.hostname,port};} -function parseTrustAnchor(value){if(!value||typeof value!=="object"||Array.isArray(value))throw new Error("Trusted Epiphany host identity is required.");const keys=Object.keys(value).sort(),expected=["assurance","identityCreatedAt","identityId","publicKey","schemaVersion","sourceIdentityRecordSha256"].sort();if(keys.join("\0")!==expected.join("\0")||value.schemaVersion!=="epiphany.host_identity_trust_anchor.v0"||typeof value.identityId!=="string"||!value.assurance?.trim()||!/^sha256-[0-9a-f]{64}$/.test(value.sourceIdentityRecordSha256)||!Number.isFinite(Date.parse(value.identityCreatedAt)))throw new Error("Trusted Epiphany host identity violates its exact schema.");value.publicKey=value.publicKey instanceof Uint8Array?value.publicKey:Array.isArray(value.publicKey)&&value.publicKey.every(x=>Number.isInteger(x)&&x>=0&&x<=255)?Uint8Array.from(value.publicKey):null;if(!value.publicKey||value.publicKey.length!==32)throw new Error("Trusted Epiphany host identity public key must be 32 bytes.");const derived=createHash("sha256").update(Buffer.from("epiphany.host-incarnation.identity.v0\0")).update(value.publicKey).digest("hex");if(derived!==value.identityId)throw new Error("Trusted Epiphany host identity id does not match its public key.");return value;} -function loadCultNetRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error(`CultNet TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return {cultnet:requireCult("cultnet-ts"),msgpack:requireCult("@msgpack/msgpack")};} -function required(value,label){if(typeof value!=="string"||!value.trim())throw new Error(`${label} is required.`);return value;} diff --git a/tools/epiphany-operator-discord-outbox.mjs b/tools/epiphany-operator-discord-outbox.mjs deleted file mode 100644 index a3b2bb9..0000000 --- a/tools/epiphany-operator-discord-outbox.mjs +++ /dev/null @@ -1,21 +0,0 @@ - -import { createHash } from "node:crypto"; -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { buildOperatorDefinitions,operatorRequestTuple } from "./epiphany-operator-command-documents.mjs"; - -export class EpiphanyOperatorDiscordOutbox { - constructor(cache,definition,encode){this.cache=cache;this.definition=definition;this.encode=encode;} - static async open(path,{cultcacheRuntime,encode}={}){const runtime=cultcacheRuntime??loadCultCacheRuntime(),definition=buildOperatorDefinitions(runtime.defineDocumentType).discordDelivery,cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();if(typeof encode!=="function")throw new Error("operator Discord outbox requires the canonical MessagePack encoder");await cache.pullAllBackingStores();return new EpiphanyOperatorDiscordOutbox(cache,definition,encode);} - get(requestId){return this.cache.get(this.definition,requestId);} - isExactDelivery(request){const value=this.get(request.requestId);return Boolean(value&&value.requestPayloadSha256===this.requestDigest(request)&&value.commandId===request.commandId&&value.discordGuildId===request.discordGuildId&&value.discordChannelId===request.discordChannelId&&value.discordInteractionId===request.sourceEventId&&value.targetRuntimeId===request.targetRuntimeId);} - async publishCompleted(request,receipt){validateReviewProjection(request,receipt.result);const value=this.base(request,{status:"completed",disposition:receipt.result.disposition,failureCode:"",detail:receipt.result.detail,operatorStatus:receipt.result.operatorStatus,stateStatus:receipt.result.stateStatus,coordinatorAction:receipt.result.coordinatorAction,brakeStatus:receipt.result.brakeStatus,reviews:receipt.result.reviews??[],reviewCandidateId:receipt.result.reviewCandidateId??"",reviewDecision:receipt.result.reviewDecision??"",statusV2:receipt.result.statusV2??null,sealedResultPayloadSha256:receipt.resultPayloadSha256,executorSignatureSha256:`sha256-${createHash("sha256").update(receipt.executorSignature).digest("hex")}`,resultProviderIdentityId:receipt.providerIdentityId,recordedAt:receipt.completedAt});return this.put(value);} - async publishRefused(request,terminal){const value=this.base(request,{status:"refused",disposition:"",failureCode:terminal.failureCode,detail:terminal.failureCode,operatorStatus:"",stateStatus:"",coordinatorAction:"",brakeStatus:"",reviews:[],reviewCandidateId:"",reviewDecision:"",statusV2:null,sealedResultPayloadSha256:"",executorSignatureSha256:"",resultProviderIdentityId:"",recordedAt:terminal.recordedAt});return this.put(value);} - requestDigest(request){return `sha256-${createHash("sha256").update(this.encode(operatorRequestTuple(request))).digest("hex")}`;} - base(request,result){return {schemaVersion:"bifrost.discord.epiphany_operator_delivery.v1",deliveryId:request.requestId,requestId:request.requestId,requestPayloadSha256:this.requestDigest(request),commandId:request.commandId,discordGuildId:request.discordGuildId,discordChannelId:request.discordChannelId,discordInteractionId:request.sourceEventId,targetRuntimeId:request.targetRuntimeId,...result,privateStateExposed:false};} - async put(value){const current=this.get(value.requestId);if(current&&stable(current)!==stable(value))throw new Error("operator Discord delivery identity collision");if(!current)await this.cache.put(this.definition,value.deliveryId,value);return current??value;} -} -function loadCultCacheRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultcache-ts","package.json");if(!existsSync(pkg))throw new Error(`CultCache TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return requireCult("cultcache-ts");} -function validateReviewProjection(request,result){const reviews=result.reviews??[],candidate=result.reviewCandidateId??"",decision=result.reviewDecision??"";if(request.command.kind==="reviews"){if(candidate||decision)throw new Error("Reviews result carries a decision binding");return;}if(request.command.kind==="review"){if(reviews.length||candidate!==request.command.candidateId||decision!==request.command.decision)throw new Error("Review result does not bind the exact requested candidate and decision");return;}if(reviews.length||candidate||decision)throw new Error("non-review result carries Mind review state");} -function stable(v){return JSON.stringify(v,(_k,x)=>x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/epiphany-operator-ledger.mjs b/tools/epiphany-operator-ledger.mjs deleted file mode 100644 index b1410e4..0000000 --- a/tools/epiphany-operator-ledger.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { buildOperatorDefinitions } from "./epiphany-operator-command-documents.mjs"; - -export class DurableOperatorLedger { - constructor(cache,definitions){this.cache=cache;this.definitions=definitions;} - static async open(path,{cultcacheRuntime}={}){const runtime=cultcacheRuntime??loadCultCacheRuntime(),definitions=buildOperatorDefinitions(runtime.defineDocumentType),cache=runtime.CultCache.builder().withDocumentType(definitions.request).withDocumentType(definitions.admission).withDocumentType(definitions.sealedResult).withDocumentType(definitions.terminal).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return new DurableOperatorLedger(cache,definitions);} - async admit(id,request,admission){if(id!==request.requestId||id!==admission.admissionId||request.commandId!==admission.packet.commandId)throw new Error("operator admission ledger key is not bound to request and admission identity");const currentRequest=this.cache.get(this.definitions.request,id),currentAdmission=this.cache.get(this.definitions.admission,id);if(currentRequest&&stable(currentRequest)!==stable(request)||currentAdmission&&stable(currentAdmission)!==stable(admission))throw new Error("operator request identity collision");if(!currentRequest)await this.cache.put(this.definitions.request,id,request);if(!currentAdmission)await this.cache.put(this.definitions.admission,id,admission);} - async complete(id,result,providerIdentityId,receipt){const admission=this.cache.get(this.definitions.admission,id),request=this.cache.get(this.definitions.request,id);if(!admission||!request)throw new Error("operator result has no durable admitted request");if(!receipt||receipt.commandId!==result.commandId||receipt.providerIdentityId!==providerIdentityId||stable(receipt.result)!==stable(result)||receipt.commandId!==admission.packet.commandId||receipt.packetSha256!==admission.packetSha256||receipt.targetRuntimeId!==admission.packet.targetRuntimeId||request.commandId!==receipt.commandId)throw new Error("operator completion is not bound to its exact durable admission");const current=this.cache.get(this.definitions.sealedResult,result.commandId);if(current&&stable(current)!==stable(receipt))throw new Error("operator result identity collision");if(!current)await this.cache.put(this.definitions.sealedResult,result.commandId,receipt);} - async terminalize(value){const current=this.cache.get(this.definitions.terminal,value.requestId);if(current&&stable(current)!==stable(value))throw new Error("operator terminal disposition identity collision");if(!current)await this.cache.put(this.definitions.terminal,value.requestId,value);} - getAdmission(id){return this.cache.get(this.definitions.admission,id);} - getRequest(id){return this.cache.get(this.definitions.request,id);} - getCompletion(commandId){return this.cache.get(this.definitions.sealedResult,commandId);} - getTerminal(requestId){return this.cache.get(this.definitions.terminal,requestId);} - isExactCompletion(requestId,commandId){const request=this.getRequest(requestId),admission=this.getAdmission(requestId),completion=this.getCompletion(commandId);return Boolean(request&&admission&&completion&&request.commandId===commandId&&admission.packet.commandId===commandId&&completion.commandId===commandId&&completion.packetSha256===admission.packetSha256&&completion.targetRuntimeId===admission.packet.targetRuntimeId);} -} - -function loadCultCacheRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultcache-ts","package.json");if(!existsSync(pkg))throw new Error(`CultCache TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return requireCult("cultcache-ts");} -function stable(v){return JSON.stringify(v,(_k,x)=>x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/epiphany-operator-worker.mjs b/tools/epiphany-operator-worker.mjs deleted file mode 100644 index 0e61f36..0000000 --- a/tools/epiphany-operator-worker.mjs +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { chmod, mkdir, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { buildOperatorDefinitions } from "./epiphany-operator-command-documents.mjs"; -import { buildFeedbackDefinitions } from "./persona-feedback-documents.mjs"; -import { admitOperatorRequest,enrollOperatorIdentity,loadOperatorIdentity,OperatorCommandPermanentRefusal,trustAnchor } from "./epiphany-operator-commands.mjs"; -import { EpiphanyOperatorCultNetRudpTransport,loadEpiphanyOperatorTrustAnchor } from "./epiphany-operator-cultnet-rudp.mjs"; -import { DurableOperatorLedger } from "./epiphany-operator-ledger.mjs"; -import { EpiphanyOperatorDiscordOutbox } from "./epiphany-operator-discord-outbox.mjs"; - -const {command,options}=parseArgs(process.argv.slice(2)); -if(command==="help"){ - process.stdout.write("epiphany-operator-worker --request-store --private-store --ledger-store --delivery-store --private-key --epiphany-trust-anchor --epiphany-rudp --owner-discord-id [--trust-anchor-out ] [--runtime-id ] [--interval-ms ] [--timeout-ms ]\n"); - process.exit(0); -} -await main().catch(error=>{process.stderr.write(`${error instanceof Error?error.message:String(error)}\n`);process.exitCode=1;}); - -async function main(){ - const runtime=loadRuntime(); - if(command==="enroll-identity")return enrollIdentity(runtime); - if(!["once","serve"].includes(command))throw new Error("operator worker command must be once, serve, or enroll-identity"); - const operatorDefs=buildOperatorDefinitions(runtime.defineDocumentType); - const feedbackDefs=buildFeedbackDefinitions(runtime.defineDocumentType); - const requestStore=requiredOption("request-store","BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE"); - const privateStore=requiredOption("private-store","BIFROST_PERSONA_FEEDBACK_PRIVATE_STORE"); - const ledgerStore=requiredOption("ledger-store","BIFROST_EPIPHANY_OPERATOR_LEDGER_STORE"); - const deliveryStore=requiredOption("delivery-store","BIFROST_EPIPHANY_OPERATOR_DISCORD_DELIVERY_STORE"); - const ownerDiscordId=requiredOption("owner-discord-id","BIFROST_EPIPHANY_OPERATOR_OWNER_DISCORD_ID"); - assertDistinctStores(requestStore,privateStore,ledgerStore,deliveryStore); - const identity=await loadOperatorIdentity(resolve(requiredOption("private-key","BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY"))); - const anchor=await loadEpiphanyOperatorTrustAnchor(resolve(requiredOption("epiphany-trust-anchor","BIFROST_EPIPHANY_EXECUTOR_TRUST_ANCHOR")),{decode:runtime.decode}); - const transport=new EpiphanyOperatorCultNetRudpTransport({endpoint:requiredOption("epiphany-rudp","BIFROST_EPIPHANY_OPERATOR_RUDP"),runtimeId:option("runtime-id","BIFROST_RUNTIME_ID")||"bifrost-yggdrasil",trustedEpiphanyIdentity:anchor,timeoutMs:integerOption("timeout-ms",5000,100,60000),cultnetRuntime:runtime.cultnet,msgpackRuntime:runtime.msgpack}); - const ledger=await DurableOperatorLedger.open(resolve(ledgerStore),{cultcacheRuntime:runtime.cultcache}); - const outbox=await EpiphanyOperatorDiscordOutbox.open(resolve(deliveryStore),{cultcacheRuntime:runtime.cultcache,encode:runtime.encode}); - const requests=runtime.CultCache.builder().withDocumentType(operatorDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(resolve(requestStore))).build(); - const privateCache=runtime.CultCache.builder().withDocumentType(feedbackDefs.actorLink).withGenericStore(new runtime.SingleFileMessagePackBackingStore(resolve(privateStore))).build(); - const interval=integerOption("interval-ms",500,100,60000); - do{ - const output=await processPending({requests,privateCache,operatorDefs,feedbackDefs,ledger,outbox,ownerDiscordId,identity,transport,encode:runtime.encode}); - process.stdout.write(`${JSON.stringify({schemaVersion:"bifrost.epiphany_operator_worker_pulse.v0",status:output.every(x=>["completed","refused"].includes(x.status))?"completed":"degraded",processed:output.length,results:output,privateStateExposed:false})}\n`); - if(command==="once")return; - await new Promise(done=>setTimeout(done,interval)); - }while(true); -} - -async function enrollIdentity(runtime){ - const privatePath=resolve(requiredOption("private-key","BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY")); - const outputPath=resolve(requiredOption("trust-anchor-out","BIFROST_EPIPHANY_OPERATOR_TRUST_ANCHOR_OUT")); - if(privatePath===outputPath)throw new Error("operator private identity and public trust anchor must be physically distinct"); - const identity=await enrollOperatorIdentity(privatePath),anchor=trustAnchor(identity,new Date().toISOString()); - await mkdir(resolve(outputPath,".."),{recursive:true}); - await writeFile(outputPath,runtime.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]),{flag:"wx",mode:0o640}); - await chmod(outputPath,0o640); - process.stdout.write(`${JSON.stringify({schemaVersion:"bifrost.epiphany_operator_identity_enrollment.v0",identityId:identity.identityId,trustAnchor:outputPath,privateStateExposed:false})}\n`); -} - -async function processPending({requests,privateCache,operatorDefs,feedbackDefs,ledger,outbox,ownerDiscordId,identity,transport,encode}){ - await requests.pullAllBackingStores();await privateCache.pullAllBackingStores(); - const pending=requests.getAll(operatorDefs.request).filter(request=>!outbox.isExactDelivery(request)).sort((a,b)=>a.issuedAt.localeCompare(b.issuedAt)); - const output=[]; - for(const request of pending){ - const terminal=ledger.getTerminal(request.requestId); - if(terminal){await outbox.publishRefused(request,terminal);output.push(summary(request,"refused","",terminal.failureCode));continue;} - try{ - const actorLink=privateCache.get(feedbackDefs.actorLink,`${request.discordGuildId}:${request.sourceActorDiscordId}`); - const result=await admitOperatorRequest({request,ownerDiscordId,actorLink,identity,encode,transport,ledger}); - await outbox.publishCompleted(request,result.receipt); - output.push(summary(request,"completed",result.result.disposition,result.result.detail)); - }catch(error){ - if(error instanceof OperatorCommandPermanentRefusal){ - const value={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:request.requestId,commandId:request.commandId,status:"refused",failureCode:error.code,recordedAt:new Date().toISOString(),retryable:false,privateStateExposed:false}; - await ledger.terminalize(value);await outbox.publishRefused(request,value); - } - output.push(summary(request,error instanceof OperatorCommandPermanentRefusal?"refused":"retryable-failure","",error instanceof OperatorCommandPermanentRefusal?error.code:"transport-or-ledger-unavailable")); - } - } - return output; -} - -function summary(request,status,disposition,detail){return {requestId:request.requestId,commandId:request.commandId,status,disposition,detail};} -function loadRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),cachePkg=resolve(cult,"packages","cultcache-ts","package.json"),netPkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(cachePkg)||!existsSync(netPkg))throw new Error("CultLib CultCache/CultNet TypeScript runtimes are unavailable");const cr=createRequire(cachePkg),nr=createRequire(netPkg),cultcache=cr("cultcache-ts"),msgpack=nr("@msgpack/msgpack");return {cultcache,cultnet:nr("cultnet-ts"),msgpack,encode:msgpack.encode,decode:msgpack.decode,CultCache:cultcache.CultCache,SingleFileMessagePackBackingStore:cultcache.SingleFileMessagePackBackingStore,defineDocumentType:cultcache.defineDocumentType};} -function parseArgs(values){const command=values.shift()??"help",options={},allowed=new Set(["request-store","private-store","ledger-store","delivery-store","private-key","trust-anchor-out","epiphany-trust-anchor","epiphany-rudp","owner-discord-id","runtime-id","interval-ms","timeout-ms"]);while(values.length){const flag=values.shift();if(!flag?.startsWith("--")||!values.length)throw new Error(`invalid operator worker option ${flag??""}`);const key=flag.slice(2);if(!allowed.has(key))throw new Error(`unknown operator worker option --${key}`);if(Object.hasOwn(options,key))throw new Error(`duplicate operator worker option --${key}`);options[key]=values.shift();}return {command,options};} -function option(name,environment){return options[name]??process.env[environment];} -function requiredOption(name,environment){const value=option(name,environment);if(typeof value!=="string"||!value.trim())throw new Error(`operator worker requires --${name} or ${environment}`);return value;} -function integerOption(name,fallback,min,max){const raw=options[name],value=raw===undefined?fallback:Number(raw);if(!Number.isInteger(value)||valuemax)throw new Error(`operator worker --${name} must be ${min}..=${max}`);return value;} -function assertDistinctStores(...paths){const resolved=paths.map(path=>resolve(path).toLowerCase());if(new Set(resolved).size!==resolved.length)throw new Error("operator request, actor-link, immutable ledger, and Discord delivery stores must be physically distinct");} diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 5dd4465..32563af 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -164,12 +164,6 @@ const canonicalService = `${rootVerse}.bifrost`; const locatedService = `${rootVerse}.${currentMachine}.bifrost`; const motionSurfaceCultMeshUri = `cultmesh://${locatedService}/eve/governance/surface`; const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`; -const epiphanyOperatorRequestSchemaId = "voidbot.discord.epiphany_operator_request.v1"; -const epiphanyOperatorAdmissionSchemaId = "bifrost.operator_command.delivery.v1"; -const epiphanyOperatorResultSchemaId = "epiphany.operator_command.result.v1"; -const epiphanyOperatorSealedResultSchemaId = "epiphany.operator_command.sealed_result.v1"; -const epiphanyOperatorTerminalSchemaId = "bifrost.operator_command.terminal.v0"; -const epiphanyOperatorDiscordDeliverySchemaId = "bifrost.discord.epiphany_operator_delivery.v1"; const advertisementDefinition = defineDocumentType({ type: documentType, @@ -334,9 +328,6 @@ function buildAdvertisement(options) { endpoint("github-webhooks", `${locatedService}/github/webhooks`, "bifrost.work_item.v0", ["github", "work-sync", "review-sync"]), endpoint("motion-surface", motionSurfaceCultMeshUri, "gamecult.eve.surface.v1", ["cultmesh", "governance", "motion"]), endpoint("motion-commands", motionCommandCultMeshUri, "bifrost.motion_command.v0", ["command", "cultmesh", "governance", "motion"]), - endpoint("epiphany-operator-requests", `${locatedService}/commands/epiphany-operator`, epiphanyOperatorRequestSchemaId, ["command", "discord", "operator", "cultmesh"]), - endpoint("epiphany-operator-results", `${locatedService}/receipts/epiphany-operator`, epiphanyOperatorSealedResultSchemaId, ["receipt", "epiphany", "operator", "cultnet", "rudp"]), - endpoint("epiphany-operator-discord-deliveries", `${locatedService}/deliveries/discord/epiphany-operator`, epiphanyOperatorDiscordDeliverySchemaId, ["delivery", "discord", "operator", "cultcache"]), ], routes: [ route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true), @@ -376,12 +367,6 @@ function buildAdvertisement(options) { schema(discordPostReceiptSchemaId, discordPostReceiptDocumentType, "CultMesh receipt for Bifrost-owned Discord persona/bot posts"), schema("bifrost.patron_support_event.v0", "bifrost.patron-support-event", "current hosted Heimdall-signed patron support fact consumed by Bifrost"), schema("bifrost.member_capability_snapshot.v0", "bifrost.member-capability-snapshot", "planned Heimdall-consumed membership capability witness"), - schema(epiphanyOperatorRequestSchemaId, "voidbot.discord.epiphany_operator_request", "strict owner-authored Discord operator request inbox"), - schema(epiphanyOperatorAdmissionSchemaId, "bifrost.operator_command.delivery", "Bifrost-signed expiring Epiphany operator admission"), - schema(epiphanyOperatorResultSchemaId, "epiphany.operator_command.result", "Epiphany-origin command result returned across the authenticated transport boundary"), - schema(epiphanyOperatorSealedResultSchemaId, "epiphany.operator_command.sealed_result", "Epiphany host-signed receipt binding the exact application result to command, packet, runtime, and payload digest"), - schema(epiphanyOperatorTerminalSchemaId, "bifrost.operator_command.terminal", "Bifrost-owned immutable terminal refusal for a non-retryable operator request"), - schema(epiphanyOperatorDiscordDeliverySchemaId, "bifrost.discord.epiphany_operator_delivery", "Bifrost-owned immutable Discord result delivery bound to the original request and sealed Epiphany result or terminal refusal"), ], witnesses: [ witness(".bifrost/provider-store.cc", `${schemaId}; ${surfaceSchemaId}; ${interfaceBindingSchemaId}; ${discordPostCommandSchemaId}; ${discordPostReceiptSchemaId}`, "current", "typed provider advertisement, operator surface, Eve interface binding, and Bifrost Discord command request/receipt store"), @@ -393,9 +378,6 @@ function buildAdvertisement(options) { 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", crossingReceiptSchemaId, "current", "canonical governed public crossing receipt history"), witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "future product Eve/CultUI surfaces not yet in the provider store"), - witness(".bifrost/epiphany-operator-requests.cc", epiphanyOperatorRequestSchemaId, "configured", "dedicated VoidBot-writable Bifrost operator request inbox; never an Epiphany store"), - witness(".bifrost/epiphany-operator-ledger.cc", `${epiphanyOperatorAdmissionSchemaId}; ${epiphanyOperatorSealedResultSchemaId}; ${epiphanyOperatorTerminalSchemaId}`, "configured", "Bifrost-owned immutable admission, host-sealed application-result, and terminal-refusal ledger"), - witness(".bifrost/epiphany-operator-discord-deliveries.cc", epiphanyOperatorDiscordDeliverySchemaId, "configured", "Bifrost-writable and VoidBot-readable immutable operator result delivery outbox"), ], surfaces: [ surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-store.cc", "eve", [ @@ -505,17 +487,6 @@ function buildAdvertisement(options) { "surface-specific receipts cannot decide crossing completion", "does not touch secrets in this advertisement", ]), - boundary("epiphany-operator", "Bifrost admission; Epiphany execution", [ - "admit exact owner-only status, sleep, wake, directive, reviews, and review requests", - "require the exact configured Discord owner and persisted Bifrost actor-link mapping; Heimdall reference is provenance until signed claims are live", - "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity", - "relay only Epiphany-origin results bound to the admitted command", - ], [ - "does not accept arbitrary argv or shell commands", - "does not mount or mutate Epiphany stores", - "does not synthesize successful Epiphany results", - "ordinary Persona feedback remains feedback_only", - ]), ], styleCapabilities: [ style("density", ["compact TUI grids", "operator scan tables", "member-facing board/list/detail lowerings"]), From ecc170dfe3a16f31adf1044ae3b09231bcf2a0fd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 3 Sep 2026 22:21:23 +0200 Subject: [PATCH 030/155] Wire AGENTS.md into Claude Code memory Claude Code reads CLAUDE.md, not AGENTS.md, so this repo's doctrine was invisible to it. This one-line import makes the existing AGENTS.md load without duplicating or renaming it. Codex is unaffected. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..640c1a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +Repository doctrine for this project lives in AGENTS.md, shared with Codex. + +@AGENTS.md From c289c820c65cd4507d6dc051b1661fc87dcdd1af Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 01:54:21 +0200 Subject: [PATCH 031/155] Stop double-wrapping the operator notification schema publishIdunnAlarm has never successfully published. Every non-dry run died with "definition.schema.parse is not a function" inside CultMeshNode.put. parseObjectDocument already returns a schema-shaped object: function parseObjectDocument(label) { return { parse(input) { ... } }; } but all five document definitions wrapped it a second time: schema: { parse: parseObjectDocument("Operator DM request") } so definition.schema.parse was an object, and CultMesh called it as a function. Pass the helper's result straight through instead. This was present in the file's only prior commit, so the alarm path has never worked. The dry-run branch returns before touching CultMesh, which is why the publisher looked healthy: --dry-run prints a well-formed request and exits 0 without ever exercising put. Verified end to end against a throwaway store: exit 0, ok:true, the .cc is written, and the document reads back as {"alarm":"verify-fix","svc":"probe-daemon","cmd":"owner.dm.send","status":"pending"}. CultMesh creates missing store directories, so an absent .voidbot tree is not a blocker. Not exercised here: real delivery of the DM by VoidBot's worker, which needs the operator's say-so rather than a probe. Co-Authored-By: Claude Opus 5 --- tools/operator-notification.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/operator-notification.mjs b/tools/operator-notification.mjs index a995dcb..ac4cc10 100644 --- a/tools/operator-notification.mjs +++ b/tools/operator-notification.mjs @@ -60,7 +60,7 @@ async function publishIdunnAlarm(options) { contentHash: requestSchemaId, global: false, name: "requestId", - schema: { parse: parseObjectDocument("Operator DM request") }, + schema: parseObjectDocument("Operator DM request"), }); const node = await CultMesh.createNode(storePath, { documents: [ @@ -72,7 +72,7 @@ async function publishIdunnAlarm(options) { schemaVersion: "voidbot.swarm_state_snapshot.v1", contentHash: "voidbot.swarm_state_snapshot.v1", global: false, - schema: { parse: parseObjectDocument("VoidBot swarm snapshot") }, + schema: parseObjectDocument("VoidBot swarm snapshot"), }), defineDocumentType({ type: "gamecult.eve.provider_advertisement", @@ -82,7 +82,7 @@ async function publishIdunnAlarm(options) { contentHash: "gamecult.eve.provider_advertisement.v1", global: false, name: "providerId", - schema: { parse: parseObjectDocument("Eve provider advertisement") }, + schema: parseObjectDocument("Eve provider advertisement"), }), defineDocumentType({ type: "gamecult.eve.surface_state", @@ -92,7 +92,7 @@ async function publishIdunnAlarm(options) { contentHash: "gamecult.eve.surface_state.v1", global: false, name: "providerId", - schema: { parse: parseObjectDocument("Eve surface state") }, + schema: parseObjectDocument("Eve surface state"), }), defineDocumentType({ type: "gamecult.eve.interface_binding", @@ -102,7 +102,7 @@ async function publishIdunnAlarm(options) { contentHash: "gamecult.eve.interface_binding.v1", global: false, name: "bindingId", - schema: { parse: parseObjectDocument("Eve interface binding") }, + schema: parseObjectDocument("Eve interface binding"), }), ], }); From c063f27014323cd489eb3ee9557d52bbffabaa87 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 02:49:42 +0200 Subject: [PATCH 032/155] Route Idunn operator alarms through the Bifrost Discord gate operator-notification.mjs invented gamecult.operator_dm_request.v1 and hand-rolled its document definitions, while the gate's own vocabulary and an exported definition helper sat in the same tools/ directory. Nothing consumed the invented type, so a published alarm sat pending forever with no receipt and no way to notice. Two command vocabularies for one crossing, one of them unreadable. Publish bifrost.bridge.discord_post_command.v1 instead, via discordPostCommandDefinition, as a discord-dm command carrying the operator recipient and the alarm text. gamecult.operator_dm_request is gone, along with the four unrelated Eve/VoidBot document definitions the publisher registered to open somebody else's store. The default store is now the gate's own .bifrost/provider-store.cc, which is what the bridge command pump reads. Per AGENTS.md, Bifrost owns the Discord crossing; this tool only asks. The bridge command route was discord-post only, so teach it the verb the command document already carries: - resolveBridgeVerb accepts discord-post and discord-dm and rejects anything else before the command is marked running; - discord-dm passes --recipient-id, discord-post keeps --channel-id, and the persona/reply options stay post-only; - listCommandRecords selects on the supported verb set instead of hardcoding discord-post, so a discord-dm command is actually picked up; - receipts record the verb that ran rather than a literal "discord-post". Two changes exist because of how this bug hid rather than what it was: - --dry-run now builds the runtime and definitions before returning. The old dry run skipped CultMesh entirely, which is why a publisher that could never publish looked healthy in every check anyone ran. - A receipt reporting a failed crossing sets a non-zero exit code. Printing ok:false and exiting 0 would hand Idunn a success it would act on. --wait-seconds is opt-in; without it the tool returns at publish and says the delivery is unconfirmed rather than implying it landed. Verified end to end against a throwaway store: the command is published, the pump claims it, dispatches discord-dm, and writes back a typed receipt. With no bot token present the receipt reads "Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM" and the publisher exits 1. The missing credential is now a reported failure instead of silence. Co-Authored-By: Claude Opus 5 --- tools/cultmesh-bridge-commands.mjs | 36 +++-- tools/operator-notification.mjs | 219 ++++++++++++++++------------- 2 files changed, 144 insertions(+), 111 deletions(-) diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 9e5b372..20b98a5 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -131,6 +131,7 @@ async function deleteCommand(options) { } async function processOneCommand(node, command, options) { + const verb = resolveBridgeVerb(command); const running = { ...command, status: "running", @@ -147,9 +148,10 @@ async function processOneCommand(node, command, options) { try { const args = [ "tools/bifrost-bridge.mjs", - "discord-post", - "--channel-id", - requireString(payload.channelId, "payload.channelId"), + verb, + ...(verb === "discord-dm" + ? ["--recipient-id", requireString(payload.recipientId, "payload.recipientId")] + : ["--channel-id", requireString(payload.channelId, "payload.channelId")]), "--content-file", contentPath, "--cultmesh-command-id", @@ -161,9 +163,11 @@ async function processOneCommand(node, command, options) { "--identity", optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost", ]; - pushOption(args, "--persona-name", payload.personaName); - pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); - pushOption(args, "--reply-to-message-id", payload.replyToMessageId); + if (verb === "discord-post") { + pushOption(args, "--persona-name", payload.personaName); + pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); + pushOption(args, "--reply-to-message-id", payload.replyToMessageId); + } pushOption(args, "--receipt-store", options["receipt-store"]); const result = spawnSync(process.execPath, args, { @@ -178,11 +182,11 @@ async function processOneCommand(node, command, options) { if (result.status !== 0 || result.error) { throw new Error(renderSpawnFailure(result)); } - const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt"); + const posted = parseJson(result.stdout, `bifrost bridge ${verb} receipt`); const receipt = buildReceipt(command, { status: "completed", ok: true, - action: "discord-post", + action: verb, channelId: posted.channelId, messageId: posted.messageId, transport: posted.transport, @@ -202,7 +206,7 @@ async function processOneCommand(node, command, options) { const receipt = buildReceipt(command, { status: "failed", ok: false, - action: "discord-post", + action: verb, canonicalReceiptId: `crossing_${command.commandId}`, error: error instanceof Error ? error.message : String(error), }); @@ -220,6 +224,18 @@ async function processOneCommand(node, command, options) { } } +const supportedBridgeVerbs = new Set(["discord-post", "discord-dm"]); + +function resolveBridgeVerb(command) { + const verb = optionalString(command.command) ?? "discord-post"; + if (!supportedBridgeVerbs.has(verb)) { + throw new Error( + `Unsupported Bifrost bridge command "${verb}". Supported: ${[...supportedBridgeVerbs].join(", ")}.`, + ); + } + return verb; +} + function buildReceipt(command, result) { const completedAt = new Date().toISOString(); return { @@ -251,7 +267,7 @@ function listCommandRecords(node) { } return node.cache.getAll(commandDefinition()) .map(unwrapRecord) - .filter((entry) => entry && entry.command === "discord-post" && entry.commandId); + .filter((entry) => entry && entry.commandId && supportedBridgeVerbs.has(entry.command ?? "discord-post")); } async function openCommandNode(storePath) { diff --git a/tools/operator-notification.mjs b/tools/operator-notification.mjs index ac4cc10..685f41b 100644 --- a/tools/operator-notification.mjs +++ b/tools/operator-notification.mjs @@ -3,28 +3,37 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { createHash } from "node:crypto"; +import { + discordPostCommandDefinition, + discordPostCommandDocumentType as commandDocumentType, + discordPostCommandSchemaId as commandSchemaId, + discordPostReceiptDefinition, + discordPostReceiptDocumentType as receiptDocumentType, +} from "./bifrost-discord-command-documents.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const projectsRoot = resolve(repoRoot, ".."); -const defaultStorePath = resolve(projectsRoot, "VoidBot", ".voidbot", "status", "cultmesh", "voidbot-swarm-state.cc"); -const requestDocumentType = "gamecult.operator_dm_request"; -const requestSchemaId = "gamecult.operator_dm_request.v1"; + +// The gate's own provider store. Bifrost owns the Discord crossing, so an alarm +// is a command addressed to the gate, not a document dropped into somebody +// else's state. cultmesh-bridge-commands.mjs reads this same path by default. +const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); async function main() { - const [command, ...rawArgs] = process.argv.slice(2); + const [verb, ...rawArgs] = process.argv.slice(2); const options = parseArgs(rawArgs); - if (!command || command === "help" || command === "--help" || command === "-h") { + if (!verb || verb === "help" || verb === "--help" || verb === "-h") { printHelp(); return; } - switch (command) { + switch (verb) { case "publish-idunn-alarm": await publishIdunnAlarm(options); return; default: - throw new Error(`Unknown command "${command}". Run "node tools/operator-notification.mjs help".`); + throw new Error(`Unknown command "${verb}". Run "node tools/operator-notification.mjs help".`); } } @@ -37,94 +46,81 @@ async function publishIdunnAlarm(options) { reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."), raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()), }; - const request = buildOperatorDmRequest(alarm); + const recipientId = + optionalString(options["recipient-id"]) ?? + optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID) ?? + optionalString(process.env.BIFROST_OPERATOR_DISCORD_ID) ?? + optionalString(process.env.OWNER_DISCORD_ID); + if (!recipientId) { + throw new Error( + "Set --recipient-id, IDUNN_OPERATOR_DISCORD_ID, BIFROST_OPERATOR_DISCORD_ID, or OWNER_DISCORD_ID " + + "so Bifrost knows which operator to reach.", + ); + } + + const command = buildDiscordDmCommand(alarm, recipientId); + const waitSeconds = + Number.parseInt(readOptionOrEnv(options, "wait-seconds", "IDUNN_ALARM_WAIT_SECONDS", "0"), 10) || 0; + + // Build the runtime and definitions before honouring --dry-run. A dry run that + // skips the runtime cannot notice a broken document definition, which is how + // this publisher shipped unable to publish anything at all. + const { CultMesh, defineDocumentType } = loadCultMeshRuntime(); + const commandDefinition = discordPostCommandDefinition(defineDocumentType); + const receiptDefinition = discordPostReceiptDefinition(defineDocumentType); if (options["dry-run"] === "true") { printJson({ dryRun: true, action: "publish-idunn-alarm", storePath, - documentType: requestDocumentType, - schemaId: requestSchemaId, - request, + documentType: commandDocumentType, + schemaId: commandSchemaId, + command, }); return; } - const { CultMesh, defineDocumentType } = loadCultMeshRuntime(); - const requestDefinition = defineDocumentType({ - type: requestDocumentType, - schemaName: requestDocumentType, - schemaId: requestSchemaId, - schemaVersion: requestSchemaId, - contentHash: requestSchemaId, - global: false, - name: "requestId", - schema: parseObjectDocument("Operator DM request"), - }); const node = await CultMesh.createNode(storePath, { - documents: [ - requestDefinition, - defineDocumentType({ - type: "voidbot.swarm_state_snapshot", - schemaName: "voidbot.swarm_state_snapshot", - schemaId: "voidbot.swarm_state_snapshot.v1", - schemaVersion: "voidbot.swarm_state_snapshot.v1", - contentHash: "voidbot.swarm_state_snapshot.v1", - global: false, - schema: parseObjectDocument("VoidBot swarm snapshot"), - }), - defineDocumentType({ - type: "gamecult.eve.provider_advertisement", - schemaName: "gamecult.eve.provider_advertisement", - schemaId: "gamecult.eve.provider_advertisement.v1", - schemaVersion: "gamecult.eve.provider_advertisement.v1", - contentHash: "gamecult.eve.provider_advertisement.v1", - global: false, - name: "providerId", - schema: parseObjectDocument("Eve provider advertisement"), - }), - defineDocumentType({ - type: "gamecult.eve.surface_state", - schemaName: "gamecult.eve.surface_state", - schemaId: "gamecult.eve.surface_state.v1", - schemaVersion: "gamecult.eve.surface_state.v1", - contentHash: "gamecult.eve.surface_state.v1", - global: false, - name: "providerId", - schema: parseObjectDocument("Eve surface state"), - }), - defineDocumentType({ - type: "gamecult.eve.interface_binding", - schemaName: "gamecult.eve.interface_binding", - schemaId: "gamecult.eve.interface_binding.v1", - schemaVersion: "gamecult.eve.interface_binding.v1", - contentHash: "gamecult.eve.interface_binding.v1", - global: false, - name: "bindingId", - schema: parseObjectDocument("Eve interface binding"), - }), - ], + documents: [commandDefinition, receiptDefinition], }); - await node.put(requestDefinition, request.requestId, request); + await node.put(commandDefinition, command.commandId, command); await node.flush?.(); + const receipt = + waitSeconds > 0 + ? await waitForReceipt(node, receiptDefinition, command.commandId, waitSeconds * 1000) + : undefined; + printJson({ - ok: true, + ok: receipt ? receipt.ok === true : true, action: "publish-idunn-alarm", storePath, - documentType: requestDocumentType, - schemaId: requestSchemaId, - requestId: request.requestId, + documentType: commandDocumentType, + schemaId: commandSchemaId, + commandId: command.commandId, + delivery: receipt + ? { + status: receipt.status, + ok: receipt.ok, + messageId: receipt.messageId, + transport: receipt.transport, + error: receipt.error, + } + : { status: "pending", note: "Not awaited. Pass --wait-seconds to confirm delivery." }, }); + + // A receipt that says the crossing failed must fail the caller. Idunn reads + // the exit code; reporting ok:false on stdout and exiting 0 would hand it a + // success it can act on. + if (receipt && receipt.ok !== true) { + process.exitCode = 1; + } } -function buildOperatorDmRequest(alarm) { - const requestId = `idunn-alarm-${createHash("sha1") - .update(JSON.stringify(alarm)) - .digest("hex") - .slice(0, 16)}`; - const message = [ +function buildDiscordDmCommand(alarm, recipientId) { + const commandId = `idunn-alarm-${createHash("sha1").update(JSON.stringify(alarm)).digest("hex").slice(0, 16)}`; + const content = [ "Idunn needs operator intervention.", "", `Service: ${alarm.daemonId}`, @@ -135,23 +131,43 @@ function buildOperatorDmRequest(alarm) { "", "Automatic recovery either was not authorized or failed.", ].join("\n"); + const requestedAt = new Date().toISOString(); - return { - requestId, - command: "owner.dm.send", + const command = { + schemaName: commandDocumentType, + schemaVersion: commandSchemaId, + commandId, + command: "discord-dm", status: "pending", - service: alarm.daemonId, - sourceId: alarm.alarmId, - severity: alarm.severity, - reason: alarm.reason, - message, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), requestedBy: "idunn", - transportOwner: "bifrost", - schemaName: requestDocumentType, - schemaVersion: requestSchemaId, + requestedAt, + updatedAt: requestedAt, + source: { kind: "idunn-operator-alarm", id: alarm.alarmId }, + actor: { id: "idunn", displayName: "Idunn" }, + payload: { recipientId, content }, }; + + const commandUri = optionalString(process.env.BIFROST_CULTMESH_COMMAND_URI); + if (commandUri) { + command.commandUri = commandUri; + } + return command; +} + +async function waitForReceipt(node, definition, commandId, timeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt <= timeoutMs) { + await node.cache?.pullAllBackingStores?.(); + const receipt = await node.get(definition, commandId); + if (receipt) { + return receipt; + } + await new Promise((done) => setTimeout(done, 250)); + } + throw new Error( + `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for a ${receiptDocumentType} for ${commandId}. ` + + "Is the Bifrost bridge command pump running?", + ); } function loadCultMeshRuntime() { @@ -178,17 +194,6 @@ function loadCultMeshRuntime() { throw new Error("CultMesh/CultCache TypeScript runtime is unavailable."); } -function parseObjectDocument(label) { - return { - parse(input) { - if (!input || typeof input !== "object") { - throw new Error(`${label} must be an object.`); - } - return input; - }, - }; -} - function readOptionOrEnv(options, optionName, envName, fallback) { return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback; } @@ -227,12 +232,24 @@ function printJson(value) { function printHelp() { process.stdout.write(`Bifrost operator notification CultMesh publisher +Publishes an Idunn operator alarm as a ${commandSchemaId} document addressed to +the Bifrost Discord gate. Bifrost owns the crossing; this tool only asks. The +bridge command pump turns the command into a DM and writes back a +${receiptDocumentType}.v1 receipt. + Commands: - publish-idunn-alarm Publish an Idunn alarm as gamecult.operator_dm_request.v1 + publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command + +Options: + --recipient-id Operator Discord user id (or IDUNN_OPERATOR_DISCORD_ID / + BIFROST_OPERATOR_DISCORD_ID / OWNER_DISCORD_ID) + --store Gate provider store (default: .bifrost/provider-store.cc) + --wait-seconds Wait for the delivery receipt instead of returning at publish + --dry-run Build the command and definitions without writing Examples: node tools/operator-notification.mjs publish-idunn-alarm --dry-run - node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" + node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" --wait-seconds 30 `); } From 26a40f494354b572c77ede2e248e466bef1e2cb6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 04:06:24 +0200 Subject: [PATCH 033/155] Read the established DISCORD_OWNER_ID rather than a new spelling The rewrite invented IDUNN_OPERATOR_DISCORD_ID, BIFROST_OPERATOR_DISCORD_ID, and OWNER_DISCORD_ID for a value VoidBot config already names DISCORD_OWNER_ID. That is the same parallel-vocabulary mistake this tool was just repaired for, in a smaller place. Read DISCORD_OWNER_ID first and keep one alias. Co-Authored-By: Claude Opus 5 --- tools/operator-notification.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/operator-notification.mjs b/tools/operator-notification.mjs index 685f41b..7b98030 100644 --- a/tools/operator-notification.mjs +++ b/tools/operator-notification.mjs @@ -46,15 +46,15 @@ async function publishIdunnAlarm(options) { reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."), raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()), }; + // DISCORD_OWNER_ID is the established GameCult name for this, used by VoidBot's + // config schema. Read it first rather than minting another spelling. const recipientId = optionalString(options["recipient-id"]) ?? - optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID) ?? - optionalString(process.env.BIFROST_OPERATOR_DISCORD_ID) ?? - optionalString(process.env.OWNER_DISCORD_ID); + optionalString(process.env.DISCORD_OWNER_ID) ?? + optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID); if (!recipientId) { throw new Error( - "Set --recipient-id, IDUNN_OPERATOR_DISCORD_ID, BIFROST_OPERATOR_DISCORD_ID, or OWNER_DISCORD_ID " + - "so Bifrost knows which operator to reach.", + "Set --recipient-id, DISCORD_OWNER_ID, or IDUNN_OPERATOR_DISCORD_ID so Bifrost knows which operator to reach.", ); } @@ -241,8 +241,8 @@ Commands: publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command Options: - --recipient-id Operator Discord user id (or IDUNN_OPERATOR_DISCORD_ID / - BIFROST_OPERATOR_DISCORD_ID / OWNER_DISCORD_ID) + --recipient-id Operator Discord user id (or DISCORD_OWNER_ID / + IDUNN_OPERATOR_DISCORD_ID) --store Gate provider store (default: .bifrost/provider-store.cc) --wait-seconds Wait for the delivery receipt instead of returning at publish --dry-run Build the command and definitions without writing From 42728a677ab8a4337068491371d0e1d34f13256b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 14:21:10 +0200 Subject: [PATCH 034/155] Remove unused postBridgeJson/postDispatchRunJson and their ensureTrailingSlash copies REC-099: `postBridgeJson` (tools/bifrost-bridge.mjs) and `postDispatchRunJson` (tools/dispatch-agent-requests.mjs) each occur exactly once in their file: their own definition, with no caller. Each was the sole caller of that file's private `ensureTrailingSlash` copy, which becomes dead in the same cut. The `ensureTrailingSlash` copies in agent-transport.mjs and governance-threads.mjs are separate, still-called definitions and were left untouched. Kill attempts that failed: none. - `rg -n "postBridgeJson" tools/*.mjs` -> only its own (now removed) definition. - `rg -n "postDispatchRunJson" tools/*.mjs` -> only its own (now removed) definition. - `rg -n "ensureTrailingSlash" tools/bifrost-bridge.mjs` and the same against tools/dispatch-agent-requests.mjs -> confirmed each file's copy had exactly one caller (the function just removed) before cutting it. Checks run: `node --check tools/bifrost-bridge.mjs` and `node --check tools/dispatch-agent-requests.mjs` -> both syntax OK. No package.json/test suite exists for these standalone tool scripts. Co-Authored-By: Claude Sonnet 5 --- tools/agent-transport.mjs | 4 ---- tools/bifrost-bridge.mjs | 36 ------------------------------- tools/dispatch-agent-requests.mjs | 36 ------------------------------- tools/governance-threads.mjs | 4 ---- 4 files changed, 80 deletions(-) diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index 5f7c54e..755eda8 100644 --- a/tools/agent-transport.mjs +++ b/tools/agent-transport.mjs @@ -810,10 +810,6 @@ function resolveOptionPath(path) { return resolve(process.cwd(), path); } -function ensureTrailingSlash(value) { - return value.endsWith("/") ? value : `${value}/`; -} - function runNodeJson(args, cwd) { const result = spawnSync(process.execPath, args, { cwd, diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 1e872e6..5b4179b 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -1302,42 +1302,6 @@ function rejectRemovedHttpBridgeConfig(options) { } } -async function postBridgeJson(baseUrl, token, path, payload, allowedStatuses) { - const url = new URL(path, ensureTrailingSlash(baseUrl)); - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Bifrost-Bridge-Token": token, - }, - body: payload === undefined ? undefined : JSON.stringify(payload), - }); - - const text = await response.text(); - let body = null; - if (text) { - try { - body = JSON.parse(text); - } catch { - body = null; - } - } - - if (!allowedStatuses.has(response.status)) { - throw new Error(`Bifrost bridge call to ${path} failed with ${response.status}: ${text}`); - } - - return { - status: response.status, - text, - body, - }; -} - -function ensureTrailingSlash(value) { - return value.endsWith("/") ? value : `${value}/`; -} - function detectGitHubRepositoryFullName(options, repoRoot) { const result = runGitHubCli( options, diff --git a/tools/dispatch-agent-requests.mjs b/tools/dispatch-agent-requests.mjs index 2376641..ca474f8 100644 --- a/tools/dispatch-agent-requests.mjs +++ b/tools/dispatch-agent-requests.mjs @@ -849,42 +849,6 @@ async function beginDispatchRun(request, input) { return null; } -async function postDispatchRunJson(baseUrl, token, path, payload, allowedStatuses) { - const url = new URL(path, ensureTrailingSlash(baseUrl)); - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Bifrost-Bridge-Token": token, - }, - body: JSON.stringify(payload), - }); - - const text = await response.text(); - let body = null; - if (text) { - try { - body = JSON.parse(text); - } catch { - body = null; - } - } - - if (!allowedStatuses.has(response.status)) { - throw new Error(`Bifrost dispatch run call to ${path} failed with ${response.status}: ${text}`); - } - - return { - status: response.status, - text, - body, - }; -} - -function ensureTrailingSlash(value) { - return value.endsWith("/") ? value : `${value}/`; -} - function summarizeError(error) { if (error instanceof Error) { return error.stack ?? error.message; diff --git a/tools/governance-threads.mjs b/tools/governance-threads.mjs index f2c34d5..52f7bdb 100644 --- a/tools/governance-threads.mjs +++ b/tools/governance-threads.mjs @@ -917,10 +917,6 @@ function resolveOptionPath(path) { return resolve(process.cwd(), path); } -function ensureTrailingSlash(value) { - return value.endsWith("/") ? value : `${value}/`; -} - function printJson(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } From 4619939e64e7f9bb6c0c497b3f33921554b98fe6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:13:43 +0200 Subject: [PATCH 035/155] Retire publish-idunn-alarm and the pump's discord-dm verb Delivery-crossing ruling: Bifrost reads Idunn's incident projection with its own delivery journal, so Idunn no longer publishes alarms into Bifrost. - Delete tools/operator-notification.mjs (259 lines); its only callers were gamecult-ops notify-idunn-operator-alarm scripts. - cultmesh-bridge-commands.mjs: drop discord-dm from supportedBridgeVerbs and the recipient-id arm; --channel-id is always passed. - bifrost-bridge.mjs discord-dm is unchanged (docs/bridge.md, BridgeCliTests). Builds: yes. Co-Authored-By: Claude Sonnet 5.5 --- tools/cultmesh-bridge-commands.mjs | 7 +- tools/operator-notification.mjs | 259 ----------------------------- 2 files changed, 3 insertions(+), 263 deletions(-) delete mode 100644 tools/operator-notification.mjs diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 20b98a5..576ace4 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -149,9 +149,8 @@ async function processOneCommand(node, command, options) { const args = [ "tools/bifrost-bridge.mjs", verb, - ...(verb === "discord-dm" - ? ["--recipient-id", requireString(payload.recipientId, "payload.recipientId")] - : ["--channel-id", requireString(payload.channelId, "payload.channelId")]), + "--channel-id", + requireString(payload.channelId, "payload.channelId"), "--content-file", contentPath, "--cultmesh-command-id", @@ -224,7 +223,7 @@ async function processOneCommand(node, command, options) { } } -const supportedBridgeVerbs = new Set(["discord-post", "discord-dm"]); +const supportedBridgeVerbs = new Set(["discord-post"]); function resolveBridgeVerb(command) { const verb = optionalString(command.command) ?? "discord-post"; diff --git a/tools/operator-notification.mjs b/tools/operator-notification.mjs deleted file mode 100644 index 7b98030..0000000 --- a/tools/operator-notification.mjs +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { createHash } from "node:crypto"; -import { - discordPostCommandDefinition, - discordPostCommandDocumentType as commandDocumentType, - discordPostCommandSchemaId as commandSchemaId, - discordPostReceiptDefinition, - discordPostReceiptDocumentType as receiptDocumentType, -} from "./bifrost-discord-command-documents.mjs"; - -const repoRoot = resolve(import.meta.dirname, ".."); -const projectsRoot = resolve(repoRoot, ".."); - -// The gate's own provider store. Bifrost owns the Discord crossing, so an alarm -// is a command addressed to the gate, not a document dropped into somebody -// else's state. cultmesh-bridge-commands.mjs reads this same path by default. -const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); - -async function main() { - const [verb, ...rawArgs] = process.argv.slice(2); - const options = parseArgs(rawArgs); - - if (!verb || verb === "help" || verb === "--help" || verb === "-h") { - printHelp(); - return; - } - - switch (verb) { - case "publish-idunn-alarm": - await publishIdunnAlarm(options); - return; - default: - throw new Error(`Unknown command "${verb}". Run "node tools/operator-notification.mjs help".`); - } -} - -async function publishIdunnAlarm(options) { - const storePath = resolve(options.store ?? defaultStorePath); - const alarm = { - alarmId: readOptionOrEnv(options, "alarm-id", "IDUNN_ALARM_ID", "unknown-alarm"), - daemonId: readOptionOrEnv(options, "daemon-id", "IDUNN_ALARM_DAEMON_ID", "unknown-daemon"), - severity: readOptionOrEnv(options, "severity", "IDUNN_ALARM_SEVERITY", "operator-action-required"), - reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."), - raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()), - }; - // DISCORD_OWNER_ID is the established GameCult name for this, used by VoidBot's - // config schema. Read it first rather than minting another spelling. - const recipientId = - optionalString(options["recipient-id"]) ?? - optionalString(process.env.DISCORD_OWNER_ID) ?? - optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID); - if (!recipientId) { - throw new Error( - "Set --recipient-id, DISCORD_OWNER_ID, or IDUNN_OPERATOR_DISCORD_ID so Bifrost knows which operator to reach.", - ); - } - - const command = buildDiscordDmCommand(alarm, recipientId); - const waitSeconds = - Number.parseInt(readOptionOrEnv(options, "wait-seconds", "IDUNN_ALARM_WAIT_SECONDS", "0"), 10) || 0; - - // Build the runtime and definitions before honouring --dry-run. A dry run that - // skips the runtime cannot notice a broken document definition, which is how - // this publisher shipped unable to publish anything at all. - const { CultMesh, defineDocumentType } = loadCultMeshRuntime(); - const commandDefinition = discordPostCommandDefinition(defineDocumentType); - const receiptDefinition = discordPostReceiptDefinition(defineDocumentType); - - if (options["dry-run"] === "true") { - printJson({ - dryRun: true, - action: "publish-idunn-alarm", - storePath, - documentType: commandDocumentType, - schemaId: commandSchemaId, - command, - }); - return; - } - - const node = await CultMesh.createNode(storePath, { - documents: [commandDefinition, receiptDefinition], - }); - await node.put(commandDefinition, command.commandId, command); - await node.flush?.(); - - const receipt = - waitSeconds > 0 - ? await waitForReceipt(node, receiptDefinition, command.commandId, waitSeconds * 1000) - : undefined; - - printJson({ - ok: receipt ? receipt.ok === true : true, - action: "publish-idunn-alarm", - storePath, - documentType: commandDocumentType, - schemaId: commandSchemaId, - commandId: command.commandId, - delivery: receipt - ? { - status: receipt.status, - ok: receipt.ok, - messageId: receipt.messageId, - transport: receipt.transport, - error: receipt.error, - } - : { status: "pending", note: "Not awaited. Pass --wait-seconds to confirm delivery." }, - }); - - // A receipt that says the crossing failed must fail the caller. Idunn reads - // the exit code; reporting ok:false on stdout and exiting 0 would hand it a - // success it can act on. - if (receipt && receipt.ok !== true) { - process.exitCode = 1; - } -} - -function buildDiscordDmCommand(alarm, recipientId) { - const commandId = `idunn-alarm-${createHash("sha1").update(JSON.stringify(alarm)).digest("hex").slice(0, 16)}`; - const content = [ - "Idunn needs operator intervention.", - "", - `Service: ${alarm.daemonId}`, - `Severity: ${alarm.severity}`, - `Reason: ${alarm.reason}`, - `Alarm: ${alarm.alarmId}`, - `Raised: ${alarm.raisedAt}`, - "", - "Automatic recovery either was not authorized or failed.", - ].join("\n"); - const requestedAt = new Date().toISOString(); - - const command = { - schemaName: commandDocumentType, - schemaVersion: commandSchemaId, - commandId, - command: "discord-dm", - status: "pending", - requestedBy: "idunn", - requestedAt, - updatedAt: requestedAt, - source: { kind: "idunn-operator-alarm", id: alarm.alarmId }, - actor: { id: "idunn", displayName: "Idunn" }, - payload: { recipientId, content }, - }; - - const commandUri = optionalString(process.env.BIFROST_CULTMESH_COMMAND_URI); - if (commandUri) { - command.commandUri = commandUri; - } - return command; -} - -async function waitForReceipt(node, definition, commandId, timeoutMs) { - const startedAt = Date.now(); - while (Date.now() - startedAt <= timeoutMs) { - await node.cache?.pullAllBackingStores?.(); - const receipt = await node.get(definition, commandId); - if (receipt) { - return receipt; - } - await new Promise((done) => setTimeout(done, 250)); - } - throw new Error( - `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for a ${receiptDocumentType} for ${commandId}. ` + - "Is the Bifrost bridge command pump running?", - ); -} - -function loadCultMeshRuntime() { - const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"), - resolve(projectsRoot, "CultMeshTS", "package.json"), - ]; - - for (const packageJson of candidates) { - if (!existsSync(packageJson)) { - continue; - } - try { - const requireCult = createRequire(packageJson); - const { CultMesh } = requireCult("cultmesh-ts"); - const { defineDocumentType } = requireCult("cultcache-ts"); - if (CultMesh && defineDocumentType) { - return { CultMesh, defineDocumentType }; - } - } catch { - } - } - - throw new Error("CultMesh/CultCache TypeScript runtime is unavailable."); -} - -function readOptionOrEnv(options, optionName, envName, fallback) { - return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback; -} - -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 optionalString(value) { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function printHelp() { - process.stdout.write(`Bifrost operator notification CultMesh publisher - -Publishes an Idunn operator alarm as a ${commandSchemaId} document addressed to -the Bifrost Discord gate. Bifrost owns the crossing; this tool only asks. The -bridge command pump turns the command into a DM and writes back a -${receiptDocumentType}.v1 receipt. - -Commands: - publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command - -Options: - --recipient-id Operator Discord user id (or DISCORD_OWNER_ID / - IDUNN_OPERATOR_DISCORD_ID) - --store Gate provider store (default: .bifrost/provider-store.cc) - --wait-seconds Wait for the delivery receipt instead of returning at publish - --dry-run Build the command and definitions without writing - -Examples: - node tools/operator-notification.mjs publish-idunn-alarm --dry-run - node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" --wait-seconds 30 -`); -} - -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); From 9c954e7502bcea700acff8d8b826ba1fa52f5f89 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:59:51 +0200 Subject: [PATCH 036/155] Bifrost pump: one verb, one path; refuse anything else with a failed receipt With discord-dm gone the verb set, listCommandRecords filter, resolveBridgeVerb throw and the verb variable decided nothing, and the filter hid non-discord-post commands so they stayed pending with no receipt. Delete all of it: a command whose verb is not discord-post now reaches processOneCommand and is refused with a failed receipt (error names no input value). Payload staging moves inside the try so a refusal cannot escape as a thrown error. Adds tests/cultmesh-bridge-commands.test.mjs: discord-dm refused with failed receipt and non-ok result, canary-free error, discord-post still reaches the actuator. Co-Authored-By: Claude Sonnet 5.5 --- tests/cultmesh-bridge-commands.test.mjs | 89 +++++++++++++++++++++++++ tools/cultmesh-bridge-commands.mjs | 38 ++++------- 2 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 tests/cultmesh-bridge-commands.test.mjs diff --git a/tests/cultmesh-bridge-commands.test.mjs b/tests/cultmesh-bridge-commands.test.mjs new file mode 100644 index 0000000..2c0d56f --- /dev/null +++ b/tests/cultmesh-bridge-commands.test.mjs @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { + discordPostCommandDefinition, + discordPostCommandDocumentType, + discordPostCommandSchemaId, +} from "../tools/bifrost-discord-command-documents.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const cultLib = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "CultLib")); +const tool = resolve(root, "tools", "cultmesh-bridge-commands.mjs"); +const canary = "CANARY-9f3a1c77"; + +function loadRuntime() { + const mesh = resolve(cultLib, "packages", "cultmesh-ts", "dist", "index.js"); + const cache = resolve(cultLib, "packages", "cultcache-ts", "dist", "index.js"); + const requireCult = createRequire(mesh); + return { CultMesh: requireCult(mesh).CultMesh, defineDocumentType: requireCult(cache).defineDocumentType }; +} + +async function putCommand(store, commandId, command) { + const { CultMesh, defineDocumentType } = loadRuntime(); + const definition = discordPostCommandDefinition(defineDocumentType); + const node = await CultMesh.createNode(store, { documents: [definition] }); + await node.put(definition, commandId, { + schemaName: discordPostCommandDocumentType, + schemaVersion: discordPostCommandSchemaId, + commandId, + command, + status: "pending", + requestedBy: "test", + requestedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + source: { kind: "test", id: commandId }, + payload: { channelId: "123", content: canary }, + }); + await node.flush?.(); +} + +function run(...args) { + const env = { ...process.env, BIFROST_SKIP_LOCAL_ENV: "true", VOIDBOT_CULTLIB_ROOT: cultLib }; + delete env.BIFROST_DISCORD_BOT_TOKEN; + delete env.DISCORD_BOT_TOKEN; + const result = spawnSync(process.execPath, [tool, ...args], { cwd: root, encoding: "utf8", env }); + return { ...result, json: JSON.parse(result.stdout) }; +} + +test("a non-discord-post command is refused with a failed receipt, never left pending", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "dm1", "discord-dm"); + + const processed = run("process", "--store", store); + assert.equal(processed.json.commandCount, 1); + assert.equal(processed.json.ok, false); + assert.equal(processed.json.processed[0].status, "failed"); + + const receipt = run("receipt", "--store", store, "--command-id", "dm1").json; + assert.equal(receipt.ok, false); + assert.equal(receipt.receipt.status, "failed"); + assert.equal(receipt.receipt.ok, false); + assert.match(receipt.receipt.error, /only discord-post is supported/); + + const again = run("process", "--store", store); + assert.equal(again.json.commandCount, 0, "a refused command is not pending or running afterwards"); +}); + +test("a refusal echoes no input value", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "dm2", `${canary}-verb`); + const processed = run("process", "--store", store, "--command-id", "dm2"); + assert.equal(processed.json.processed[0].status, "failed"); + assert.equal(processed.json.processed[0].error.includes(canary), false); +}); + +test("a discord-post command still reaches the actuator", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "post1", "discord-post"); + const processed = run("process", "--store", store); + assert.equal(processed.json.commandCount, 1); + const receipt = processed.json.processed[0]; + assert.equal(receipt.action, "discord-post"); + assert.match(receipt.error, /Bifrost Discord actuator failed/); + assert.doesNotMatch(receipt.error, /only discord-post is supported/); +}); diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 576ace4..8d4885b 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -131,7 +131,6 @@ async function deleteCommand(options) { } async function processOneCommand(node, command, options) { - const verb = resolveBridgeVerb(command); const running = { ...command, status: "running", @@ -142,13 +141,16 @@ async function processOneCommand(node, command, options) { const payload = command.payload && typeof command.payload === "object" ? command.payload : {}; const tempDir = resolve(repoRoot, ".bifrost", "cultmesh-command-payloads", command.commandId); const contentPath = resolve(tempDir, "content.md"); - await mkdir(tempDir, { recursive: true }); - await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8"); try { + if ((optionalString(command.command) ?? "discord-post") !== "discord-post") { + throw new Error("Unsupported Bifrost bridge command; only discord-post is supported."); + } + await mkdir(tempDir, { recursive: true }); + await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8"); const args = [ "tools/bifrost-bridge.mjs", - verb, + "discord-post", "--channel-id", requireString(payload.channelId, "payload.channelId"), "--content-file", @@ -162,11 +164,9 @@ async function processOneCommand(node, command, options) { "--identity", optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost", ]; - if (verb === "discord-post") { - pushOption(args, "--persona-name", payload.personaName); - pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); - pushOption(args, "--reply-to-message-id", payload.replyToMessageId); - } + pushOption(args, "--persona-name", payload.personaName); + pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); + pushOption(args, "--reply-to-message-id", payload.replyToMessageId); pushOption(args, "--receipt-store", options["receipt-store"]); const result = spawnSync(process.execPath, args, { @@ -181,11 +181,11 @@ async function processOneCommand(node, command, options) { if (result.status !== 0 || result.error) { throw new Error(renderSpawnFailure(result)); } - const posted = parseJson(result.stdout, `bifrost bridge ${verb} receipt`); + const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt"); const receipt = buildReceipt(command, { status: "completed", ok: true, - action: verb, + action: "discord-post", channelId: posted.channelId, messageId: posted.messageId, transport: posted.transport, @@ -205,7 +205,7 @@ async function processOneCommand(node, command, options) { const receipt = buildReceipt(command, { status: "failed", ok: false, - action: verb, + action: "discord-post", canonicalReceiptId: `crossing_${command.commandId}`, error: error instanceof Error ? error.message : String(error), }); @@ -223,18 +223,6 @@ async function processOneCommand(node, command, options) { } } -const supportedBridgeVerbs = new Set(["discord-post"]); - -function resolveBridgeVerb(command) { - const verb = optionalString(command.command) ?? "discord-post"; - if (!supportedBridgeVerbs.has(verb)) { - throw new Error( - `Unsupported Bifrost bridge command "${verb}". Supported: ${[...supportedBridgeVerbs].join(", ")}.`, - ); - } - return verb; -} - function buildReceipt(command, result) { const completedAt = new Date().toISOString(); return { @@ -266,7 +254,7 @@ function listCommandRecords(node) { } return node.cache.getAll(commandDefinition()) .map(unwrapRecord) - .filter((entry) => entry && entry.commandId && supportedBridgeVerbs.has(entry.command ?? "discord-post")); + .filter((entry) => entry && entry.commandId); } async function openCommandNode(storePath) { From 868df35449bc0fe3f6d79177e0aac13556d02211 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 17:16:39 +0200 Subject: [PATCH 037/155] Bifrost suite: load @gamecult/cultcache-ts; skip Rust fixture tests with a stated reason CultLib 8cb3b728 renamed only cultcache-ts to @gamecult/cultcache-ts; the suite and three tools still required the bare name. Rename the require string in tests/persona-feedback-cli, persona-discord-delivery, persona-discord-crossing-rust-smoke, tools/persona-feedback (:114), agent-transport and governance-threads. No fallback: a pre-8cb3b728 CultLib fails loudly at import. operator-notification.mjs is untouched (retire cut). The two Rust fixture tests skip, naming the missing producer (Epiphany removed both fixture bins in 387afe49), and drop the hard-coded CARGO_TARGET_DIR override. Builds. Co-Authored-By: Claude Sonnet 5.5 --- tests/persona-discord-crossing-rust-smoke.test.mjs | 6 ++++-- tests/persona-discord-delivery.test.mjs | 2 +- tests/persona-discord-rudp-cross-language.test.mjs | 6 ++++-- tests/persona-feedback-cli.test.mjs | 6 +++--- tools/agent-transport.mjs | 2 +- tools/governance-threads.mjs | 2 +- tools/persona-feedback.mjs | 2 +- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/persona-discord-crossing-rust-smoke.test.mjs b/tests/persona-discord-crossing-rust-smoke.test.mjs index d48dce5..482fcf9 100644 --- a/tests/persona-discord-crossing-rust-smoke.test.mjs +++ b/tests/persona-discord-crossing-rust-smoke.test.mjs @@ -1,4 +1,5 @@ import test from "node:test"; +import {existsSync} from "node:fs"; import assert from "node:assert/strict"; import {spawnSync} from "node:child_process"; import {chmod,mkdtemp,writeFile} from "node:fs/promises"; @@ -10,9 +11,10 @@ import {receiptIdentityDomain} from "../tools/persona-discord-delivery-documents import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; const epiphany=resolve(import.meta.dirname,"..","..","Epiphany"),cult=resolve(import.meta.dirname,"..","..","CultLib"); -function runtime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} +function runtime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("@gamecult/cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} -test("Bifrost consumes a Rust-authored request store through its read-only crossing directory",async()=>{ +const rustFixture=existsSync(resolve(epiphany,"epiphany-core","src","bin","epiphany-persona-discord-crossing-fixture.rs"))&&spawnSync("cargo",["--version"]).status===0; +test("Bifrost consumes a Rust-authored request store through its read-only crossing directory",{skip:rustFixture?false:"needs cargo and Epiphany's epiphany-persona-discord-crossing-fixture fixture (removed in Epiphany 387afe49)"},async()=>{ const root=await mkdtemp(resolve(tmpdir(),"rust-bifrost-crossing-")),requestDir=resolve(root,"request"),receiptDir=resolve(root,"receipt"),requestStore=resolve(requestDir,"requests.cc"),anchor=resolve(root,"mouth-anchor.msgpack"),identity=resolve(root,"mouth.cc"),deliveryKey=resolve(root,"delivery.seed"),bridge=resolve(root,"bridge.mjs"); const cargo=spawnSync("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-crossing-fixture","--","--request-store",requestStore,"--identity-store",identity,"--request-anchor",anchor],{cwd:epiphany,encoding:"utf8",timeout:180000,windowsHide:true,env:{...process.env,CARGO_TARGET_DIR:"C:\\Users\\Meta\\.cargo-target-codex"}}); assert.equal(cargo.status,0,cargo.stderr||cargo.stdout); diff --git a/tests/persona-discord-delivery.test.mjs b/tests/persona-discord-delivery.test.mjs index 53e73e8..ca4b65f 100644 --- a/tests/persona-discord-delivery.test.mjs +++ b/tests/persona-discord-delivery.test.mjs @@ -30,7 +30,7 @@ test("permit intent survives network failure and restart reuses its exact reques test("expired permit intent restarts as terminal unknown without requesting or posting",async()=>{const f=await deliveryFixture("permit-expired-"),digest=`sha256-${createHash("sha256").update(f.runtime.encode(requestSigningTuple(f.value))).digest("hex")}`,executions=await open(f.runtime,f.defs.execution,f.executionStore),issuedAt=new Date(Date.now()-6000).toISOString(),expiresAt=new Date(Date.now()-1000).toISOString();await executions.put(f.defs.execution,f.value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:f.value.requestId,requestPayloadSha256:digest,status:"permit_pending",recordedAt:issuedAt,permitNonce:"persisted-nonce",permitIssuedAt:issuedAt,permitExpiresAt:expiresAt,detail:"restart fixture"});let permitCalls=0;const results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async()=>{permitCalls++;throw new Error("must not request");},bridgeCliPath:resolve(f.root,"must-not-post.mjs")});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");assert.equal(permitCalls,0);await assert.rejects(import("node:fs/promises").then(fs=>fs.readFile(f.counterPath,"utf8")));}); -function loadRuntime(){const cult=resolve(import.meta.dirname,"..","..","CultLib"),cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} +function loadRuntime(){const cult=resolve(import.meta.dirname,"..","..","CultLib"),cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("@gamecult/cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} async function open(runtime,definition,path){const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return cache;} function requestAnchor(identity){return ["gamecult.service_trust_anchor.v1","epiphany-persona-mouth:epiphany-yggdrasil:v0","epiphany-persona-mouth","epiphany-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",requestSigningPurpose,"epiphany.persona_discord_delivery_request.v0","root",Date.now(),null,false];} async function deliveryFixture(prefix){const root=await mkdtemp(resolve(tmpdir(),prefix)),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgeCliPath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain);await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgeCliPath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);return {root,runtime,defs,value,executionStore,counterPath,options:{runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath,bifrostRoot:root}};} diff --git a/tests/persona-discord-rudp-cross-language.test.mjs b/tests/persona-discord-rudp-cross-language.test.mjs index d52e542..8212f97 100644 --- a/tests/persona-discord-rudp-cross-language.test.mjs +++ b/tests/persona-discord-rudp-cross-language.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; +import {existsSync} from "node:fs"; import assert from "node:assert/strict"; -import {spawn} from "node:child_process"; +import {spawn,spawnSync} from "node:child_process"; import {createHash} from "node:crypto"; import {createRequire} from "node:module"; import {mkdtemp,readFile,writeFile} from "node:fs/promises"; @@ -12,7 +13,8 @@ import {startPersonaDiscordDeliveryRudpServer} from "../tools/persona-discord-ru const epiphany=resolve(import.meta.dirname,"..","..","Epiphany"); -test("Rust Persona mouth and Node Bifrost exchange one signed request and terminal receipt over CultNet RUDP",async()=>{ +const rustFixture=existsSync(resolve(epiphany,"epiphany-core","src","bin","epiphany-persona-discord-rudp-client-fixture.rs"))&&spawnSync("cargo",["--version"]).status===0; +test("Rust Persona mouth and Node Bifrost exchange one signed request and terminal receipt over CultNet RUDP",{skip:rustFixture?false:"needs cargo and Epiphany's epiphany-persona-discord-rudp-client-fixture fixture (removed in Epiphany 387afe49)"},async()=>{ const root=await mkdtemp(resolve(tmpdir(),"persona-rudp-cross-language-")),runtime=loadRuntime(),deliveryIdentity=await enrollDomainIdentity(resolve(root,"delivery.seed"),"delivery identity",receiptIdentityDomain),receiptAnchor=resolve(root,"receipt-anchor.msgpack"),requestAnchor=resolve(root,"request-anchor.msgpack"),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),identityStore=resolve(root,"mouth.cc"); await writeFile(receiptAnchor,runtime.msgpack.encode(["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",deliveryIdentity.identityId,new Uint8Array(deliveryIdentity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false])); let admitted; diff --git a/tests/persona-feedback-cli.test.mjs b/tests/persona-feedback-cli.test.mjs index 3333802..ff6f749 100644 --- a/tests/persona-feedback-cli.test.mjs +++ b/tests/persona-feedback-cli.test.mjs @@ -12,7 +12,7 @@ import { conversationFromDiscordMessage, discordGatewayReady } from "../tools/bi 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 {defineDocumentType}=cr("@gamecult/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()=>{ @@ -52,14 +52,14 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async( 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 deliveryCache=cr("@gamecult/cultcache-ts").CultCache.builder().withDocumentType(defs.delivery).withGenericStore(new (cr("@gamecult/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.pendingFailedCount,0);assert.equal(readiness.privateStateExposed,false); const staged=cli("status",store,"--observation-store",observationStore,"--private-key",key,"--epiphany-persona-mouth-trust-anchor",resolve(dir,"missing-mouth-anchor.msgpack"),"--epiphany-runtime-id","epiphany-yggdrasil","--epiphany-persona-permit-trust-anchor",resolve(dir,"missing-permit-anchor.msgpack"),"--epiphany-persona-permit-rudp","rudp://127.0.0.1:9","--persona-delivery-private-key",resolve(dir,"missing-delivery.seed"),"--persona-permit-request-private-key",resolve(dir,"missing-permit-request.seed")); assert.equal(staged.ingressReady,true);assert.deepEqual(staged.ingressReasons,[]);assert.equal(staged.personaDelivery.ready,false);assert.equal(staged.status,"degraded"); const afterStatus=await Promise.all([store,observationStore,deliveryStore,key].map(path=>readFile(path)));assert.deepEqual(afterStatus,beforeStatus); - const Store=cr("cultcache-ts").SingleFileMessagePackBackingStore; + const Store=cr("@gamecult/cultcache-ts").SingleFileMessagePackBackingStore; assert.deepEqual([...new Set((await new Store(observationStore).pullAll()).map(entry=>entry.type))],["bifrost.discord.persona_conversation_event"]); assert.equal((await new Store(store).pullAll()).some(entry=>entry.type==="bifrost.discord.persona_conversation_event"),false); assert.deepEqual([...new Set((await new Store(deliveryStore).pullAll()).map(entry=>entry.type))],["bifrost.persona_feedback.delivery"]); diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index 755eda8..0940caa 100644 --- a/tools/agent-transport.mjs +++ b/tools/agent-transport.mjs @@ -37,7 +37,7 @@ const { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, -} = cultCacheRequire("cultcache-ts"); +} = cultCacheRequire("@gamecult/cultcache-ts"); const { CultNetDocumentRegistry, defineCultNetDocumentBinding, diff --git a/tools/governance-threads.mjs b/tools/governance-threads.mjs index 52f7bdb..832e5f7 100644 --- a/tools/governance-threads.mjs +++ b/tools/governance-threads.mjs @@ -33,7 +33,7 @@ const { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, -} = cultCacheRequire("cultcache-ts"); +} = cultCacheRequire("@gamecult/cultcache-ts"); function resolveFirstExisting(label, candidates) { for (const candidate of candidates) { diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index 4bb508c..b586a58 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -111,7 +111,7 @@ async function exportSnapshot(){const n=await privateNode();await n.cache?.pullA async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.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).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.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){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||command==="serve-persona-delivery"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"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"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} +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("@gamecult/cultcache-ts"),cn=nr("cultnet-ts"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} function bindingId(o){return `feedback:${o["guild-id"]}:${o["channel-id"]}:${o["runtime-id"]}:${o.repo}:${o["persona-id"]}:${o["producer-id"]||"bifrost-discord"}:${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];} From e8483c056b0410eebbc2e781f8a971f49004d979 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 17:16:55 +0200 Subject: [PATCH 038/155] Bifrost suite: drop the hard-coded CARGO_TARGET_DIR override from the Rust fixture tests The two fixture tests forced a workstation-only target dir (C:\Users\Meta\.cargo-target-codex); the child now inherits process.env. Follow-up to 868df354, which missed this deletion. Builds. Co-Authored-By: Claude Sonnet 5.5 --- tests/persona-discord-crossing-rust-smoke.test.mjs | 2 +- tests/persona-discord-rudp-cross-language.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/persona-discord-crossing-rust-smoke.test.mjs b/tests/persona-discord-crossing-rust-smoke.test.mjs index 482fcf9..cc9ed61 100644 --- a/tests/persona-discord-crossing-rust-smoke.test.mjs +++ b/tests/persona-discord-crossing-rust-smoke.test.mjs @@ -16,7 +16,7 @@ function runtime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts" const rustFixture=existsSync(resolve(epiphany,"epiphany-core","src","bin","epiphany-persona-discord-crossing-fixture.rs"))&&spawnSync("cargo",["--version"]).status===0; test("Bifrost consumes a Rust-authored request store through its read-only crossing directory",{skip:rustFixture?false:"needs cargo and Epiphany's epiphany-persona-discord-crossing-fixture fixture (removed in Epiphany 387afe49)"},async()=>{ const root=await mkdtemp(resolve(tmpdir(),"rust-bifrost-crossing-")),requestDir=resolve(root,"request"),receiptDir=resolve(root,"receipt"),requestStore=resolve(requestDir,"requests.cc"),anchor=resolve(root,"mouth-anchor.msgpack"),identity=resolve(root,"mouth.cc"),deliveryKey=resolve(root,"delivery.seed"),bridge=resolve(root,"bridge.mjs"); - const cargo=spawnSync("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-crossing-fixture","--","--request-store",requestStore,"--identity-store",identity,"--request-anchor",anchor],{cwd:epiphany,encoding:"utf8",timeout:180000,windowsHide:true,env:{...process.env,CARGO_TARGET_DIR:"C:\\Users\\Meta\\.cargo-target-codex"}}); + const cargo=spawnSync("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-crossing-fixture","--","--request-store",requestStore,"--identity-store",identity,"--request-anchor",anchor],{cwd:epiphany,encoding:"utf8",timeout:180000,windowsHide:true}); assert.equal(cargo.status,0,cargo.stderr||cargo.stdout); await chmod(requestStore,0o444); await enrollDomainIdentity(deliveryKey,"delivery",receiptIdentityDomain); diff --git a/tests/persona-discord-rudp-cross-language.test.mjs b/tests/persona-discord-rudp-cross-language.test.mjs index 8212f97..f22f93f 100644 --- a/tests/persona-discord-rudp-cross-language.test.mjs +++ b/tests/persona-discord-rudp-cross-language.test.mjs @@ -20,7 +20,7 @@ test("Rust Persona mouth and Node Bifrost exchange one signed request and termin let admitted; const server=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:"rudp://127.0.0.1:0",onRequest:async request=>{admitted=request;const digest=`sha256-${createHash("sha256").update(runtime.msgpack.encode(requestSigningTuple(request))).digest("hex")}`,receipt={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status:"completed",channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:"node-rudp-message",transport:"bifrost.discord-post",crossingReceiptId:"node-rudp-crossing",receiptUrl:`https://discord.com/channels/g/${request.channelId}/node-rudp-message`,completedAt:new Date().toISOString(),providerIdentityId:deliveryIdentity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(deliveryIdentity,receiptSignatureDomain,receiptSigningPurpose,runtime.msgpack.encode(receiptSigningTuple(receipt))));return receipt;}}); try{ - const output=await run("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-rudp-client-fixture","--","--request-store",requestStore,"--receipt-store",receiptStore,"--identity-store",identityStore,"--request-anchor",requestAnchor,"--receipt-anchor",receiptAnchor,"--endpoint",`127.0.0.1:${server.address.port}`],{cwd:epiphany,env:{...process.env,CARGO_TARGET_DIR:"C:\\Users\\Meta\\.cargo-target-codex"}}); + const output=await run("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-rudp-client-fixture","--","--request-store",requestStore,"--receipt-store",receiptStore,"--identity-store",identityStore,"--request-anchor",requestAnchor,"--receipt-anchor",receiptAnchor,"--endpoint",`127.0.0.1:${server.address.port}`],{cwd:epiphany}); assert.equal(output.code,0,`${output.stderr}\nserver diagnostics: ${JSON.stringify(server.diagnostics)}`);const result=JSON.parse(output.stdout.trim());assert.equal(result.status,"completed");assert.equal(result.messageId,"node-rudp-message");assert.equal(admitted.targetRuntimeId,"epiphany-starfire");assert.ok((await readFile(requestStore)).length>0);assert.ok((await readFile(receiptStore)).length>0); }finally{server.close();} }); From 417454c28f8776b5ec0b56a52d411194e6ebce40 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:15:18 +0200 Subject: [PATCH 039/155] Watchdog notice reader: Idunn incidents become owner DMs through Bifrost's own journal tools/watchdog-notice.mjs is a one-shot reader. It reads Idunn's incident store unlocked with SingleFileMessagePackBackingStore.pullAll, sends one opening and one closing DM per incident through bifrost-bridge.mjs, and keeps a bifrost.watchdog_notice_execution.v1 journal: a running entry becomes unknown and is never re-posted, a failed one is retried with the same nonce up to five times, terminal entries are pruned once Idunn retires the incident. The exit status is the delivery-failure signal. discord-dm accepts --nonce (at most 25 chars) and sends nonce with enforce_nonce so a retry inside Discord's window returns the original message. The tests run the CLI against the store bytes Idunn wrote (fixture from GameCult/Idunn 7f7528b3) with a counting bridge double, and once through the real bridge with fetch stubbed. docs/watchdog-notice.md states the CultLib >= 8cb3b728 floor. Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 39 +++ tests/fixtures/idunn.operator_incident.v1.cc | Bin 0 -> 737 bytes tests/watchdog-notice.test.mjs | 323 +++++++++++++++++++ tools/bifrost-bridge.mjs | 9 +- tools/watchdog-notice.mjs | 284 ++++++++++++++++ 5 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 docs/watchdog-notice.md create mode 100644 tests/fixtures/idunn.operator_incident.v1.cc create mode 100644 tests/watchdog-notice.test.mjs create mode 100644 tools/watchdog-notice.mjs diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md new file mode 100644 index 0000000..50e8235 --- /dev/null +++ b/docs/watchdog-notice.md @@ -0,0 +1,39 @@ +# Watchdog notice reader + +`tools/watchdog-notice.mjs` is a one-shot reader. It turns Idunn's operator +incidents into Discord DMs to the owner. Run it from a timer. + +``` +node tools/watchdog-notice.mjs process --incident-store P --journal-store J --receipt-store R [--bridge-cli B] +node tools/watchdog-notice.mjs status --journal-store J +``` + +## Requirements + +- **CultLib at `8cb3b728` or later.** The reader loads `@gamecult/cultcache-ts`, + the name CultLib gave the package in `8cb3b728` (2026-09-04), from + `$VOIDBOT_CULTLIB_ROOT` or the sibling `../CultLib`. An older CultLib fails at + import with `MODULE_NOT_FOUND`; the reader reports `cultlib-unavailable`. The + same floor applies to every Bifrost tool that loads CultCache + (`tools/persona-feedback.mjs` and the others). A deployment pairs a Bifrost + release with a CultLib commit at or after this one. +- `DISCORD_OWNER_ID` in the environment: the only source of the recipient. The + reader exits non-zero before writing the journal if it is missing. +- `BIFROST_DISCORD_BOT_TOKEN` in the environment, read only by + `bifrost-bridge.mjs`, which the reader spawns. + +## Behaviour + +- P is Idunn's `idunn.operator_incident.v1` store. The reader reads it without a + lock and never writes it. An absent P is a clean no-op. +- One notice when an incident opens and one when it closes, each at most once. + The closing notice is sent only after the opening notice completed. +- J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed + `#opened` or `#closed`. A `running` entry found at + start becomes `unknown` and is never sent again. A `failed` entry is retried on + later runs with the same nonce, up to five attempts. Terminal entries are + deleted once Idunn no longer lists the incident. +- The exit status is non-zero while any entry is `unknown` or out of attempts, or + while P holds a record of this schema that breaks its contract. systemd shows + that; `status` lists the entries. +- Notice text is built from `condition`, `subject` and ISO UTC times only. diff --git a/tests/fixtures/idunn.operator_incident.v1.cc b/tests/fixtures/idunn.operator_incident.v1.cc new file mode 100644 index 0000000000000000000000000000000000000000..180c6f0f2ae325984fa3c77bc4f3d193916ac76d GIT binary patch literal 737 zcmbQdDY-PKBsnoTBUP`sB)=$Cugq}b^j(=LrFnUJ`30#(i9o^l%)I2xl+?TupyYNO zQhRZT1GNAV9s_S?R4Ww&tZVp?B$gJJq^4LU=j0csrs$R=7Nw__SQ(ld zAOIfob{ZKN8S5Gv>KdAa7#Ud^npzncXd4&+$s@h^f|qnhY{BO0wJ5GW&%nUgeg0_% z1BCJH-GY@xsmb|esYR(NQ*P>EGr$TM_jzcZHU#^eJWp5P^)!i2!0P4ING^u^^S}WB D;7=HM literal 0 HcmV?d00001 diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs new file mode 100644 index 0000000..3bf1dd2 --- /dev/null +++ b/tests/watchdog-notice.test.mjs @@ -0,0 +1,323 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, stat, writeFile, copyFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +// The fixture is the store Idunn's incident cut wrote: GameCult/Idunn branch +// hands/watchdog-idunn-incident, commit 7f7528b33e8e5c607c19cc7f8f8a8da4def50fca +// ("Add the idunn.operator_incident.v1 wire fixture"), tests/fixtures/ there. +// It holds two records: closed-target (closed, recovered) and open-target. +const FIXTURE = resolve(import.meta.dirname, "fixtures", "idunn.operator_incident.v1.cc"); +const FIXTURE_SHA256 = "f446770b07da99204e9036ef521f3136be6251e5b0a68e4e9533a89f25f18cab"; +const CLOSED_KEY = "continuity-exhausted:closed-target:1700000000000"; +const OPEN_KEY = "continuity-exhausted:open-target:1700000100000"; +const TOOL = resolve(import.meta.dirname, "..", "tools", "watchdog-notice.mjs"); +const BRIDGE = resolve(import.meta.dirname, "..", "tools", "bifrost-bridge.mjs"); +const RECIPIENT = "424242RECIPIENTCANARY"; + +const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on ${subject} at ${at}`; +const T_OPEN = "2023-11-14T22:13:20.000Z"; +const T_CLOSE = "2023-11-14T22:15:00.000Z"; +const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on closed-target, opened ${T_OPEN}, closed ${T_CLOSE}`; + +// CultLib is the sibling ../CultLib, like the other tests. +const cult = resolve(import.meta.dirname, "..", "..", "CultLib"); +const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.json"))("@gamecult/cultcache-ts"); +const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack"); + +// A bridge double that logs each call (with the content it was handed) and +// behaves as the `mode` file says: ok, fail, or crash (kills its parent, the +// reader, after logging, as a power cut between send and journal would). +const FAKE_BRIDGE = ` +import { appendFileSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +const here = dirname(fileURLToPath(import.meta.url)); +const args = process.argv.slice(2); +const opt = (name) => args[args.indexOf("--" + name) + 1]; +const calls = resolve(here, "calls.jsonl"); +const n = (() => { try { return readFileSync(calls, "utf8").trim().split("\\n").length; } catch { return 0; } })(); +appendFileSync(calls, JSON.stringify({ args, content: readFileSync(opt("content-file"), "utf8") }) + "\\n"); +const mode = readFileSync(resolve(here, "mode"), "utf8").trim(); +if (mode === "crash") { process.kill(process.ppid, "SIGKILL"); process.exit(0); } +if (mode === "fail") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(1); } +process.stdout.write(JSON.stringify({ action: "discord-dm", ok: true, messageId: "m" + (n + 1) })); +`; + +async function world(prefix, { incidents = FIXTURE, mode = "ok" } = {}) { + const dir = await mkdtemp(resolve(tmpdir(), prefix)); + const w = { + dir, + incidents: resolve(dir, "idunn", "incidents.cc"), + journal: resolve(dir, "bifrost", "journal.cc"), + receipts: resolve(dir, "bifrost", "receipts.cc"), + bridge: resolve(dir, "fake-bridge.mjs"), + calls: resolve(dir, "calls.jsonl"), + }; + await mkdir(resolve(dir, "idunn")); + await writeFile(w.bridge, FAKE_BRIDGE); + await writeFile(resolve(dir, "mode"), mode); + if (incidents) await copyFile(incidents, w.incidents); + w.setMode = (value) => writeFile(resolve(dir, "mode"), value); + w.run = (extra = {}) => run(w, extra); + w.status = () => runStatus(w); + w.posts = async () => (existsSync(w.calls) ? (await readFile(w.calls, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : []); + return w; +} + +function run(w, { env = {}, bridge = w.bridge } = {}) { + return spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { + encoding: "utf8", + env: { ...process.env, DISCORD_OWNER_ID: RECIPIENT, ...env }, + }); +} + +function runStatus(w) { + const result = spawnSync(process.execPath, [TOOL, "status", "--journal-store", w.journal], { encoding: "utf8", env: process.env }); + return { ...result, rows: result.status === null || !result.stdout ? [] : JSON.parse(result.stdout) }; +} + +const opt = (post, name) => post.args[post.args.indexOf(`--${name}`) + 1]; +const notice = (post) => opt(post, "cultmesh-command-id").split("#")[1]; + +// Builds an incident store from the fixture's own envelopes with the payload +// tuples rewritten by `edit`. +async function variantStore(path, edit) { + const source = await new cache.SingleFileMessagePackBackingStore(FIXTURE).pullAll(); + const tuples = edit(source.map((envelope) => msgpack.decode(envelope.payload))); + const envelopes = tuples.map((tuple, index) => ({ ...source[0], key: `variant-${index}-${tuple[1]}`, payload: msgpack.encode(tuple), schemaId: source[0].schemaId, catalogEntry: source[0].catalogEntry })); + await new cache.SingleFileMessagePackBackingStore(path).pushAll(envelopes); +} + +test("the Idunn fixture decodes as the incidents Idunn wrote", async () => { + const bytes = await readFile(FIXTURE); + assert.equal(createHash("sha256").update(bytes).digest("hex"), FIXTURE_SHA256); + const w = await world("wn-fixture-"); + const result = w.run(); + assert.equal(result.status, 0, result.stderr); + const posts = await w.posts(); + const byKey = Object.fromEntries(posts.map((post) => [opt(post, "cultmesh-command-id"), post.content])); + assert.deepEqual(byKey, { + [`${CLOSED_KEY}#opened`]: OPENED_TEXT("closed-target", T_OPEN), + [`${CLOSED_KEY}#closed`]: CLOSED_TEXT, + [`${OPEN_KEY}#opened`]: OPENED_TEXT("open-target", T_CLOSE), + }); +}); + +test("one incident yields one opening post across runs", async () => { + const w = await world("wn-once-"); + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + const posts = await w.posts(); + const openings = posts.filter((post) => notice(post) === "opened"); + assert.equal(openings.length, 2); + assert.deepEqual(openings.map((post) => opt(post, "source-id")).sort(), [CLOSED_KEY, OPEN_KEY]); + assert.equal(posts.length, 3, "no second run posts again"); + for (const post of posts) { + const key = opt(post, "cultmesh-command-id"); + assert.equal(opt(post, "recipient-id"), RECIPIENT); + assert.equal(opt(post, "nonce"), createHash("sha256").update(key).digest("hex").slice(0, 25)); + assert.equal(opt(post, "receipt-store"), w.receipts); + assert.equal(opt(post, "source-kind"), "idunn-operator-incident"); + } +}); + +test("closure is posted once and only after a completed opening", async () => { + const w = await world("wn-closure-"); + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + const closures = (await w.posts()).filter((post) => notice(post) === "closed"); + assert.equal(closures.length, 1); + assert.equal(opt(closures[0], "source-id"), CLOSED_KEY); + + // An opening that never completes blocks its closure, now and later. + const failing = await world("wn-closure-failed-", { mode: "fail" }); + for (let run = 0; run < 5; run += 1) failing.run(); + await failing.setMode("ok"); + failing.run(); + failing.run(); + assert.equal((await failing.posts()).filter((post) => notice(post) === "closed").length, 0); + const rows = (await failing.status()).rows; + assert.deepEqual(rows.map((row) => `${row.notice}:${row.status}:${row.attempts}`), ["opened:failed:5", "opened:failed:5"]); + + const crashed = await world("wn-closure-unknown-", { mode: "crash" }); + assert.equal(crashed.run().status, null, "the reader died mid-send"); + await crashed.setMode("ok"); + crashed.run(); + crashed.run(); + assert.equal((await crashed.posts()).filter((post) => notice(post) === "closed").length, 0); +}); + +test("a running entry becomes unknown and is never re-posted", async () => { + const w = await world("wn-running-", { mode: "crash" }); + assert.equal(w.run().status, null); + assert.equal((await w.status()).rows[0].status, "running"); + await w.setMode("ok"); + const recovered = w.run(); + assert.notEqual(recovered.status, 0, "an unknown entry is a delivery failure"); + assert.equal(JSON.parse(recovered.stdout).unknown, 1); + const posts = await w.posts(); + assert.equal(posts.filter((post) => opt(post, "cultmesh-command-id") === `${CLOSED_KEY}#opened`).length, 1, "the interrupted notice is not sent again"); + const rows = (await w.status()).rows; + assert.equal(rows.find((row) => row.incidentKey === CLOSED_KEY && row.notice === "opened").status, "unknown"); + assert.notEqual(w.run().status, 0, "still failing on the next run"); + assert.equal((await w.posts()).length, posts.length); +}); + +test("a failed send is retried with the same nonce up to five attempts", async () => { + const w = await world("wn-retry-", { mode: "fail" }); + const exits = []; + for (let run = 0; run < 5; run += 1) exits.push(w.run().status); + assert.deepEqual(exits, [0, 0, 0, 0, 1], "non-zero once attempts are exhausted"); + const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); + assert.equal(open.length, 5); + assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1); + const before = (await w.posts()).length; + const again = w.run(); + assert.notEqual(again.status, 0); + assert.equal((await w.posts()).length, before, "no spawn after the fifth attempt"); + const status = await w.status(); + assert.notEqual(status.status, 0); + for (const text of [again.stdout, again.stderr, status.stdout, await readFile(w.journal, "latin1")]) { + assert.ok(!text.includes("RECIPIENTCANARY"), "bridge output and recipient never reach the reader's output or journal"); + } +}); + +test("the reader never writes or locks the incident store", async () => { + const w = await world("wn-readonly-"); + const before = { bytes: await readFile(w.incidents), mtime: (await stat(w.incidents)).mtimeMs }; + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + assert.deepEqual(await readFile(w.incidents), before.bytes); + assert.equal((await stat(w.incidents)).mtimeMs, before.mtime); + assert.deepEqual(await readdir(resolve(w.dir, "idunn")), ["incidents.cc"], "no lock or temp file beside the store"); +}); + +test("notice content carries no sensitive text", async () => { + const w = await world("wn-egress-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + const [closed, open] = tuples; + const longer = [...open, "extra-CANARYFIELD"]; + const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH", open[4], null, null]; + const otherSchema = ["idunn.operator_incident.v0", "CANARYSCHEMA", "continuity-exhausted", "x", 1, null, null]; + return [closed, longer, pathSubject, otherSchema]; + }); + const result = w.run(); + assert.notEqual(result.status, 0, "refused records fail the run"); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, exhausted: 0 }); + const posts = await w.posts(); + assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); + for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on closed-target at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on closed-target, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); + for (const text of [result.stdout, result.stderr, (await w.status()).stdout, await readFile(w.journal, "latin1"), JSON.stringify(posts)]) { + assert.ok(!text.includes("CANARY"), "no byte of a refused record is echoed"); + } +}); + +test("no recipient means no journal write and a non-zero exit", async () => { + for (const value of [undefined, "", " "]) { + const w = await world("wn-recipient-"); + const env = value === undefined ? { DISCORD_OWNER_ID: undefined } : { DISCORD_OWNER_ID: value }; + const result = spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", w.bridge], { encoding: "utf8", env: { ...process.env, ...env } }); + assert.notEqual(result.status, 0); + assert.ok(!existsSync(w.journal)); + assert.deepEqual(await w.posts(), []); + } +}); + +test("an absent incident store is a clean no-op", async () => { + const w = await world("wn-absent-", { incidents: null }); + const result = w.run(); + assert.equal(result.status, 0, result.stderr); + assert.ok(!existsSync(w.journal)); + assert.deepEqual(await w.posts(), []); +}); + +test("terminal entries are pruned only after Idunn retires the incident", async () => { + const w = await world("wn-prune-"); + assert.equal(w.run().status, 0); + const keys = async () => (await w.status()).rows.map((row) => `${row.incidentKey}#${row.notice}`).sort(); + const all = [`${CLOSED_KEY}#closed`, `${CLOSED_KEY}#opened`, `${OPEN_KEY}#opened`]; + assert.deepEqual(await keys(), all); + assert.equal(w.run().status, 0); + assert.deepEqual(await keys(), all, "entries stay while Idunn still lists the incident"); + await variantStore(w.incidents, (tuples) => [tuples[1]]); + assert.equal(w.run().status, 0); + assert.deepEqual(await keys(), [`${OPEN_KEY}#opened`]); + await variantStore(w.incidents, () => []); + assert.equal(w.run().status, 0); + assert.deepEqual(await keys(), []); + assert.equal((await w.posts()).length, 3, "retirement never reposts"); +}); + +test("errors name a code and never echo a path or an input", async () => { + const w = await world("wn-canary-", { incidents: null }); + await mkdir(resolve(w.dir, "CANARYDIR")); + const corrupt = resolve(w.dir, "CANARYDIR", "incidents.cc"); + await writeFile(corrupt, "not a cultcache store CANARYBYTES"); + const result = spawnSync(process.execPath, [TOOL, "process", "--incident-store", corrupt, "--journal-store", resolve(w.dir, "CANARYDIR", "j.cc"), "--receipt-store", w.receipts, "--bridge-cli", w.bridge], { encoding: "utf8", env: { ...process.env, DISCORD_OWNER_ID: RECIPIENT } }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /incident-store-unreadable/); + assert.ok(!(result.stdout + result.stderr).includes("CANARY")); +}); + +// The real bridge, reached through the real reader, with Discord's HTTP API +// stubbed at fetch (NODE_OPTIONS reaches the spawned bridge). +async function fetchStub(dir) { + const log = resolve(dir, "fetch.jsonl"); + const stub = resolve(dir, "fetch-stub.mjs"); + await writeFile(stub, ` +import { appendFileSync } from "node:fs"; +globalThis.fetch = async (url, init) => { + appendFileSync(${JSON.stringify(log)}, JSON.stringify({ url: String(url), body: JSON.parse(init.body) }) + "\\n"); + const body = String(url).endsWith("/users/@me/channels") ? { id: "dm-channel" } : { id: "message-1" }; + return new Response(JSON.stringify(body), { status: 200 }); +}; +`); + return { log, options: `--import=${pathToFileURL(stub).href}` }; +} +const fetches = async (log) => (existsSync(log) ? (await readFile(log, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : []); + +test("discord-dm carries nonce and enforce_nonce when asked", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "wn-bridge-")); + const stub = await fetchStub(dir); + const env = { ...process.env, NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" }; + const dm = (...extra) => spawnSync(process.execPath, [BRIDGE, "discord-dm", "--recipient-id", "1", "--content", "hi", "--cultmesh-command-id", "cmd", "--receipt-store", resolve(dir, "r.cc"), ...extra], { encoding: "utf8", env }); + + const with_nonce = dm("--nonce", "abc123"); + assert.equal(with_nonce.status, 0, with_nonce.stderr); + let posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/channels/dm-channel/messages")); + assert.equal(posted.at(-1).body.nonce, "abc123"); + assert.equal(posted.at(-1).body.enforce_nonce, true); + + const without = dm(); + assert.equal(without.status, 0, without.stderr); + posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/channels/dm-channel/messages")); + assert.ok(!("nonce" in posted.at(-1).body) && !("enforce_nonce" in posted.at(-1).body)); + + const before = (await fetches(stub.log)).length; + const tooLong = dm("--nonce", "x".repeat(26)); + assert.notEqual(tooLong.status, 0); + assert.equal((await fetches(stub.log)).length, before, "an over-long nonce posts nothing"); +}); + +test("the reader posts through the real bridge with its nonce", async () => { + const w = await world("wn-real-bridge-"); + const stub = await fetchStub(w.dir); + const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } }); + assert.equal(result.status, 0, result.stderr); + const posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")); + assert.deepEqual(posted.map((entry) => entry.body.content).sort(), [CLOSED_TEXT, OPENED_TEXT("closed-target", T_OPEN), OPENED_TEXT("open-target", T_CLOSE)].sort()); + for (const entry of posted) { + assert.equal(entry.body.nonce.length, 25); + assert.equal(entry.body.enforce_nonce, true); + } + assert.ok(existsSync(w.receipts), "the bridge wrote its crossing receipts to the reader's receipt store"); + assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); +}); + diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 5b4179b..1c4f0cd 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -475,6 +475,10 @@ async function sendDiscordDm(options) { const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN; const recipientId = requireOption(options, "recipient-id"); const content = await readOptionText(options, "content", "content-file"); + const nonce = optionalString(options.nonce); + if (options.nonce !== undefined && (!nonce || nonce.length > 25)) { + throw new Error("--nonce must be 1 to 25 characters."); + } const dryRun = options["dry-run"] === "true"; if (dryRun) { @@ -506,7 +510,7 @@ async function sendDiscordDm(options) { throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM."); } channelId = await openDiscordDmChannel(token, recipientId); - result = await postDiscordBotMessage(token, channelId, content, undefined); + result = await postDiscordBotMessage(token, channelId, content, undefined, nonce); await bridgeAction?.complete({ receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`, externalReceiptId: result.id, @@ -755,7 +759,7 @@ async function openDiscordDmChannel(token, recipientId) { return channel.id; } -async function postDiscordBotMessage(token, channelId, content, replyToMessageId) { +async function postDiscordBotMessage(token, channelId, content, replyToMessageId, nonce) { const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", headers: { @@ -773,6 +777,7 @@ async function postDiscordBotMessage(token, channelId, content, replyToMessageId allowed_mentions: { parse: [], }, + ...(nonce ? { nonce, enforce_nonce: true } : {}), }), }); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs new file mode 100644 index 0000000..18fa6a7 --- /dev/null +++ b/tools/watchdog-notice.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// One-shot reader: turns Idunn's operator incidents into owner DMs. +// +// process --incident-store P --journal-store J --receipt-store R [--bridge-cli B] +// status --journal-store J +// +// Owner of the decision to send, the journal (J), the recipient +// (DISCORD_OWNER_ID) and the token (BIFROST_DISCORD_BOT_TOKEN, read only by the +// bridge it spawns). Idunn's store P is read unlocked and never written: no +// CultMesh node, no lock, only SingleFileMessagePackBackingStore.pullAll(). +// +// Requires CultLib 8cb3b728 or later (package @gamecult/cultcache-ts); see +// docs/watchdog-notice.md. +// +// Nothing here echoes an input value. Errors are fixed codes, and the +// journal's lastError is a fixed code, never bridge output. +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const cult = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "CultLib")); + +const INCIDENT_SCHEMA = "idunn.operator_incident.v1"; +const JOURNAL_TYPE = "bifrost.watchdog_notice_execution"; +const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; +const MAX_ATTEMPTS = 5; +const BRIDGE_TIMEOUT_MS = 60_000; +const STATUSES = ["running", "completed", "failed", "unknown"]; +const NAME = /^[a-z][a-z-]{0,63}$/; +const SUBJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +class NoticeError extends Error {} +const fail = (code) => new NoticeError(code); + +try { + process.exitCode = await main(process.argv.slice(2)); +} catch (error) { + process.stderr.write(`watchdog-notice: ${error instanceof NoticeError ? error.message : "unexpected-error"}\n`); + process.exitCode = 1; +} + +async function main(args) { + const [verb, ...rest] = args; + const options = parseArgs(rest); + if (verb === "status") return status(options); + if (verb === "process") return processIncidents(options); + throw fail("unknown-verb"); +} + +async function processIncidents(options) { + const incidentStore = requireOption(options, "incident-store"); + const journalStore = requireOption(options, "journal-store"); + const receiptStore = requireOption(options, "receipt-store"); + const bridgeCli = resolve(options["bridge-cli"] || resolve(root, "tools", "bifrost-bridge.mjs")); + const recipientId = (process.env.DISCORD_OWNER_ID ?? "").trim(); + if (!recipientId) throw fail("recipient-missing"); + + const runtime = loadRuntime(); + const { records, skipped, refused } = await readIncidents(runtime, incidentStore); + if (records === null) return 0; + + const journal = await openJournal(runtime, journalStore); + for (const entry of journal.cache.getAll(journal.definition)) { + if (entry.status === "running") { + await journal.put({ ...entry, status: "unknown", lastError: "interrupted-before-outcome", updatedAt: now() }); + } + } + + const deliver = async (record, notice) => { + const key = `${record.incidentKey}#${notice}`; + const previous = journal.cache.get(journal.definition, key); + const attempts = (previous?.attempts ?? 0) + 1; + const nonce = createHash("sha256").update(key).digest("hex").slice(0, 25); + const base = { schemaVersion: JOURNAL_SCHEMA, incidentKey: record.incidentKey, notice, attempts, nonce }; + await journal.put({ ...base, status: "running", messageId: "", lastError: "", updatedAt: now() }); + const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }); + const entry = outcome.messageId + ? { ...base, status: "completed", messageId: outcome.messageId, lastError: "" } + : { ...base, status: "failed", messageId: "", lastError: outcome.error }; + await journal.put({ ...entry, updatedAt: now() }); + }; + const retryable = (entry) => !entry || (entry.status === "failed" && entry.attempts < MAX_ATTEMPTS); + const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); + + for (const record of records) { + if (retryable(entryFor(record, "opened"))) await deliver(record, "opened"); + if (record.closedAt !== null && entryFor(record, "opened")?.status === "completed" && retryable(entryFor(record, "closed"))) { + await deliver(record, "closed"); + } + } + + const present = new Set(records.map((record) => record.incidentKey)); + for (const entry of journal.cache.getAll(journal.definition)) { + if (isTerminal(entry) && !present.has(entry.incidentKey)) { + await journal.cache.delete(journal.definition, `${entry.incidentKey}#${entry.notice}`); + } + } + + const entries = journal.cache.getAll(journal.definition); + const unknown = entries.filter((entry) => entry.status === "unknown").length; + const exhausted = entries.filter(isExhausted).length; + process.stdout.write(`${JSON.stringify({ skipped, refused, unknown, exhausted })}\n`); + return unknown + exhausted + refused > 0 ? 1 : 0; +} + +async function status(options) { + const runtime = loadRuntime(); + const journal = await openJournal(runtime, requireOption(options, "journal-store")); + const entries = journal.cache.getAll(journal.definition); + const rows = entries + .map(({ incidentKey, notice, status, attempts, updatedAt, lastError }) => ({ incidentKey, notice, status, attempts, updatedAt, lastError })) + .sort((a, b) => (a.incidentKey + a.notice).localeCompare(b.incidentKey + b.notice)); + process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); + return entries.some((entry) => entry.status === "unknown" || isExhausted(entry)) ? 1 : 0; +} + +const isExhausted = (entry) => entry.status === "failed" && entry.attempts >= MAX_ATTEMPTS; +const isTerminal = (entry) => entry.status === "completed" || entry.status === "unknown" || isExhausted(entry); +const now = () => new Date().toISOString(); + +// Reads P unlocked. An absent file is "nothing to do" (records: null). A +// record of another schema is counted as skipped; a record of this schema +// that breaks the contract is counted as refused. Neither is echoed. +async function readIncidents(runtime, path) { + try { + await stat(path); + } catch (error) { + if (error?.code === "ENOENT") return { records: null, skipped: 0, refused: 0 }; + throw fail("incident-store-unreadable"); + } + let envelopes; + try { + envelopes = await new runtime.SingleFileMessagePackBackingStore(path).pullAll(); + } catch { + throw fail("incident-store-unreadable"); + } + const records = []; + let skipped = 0; + let refused = 0; + for (const envelope of envelopes) { + let tuple; + try { + tuple = runtime.decode(envelope.payload); + } catch { + skipped += 1; + continue; + } + if (!Array.isArray(tuple) || tuple[0] !== INCIDENT_SCHEMA) { + skipped += 1; + continue; + } + const record = parseIncident(tuple); + if (record) records.push(record); + else refused += 1; + } + records.sort((a, b) => a.openedAt - b.openedAt || a.incidentKey.localeCompare(b.incidentKey)); + return { records, skipped, refused }; +} + +// [schema_version, incident_key, condition, subject, opened_at, closed_at, close_reason] +function parseIncident(tuple) { + if (tuple.length !== 7) return null; + const [, incidentKey, condition, subject, openedAt, closedAt, closeReason] = tuple; + if (typeof condition !== "string" || !NAME.test(condition)) return null; + if (typeof subject !== "string" || !SUBJECT.test(subject)) return null; + if (!Number.isSafeInteger(openedAt) || openedAt <= 0) return null; + if (incidentKey !== `${condition}:${subject}:${openedAt}`) return null; + if (closedAt === null) { + if (closeReason !== null) return null; + } else { + if (!Number.isSafeInteger(closedAt) || closedAt < openedAt) return null; + if (typeof closeReason !== "string" || !NAME.test(closeReason)) return null; + } + return { incidentKey, condition, subject, openedAt, closedAt, closeReason }; +} + +function noticeText(record, notice) { + const opened = new Date(record.openedAt).toISOString(); + if (notice === "opened") return `Idunn incident opened: ${record.condition} on ${record.subject} at ${opened}`; + const closed = new Date(record.closedAt).toISOString(); + return `Idunn incident closed (${record.closeReason}): ${record.condition} on ${record.subject}, opened ${opened}, closed ${closed}`; +} + +// The one send primitive. The outcome carries a message id, or a fixed error +// code; bridge stdout and stderr are never copied into the journal. +async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }) { + const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-")); + try { + const contentFile = resolve(dir, "content.txt"); + await writeFile(contentFile, noticeText(record, notice), "utf8"); + const result = spawnSync(process.execPath, [ + bridgeCli, "discord-dm", + "--recipient-id", recipientId, + "--content-file", contentFile, + "--nonce", nonce, + "--cultmesh-command-id", key, + "--receipt-store", receiptStore, + "--source-kind", "idunn-operator-incident", + "--source-id", record.incidentKey, + ], { encoding: "utf8", env: process.env, windowsHide: true, timeout: BRIDGE_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }); + if (result.error) return { error: "bridge-spawn-failed" }; + if (result.status !== 0) return { error: "bridge-exit-nonzero" }; + try { + const value = JSON.parse(result.stdout); + if (value.action === "discord-dm" && value.ok === true && typeof value.messageId === "string" && value.messageId) { + return { messageId: value.messageId }; + } + } catch { + // falls through to the fixed code + } + return { error: "bridge-output-invalid" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function openJournal(runtime, path) { + const definition = runtime.defineDocumentType({ + type: JOURNAL_TYPE, + schemaName: JOURNAL_TYPE, + schemaId: JOURNAL_SCHEMA, + schemaVersion: JOURNAL_SCHEMA, + contentHash: JOURNAL_SCHEMA, + global: false, + schema: { parse: parseEntry }, + members: ["schemaVersion", "incidentKey", "notice", "status", "attempts", "nonce", "messageId", "lastError", "updatedAt"] + .map((memberName, slot) => ({ slot, memberName, typeName: memberName === "attempts" ? "uint32" : "string" })), + }); + const cache = runtime.CultCache.builder() + .withDocumentType(definition) + .withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)) + .build(); + try { + await cache.pullAllBackingStores(); + } catch { + throw fail("journal-unreadable"); + } + return { cache, definition, put: (entry) => cache.put(definition, `${entry.incidentKey}#${entry.notice}`, entry) }; +} + +function parseEntry(value) { + const ok = value && value.schemaVersion === JOURNAL_SCHEMA + && typeof value.incidentKey === "string" && value.incidentKey + && (value.notice === "opened" || value.notice === "closed") + && STATUSES.includes(value.status) + && Number.isSafeInteger(value.attempts) && value.attempts >= 0 + && ["nonce", "messageId", "lastError", "updatedAt"].every((field) => typeof value[field] === "string"); + if (!ok) throw fail("journal-entry-invalid"); + return value; +} + +function loadRuntime() { + try { + const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.json")); + const net = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json")); + const cc = cache("@gamecult/cultcache-ts"); + return { + CultCache: cc.CultCache, + SingleFileMessagePackBackingStore: cc.SingleFileMessagePackBackingStore, + defineDocumentType: cc.defineDocumentType, + decode: net("@msgpack/msgpack").decode, + }; + } catch { + throw fail("cultlib-unavailable"); + } +} + +function parseArgs(args) { + const options = {}; + for (let i = 0; i < args.length; i += 2) { + if (!args[i].startsWith("--") || args[i + 1] === undefined) throw fail("bad-arguments"); + options[args[i].slice(2)] = args[i + 1]; + } + return options; +} + +function requireOption(options, name) { + if (!options[name]) throw fail(`missing-option-${name}`); + return options[name]; +} From ef867f3e28d64f6cccbfac2cf94054bd241c0df2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:19:42 +0200 Subject: [PATCH 040/155] Watchdog notice reader: start the verb after the module finishes initialising The top-level await ran main() before the const declarations below it, so every process run died in the temporal dead zone with unexpected-error. Co-Authored-By: Claude Sonnet 5.5 --- tools/watchdog-notice.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index 18fa6a7..df773c6 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -36,13 +36,6 @@ const SUBJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; class NoticeError extends Error {} const fail = (code) => new NoticeError(code); -try { - process.exitCode = await main(process.argv.slice(2)); -} catch (error) { - process.stderr.write(`watchdog-notice: ${error instanceof NoticeError ? error.message : "unexpected-error"}\n`); - process.exitCode = 1; -} - async function main(args) { const [verb, ...rest] = args; const options = parseArgs(rest); @@ -282,3 +275,10 @@ function requireOption(options, name) { if (!options[name]) throw fail(`missing-option-${name}`); return options[name]; } + +try { + process.exitCode = await main(process.argv.slice(2)); +} catch (error) { + process.stderr.write(`watchdog-notice: ${error instanceof NoticeError ? error.message : "unexpected-error"}\n`); + process.exitCode = 1; +} From 7e92687716cd63e80104e5e0c70c20272b8e22ab Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:20:47 +0200 Subject: [PATCH 041/155] Watchdog notice tests: canary the refused record's bytes, not the recipient The egress test searched for the substring CANARY, which the recipient double also carries, so it failed on its own input. Co-Authored-By: Claude Sonnet 5.5 --- tests/watchdog-notice.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 3bf1dd2..3fa8827 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -215,7 +215,7 @@ test("notice content carries no sensitive text", async () => { assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on closed-target at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on closed-target, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); for (const text of [result.stdout, result.stderr, (await w.status()).stdout, await readFile(w.journal, "latin1"), JSON.stringify(posts)]) { - assert.ok(!text.includes("CANARY"), "no byte of a refused record is echoed"); + for (const canary of ["CANARYFIELD", "CANARYPATH", "CANARYSCHEMA"]) assert.ok(!text.includes(canary), "no byte of a refused record is echoed"); } }); From 1f001e11d20d0a7b4945edfb92c78b30112afeef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:54:23 +0200 Subject: [PATCH 042/155] Watchdog notice reader: the journal owns "sent"; only exit 75 is retried Fix batch for Soul pass s1 (spec r3): - Delete prune-on-absence and its test. Absence from Idunn's store no longer deletes a journal entry, so an empty or restored store never re-sends. - discord-dm exits 75 only when no message can have been sent (arguments, token, DM channel open, non-2xx on the message POST). The reader records failed for 75 only; any other non-success without a message id is unknown and is never re-posted. - Subject grammar is Idunn's require_id; the notice puts it in backticks. - Times are bounded to the Date range; notice text is built before the running entry is written. Builds and passes on Linux. On Windows the two tests that SIGKILL the reader cannot pass (process.kill does not deliver a signal there). Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 15 +++- tests/watchdog-notice.test.mjs | 123 ++++++++++++++++++++++++++++----- tools/bifrost-bridge.mjs | 23 +++++- tools/watchdog-notice.mjs | 49 ++++++------- 4 files changed, 161 insertions(+), 49 deletions(-) diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index 50e8235..a1c7004 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -31,9 +31,18 @@ node tools/watchdog-notice.mjs status --journal-store J - J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed `#opened` or `#closed`. A `running` entry found at start becomes `unknown` and is never sent again. A `failed` entry is retried on - later runs with the same nonce, up to five attempts. Terminal entries are - deleted once Idunn no longer lists the incident. + later runs with the same nonce, up to five attempts. The journal is never + pruned: what Bifrost has sent is Bifrost's memory, whatever P holds. +- The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 only when it knows no + message was sent: argument or token errors, a failure opening the DM channel, + or a non-2xx answer to the message POST. The reader records `failed` for + exit 75 only. Any other non-success without a message id (another exit code, + a signal, the spawn timeout, unreadable output) is `unknown`: never sent again, + counted by the exit status. - The exit status is non-zero while any entry is `unknown` or out of attempts, or while P holds a record of this schema that breaks its contract. systemd shows that; `status` lists the entries. -- Notice text is built from `condition`, `subject` and ISO UTC times only. +- Notice text is built from `condition`, `subject` and ISO UTC times only. The + subject follows Idunn's `require_id` (1-256 bytes of `[A-Za-z0-9-_.:/]`) and + is placed in inline code. A time outside the ECMAScript Date range refuses the + record. diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 3fa8827..21a9223 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -21,10 +21,10 @@ const TOOL = resolve(import.meta.dirname, "..", "tools", "watchdog-notice.mjs"); const BRIDGE = resolve(import.meta.dirname, "..", "tools", "bifrost-bridge.mjs"); const RECIPIENT = "424242RECIPIENTCANARY"; -const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on ${subject} at ${at}`; +const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on \`${subject}\` at ${at}`; const T_OPEN = "2023-11-14T22:13:20.000Z"; const T_CLOSE = "2023-11-14T22:15:00.000Z"; -const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on closed-target, opened ${T_OPEN}, closed ${T_CLOSE}`; +const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on \`closed-target\`, opened ${T_OPEN}, closed ${T_CLOSE}`; // CultLib is the sibling ../CultLib, like the other tests. const cult = resolve(import.meta.dirname, "..", "..", "CultLib"); @@ -32,8 +32,9 @@ const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.j const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack"); // A bridge double that logs each call (with the content it was handed) and -// behaves as the `mode` file says: ok, fail, or crash (kills its parent, the -// reader, after logging, as a power cut between send and journal would). +// behaves as the `mode` file says: ok; fail (exit 75, known not sent); die +// (exit 1 after "sending"); signal (killed after "sending"); or crash (kills its +// parent, the reader, after logging, as a power cut between send and journal would). const FAKE_BRIDGE = ` import { appendFileSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -46,7 +47,8 @@ const n = (() => { try { return readFileSync(calls, "utf8").trim().split("\\n"). appendFileSync(calls, JSON.stringify({ args, content: readFileSync(opt("content-file"), "utf8") }) + "\\n"); const mode = readFileSync(resolve(here, "mode"), "utf8").trim(); if (mode === "crash") { process.kill(process.ppid, "SIGKILL"); process.exit(0); } -if (mode === "fail") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(1); } +if (mode === "fail" || mode === "die") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(mode === "fail" ? 75 : 1); } +if (mode === "signal") process.kill(process.pid, "SIGKILL"); process.stdout.write(JSON.stringify({ action: "discord-dm", ok: true, messageId: "m" + (n + 1) })); `; @@ -170,11 +172,12 @@ test("a running entry becomes unknown and is never re-posted", async () => { assert.equal((await w.posts()).length, posts.length); }); -test("a failed send is retried with the same nonce up to five attempts", async () => { +test("only a known-unsent bridge exit is retried, with the same nonce, up to five attempts", async () => { const w = await world("wn-retry-", { mode: "fail" }); const exits = []; for (let run = 0; run < 5; run += 1) exits.push(w.run().status); assert.deepEqual(exits, [0, 0, 0, 0, 1], "non-zero once attempts are exhausted"); + assert.deepEqual((await w.status()).rows.map((row) => row.lastError), ["bridge-not-sent", "bridge-not-sent"]); const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); assert.equal(open.length, 5); assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1); @@ -204,7 +207,7 @@ test("notice content carries no sensitive text", async () => { await variantStore(w.incidents, (tuples) => { const [closed, open] = tuples; const longer = [...open, "extra-CANARYFIELD"]; - const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH", open[4], null, null]; + const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH x:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH x", open[4], null, null]; const otherSchema = ["idunn.operator_incident.v0", "CANARYSCHEMA", "continuity-exhausted", "x", 1, null, null]; return [closed, longer, pathSubject, otherSchema]; }); @@ -213,7 +216,7 @@ test("notice content carries no sensitive text", async () => { assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, exhausted: 0 }); const posts = await w.posts(); assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); - for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on closed-target at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on closed-target, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); + for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on `closed-target` at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on `closed-target`, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); for (const text of [result.stdout, result.stderr, (await w.status()).stdout, await readFile(w.journal, "latin1"), JSON.stringify(posts)]) { for (const canary of ["CANARYFIELD", "CANARYPATH", "CANARYSCHEMA"]) assert.ok(!text.includes(canary), "no byte of a refused record is echoed"); } @@ -238,21 +241,39 @@ test("an absent incident store is a clean no-op", async () => { assert.deepEqual(await w.posts(), []); }); -test("terminal entries are pruned only after Idunn retires the incident", async () => { - const w = await world("wn-prune-"); +test("an emptied or restored incident store never re-sends", async () => { + const w = await world("wn-restore-"); assert.equal(w.run().status, 0); const keys = async () => (await w.status()).rows.map((row) => `${row.incidentKey}#${row.notice}`).sort(); const all = [`${CLOSED_KEY}#closed`, `${CLOSED_KEY}#opened`, `${OPEN_KEY}#opened`]; assert.deepEqual(await keys(), all); - assert.equal(w.run().status, 0); - assert.deepEqual(await keys(), all, "entries stay while Idunn still lists the incident"); await variantStore(w.incidents, (tuples) => [tuples[1]]); - assert.equal(w.run().status, 0); - assert.deepEqual(await keys(), [`${OPEN_KEY}#opened`]); + w.run(); await variantStore(w.incidents, () => []); + w.run(); + await writeFile(w.incidents, ""); + w.run(); + assert.deepEqual(await keys(), all, "absence from Idunn's store deletes nothing"); + await copyFile(FIXTURE, w.incidents); assert.equal(w.run().status, 0); - assert.deepEqual(await keys(), []); - assert.equal((await w.posts()).length, 3, "retirement never reposts"); + assert.equal((await w.posts()).length, 3, "exactly one post per notice across all views of the store"); +}); + +test("a bridge that dies after sending is unknown and never re-posted", async () => { + for (const mode of ["die", "signal"]) { + const w = await world(`wn-${mode}-`, { mode }); + const first = w.run(); + assert.notEqual(first.status, 0); + assert.equal(JSON.parse(first.stdout).unknown, 2); + await w.setMode("ok"); + assert.notEqual(w.run().status, 0); + assert.notEqual(w.run().status, 0); + const posts = await w.posts(); + assert.equal(posts.length, 2, "one opening post per incident, no retry, no closure"); + assert.ok(posts.every((post) => notice(post) === "opened")); + assert.deepEqual((await w.status()).rows.map((row) => `${row.notice}:${row.status}:${row.attempts}:${row.lastError}`), ["opened:unknown:1:bridge-exit-unknown", "opened:unknown:1:bridge-exit-unknown"]); + assert.ok(!(await readFile(w.journal, "latin1")).includes("RECIPIENTCANARY")); + } }); test("errors name a code and never echo a path or an input", async () => { @@ -275,8 +296,12 @@ async function fetchStub(dir) { import { appendFileSync } from "node:fs"; globalThis.fetch = async (url, init) => { appendFileSync(${JSON.stringify(log)}, JSON.stringify({ url: String(url), body: JSON.parse(init.body) }) + "\\n"); - const body = String(url).endsWith("/users/@me/channels") ? { id: "dm-channel" } : { id: "message-1" }; - return new Response(JSON.stringify(body), { status: 200 }); + const channels = String(url).endsWith("/users/@me/channels"); + const behavior = process.env.STUB_BEHAVIOR; + if (channels && behavior === "dm-channel-500") return new Response("{}", { status: 500 }); + if (!channels && behavior === "message-400") return new Response("{}", { status: 400 }); + if (!channels && behavior === "message-network-error") throw new TypeError("fetch failed"); + return new Response(JSON.stringify(channels ? { id: "dm-channel" } : { id: "message-1" }), { status: 200 }); }; `); return { log, options: `--import=${pathToFileURL(stub).href}` }; @@ -306,6 +331,68 @@ test("discord-dm carries nonce and enforce_nonce when asked", async () => { assert.equal((await fetches(stub.log)).length, before, "an over-long nonce posts nothing"); }); +test("discord-dm exits 75 only before delivery", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "wn-exit75-")); + const stub = await fetchStub(dir); + const dm = (behavior, { token = "test-token-not-real", recipient = "1" } = {}) => { + const env = { ...process.env, NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: token, DISCORD_BOT_TOKEN: token }; + if (behavior) env.STUB_BEHAVIOR = behavior; + if (!token) { delete env.BIFROST_DISCORD_BOT_TOKEN; delete env.DISCORD_BOT_TOKEN; } + const args = ["discord-dm", ...(recipient ? ["--recipient-id", recipient] : []), "--content", "hi", "--cultmesh-command-id", "cmd", "--receipt-store", resolve(dir, "r.cc")]; + return spawnSync(process.execPath, [BRIDGE, ...args], { encoding: "utf8", env }); + }; + const messagePosts = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).length; + + assert.equal(dm(undefined, { token: "" }).status, 75, "missing token"); + assert.equal(dm(undefined, { recipient: "" }).status, 75, "missing argument"); + assert.equal(dm("dm-channel-500").status, 75, "DM channel failure"); + assert.equal(await messagePosts(), 0, "nothing was posted by any of those"); + assert.equal(dm("message-400").status, 75, "definite non-2xx on the message POST"); + assert.equal(await messagePosts(), 1); + const lost = dm("message-network-error"); + assert.equal(lost.status, 1, "a network error after the message POST was sent is not known-unsent"); + assert.equal(await messagePosts(), 2); + assert.equal(dm().status, 0); +}); + +test("every subject Idunn accepts is delivered, inside backticks", async () => { + const good = ["gamecult/odin", "odin:store", "_odin", "a".repeat(256), "Odin.v2-x"]; + const bad = ["a".repeat(257), "has space", "back`tick", "new\nline", "", "ün"]; + const w = await world("wn-subject-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + return [...good, ...bad].map((subject, index) => { + const opened = 1_700_000_200_000 + index; + return ["idunn.operator_incident.v1", `continuity-exhausted:${subject}:${opened}`, "continuity-exhausted", subject, opened, null, null]; + }); + }); + const stub = await fetchStub(w.dir); + const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: bad.length, unknown: 0, exhausted: 0 }); + const contents = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).map((entry) => entry.body.content).sort(); + const at = (index) => new Date(1_700_000_200_000 + index).toISOString(); + assert.deepEqual(contents, good.map((subject, index) => OPENED_TEXT(subject, at(index))).sort()); + for (const content of contents) assert.match(content, /on `[A-Za-z0-9_.:/-]{1,256}` at /); +}); + +test("an out-of-range time refuses the record without aborting the run", async () => { + const w = await world("wn-time-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + const [closed, open] = tuples; + const hugeClose = [...closed]; + hugeClose[5] = 8.7e15; + hugeClose[1] = closed[1]; + const hugeOpen = ["idunn.operator_incident.v1", "continuity-exhausted:huge-open:8700000000000000", "continuity-exhausted", "huge-open", 8.7e15, null, null]; + const edge = ["idunn.operator_incident.v1", "continuity-exhausted:edge:8640000000000000", "continuity-exhausted", "edge", 8.64e15, null, null]; + return [hugeClose, hugeOpen, edge, open]; + }); + const result = w.run(); + assert.equal(result.stderr, "", "no unexpected-error"); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: 2, unknown: 0, exhausted: 0 }); + assert.notEqual(result.status, 0, "refused records fail the run"); + assert.deepEqual((await w.posts()).map((post) => opt(post, "source-id")).sort(), ["continuity-exhausted:edge:8640000000000000", OPEN_KEY]); + assert.ok((await w.status()).rows.every((row) => row.status === "completed")); +}); + test("the reader posts through the real bridge with its nonce", async () => { const w = await world("wn-real-bridge-"); const stub = await fetchStub(w.dir); diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 1c4f0cd..3d9e7db 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -470,7 +470,25 @@ async function postDiscordMessage(options) { }); } +// discord-dm exits NOT_SENT_EXIT only when it knows no message was delivered: +// anything before the message POST, or a definite non-2xx answer to it. A +// failure after the POST was sent (network error, receipt completion, output) +// keeps exit 1, so the caller treats the outcome as unknown. +const NOT_SENT_EXIT = 75; +class NotSentError extends Error {} +class DiscordRejection extends Error {} + async function sendDiscordDm(options) { + const state = { postStarted: false }; + try { + await deliverDiscordDm(options, state); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw state.postStarted && !(error instanceof DiscordRejection) ? error : new NotSentError(message); + } +} + +async function deliverDiscordDm(options, state) { ensureBridgeReceiptGate(options); const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN; const recipientId = requireOption(options, "recipient-id"); @@ -510,6 +528,7 @@ async function sendDiscordDm(options) { throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM."); } channelId = await openDiscordDmChannel(token, recipientId); + state.postStarted = true; result = await postDiscordBotMessage(token, channelId, content, undefined, nonce); await bridgeAction?.complete({ receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`, @@ -783,7 +802,7 @@ async function postDiscordBotMessage(token, channelId, content, replyToMessageId const text = await response.text(); if (!response.ok) { - throw new Error(`Discord post failed with ${response.status}: ${text}`); + throw new DiscordRejection(`Discord post failed with ${response.status}: ${text}`); } const message = JSON.parse(text); @@ -1450,5 +1469,5 @@ Provenance note: main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; + process.exitCode = error instanceof NotSentError ? NOT_SENT_EXIT : 1; }); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index df773c6..9fbee59 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -31,7 +31,13 @@ const MAX_ATTEMPTS = 5; const BRIDGE_TIMEOUT_MS = 60_000; const STATUSES = ["running", "completed", "failed", "unknown"]; const NAME = /^[a-z][a-z-]{0,63}$/; -const SUBJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +// Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): +// 1-256 bytes of [A-Za-z0-9-_.:/]. The notice puts the subject in inline code. +const SUBJECT = /^[A-Za-z0-9_.:/-]{1,256}$/; +const MAX_DATE_MS = 8.64e15; +// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) only when it knows the +// message was not sent. Every other non-success leaves the outcome unknown. +const BRIDGE_NOT_SENT = 75; class NoticeError extends Error {} const fail = (code) => new NoticeError(code); @@ -68,13 +74,11 @@ async function processIncidents(options) { const previous = journal.cache.get(journal.definition, key); const attempts = (previous?.attempts ?? 0) + 1; const nonce = createHash("sha256").update(key).digest("hex").slice(0, 25); + const content = noticeText(record, notice); const base = { schemaVersion: JOURNAL_SCHEMA, incidentKey: record.incidentKey, notice, attempts, nonce }; await journal.put({ ...base, status: "running", messageId: "", lastError: "", updatedAt: now() }); - const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }); - const entry = outcome.messageId - ? { ...base, status: "completed", messageId: outcome.messageId, lastError: "" } - : { ...base, status: "failed", messageId: "", lastError: outcome.error }; - await journal.put({ ...entry, updatedAt: now() }); + const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }); + await journal.put({ ...base, messageId: outcome.messageId ?? "", status: outcome.status, lastError: outcome.error ?? "", updatedAt: now() }); }; const retryable = (entry) => !entry || (entry.status === "failed" && entry.attempts < MAX_ATTEMPTS); const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); @@ -86,13 +90,6 @@ async function processIncidents(options) { } } - const present = new Set(records.map((record) => record.incidentKey)); - for (const entry of journal.cache.getAll(journal.definition)) { - if (isTerminal(entry) && !present.has(entry.incidentKey)) { - await journal.cache.delete(journal.definition, `${entry.incidentKey}#${entry.notice}`); - } - } - const entries = journal.cache.getAll(journal.definition); const unknown = entries.filter((entry) => entry.status === "unknown").length; const exhausted = entries.filter(isExhausted).length; @@ -112,7 +109,6 @@ async function status(options) { } const isExhausted = (entry) => entry.status === "failed" && entry.attempts >= MAX_ATTEMPTS; -const isTerminal = (entry) => entry.status === "completed" || entry.status === "unknown" || isExhausted(entry); const now = () => new Date().toISOString(); // Reads P unlocked. An absent file is "nothing to do" (records: null). A @@ -160,12 +156,12 @@ function parseIncident(tuple) { const [, incidentKey, condition, subject, openedAt, closedAt, closeReason] = tuple; if (typeof condition !== "string" || !NAME.test(condition)) return null; if (typeof subject !== "string" || !SUBJECT.test(subject)) return null; - if (!Number.isSafeInteger(openedAt) || openedAt <= 0) return null; + if (!Number.isSafeInteger(openedAt) || openedAt <= 0 || openedAt > MAX_DATE_MS) return null; if (incidentKey !== `${condition}:${subject}:${openedAt}`) return null; if (closedAt === null) { if (closeReason !== null) return null; } else { - if (!Number.isSafeInteger(closedAt) || closedAt < openedAt) return null; + if (!Number.isSafeInteger(closedAt) || closedAt < openedAt || closedAt > MAX_DATE_MS) return null; if (typeof closeReason !== "string" || !NAME.test(closeReason)) return null; } return { incidentKey, condition, subject, openedAt, closedAt, closeReason }; @@ -173,18 +169,19 @@ function parseIncident(tuple) { function noticeText(record, notice) { const opened = new Date(record.openedAt).toISOString(); - if (notice === "opened") return `Idunn incident opened: ${record.condition} on ${record.subject} at ${opened}`; + if (notice === "opened") return `Idunn incident opened: ${record.condition} on \`${record.subject}\` at ${opened}`; const closed = new Date(record.closedAt).toISOString(); - return `Idunn incident closed (${record.closeReason}): ${record.condition} on ${record.subject}, opened ${opened}, closed ${closed}`; + return `Idunn incident closed (${record.closeReason}): ${record.condition} on \`${record.subject}\`, opened ${opened}, closed ${closed}`; } -// The one send primitive. The outcome carries a message id, or a fixed error -// code; bridge stdout and stderr are never copied into the journal. -async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }) { +// The one send primitive. The outcome is completed with a message id; failed +// only when the bridge reports exit 75 (known not sent); otherwise unknown. The +// error is a fixed code: bridge stdout and stderr never reach the journal. +async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }) { const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-")); try { const contentFile = resolve(dir, "content.txt"); - await writeFile(contentFile, noticeText(record, notice), "utf8"); + await writeFile(contentFile, content, "utf8"); const result = spawnSync(process.execPath, [ bridgeCli, "discord-dm", "--recipient-id", recipientId, @@ -195,17 +192,17 @@ async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, re "--source-kind", "idunn-operator-incident", "--source-id", record.incidentKey, ], { encoding: "utf8", env: process.env, windowsHide: true, timeout: BRIDGE_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }); - if (result.error) return { error: "bridge-spawn-failed" }; - if (result.status !== 0) return { error: "bridge-exit-nonzero" }; + if (result.status === BRIDGE_NOT_SENT) return { status: "failed", error: "bridge-not-sent" }; + if (result.error || result.status !== 0) return { status: "unknown", error: "bridge-exit-unknown" }; try { const value = JSON.parse(result.stdout); if (value.action === "discord-dm" && value.ok === true && typeof value.messageId === "string" && value.messageId) { - return { messageId: value.messageId }; + return { status: "completed", messageId: value.messageId }; } } catch { // falls through to the fixed code } - return { error: "bridge-output-invalid" }; + return { status: "unknown", error: "bridge-output-invalid" }; } finally { await rm(dir, { recursive: true, force: true }); } From ff087a554b260305941c7d9987b2e1f1929cfd0f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 22:56:27 +0200 Subject: [PATCH 043/155] Watchdog notice reader: pin timeout, spawn-error, 5xx/429 and guard behaviour; say what non-2xx is Tests only, plus wording. No change to what the reader or bridge does. - A spawn timeout and a spawn error (ENOBUFS from a bridge that floods stdout) record unknown, are never re-posted, and block the closure. WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS overrides the 60 s timeout so the test reaches that branch; the default is unchanged. - A 500/502/503/429 answer to the message POST exits 75 from the bridge, and through the reader is retried with the same nonce until it completes. - allowed_mentions {parse: []} is asserted in the posted body; status exits non-zero while an entry is unknown and zero when none is. - The bridge comment and docs/watchdog-notice.md no longer call a non-2xx answer "definite": it is retried with the same nonce, relying on enforce_nonce inside Discord's window, and may double-send if Discord acted on a 5xx outside it. Builds. Two tests fail on Windows (SIGKILL status), as before; they pass on Yggdrasil. Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 12 ++++--- tests/watchdog-notice.test.mjs | 64 ++++++++++++++++++++++++++++++---- tools/bifrost-bridge.mjs | 9 +++-- tools/watchdog-notice.mjs | 10 +++--- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index a1c7004..610f46c 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -33,10 +33,14 @@ node tools/watchdog-notice.mjs status --journal-store J start becomes `unknown` and is never sent again. A `failed` entry is retried on later runs with the same nonce, up to five attempts. The journal is never pruned: what Bifrost has sent is Bifrost's memory, whatever P holds. -- The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 only when it knows no - message was sent: argument or token errors, a failure opening the DM channel, - or a non-2xx answer to the message POST. The reader records `failed` for - exit 75 only. Any other non-success without a message id (another exit code, +- The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 for argument or token + errors, a failure opening the DM channel, or any non-2xx answer to the message + POST. A non-2xx is retried, not proven unsent: the retry carries the same nonce + and relies on Discord's `enforce_nonce` inside its window, so a 5xx that Discord + answered after creating the message can be delivered twice if the retry falls + outside that window. A 5xx stays retryable because parking it as `unknown` + would lose the notice on every routine Discord outage. The reader records + `failed` for exit 75 only. Any other non-success without a message id (another exit code, a signal, the spawn timeout, unreadable output) is `unknown`: never sent again, counted by the exit status. - The exit status is non-zero while any entry is `unknown` or out of attempts, or diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 21a9223..ff7c6ae 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -32,8 +32,9 @@ const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.j const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack"); // A bridge double that logs each call (with the content it was handed) and -// behaves as the `mode` file says: ok; fail (exit 75, known not sent); die -// (exit 1 after "sending"); signal (killed after "sending"); or crash (kills its +// behaves as the `mode` file says: ok; fail (exit 75, retryable); die +// (exit 1 after "sending"); signal (killed after "sending"); hang (never answers after +// "sending"); flood (answers with more stdout than spawnSync buffers); or crash (kills its // parent, the reader, after logging, as a power cut between send and journal would). const FAKE_BRIDGE = ` import { appendFileSync, readFileSync } from "node:fs"; @@ -49,6 +50,9 @@ const mode = readFileSync(resolve(here, "mode"), "utf8").trim(); if (mode === "crash") { process.kill(process.ppid, "SIGKILL"); process.exit(0); } if (mode === "fail" || mode === "die") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(mode === "fail" ? 75 : 1); } if (mode === "signal") process.kill(process.pid, "SIGKILL"); +if (mode === "hang") setTimeout(() => {}, 30000); +else if (mode === "flood") process.stdout.write("x".repeat(2 * 1024 * 1024)); +else process.stdout.write(JSON.stringify({ action: "discord-dm", ok: true, messageId: "m" + (n + 1) })); `; @@ -172,7 +176,7 @@ test("a running entry becomes unknown and is never re-posted", async () => { assert.equal((await w.posts()).length, posts.length); }); -test("only a known-unsent bridge exit is retried, with the same nonce, up to five attempts", async () => { +test("only a bridge exit 75 is retried, with the same nonce, up to five attempts", async () => { const w = await world("wn-retry-", { mode: "fail" }); const exits = []; for (let run = 0; run < 5; run += 1) exits.push(w.run().status); @@ -265,6 +269,7 @@ test("a bridge that dies after sending is unknown and never re-posted", async () const first = w.run(); assert.notEqual(first.status, 0); assert.equal(JSON.parse(first.stdout).unknown, 2); + assert.notEqual((await w.status()).status, 0, "status exits non-zero while an entry is unknown"); await w.setMode("ok"); assert.notEqual(w.run().status, 0); assert.notEqual(w.run().status, 0); @@ -276,6 +281,22 @@ test("a bridge that dies after sending is unknown and never re-posted", async () } }); +test("a spawn timeout and a spawn error are unknown, never failed, never re-posted", async () => { + for (const mode of ["hang", "flood"]) { + const w = await world(`wn-${mode}-`, { mode }); + const first = w.run({ env: { WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS: "1500" } }); + assert.notEqual(first.status, 0, mode); + assert.equal(JSON.parse(first.stdout).unknown, 2, mode); + await w.setMode("ok"); + assert.notEqual(w.run().status, 0); + assert.notEqual(w.run().status, 0); + const posts = await w.posts(); + assert.equal(posts.length, 2, `${mode}: one opening post per incident, no retry, no closure`); + assert.ok(posts.every((post) => notice(post) === "opened")); + assert.deepEqual((await w.status()).rows.map((row) => `${row.notice}:${row.status}:${row.attempts}:${row.lastError}`), ["opened:unknown:1:bridge-exit-unknown", "opened:unknown:1:bridge-exit-unknown"], mode); + } +}); + test("errors name a code and never echo a path or an input", async () => { const w = await world("wn-canary-", { incidents: null }); await mkdir(resolve(w.dir, "CANARYDIR")); @@ -299,7 +320,8 @@ globalThis.fetch = async (url, init) => { const channels = String(url).endsWith("/users/@me/channels"); const behavior = process.env.STUB_BEHAVIOR; if (channels && behavior === "dm-channel-500") return new Response("{}", { status: 500 }); - if (!channels && behavior === "message-400") return new Response("{}", { status: 400 }); + const rejected = /^message-(\\d{3})$/.exec(behavior ?? ""); + if (!channels && rejected) return new Response("{}", { status: Number(rejected[1]) }); if (!channels && behavior === "message-network-error") throw new TypeError("fetch failed"); return new Response(JSON.stringify(channels ? { id: "dm-channel" } : { id: "message-1" }), { status: 200 }); }; @@ -319,6 +341,7 @@ test("discord-dm carries nonce and enforce_nonce when asked", async () => { let posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/channels/dm-channel/messages")); assert.equal(posted.at(-1).body.nonce, "abc123"); assert.equal(posted.at(-1).body.enforce_nonce, true); + assert.deepEqual(posted.at(-1).body.allowed_mentions, { parse: [] }, "no mention is ever parsed"); const without = dm(); assert.equal(without.status, 0, without.stderr); @@ -347,14 +370,40 @@ test("discord-dm exits 75 only before delivery", async () => { assert.equal(dm(undefined, { recipient: "" }).status, 75, "missing argument"); assert.equal(dm("dm-channel-500").status, 75, "DM channel failure"); assert.equal(await messagePosts(), 0, "nothing was posted by any of those"); - assert.equal(dm("message-400").status, 75, "definite non-2xx on the message POST"); + assert.equal(dm("message-400").status, 75, "a 4xx answer to the message POST"); assert.equal(await messagePosts(), 1); + for (const status of [500, 502, 503, 429]) { + const before = await messagePosts(); + assert.equal(dm(`message-${status}`).status, 75, `a ${status} answer is retryable, not unknown`); + assert.equal(await messagePosts(), before + 1); + } const lost = dm("message-network-error"); - assert.equal(lost.status, 1, "a network error after the message POST was sent is not known-unsent"); - assert.equal(await messagePosts(), 2); + assert.equal(lost.status, 1, "a network error after the message POST was sent is not exit 75"); + assert.equal(await messagePosts(), 6); assert.equal(dm().status, 0); }); +test("a 5xx or 429 on the message POST is retried with the same nonce", async () => { + for (const status of [502, 429]) { + const w = await world(`wn-${status}-`); + const stub = await fetchStub(w.dir); + const env = { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" }; + const messages = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")); + for (let tick = 1; tick <= 2; tick += 1) { + const result = w.run({ bridge: BRIDGE, env: { ...env, STUB_BEHAVIOR: `message-${status}` } }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), [`failed:${tick}`, `failed:${tick}`]); + } + const sent = await messages(); + assert.equal(sent.length, 4, "two openings, each tried twice"); + const nonces = Map.groupBy(sent, (entry) => entry.body.content); + assert.equal(nonces.size, 2); + for (const group of nonces.values()) assert.equal(new Set(group.map((entry) => entry.body.nonce)).size, 1, "a retry reuses its nonce"); + assert.equal(w.run({ bridge: BRIDGE, env }).status, 0); + assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); + } +}); + test("every subject Idunn accepts is delivered, inside backticks", async () => { const good = ["gamecult/odin", "odin:store", "_odin", "a".repeat(256), "Odin.v2-x"]; const bad = ["a".repeat(257), "has space", "back`tick", "new\nline", "", "ün"]; @@ -406,5 +455,6 @@ test("the reader posts through the real bridge with its nonce", async () => { } assert.ok(existsSync(w.receipts), "the bridge wrote its crossing receipts to the reader's receipt store"); assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); + assert.equal((await w.status()).status, 0, "status exits zero when nothing is unknown or exhausted"); }); diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 3d9e7db..e51b2f0 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -471,9 +471,12 @@ async function postDiscordMessage(options) { } // discord-dm exits NOT_SENT_EXIT only when it knows no message was delivered: -// anything before the message POST, or a definite non-2xx answer to it. A -// failure after the POST was sent (network error, receipt completion, output) -// keeps exit 1, so the caller treats the outcome as unknown. +// anything before the message POST, or a non-2xx answer to it. A 5xx or 429 +// is not proof of non-delivery (Discord may have created the message before +// answering): the caller retries with the same nonce and relies on +// enforce_nonce inside Discord's window. A failure after the POST was sent +// (network error, receipt completion, output) keeps exit 1, so the caller +// treats the outcome as unknown. const NOT_SENT_EXIT = 75; class NotSentError extends Error {} class DiscordRejection extends Error {} diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index 9fbee59..485a59d 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -28,15 +28,17 @@ const INCIDENT_SCHEMA = "idunn.operator_incident.v1"; const JOURNAL_TYPE = "bifrost.watchdog_notice_execution"; const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; const MAX_ATTEMPTS = 5; -const BRIDGE_TIMEOUT_MS = 60_000; +// The override exists so a test can reach the timeout branch without a 60 s wait. +const BRIDGE_TIMEOUT_MS = Number(process.env.WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS) || 60_000; const STATUSES = ["running", "completed", "failed", "unknown"]; const NAME = /^[a-z][a-z-]{0,63}$/; // Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): // 1-256 bytes of [A-Za-z0-9-_.:/]. The notice puts the subject in inline code. const SUBJECT = /^[A-Za-z0-9_.:/-]{1,256}$/; const MAX_DATE_MS = 8.64e15; -// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) only when it knows the -// message was not sent. Every other non-success leaves the outcome unknown. +// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) for failures before +// the message POST and for any non-2xx answer to it: retried with the same nonce. +// Every other non-success leaves the outcome unknown. const BRIDGE_NOT_SENT = 75; class NoticeError extends Error {} @@ -175,7 +177,7 @@ function noticeText(record, notice) { } // The one send primitive. The outcome is completed with a message id; failed -// only when the bridge reports exit 75 (known not sent); otherwise unknown. The +// only when the bridge reports exit 75 (retryable, same nonce); otherwise unknown. The // error is a fixed code: bridge stdout and stderr never reach the journal. async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }) { const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-")); From 69e12e3867a9e4136164fa24e8fd6e1c0b07e7e4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 01:48:44 +0200 Subject: [PATCH 044/155] Watchdog notice reader: a failed send is retried forever with capped backoff Ruling notice-retry-policy. The five-attempt cap and its terminal "exhausted" state are gone. A failed entry is eligible again once min(2^(attempts-1), 60) minutes have passed since its last journal update; a run inside the delay skips it without spawning the bridge. Every attempt keeps the same nonce. The exit status is non-zero while any entry is unknown or has failed 3 or more times, and retries go on. status shows nextEligibleAt for failed entries. The clock is one now() that WATCHDOG_NOTICE_NOW_MS can override, like the bridge timeout override, so tests drive the backoff without sleeping. The process summary field "exhausted" is renamed "failing". A journal entry whose updatedAt is not a date is now refused as journal-entry-invalid, since the delay is measured from it. All commits build. Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 19 +++++-- tests/watchdog-notice.test.mjs | 99 ++++++++++++++++++++++++++-------- tools/watchdog-notice.mjs | 35 ++++++++---- 3 files changed, 116 insertions(+), 37 deletions(-) diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index 610f46c..5419673 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -22,6 +22,15 @@ node tools/watchdog-notice.mjs status --journal-store J - `BIFROST_DISCORD_BOT_TOKEN` in the environment, read only by `bifrost-bridge.mjs`, which the reader spawns. +## Retry + +A notice whose send failed (bridge exit 75) is never dropped. It is eligible +again once `min(2^(attempts-1), 60)` minutes have passed since its last attempt +in the journal: 1, 2, 4, 8, 16, 32, then every 60 minutes. A run that finds it +inside its delay skips it without spawning the bridge. Every attempt carries the +same nonce, so a Discord outage of any length costs a late notice, not a lost +one, and a retry that succeeds is the one post. + ## Behaviour - P is Idunn's `idunn.operator_incident.v1` store. The reader reads it without a @@ -31,7 +40,7 @@ node tools/watchdog-notice.mjs status --journal-store J - J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed `#opened` or `#closed`. A `running` entry found at start becomes `unknown` and is never sent again. A `failed` entry is retried on - later runs with the same nonce, up to five attempts. The journal is never + later runs with the same nonce, never given up on (see Retry). The journal is never pruned: what Bifrost has sent is Bifrost's memory, whatever P holds. - The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 for argument or token errors, a failure opening the DM channel, or any non-2xx answer to the message @@ -43,9 +52,11 @@ node tools/watchdog-notice.mjs status --journal-store J `failed` for exit 75 only. Any other non-success without a message id (another exit code, a signal, the spawn timeout, unreadable output) is `unknown`: never sent again, counted by the exit status. -- The exit status is non-zero while any entry is `unknown` or out of attempts, or - while P holds a record of this schema that breaks its contract. systemd shows - that; `status` lists the entries. +- The exit status is non-zero while any entry is `unknown` or has failed three or + more times, or while P holds a record of this schema that breaks its contract. + systemd shows that; `status` lists the entries, with `attempts` and, for a + `failed` entry, `nextEligibleAt`. The non-zero status is the signal only: the + entry keeps being retried. - Notice text is built from `condition`, `subject` and ISO UTC times only. The subject follows Idunn's `require_id` (1-256 bytes of `[A-Za-z0-9-_.:/]`) and is placed in inline code. A time outside the ECMAScript Date range refuses the diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index ff7c6ae..5ce633a 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -77,6 +77,11 @@ async function world(prefix, { incidents = FIXTURE, mode = "ok" } = {}) { return w; } +// The reader's clock is WATCHDOG_NOTICE_NOW_MS; a test drives the backoff by +// naming the minute (from T0) each run happens at. +const T0 = 1_800_000_000_000; +const at = (minutes) => ({ WATCHDOG_NOTICE_NOW_MS: String(T0 + Math.round(minutes * 60_000)) }); + function run(w, { env = {}, bridge = w.bridge } = {}) { return spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { encoding: "utf8", @@ -144,13 +149,14 @@ test("closure is posted once and only after a completed opening", async () => { // An opening that never completes blocks its closure, now and later. const failing = await world("wn-closure-failed-", { mode: "fail" }); - for (let run = 0; run < 5; run += 1) failing.run(); - await failing.setMode("ok"); - failing.run(); - failing.run(); + for (const minute of [0, 1, 3, 7, 15]) failing.run({ env: at(minute) }); assert.equal((await failing.posts()).filter((post) => notice(post) === "closed").length, 0); const rows = (await failing.status()).rows; assert.deepEqual(rows.map((row) => `${row.notice}:${row.status}:${row.attempts}`), ["opened:failed:5", "opened:failed:5"]); + await failing.setMode("ok"); + assert.equal(failing.run({ env: at(31) }).status, 0); + assert.equal(failing.run({ env: at(32) }).status, 0); + assert.deepEqual((await failing.posts()).filter((post) => notice(post) === "closed").map((post) => opt(post, "source-id")), [CLOSED_KEY], "the closure follows the completed opening, once"); const crashed = await world("wn-closure-unknown-", { mode: "crash" }); assert.equal(crashed.run().status, null, "the reader died mid-send"); @@ -176,26 +182,75 @@ test("a running entry becomes unknown and is never re-posted", async () => { assert.equal((await w.posts()).length, posts.length); }); -test("only a bridge exit 75 is retried, with the same nonce, up to five attempts", async () => { +const backoffMinutes = [0, 1, 3, 7, 15, 31, 63, 123, 183]; + +test("a failed notice is retried after its backoff and never exhausted", async () => { const w = await world("wn-retry-", { mode: "fail" }); - const exits = []; - for (let run = 0; run < 5; run += 1) exits.push(w.run().status); - assert.deepEqual(exits, [0, 0, 0, 0, 1], "non-zero once attempts are exhausted"); - assert.deepEqual((await w.status()).rows.map((row) => row.lastError), ["bridge-not-sent", "bridge-not-sent"]); + // Run at every expected spawn minute and one minute before it: only the former may spawn. + const probes = [...new Set(backoffMinutes.flatMap((minute) => [minute - 1, minute]).filter((minute) => minute >= 0))].sort((a, b) => a - b); + const spawned = []; + for (const minute of probes) { + const before = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY).length; + w.run({ env: at(minute) }); + const after = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY).length; + if (after > before) spawned.push(minute); + } + assert.deepEqual(spawned, backoffMinutes, "attempts at 1, 2, 4, 8, 16, 32 then every 60 minutes after the last attempt"); const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); - assert.equal(open.length, 5); - assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1); - const before = (await w.posts()).length; - const again = w.run(); - assert.notEqual(again.status, 0); - assert.equal((await w.posts()).length, before, "no spawn after the fifth attempt"); + assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1, "every attempt carries the same nonce"); + const rows = (await w.status()).rows; + assert.deepEqual(rows.map((row) => `${row.status}:${row.attempts}:${row.lastError}`), ["failed:9:bridge-not-sent", "failed:9:bridge-not-sent"], "never a terminal state"); + const final = w.run({ env: at(183) }); const status = await w.status(); + assert.notEqual(final.status, 0); assert.notEqual(status.status, 0); - for (const text of [again.stdout, again.stderr, status.stdout, await readFile(w.journal, "latin1")]) { + for (const text of [final.stdout, final.stderr, status.stdout, await readFile(w.journal, "latin1")]) { assert.ok(!text.includes("RECIPIENTCANARY"), "bridge output and recipient never reach the reader's output or journal"); } }); +test("a failed notice is not retried before its delay", async () => { + const w = await world("wn-delay-", { mode: "fail" }); + const count = async () => (await w.posts()).length; + w.run({ env: at(0) }); + assert.equal(await count(), 2, "both openings tried once"); + w.run({ env: at(0.99) }); + w.run({ env: at(0) }); + assert.equal(await count(), 2, "a run inside the delay spawns nothing"); + const row = (await w.status()).rows[0]; + assert.equal(row.attempts, 1); + assert.equal(row.nextEligibleAt, new Date(T0 + 60_000).toISOString(), "status names when the entry is next eligible"); + w.run({ env: at(1) }); + assert.equal(await count(), 4, "eligible once the delay has passed"); + assert.equal((await w.status()).rows[0].nextEligibleAt, new Date(T0 + 3 * 60_000).toISOString()); +}); + +test("the unit signals from the third failed attempt while still retrying", async () => { + const w = await world("wn-signal-", { mode: "fail" }); + const exits = []; + for (const minute of [0, 1, 3]) exits.push(w.run({ env: at(minute) }).status); + assert.deepEqual(exits, [0, 0, 1], "zero after attempts 1 and 2, non-zero from 3"); + assert.equal((await w.status()).status, 1); + const before = (await w.posts()).length; + assert.equal(w.run({ env: at(7) }).status, 1); + assert.equal((await w.posts()).length, before + 2, "attempt 4 still spawns"); + assert.ok((await w.status()).rows.every((row) => row.status === "failed" && row.attempts === 4)); +}); + +test("a notice that finally sends after many failures posts once", async () => { + const w = await world("wn-finally-", { mode: "fail" }); + for (const minute of [0, 1, 3, 7, 15]) w.run({ env: at(minute) }); + await w.setMode("ok"); + assert.equal(w.run({ env: at(31) }).status, 0); + const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); + assert.equal(open.length, 6, "five failures and one success"); + assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1, "the success carries the same nonce"); + const before = (await w.posts()).length; + for (const minute of [32, 100, 1000]) assert.equal(w.run({ env: at(minute) }).status, 0); + assert.equal((await w.posts()).length, before, "completed is never posted again"); + assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), ["completed:6", "completed:6", "completed:6"]); +}); + test("the reader never writes or locks the incident store", async () => { const w = await world("wn-readonly-"); const before = { bytes: await readFile(w.incidents), mtime: (await stat(w.incidents)).mtimeMs }; @@ -217,7 +272,7 @@ test("notice content carries no sensitive text", async () => { }); const result = w.run(); assert.notEqual(result.status, 0, "refused records fail the run"); - assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, exhausted: 0 }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, failing: 0 }); const posts = await w.posts(); assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on `closed-target` at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on `closed-target`, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); @@ -390,7 +445,7 @@ test("a 5xx or 429 on the message POST is retried with the same nonce", async () const env = { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" }; const messages = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")); for (let tick = 1; tick <= 2; tick += 1) { - const result = w.run({ bridge: BRIDGE, env: { ...env, STUB_BEHAVIOR: `message-${status}` } }); + const result = w.run({ bridge: BRIDGE, env: { ...env, ...at(tick === 1 ? 0 : 1), STUB_BEHAVIOR: `message-${status}` } }); assert.equal(result.status, 0, result.stderr); assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), [`failed:${tick}`, `failed:${tick}`]); } @@ -399,7 +454,7 @@ test("a 5xx or 429 on the message POST is retried with the same nonce", async () const nonces = Map.groupBy(sent, (entry) => entry.body.content); assert.equal(nonces.size, 2); for (const group of nonces.values()) assert.equal(new Set(group.map((entry) => entry.body.nonce)).size, 1, "a retry reuses its nonce"); - assert.equal(w.run({ bridge: BRIDGE, env }).status, 0); + assert.equal(w.run({ bridge: BRIDGE, env: { ...env, ...at(3) } }).status, 0); assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); } }); @@ -416,7 +471,7 @@ test("every subject Idunn accepts is delivered, inside backticks", async () => { }); const stub = await fetchStub(w.dir); const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } }); - assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: bad.length, unknown: 0, exhausted: 0 }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: bad.length, unknown: 0, failing: 0 }); const contents = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).map((entry) => entry.body.content).sort(); const at = (index) => new Date(1_700_000_200_000 + index).toISOString(); assert.deepEqual(contents, good.map((subject, index) => OPENED_TEXT(subject, at(index))).sort()); @@ -436,7 +491,7 @@ test("an out-of-range time refuses the record without aborting the run", async ( }); const result = w.run(); assert.equal(result.stderr, "", "no unexpected-error"); - assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: 2, unknown: 0, exhausted: 0 }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: 2, unknown: 0, failing: 0 }); assert.notEqual(result.status, 0, "refused records fail the run"); assert.deepEqual((await w.posts()).map((post) => opt(post, "source-id")).sort(), ["continuity-exhausted:edge:8640000000000000", OPEN_KEY]); assert.ok((await w.status()).rows.every((row) => row.status === "completed")); @@ -455,6 +510,6 @@ test("the reader posts through the real bridge with its nonce", async () => { } assert.ok(existsSync(w.receipts), "the bridge wrote its crossing receipts to the reader's receipt store"); assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); - assert.equal((await w.status()).status, 0, "status exits zero when nothing is unknown or exhausted"); + assert.equal((await w.status()).status, 0, "status exits zero when nothing is unknown or failing"); }); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index 485a59d..741a851 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -27,9 +27,15 @@ const cult = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "Cu const INCIDENT_SCHEMA = "idunn.operator_incident.v1"; const JOURNAL_TYPE = "bifrost.watchdog_notice_execution"; const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; -const MAX_ATTEMPTS = 5; -// The override exists so a test can reach the timeout branch without a 60 s wait. +// A failed notice is retried forever: 1, 2, 4, ... minutes after its last +// attempt, capped at 60 (ruling notice-retry-policy). From FAILING_ATTEMPTS +// failures on, the exit status is the delivery-failure signal; retries go on. +const MAX_DELAY_MINUTES = 60; +const FAILING_ATTEMPTS = 3; +// The overrides exist so a test can reach the timeout branch without a 60 s wait +// and can drive the backoff without sleeping. const BRIDGE_TIMEOUT_MS = Number(process.env.WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS) || 60_000; +const clockMs = () => Number(process.env.WATCHDOG_NOTICE_NOW_MS) || Date.now(); const STATUSES = ["running", "completed", "failed", "unknown"]; const NAME = /^[a-z][a-z-]{0,63}$/; // Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): @@ -82,7 +88,7 @@ async function processIncidents(options) { const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }); await journal.put({ ...base, messageId: outcome.messageId ?? "", status: outcome.status, lastError: outcome.error ?? "", updatedAt: now() }); }; - const retryable = (entry) => !entry || (entry.status === "failed" && entry.attempts < MAX_ATTEMPTS); + const retryable = (entry) => !entry || (entry.status === "failed" && !(clockMs() < nextEligibleMs(entry))); const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); for (const record of records) { @@ -94,9 +100,9 @@ async function processIncidents(options) { const entries = journal.cache.getAll(journal.definition); const unknown = entries.filter((entry) => entry.status === "unknown").length; - const exhausted = entries.filter(isExhausted).length; - process.stdout.write(`${JSON.stringify({ skipped, refused, unknown, exhausted })}\n`); - return unknown + exhausted + refused > 0 ? 1 : 0; + const failing = entries.filter(isFailing).length; + process.stdout.write(`${JSON.stringify({ skipped, refused, unknown, failing })}\n`); + return unknown + failing + refused > 0 ? 1 : 0; } async function status(options) { @@ -104,14 +110,20 @@ async function status(options) { const journal = await openJournal(runtime, requireOption(options, "journal-store")); const entries = journal.cache.getAll(journal.definition); const rows = entries - .map(({ incidentKey, notice, status, attempts, updatedAt, lastError }) => ({ incidentKey, notice, status, attempts, updatedAt, lastError })) + .map((entry) => ({ + incidentKey: entry.incidentKey, notice: entry.notice, status: entry.status, attempts: entry.attempts, + updatedAt: entry.updatedAt, lastError: entry.lastError, + nextEligibleAt: entry.status === "failed" ? new Date(nextEligibleMs(entry)).toISOString() : null, + })) .sort((a, b) => (a.incidentKey + a.notice).localeCompare(b.incidentKey + b.notice)); process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); - return entries.some((entry) => entry.status === "unknown" || isExhausted(entry)) ? 1 : 0; + return entries.some((entry) => entry.status === "unknown" || isFailing(entry)) ? 1 : 0; } -const isExhausted = (entry) => entry.status === "failed" && entry.attempts >= MAX_ATTEMPTS; -const now = () => new Date().toISOString(); +const isFailing = (entry) => entry.status === "failed" && entry.attempts >= FAILING_ATTEMPTS; +// Measured from the failed entry's last attempt in the journal. +const nextEligibleMs = (entry) => Date.parse(entry.updatedAt) + Math.min(2 ** (entry.attempts - 1), MAX_DELAY_MINUTES) * 60_000; +const now = () => new Date(clockMs()).toISOString(); // Reads P unlocked. An absent file is "nothing to do" (records: null). A // record of another schema is counted as skipped; a record of this schema @@ -240,7 +252,8 @@ function parseEntry(value) { && (value.notice === "opened" || value.notice === "closed") && STATUSES.includes(value.status) && Number.isSafeInteger(value.attempts) && value.attempts >= 0 - && ["nonce", "messageId", "lastError", "updatedAt"].every((field) => typeof value[field] === "string"); + && ["nonce", "messageId", "lastError", "updatedAt"].every((field) => typeof value[field] === "string") + && Number.isFinite(Date.parse(value.updatedAt)); if (!ok) throw fail("journal-entry-invalid"); return value; } From c468f05eaf53f5de177bd405dc48a3f64dafefbf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 01:53:18 +0200 Subject: [PATCH 045/155] Watchdog notice reader test: the closure posted beside a late opening has one attempt The finally-sends test expected six attempts on all three journal rows, but the closure entry is first tried in the run that completes its opening, so it has one. Test-only fix; the previous commit's test failed on this line. The previous commit (69e12e3867a9e4136164fa24e8fd6e1c0b07e7e4) builds; its suite has one failing test, fixed here. Co-Authored-By: Claude Sonnet 5.5 --- tests/watchdog-notice.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 5ce633a..1817f7e 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -248,7 +248,7 @@ test("a notice that finally sends after many failures posts once", async () => { const before = (await w.posts()).length; for (const minute of [32, 100, 1000]) assert.equal(w.run({ env: at(minute) }).status, 0); assert.equal((await w.posts()).length, before, "completed is never posted again"); - assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), ["completed:6", "completed:6", "completed:6"]); + assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), ["completed:1", "completed:6", "completed:6"]); }); test("the reader never writes or locks the incident store", async () => { From 0312e8bbac7906a0b660057940dbd6650e4d4b45 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 02:19:01 +0200 Subject: [PATCH 046/155] Watchdog notice reader: a future stamp is eligible now; no production clock seam A failed entry stamped ahead of the clock (host clock stepped forward then corrected) is eligible now instead of waiting out the skew; the next attempt rewrites the stamp. The WATCHDOG_NOTICE_NOW_MS env var is deleted: the reader reads Date.now(), and tests fix it with a --import preload (tests/fake-clock.mjs). The unknown-entry tests now rerun far past the backoff so a retried unknown fails. Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 2 +- tests/fake-clock.mjs | 5 +++++ tests/watchdog-notice.test.mjs | 39 +++++++++++++++++++++++----------- tools/watchdog-notice.mjs | 17 +++++++++------ 4 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 tests/fake-clock.mjs diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index 5419673..b6e47de 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -27,7 +27,7 @@ node tools/watchdog-notice.mjs status --journal-store J A notice whose send failed (bridge exit 75) is never dropped. It is eligible again once `min(2^(attempts-1), 60)` minutes have passed since its last attempt in the journal: 1, 2, 4, 8, 16, 32, then every 60 minutes. A run that finds it -inside its delay skips it without spawning the bridge. Every attempt carries the +inside its delay skips it without spawning the bridge. An entry stamped ahead of the clock (a host clock stepped forward, then corrected) is eligible now, and its next attempt rewrites the stamp. Every attempt carries the same nonce, so a Discord outage of any length costs a late notice, not a lost one, and a retry that succeeds is the one post. diff --git a/tests/fake-clock.mjs b/tests/fake-clock.mjs new file mode 100644 index 0000000..e64aae5 --- /dev/null +++ b/tests/fake-clock.mjs @@ -0,0 +1,5 @@ +// Test preload: fixes Date.now() to FAKE_NOW_MS when a test sets it. +if (process.env.FAKE_NOW_MS) { + const fixed = Number(process.env.FAKE_NOW_MS); + Date.now = () => fixed; +} diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 1817f7e..931c579 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -72,25 +72,27 @@ async function world(prefix, { incidents = FIXTURE, mode = "ok" } = {}) { if (incidents) await copyFile(incidents, w.incidents); w.setMode = (value) => writeFile(resolve(dir, "mode"), value); w.run = (extra = {}) => run(w, extra); - w.status = () => runStatus(w); + w.status = (env) => runStatus(w, env); w.posts = async () => (existsSync(w.calls) ? (await readFile(w.calls, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : []); return w; } -// The reader's clock is WATCHDOG_NOTICE_NOW_MS; a test drives the backoff by -// naming the minute (from T0) each run happens at. +// The reader reads Date.now(). A test drives the backoff the way it stubs fetch: +// a preload (fake-clock.mjs, passed to the reader as --import) fixes Date.now +// to the minute (from T0) the run happens at. The reader has no clock seam. const T0 = 1_800_000_000_000; -const at = (minutes) => ({ WATCHDOG_NOTICE_NOW_MS: String(T0 + Math.round(minutes * 60_000)) }); +const CLOCK = resolve(import.meta.dirname, "fake-clock.mjs"); +const at = (minutes) => ({ FAKE_NOW_MS: String(T0 + Math.round(minutes * 60_000)) }); function run(w, { env = {}, bridge = w.bridge } = {}) { - return spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { + return spawnSync(process.execPath, ["--import", CLOCK, TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { encoding: "utf8", env: { ...process.env, DISCORD_OWNER_ID: RECIPIENT, ...env }, }); } -function runStatus(w) { - const result = spawnSync(process.execPath, [TOOL, "status", "--journal-store", w.journal], { encoding: "utf8", env: process.env }); +function runStatus(w, env = {}) { + const result = spawnSync(process.execPath, ["--import", CLOCK, TOOL, "status", "--journal-store", w.journal], { encoding: "utf8", env: { ...process.env, ...env } }); return { ...result, rows: result.status === null || !result.stdout ? [] : JSON.parse(result.stdout) }; } @@ -178,10 +180,23 @@ test("a running entry becomes unknown and is never re-posted", async () => { assert.equal(posts.filter((post) => opt(post, "cultmesh-command-id") === `${CLOSED_KEY}#opened`).length, 1, "the interrupted notice is not sent again"); const rows = (await w.status()).rows; assert.equal(rows.find((row) => row.incidentKey === CLOSED_KEY && row.notice === "opened").status, "unknown"); - assert.notEqual(w.run().status, 0, "still failing on the next run"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0, "still failing, and not re-posted, long past any backoff"); assert.equal((await w.posts()).length, posts.length); }); +test("an entry stamped ahead of the clock is eligible now, not deferred by the skew", async () => { + const w = await world("wn-skew-", { mode: "fail" }); + w.run({ env: at(1000) }); + assert.equal((await w.posts()).length, 2, "both openings tried once, stamped at minute 1000"); + w.run({ env: at(0) }); + assert.equal((await w.posts()).length, 4, "the clock is behind the stamp: retried at once"); + assert.equal((await w.status(at(0))).rows[0].nextEligibleAt, new Date(T0 + 2 * 60_000).toISOString(), "the retry rewrote the stamp from the corrected clock"); + w.run({ env: at(1.5) }); + assert.equal((await w.posts()).length, 4, "the cadence holds from the rewritten stamp"); + w.run({ env: at(2) }); + assert.equal((await w.posts()).length, 6); +}); + const backoffMinutes = [0, 1, 3, 7, 15, 31, 63, 123, 183]; test("a failed notice is retried after its backoff and never exhausted", async () => { @@ -326,8 +341,8 @@ test("a bridge that dies after sending is unknown and never re-posted", async () assert.equal(JSON.parse(first.stdout).unknown, 2); assert.notEqual((await w.status()).status, 0, "status exits non-zero while an entry is unknown"); await w.setMode("ok"); - assert.notEqual(w.run().status, 0); - assert.notEqual(w.run().status, 0); + assert.notEqual(w.run({ env: at(1000) }).status, 0, "far past any backoff"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0); const posts = await w.posts(); assert.equal(posts.length, 2, "one opening post per incident, no retry, no closure"); assert.ok(posts.every((post) => notice(post) === "opened")); @@ -343,8 +358,8 @@ test("a spawn timeout and a spawn error are unknown, never failed, never re-post assert.notEqual(first.status, 0, mode); assert.equal(JSON.parse(first.stdout).unknown, 2, mode); await w.setMode("ok"); - assert.notEqual(w.run().status, 0); - assert.notEqual(w.run().status, 0); + assert.notEqual(w.run({ env: at(1000) }).status, 0, "far past any backoff"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0); const posts = await w.posts(); assert.equal(posts.length, 2, `${mode}: one opening post per incident, no retry, no closure`); assert.ok(posts.every((post) => notice(post) === "opened")); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index 741a851..1e6dffc 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -32,10 +32,8 @@ const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; // failures on, the exit status is the delivery-failure signal; retries go on. const MAX_DELAY_MINUTES = 60; const FAILING_ATTEMPTS = 3; -// The overrides exist so a test can reach the timeout branch without a 60 s wait -// and can drive the backoff without sleeping. +// The override exists so a test can reach the timeout branch without a 60 s wait. const BRIDGE_TIMEOUT_MS = Number(process.env.WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS) || 60_000; -const clockMs = () => Number(process.env.WATCHDOG_NOTICE_NOW_MS) || Date.now(); const STATUSES = ["running", "completed", "failed", "unknown"]; const NAME = /^[a-z][a-z-]{0,63}$/; // Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): @@ -88,7 +86,7 @@ async function processIncidents(options) { const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }); await journal.put({ ...base, messageId: outcome.messageId ?? "", status: outcome.status, lastError: outcome.error ?? "", updatedAt: now() }); }; - const retryable = (entry) => !entry || (entry.status === "failed" && !(clockMs() < nextEligibleMs(entry))); + const retryable = (entry) => !entry || (entry.status === "failed" && Date.now() >= nextEligibleMs(entry)); const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); for (const record of records) { @@ -121,9 +119,14 @@ async function status(options) { } const isFailing = (entry) => entry.status === "failed" && entry.attempts >= FAILING_ATTEMPTS; -// Measured from the failed entry's last attempt in the journal. -const nextEligibleMs = (entry) => Date.parse(entry.updatedAt) + Math.min(2 ** (entry.attempts - 1), MAX_DELAY_MINUTES) * 60_000; -const now = () => new Date(clockMs()).toISOString(); +// Measured from the failed entry's last attempt in the journal. A stamp ahead of +// the clock is skew (a host clock stepped forward, then corrected), not a delay +// to wait out: that entry is eligible now, and its next attempt rewrites the stamp. +const nextEligibleMs = (entry) => { + const stamp = Date.parse(entry.updatedAt); + return stamp > Date.now() ? Date.now() : stamp + Math.min(2 ** (entry.attempts - 1), MAX_DELAY_MINUTES) * 60_000; +}; +const now = () => new Date(Date.now()).toISOString(); // Reads P unlocked. An absent file is "nothing to do" (records: null). A // record of another schema is counted as skipped; a record of this schema From fcf81ea564cf077a8bf054d08c889772054de9bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 02:19:14 +0200 Subject: [PATCH 047/155] Watchdog notice reader test: no environment variable moves the clock Co-Authored-By: Claude Sonnet 5.5 --- tests/watchdog-notice.test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 931c579..aeb5368 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -197,6 +197,14 @@ test("an entry stamped ahead of the clock is eligible now, not deferred by the s assert.equal((await w.posts()).length, 6); }); +test("the reader takes time from Date.now() alone, never from the environment", async () => { + const w = await world("wn-noseam-", { mode: "fail" }); + w.run({ env: { ...at(0), WATCHDOG_NOTICE_NOW_MS: "-5" } }); + w.run({ env: { ...at(0), WATCHDOG_NOTICE_NOW_MS: "8000000000000000" } }); + assert.equal((await w.posts()).length, 2, "no environment variable moves the clock"); + assert.equal((await w.status({ WATCHDOG_NOTICE_NOW_MS: "8000000000000000", ...at(0) })).rows[0].nextEligibleAt, new Date(T0 + 60_000).toISOString()); +}); + const backoffMinutes = [0, 1, 3, 7, 15, 31, 63, 123, 183]; test("a failed notice is retried after its backoff and never exhausted", async () => { From 9b28e9b27ad6a2a7e7fabba3645468cdb67268e7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 02:25:13 +0200 Subject: [PATCH 048/155] Watchdog notice reader test: status reads the same fake clock as the runs Co-Authored-By: Claude Sonnet 5.5 --- tests/watchdog-notice.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index aeb5368..21d27aa 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -240,12 +240,12 @@ test("a failed notice is not retried before its delay", async () => { w.run({ env: at(0.99) }); w.run({ env: at(0) }); assert.equal(await count(), 2, "a run inside the delay spawns nothing"); - const row = (await w.status()).rows[0]; + const row = (await w.status(at(0))).rows[0]; assert.equal(row.attempts, 1); assert.equal(row.nextEligibleAt, new Date(T0 + 60_000).toISOString(), "status names when the entry is next eligible"); w.run({ env: at(1) }); assert.equal(await count(), 4, "eligible once the delay has passed"); - assert.equal((await w.status()).rows[0].nextEligibleAt, new Date(T0 + 3 * 60_000).toISOString()); + assert.equal((await w.status(at(1))).rows[0].nextEligibleAt, new Date(T0 + 3 * 60_000).toISOString()); }); test("the unit signals from the third failed attempt while still retrying", async () => { From 20d5efa9c6bba80ff07bd50862f5f942fcbe038e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 04:55:42 +0200 Subject: [PATCH 049/155] Concept Mill votes: target and body facts The Imagination map of 2026-10-08, committed as the target document of the concept-mill-votes campaign. Typed invariants, rulings and cut specs live in the eureka mind. Co-Authored-By: Claude Opus 5.5 --- docs/concept-mill-votes-target.md | 217 ++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/concept-mill-votes-target.md diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md new file mode 100644 index 0000000..b2b9272 --- /dev/null +++ b/docs/concept-mill-votes-target.md @@ -0,0 +1,217 @@ +# Concept Mill votes through Bifrost: map + +Status: Imagination mapping pass, 2026-10-08, revised after the rulings `aetheria-release:ruling:concept-mill-votes-open` and `aetheria-release:ruling:concept-mill-votes-anonymous`. Committed 2026-10-10 as the target of the `concept-mill-votes` campaign; its invariants, rulings and cut specs are typed state in the eureka mind, and this file keeps the body facts and rationale. Where they differ, the mind is right. + +Operator's words: "I want to track upvotes and downvotes on there, if possible." Then: "Yggdrasil is the obvious place. Doesn't Bifrost have discussions and voting?" Rulings: "anyone can vote, votes are public (we can filter by members if we wanted to)", then "anonymous, too, I mean anyone". + +Short answer: Bifrost has two voting-like things, and neither fits the Mill as built. Governance topics take support and objection only as free-text comment stances. Those live in a command-line CultCache file the web app never reads, and they are not tied to accounts. Motions have real one-account-one-vote records, but they are tier-weighted decisions that close by deadline. Bifrost is the right home, but the Mill needs a small new record in it that admits anonymous voters, which nothing in Bifrost does today. It also needs an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that has to be settled first. + +## Body facts + +Bifrost repo head: `160fac2` (2026-10-01). Paths are relative to `F:\Projects\Bifrost` unless named. + +### What a topic is in code + +1. Topics and comments exist only in a Node CLI, `tools/governance-threads.mjs`. Its store is a CultCache file, `.bifrost/governance-threads.cc` (`tools/governance-threads.mjs:18`), and its types are `bifrost.governance.topic` and `bifrost.governance.topic-comment` (`:48-111`). The commands are `open`, `comment`, `approve`, `promote`, `list`, `show` and `digest` (`:113-155`, `:582`). +2. A topic is an agent-dispatch work thread. It has `jurisdictionRepoName` (required), `jurisdictionAgentIdentity`, a status lifecycle `open → consensus_ready → approved → dispatched → closed` (`:52`), and `promote`, which renders the topic into an agent update request (`:324-405`). +3. Support and objection are comment stances, not votes. The valid set is `comment, proposal, support, objection, question, approval, summary, receipt` (`:53`). A comment has `authorKind` (default `"agent"`) and `authorId`, a free string from `--author` (`:232-241`). Nothing enforces one stance per author: each call writes a new `comment_` (`:233`). Retraction is not possible. `authorId` is not tied to a Bifrost `UserAccount`. +4. The web app has no topic type, no topic page and no topic store. It knows topics only as `GovernanceActivityReceipt` rows in Postgres, with kinds `TopicOpened/Commented/Approved/Promoted` (`src/Bifrost.Web/Domain/BifrostModels.cs:235-240`, `:1050-1066`; `Features/Bridge/GovernanceActivityReceiptService.cs`). +5. No topic store exists anywhere I looked. `F:\Projects\Bifrost\.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc`. That `find` ran under `sudo -n` with errors silenced, so the absence there is likely but not proven. + +### How motions and votes differ + +6. Motions and votes are EF Core entities in Postgres: `Motion` (`BifrostModels.cs:665-700`) and `Vote` (`:702-721`, with `VoteChoice For/Against/Abstain` at `:110-115`). +7. A vote is one per account per motion: there is a unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting a vote upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`). +8. Votes are weighted. `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`). That weight is 0 unless the membership is `Active`, and otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`). Patron tier is the voting weight. +9. A motion has a category with an approval threshold, `ClosesAtUtc`, and a pass/fail resolution (`MotionGovernanceService.cs:95-131`, `MotionCategoryPolicy.cs`). It is a decision, not a standing rating. + +### Who can act + +10. Every member surface is behind `ActiveMember`: `AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)` (`src/Bifrost.Web/Program.cs:57`). The handler succeeds only when `Membership.Status == Active` (`Features/Membership/ActiveMemberAuthorizationHandler.cs`, `Features/Shared/CurrentBifrostActor.cs:17`). The motions page and its vote handler are at `Pages/App/Motions/Index.cshtml.cs:56`. +11. An account becomes `Active` in one of three ways: + - bootstrap admin GitHub logins (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`); + - admin approval on the Members page (`Pages/App/Members/Index.cshtml.cs:92-105`); + - a Heimdall member-access claim, meaning the Discord cult-member role or an active Patreon tier (`Features/Heimdall/HeimdallAuthService.cs:158,186`). + + GitHub sign-in with an invitation lands in `PendingApproval`, and without one in `Authenticated` (`MembershipSynchronizationService.cs:61`). +12. Anyone can register natively, and doing so proves nothing. `POST /auth/bifrost/register` is anonymous (`Program.cs:286-316`). It takes an identity name and a display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). It asks for no credential, and there is no native sign-in route. A native account is a single cookie: as a voter it is indistinguishable from anonymous, and it is lost when the cookie expires. +13. No public read path exists: + - Anonymous routes are only health, readiness, auth, webhooks, the patron intake and the patronage checkout (`Program.cs:211-522`). + - The Eve governance surface requires `ActiveMember` (`Program.cs:473-488`). + - No CORS policy is configured anywhere in `src/Bifrost.Web` (grep for `Cors` returns nothing). + +### Deployed versus repo + +14. The live release is `/srv/bifrost/app/releases/20260723-daedcc4`, and `bifrost.service` is active. I checked both read-only over SSH alias `yggdrasil` on 2026-10-08. `https://bifrost.gamecult.org/healthz` and `/readyz` both return 200. +15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on `main`. It exists only on `origin/codex/bifrost-migration-metadata`, which branched from `bd3a5a9`. Compared with `daedcc4`, `main` has `cde3a78`, which deletes 369 lines of HTTP bridge endpoints from `Program.cs`. `main` also lacks the migration-metadata attribute lines `daedcc4` added to seven migrations. Production therefore runs code that `main` has deleted, and `main` may still carry the EF migration-discovery bug that `daedcc4` fixed. +16. Deploys are manual. `gamecult-ops/scripts/deploy-bifrost-alpha.sh` creates a timestamped release directory, and Idunn does not manage Bifrost (`gamecult-ops/runbooks/bifrost-alpha-deploy.md:162-164`). The inventory entry (`gamecult-ops/inventory.md:941-952`) records no revision. + +### The Mill page + +17. The Mill is being built uncommitted in worktree `F:\Projects\AetheriaLore-wt-concept-mill`. It consists of `Aetheria/Game Design/Concept Mill.md` (397 lines, hand-authored HTML in Markdown), `Aetheria/media/concept-mill/` (41 files) and a `site/quartz/styles/custom.scss` change. +18. Each card is `
`, and there are 19 today (`Concept Mill.md:12` for the first, `bison`). The slug is already independent of the display name: for example, `vasuki2` is shown as "Vasuki". The ship roll links to `#`. +19. The site has a pattern for client script. A Quartz component sets `afterDOMLoaded` (`site/quartz/components/AetheriaEpubReader.tsx:110`) and re-runs on the `nav` event (`site/quartz/components/scripts/aetheriaEpubReader.inline.ts:228`). Deploys go through `.github/workflows/deploy-quartz.yml` to GitHub Pages. + +### Anonymous-voter substrate + +20. Bifrost has no anonymous actor. Every write path resolves a `CurrentBifrostActor` with a `UserAccount` and throws without one (`MotionGovernanceService.cs:18,54`). Anonymous votes therefore need a voter identity that is not a `UserAccount`. +21. Bifrost sees no client IP. nginx sends `X-Real-IP` and `X-Forwarded-For` (`gamecult-ops/nginx/bifrost.gamecult.org.conf:15-16`), but `Program.cs` never calls `UseForwardedHeaders`, so `RemoteIpAddress` is the loopback proxy for every request. Until that is fixed, any per-IP limit puts every visitor in one bucket. +22. Data Protection keys can be persisted (`Program.cs:113-117`, `DataProtection:KeysPath`). A device token signed with them would stay valid across restarts and releases, provided the live env sets the path. Cut 0 checks this. + +## Rulings in force + +- **Who may vote:** anyone, including anonymous visitors. There is no membership gate. +- **Votes are public:** up and down counts are shown, so downvotes are visible. +- **Members are a filter, not a separate system.** Each vote records the voter's class at cast time (anonymous, account, or Active member). Member-only views are a filter over the same rows. + +Derived from these rulings, not asked separately: + +- **Counts are raw: one voter, one mark.** Patron weight cannot apply to anonymous voters, and the member filter replaces it as the "members' opinion" view. +- **Concept art stays out of governance.** Mill votes are their own record, not governance topics or motions. Topics are agent-dispatch threads, and motions are tier-weighted, deadline-closing decisions (facts 2, 8, 9). Neither can admit an anonymous voter (fact 20). +- **Substrate is Postgres beside `Vote`,** to keep the optional foreign key to `UserAccount` in one transaction. "Bifrost web state is off CultCache" remains the standing doctrine debt it already is. This feature does not pay it down. + +Interpretation for Self to confirm, not a fork: "votes are public" is read as public counts, split by voter class. The export carries no per-voter identities. Anonymous voters have none to show, and publishing account names was not asked for. + +## Authority map + +- **Concept identity.** Owner: the Mill page in AetheriaLore. The card `id` slug is the key (fact 18). Bifrost stores keys and never invents one. Renaming a ship changes its title, never its key. +- **Concept subjects.** Owner: a Bifrost `ConceptSubject` record: `(collection, key)` unique, plus `title`, `registeredAt` and `retiredAt`. Writer: one idempotent admin registration command, fed from the Mill's card list and run by the render-round agent. The vote path cannot create subjects, because otherwise anonymous visitors could mint keys. +- **Voter identity.** Owner: a Bifrost `VoterKey` resolver, the one place that answers "who is casting this?": + - signed in: `account:`; + - otherwise: `device:`, where the id comes from a signed, HttpOnly, first-party device-token cookie that Bifrost issues on the vote page. + + The `VoterKey` resolver is not an owner of membership status. It reads `CurrentBifrostActor.IsActiveMember` at cast time. +- **Votes.** Owner: a `ConceptAppraisal` record with these fields: + - `subjectId` and `voterKey`, with `(subjectId, voterKey)` unique; + - `stance` (`Up` or `Down`) and `castAt`; + - `voterClass` (`Anonymous`, `Account` or `Member`), stamped at cast; + - `userAccountId`, nullable; + - `networkTag`, an HMAC of the client's /24 (IPv4) or /64 (IPv6) prefix, stored for audit and purge. + + The one writer is `ConceptAppraisalService` (Cast, Change, Retract, and a Purge for admins). Rate limiting happens before the service, and uniqueness is enforced inside it and by the index. +- **Tallies.** Derived only. Per subject, per stance, per voter class, they are computed from rows and never stored as counters. The "all" and "members" views are filters over the same rows. +- **Public tally export.** This is the JSON xenos boundary. It is one anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject: + - no voter keys, account ids or network tags; + - CORS allows exactly `https://aetheria.gamecult.org`, `GET` only, without credentials; + - `Cache-Control` is about 60 s. + + The export owns nothing. +- **The Mill page.** Read only. One fetch fills each card's counts, and a "Vote on Bifrost" link goes to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. That means no `SameSite=None`, no cross-site POST and no exposure to browser third-party-cookie blocking. +- **Demotions:** + - Governance topic support and objection are not vote owners. + - `Vote.Weight` and patron tiers stay governance-only. + - The static page owns no count. + - The client IP is not an identity: it is a rate-limit bucket and an audit tag, never a vote key. + +## Anti-stuffing defence (implementation duty) + +Prior art on lightweight anonymous voting, from memory and not freshly searched: + +- Strawpoll-style polls offer duplicate checks by IP, by browser cookie, or by sign-in. IP-only checks wrongly block shared networks, and cookie-only checks are reset by a private window. +- Proof-of-work challenges (Hashcash; today ALTCHA, mCaptcha, Friendly Captcha) make each vote cost CPU without any third party. They slow bulk scripts and barely touch a determined attacker with hardware. +- Third-party challenges (Cloudflare Turnstile, reCAPTCHA) are strong. They also send visitor data to an outside service and add an external dependency to the vote path. +- Large sites rely on after-the-fact detection more than on gates: they look for clusters by network, timing and new devices, then discount or purge. + +Recommendation: a layered defence that lives entirely inside Bifrost, with proof-of-work held back as an escalation. + +1. **Device token: one vote per device per concept.** On the first vote-page visit, Bifrost mints a random device id, signs it with Data Protection, and sets it as `bifrost_voter`: HttpOnly, Secure, SameSite=Lax, valid for one year. The vote key is `device:`. Clearing cookies resets it, and that is accepted and bounded by layer 2. +2. **Network rate limits.** Configure `UseForwardedHeaders`, trusting only loopback (fact 21 makes this a prerequisite). Then put the in-box ASP.NET Core rate limiter (`Microsoft.AspNetCore.RateLimiting`) on two paths, partitioned by network prefix: + - token minting: a small number of new devices per prefix per hour; + - vote casts: a modest number per prefix per minute. + + Signed-in accounts are partitioned by account, not by network. The limits are configuration values, and the first values are Hands' to propose and Soul's to probe. +3. **Audit and purge.** Every anonymous row carries `networkTag` and `castAt`. An admin view lists subjects whose recent votes cluster on one tag. A Purge command removes rows by tag and time window, leaving an audit event. Public counts can then be corrected after the fact, without a gate that blocks honest shared networks. +4. **Escalation, not built now:** a self-hosted proof-of-work challenge on token minting, using an ALTCHA-style library served from Bifrost. It is switched on by configuration only if layer 3 shows stuffing. It needs no third party, so votes stay Bifrost-owned. Turnstile and reCAPTCHA are refused, because they add an outside owner to the vote path. + +Where it lives: + +- Layers 1 to 3 are Bifrost web code: the `VoterKey` resolver, the rate-limiter policies in `Program.cs`, and the service's Purge. +- nginx gets nothing new, and `limit_req` is not used. Edge limits would be a second, unobservable owner of the same budget. +- The static site gets nothing at all. + +## Cuts, in order + +Each cut is one Hands brief. Bifrost .NET builds and tests run on Yggdrasil per the workstation load budget. + +**Cut 0: Reconcile the Bifrost deploy with `main`.** Repos: Bifrost and gamecult-ops. + +- Land `daedcc4`'s migration-metadata fix on `main`. +- Confirm nothing live still calls the bridge endpoints that `cde3a78` deleted (fact 15). +- Confirm that the live env sets `DataProtection__KeysPath` (fact 22). This is read-only; if it is unset, Cut 6 adds it. +- Deploy `main` per the runbook and record the revision in `inventory.md`. +- Verify: + - migrations list completely against a scratch database; + - `/healthz` returns 200; + - the admin can load the motions page; + - the inventory names the revision. + +**Cut 1: Forwarded headers.** Repo: Bifrost. + +- Call `UseForwardedHeaders` with `X-Forwarded-For` and `X-Forwarded-Proto`, trusting `127.0.0.1` and `::1` only. +- Tests: + - behind a loopback proxy, `RemoteIpAddress` is the forwarded client; + - a forwarded header from a non-loopback peer is ignored, which is the negative check against spoofing. + +**Cut 2: Subjects, voter keys and appraisals.** Repo: Bifrost. + +- Add `ConceptSubject`, `ConceptAppraisal`, a migration, the `VoterKey` resolver with the device token, and `ConceptAppraisalService` (Cast, Change, Retract, Purge, Tally). +- Tests: + - a second cast by the same voter key changes the stance and adds no row; + - Retract removes the row; + - a vote on an unknown or retired key is refused; + - `voterClass` and `userAccountId` are stamped correctly for anonymous, account and Active-member actors; + - a tampered or unsigned device cookie is rejected and a new one is minted; + - Purge removes only rows in the tag and window; + - the tally by class equals the row counts. +- Negative check: nothing outside the service writes `ConceptAppraisal`. + +**Cut 3: Subject registration.** Repo: Bifrost. + +- Add an idempotent admin command that upserts `(collection, key, title)` and can retire a key. Use whichever existing admin idiom Hands finds, `/App` page handler or `tools/` CLI. +- Tests: a rerun is a no-op; renaming a title keeps the votes; a retired key keeps its rows but leaves the export. +- Seed it with the 19 slugs. + +**Cut 4: Public export and rate limits.** Repo: Bifrost. + +- Add the anonymous tally `GET` with the one-origin CORS policy. +- Add the two rate-limiter policies, for minting and for casting. +- Tests: + - an anonymous request gets 200; + - CORS headers are present only for `https://aetheria.gamecult.org`; + - the payload contains no voter keys, account ids or tags; + - a burst from one prefix gets 429 past the limit while another prefix is unaffected; + - signed-in accounts are limited per account. + +**Cut 5: The vote page.** Repo: Bifrost. + +- Add `/mill/{collection}/{key}`, outside `/App`. It shows the title, the counts (all and members), a back link to the card, and Up, Down and Retract buttons for everyone, as HTMX posts with antiforgery. A first visit mints the device token. +- Tests: + - an anonymous visitor can cast, change and retract; + - a signed-in visitor's vote is keyed to the account; + - a cast without the antiforgery token is refused. + +**Cut 6: Deploy.** Repo: gamecult-ops. + +- Deploy per the runbook. Set `DataProtection__KeysPath` if Cut 0 found it unset, and update the inventory. +- Verify from outside: + - the export, requested with `Origin: https://aetheria.gamecult.org`, returns CORS headers and 19 keys; + - an anonymous vote from a clean browser shows in the export after the cache expires; + - a scripted burst gets 429. + +**Cut 7: The Mill reads the counts.** Repo: AetheriaLore. + +- On each card, add `data-concept-key=""`, a count slot and the vote link. +- Add one Quartz component script (`afterDOMLoaded`, re-run on `nav`, fact 19) that fetches the export once and fills the slots. On failure the slots stay hidden and the links still work. +- Add "register new slugs with the Cut 3 command" to the render-round routine. +- Verify: + - the Quartz build passes; + - the counts render in a browser; + - with JavaScript disabled the links still work; + - with Bifrost blocked there are no layout holes. + +This cut lands with or after the Mill page's own commit. + +## Open questions + +None remain from the operator. Self should confirm one interpretation, that the export carries counts by class and no voter names. If Hands finds that the live env lacks persisted Data Protection keys, that is a deploy fix inside Cut 6, not a fork. + From bf83a9321b1867f8b106e43a5e3221e1e48793f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 05:01:18 +0200 Subject: [PATCH 050/155] Research: prior art on anonymous-vote stuffing defences Served concept-mill-votes Cut 4 (anti-stuffing defence). Facts with source pointers on PoW captchas, device cookies, network buckets, ePrivacy status of anti-abuse cookies, and after-the-fact purge at HN, Stack Exchange and Reddit. Co-Authored-By: Claude Opus 5.5 --- .../anonymous-vote-stuffing-prior-art.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/research/anonymous-vote-stuffing-prior-art.md diff --git a/docs/research/anonymous-vote-stuffing-prior-art.md b/docs/research/anonymous-vote-stuffing-prior-art.md new file mode 100644 index 0000000..e48b0dd --- /dev/null +++ b/docs/research/anonymous-vote-stuffing-prior-art.md @@ -0,0 +1,98 @@ +# Anonymous vote stuffing: prior art + +Serves the fork: concept-mill-votes Cut 4, anti-stuffing defence; campaign concept-mill-votes; 2026-10-10. +Subject text: "Anti-stuffing defence" in `F:\Projects\Bifrost\docs\concept-mill-votes-target.md` (lines ~106-130). +Gathered by Eyes. Facts with pointers; no recommendation. Items marked (secondary) were not read at the primary source. Search-engine summaries were not verified against the pages unless stated. + +Gaps: (a) a query for residential-proxy and CAPTCHA-farm pricing was refused by the search tool, so no measured cost of a headless/proxy farm is here; (b) StrawPoll's own duplicate-check docs were not found; (c) Friendly Captcha's own licence/docs pages were not read; (d) ALTCHA's server-verification and PoW doc pages returned 404/empty; (e) national ePrivacy guidance beyond CNIL and ICO was not searched. + +## 1. Anonymous and low-friction voting in established systems + +- Poll software generally: duplicate checks are IP locking, cookie, or sign-in. Vendor docs say IP checks block shared networks (offices, schools), cookie checks are beaten by clearing cookies or switching browsers, and sign-in is strongest but still needs multi-account safeguards. Kentico: https://docs.kentico.com/k8/securing-websites/designing-secure-websites/securing-and-protecting-the-system/preventing-duplicate-poll-voting-and-content-rating . Academic restatement ("IP locking, cookie-based methods"): https://www.scitepress.org/PublishedPapers/2004/14026/14026.pdf (2004). WordPress poll-plugin thread reporting repeat votes despite "cookie and ip" logging: https://wordpress.org/support/?p=1278907 +- StrawPoll: its own option documentation was not found; the design doc's StrawPoll statement is not confirmed by this pass. +- Reddit: "vote fuzzing" described by staff only via press (Daily Dot, https://dailydot.com/news/reddit-vote-count-percent-like/ , undated): displayed totals altered as an anti-spam measure. Mechanics (offsetting detected bot votes, paired fake votes) come from user/moderator posts only; no official algorithm page found (secondary). +- Hacker News: a "voting ring detector" demotes rather than kills (dang, 2014, https://news.ycombinator.com/item?id=7542985 ; community list https://github.com/minimaxir/hacker-news-undocumented ). Moderators have publicly rolled back a ring penalty as a false positive (https://hn.svelte.dev/item/47457689 ). Internals unpublished. +- Stack Exchange: help page "serial voting reversed" (https://stackoverflowteams.com/help/serial-voting-reversed): one user voting on many of another user's posts in a short window is judged invalid and removed; some removal by a daily automated process, some by staff after investigation. Thresholds not public. The page says a "voting corrected" event does not imply wrongdoing. +- Product Hunt: staff reply in https://www.producthunt.com/p/producthunt/vote-selling-on-product-hunt says they will not reveal vote weights or all signals; a zero-point rule for voters with no history was under consideration. Weighting by account age/activity is asserted only by third-party guides (secondary). +- Academic, Sybil resistance: + - Douceur, "The Sybil Attack" (IPTPS 2002): absent a logically centralized authority, Sybil attacks can always be mounted barring unrealistic resource parity. https://courses.csail.mit.edu/6.895/fall02/papers/Douceur/iptps02-douceur.pdf . Identifying nodes by hash of IP address (CFS) is noted as implicitly relying on an authority. + - SybilGuard/SybilLimit (Yu, Gibbons, Kaminsky, Xiao, 2008): bound accepted Sybil nodes using social-network structure (fast mixing). https://www.comp.nus.edu.sg/~yuhf/sybillimit-tr.pdf + - SumUp (Tran, Min, Li, Subramanian, NSDI 2009): content-vote aggregation capping adversary votes at the number of attack edges in a trust graph, plus feedback-based reduction of misbehaving voters; applied to a Digg vote trace, found strong evidence of attack on many "popular" articles. https://static.usenix.org/events/nsdi09/tech/full_papers/tran/tran.pdf + - All three need a social/trust graph or authority; none targets graph-less anonymous visitors. + +## 2. Proof-of-work challenges + +| Project | Licence | Self-host | .NET | Pointers | +|---|---|---|---|---| +| ALTCHA | MIT (widget README) | Yes | Official server list: TS, Dart, PHP, Go, Python, Java, Ruby, Elixir, Rust, C++ (no C#). Community: Ixnas.AltchaNet (0.4.x; SHA-256; replay store interface; bundled in-memory store "for testing only") and unofficial Altcha.Net 1.2.0 (replay store interface; in-memory not advised multi-server) | https://raw.githubusercontent.com/altcha-org/altcha/main/README.md ; https://www.nuget.org/packages/Altcha.Net ; https://www.nuget.org/packages/Ixnas.AltchaNet (via search, not opened) | +| mCaptcha | AGPL-3.0 | Yes, Docker Compose; separate Rust service | None found | https://github.com/mCaptcha/mCaptcha ; README: "active development", no releases listed, SHA-256 PoW, difficulty scales with load (about 2 s under attack), proof configs expire after 30 s and are single-use, cookie-free, IP-independent | +| Cap | Apache-2.0 | Yes, Docker/workers | None stated | https://github.com/tiagozip/cap ; README claims "gpu-resistant" PoW plus instrumentation challenges; no algorithm parameters; size claims are marketing | +| Friendly Captcha | Third-party listings: front end MPL-2.0 (unconfirmed); back end hosted commercial, a "lite" self-host server mentioned (unconfirmed) | Hosted service | Not checked | https://friendlycaptcha.com/insights/open-source-captcha/ ; https://openalternative.co/friendly-captcha (secondary) | +| Anubis | MIT, Go | Yes, reverse-proxy sidecar | n/a | https://github.com/TecharoHQ/anubis ; aimed at scrapers; README calls itself "a bit of a nuclear response" and notes it can block good bots | + +- ALTCHA widget: PBKDF2/SHA variants bundled; Argon2id and Scrypt (memory-bound, "harder to accelerate on ASICs or GPUs") are separate imports; `minDuration` default 500 ms; `timeout` default 90 s; `humanInteractionSignature` on by default (mechanism not documented in the README). Ixnas.AltchaNet defaults: complexity 50000-100000, expiry 120 s (search summary). +- Argued or measured attacker cost: + - Laurie and Clayton, "Proof-of-Work Proves Not to Work" (WEIS 2004): difficulty high enough to stop spam would stop significant numbers of legitimate senders, since attackers use hijacked machines and run continuously. https://www.cl.cam.ac.uk/~rnc1/proofwork2.pdf . Rebuttal, Liu and Camp, "Proof of Work can Work" (2006): works if work is weighted by a reputation function. https://sites.cs.ucsb.edu/~rich/class/cs293b-cloud/papers/lui-pow.pdf + - Anubis: its author acknowledges PoW works best against crawlers that cannot run JavaScript; an operator with a solver keeps the authorization cookie and continues (LWN, https://lwn.net/Articles/1028558 ). A July 2026 CLI client (anubis-fetch) automates the challenge (secondary: https://runtimewire.com/article/anubis-bypass-proof-of-work-bot-wall-limits ). + - No numbers (seconds per solve on commodity vs GPU hardware, attacker cost per 1000 votes) found for ALTCHA, mCaptcha or Cap. +- Reuse shape: mCaptcha tokens are single-use and short-lived; Anubis issues a cookie that covers later requests. + +## 3. ASP.NET Core rate limiting and forwarded headers + +Docs read: https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit (ms.date 2026-09-03; covers 7.0-12.0) and https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer (ms.date 2026-04-23). + +- Rate limiter: `AddRateLimiter`, `GlobalLimiter` or named policies, `PartitionedRateLimiter.Create`, `RateLimitPartition.GetFixedWindowLimiter` etc. The doc's "By IP address" sample keys on `Connection.RemoteIpAddress?.ToString() ?? "unknown"`. The page contains no IPv6 prefix guidance. +- Doc warnings: partitioning on user-controlled input can exhaust memory (each distinct key creates and caches a limiter); "if you create partitions by using client IP addresses, your app becomes vulnerable to Denial of Service attacks that use IP Source Address Spoofing" (cites BCP 38). +- `UseRateLimiter` must come after `UseRouting` when endpoint-specific policies/attributes are used; global-only may precede it. +- Time-based limiters do not refund a permit already taken when a later chained limiter rejects. +- `OnRejected`/`RejectionStatusCode` are needed for 429; the doc shows setting Retry-After manually or from lease metadata. +- Forwarded headers: + - Middleware "should run before other middleware" (before `UseHsts`; may follow diagnostics/error handling). Anything reading `RemoteIpAddress`, including the rate limiter, must come after it to see the forwarded address. + - Default `KnownNetworks` is a single loopback entry (`IPNetwork(IPAddress.Loopback, 8)`), and `KnownProxies` likewise loopback-only. Forwarded values are accepted only if the immediate peer matches one of the lists. + - Default `ForwardLimit` is 1: only the rightmost `X-Forwarded-For` entry is processed (right to left). Null is advised only with known proxies/networks configured. + - Doc: "Only allow trusted proxies and networks to forward headers. Otherwise, IP spoofing attacks are possible." + - Dual-mode sockets: IPv4 entries must be given in IPv4-mapped IPv6 form; check `RemoteIpAddress` to see which applies. + - `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` uses cloud-style settings and "doesn't enable features such as the KnownProxies option". + - Unverified: whether newer .NET renames `KnownNetworks`; the page read lists `KnownNetworks` with `IPNetwork`. +- IPv6 prefix practice (practitioner sources only; no standard or vendor guidance found): + - RFC 8981 privacy extensions rotate the interface id inside one /64 (preferred lifetime 1 day, valid 2 days, up to 3 concurrent). https://www.rfc-editor.org/rfc/rfc8981.html + - A residential customer can obtain /56 or /48 via DHCPv6-PD, so per-/64 buckets can be spread across by one household. v6ops https://psg.com/lists/v6ops/v6ops.2010/msg00152.html ; NANOG https://mailman.nanog.org/pipermail/nanog/2015-October/079735.html + - One ISP policy quoted: /56 broadband, /48 business, /64 mobile; aggregating too high can block tens of thousands of mobile users. Suggested escalating aggregation /64 to /48 to /40 to /36 to /32 on observed abuse. Nextcloud thread weighing /56 default: https://help.nextcloud.com/t/is-bruteforce-protection-ipv6-ready/215084 + - No source found for a /24 IPv4 partition choice. + +## 4. Device/cookie identifiers + +- Data Protection (https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/default-settings ; `.../configuration/overview`): + - Default key storage: Azure Apps `%HOME%`, else user profile (`%LOCALAPPDATA%`, DPAPI on Windows), else IIS registry; "If none of these conditions match, keys aren't persisted outside of the current process. When the process shuts down, all generated keys are lost." + - Docker: persist keys to a volume or external store. `SetApplicationName` must match across deployments (default discriminator is content-root path). + - Default key lifetime 90 days with auto-roll; retired keys still decrypt while kept. "Deleting a key makes its protected data permanently inaccessible"; docs recommend not deleting. + - A custom key repository disables automatic encryption of keys at rest. + - Payload algorithms: AES-256-CBC + HMACSHA256 (encrypted and authenticated, not only signed). +- SameSite: Chrome treats cookies without a SameSite attribute as Lax from v80 (https://web.dev/articles/samesite-cookies-explained ). Chrome kept third-party cookies; Privacy Sandbox APIs retired Oct 2025 (secondary: https://engadget.com/cybersecurity/google-has-killed-privacy-sandbox-130029899.html ). Safari blocks third-party cookies by default, Firefox blocks known trackers (secondary, 2019). A same-origin vote page is first-party throughout. +- Safari ITP: 7-day cap applies to cookies written via `document.cookie`; WebKit's Wilander stated Secure HttpOnly server-set cookies are not affected (https://simoahava.com/privacy/first-party-cookies-webkit-revisited/ ; WebKit bugs 237350, 237474). Sources disagree on later script-cookie handling; none found capping server-set cookies. +- Private/incognito windows discard the cookie jar on close (browser behaviour; no primary source fetched). +- Regulators: + - ICO, "What are the exceptions" (https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/what-are-the-exceptions/ ): PECR examples include "ensuring the security of terminal equipment" and "preventing or detecting fraud"; necessity is assessed "from the point of view of the subscriber or user, not your own"; a second purpose voids the exception; advertising never qualifies, including click-fraud detection. + - CNIL cookies FAQ (https://www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies/FAQ), Q16: fraud-prevention trackers are in general not exempt, but may be in particular cases such as securing a user authentication mechanism. Q17 (CAPTCHA): no consent if the sole purpose is securing the site or an authentication mechanism for the user's benefit; consent if data is reused for the provider's own ends (reCAPTCHA named) or purposes unspecified; where consent is required, refusal bypasses the protection. + - EDPB Guidelines 2/2023 on Art. 5(3) ePrivacy (v2.0, 7 Oct 2024): applies to any storage/access on terminal equipment, technology-neutral, including identifiers other than cookies; lists "tracking based on IP only" as a use case. https://www.edpb.europa.eu/documents/guideline/guidelines-22023-on-technical-scope-of-art-53-of-eprivacy-directive_en . Its exemption text on identifiers was not read. + - GDPR status of an HMAC'd network tag stored per row was not searched. + +## 5. Known failure modes per layer + +- Device token: cleared cookies, private windows and fresh profiles reset it (poll vendors, Kentico); a scripted client omits or discards it. Unpersisted Data Protection keys invalidate every issued token on each restart; deleted keys invalidate tokens signed with them. +- IP/prefix limits: + - Shared NAT: poll vendors report schools and offices tripping IP checks. CGN measurement: Livadariu et al. infer 4.1K ASes deploy CGN (2014-2016 data); Mandalari et al. found CGN in 10% of 60+ tested ISPs; Richter et al. https://arxiv.org/abs/1605.05606 ; CAIDA https://www-old.caida.org/publications/papers/2018/inferring_carrier_grade_nat/ . Blocklists and address reuse: https://steel.isi.edu/members/sivaram/papers/blocklisting_impact.pdf . No CGN false-positive rate for voting found. + - Mobile IPv6: carriers commonly assign a /64 per device (forum, secondary); whether a named carrier changes the prefix per session was not found. RFC 8981 rotates addresses within the /64. + - Delegated prefixes larger than /64 span many buckets (v6ops/NANOG above). + - Header trust: forged `X-Forwarded-For` is accepted only when the immediate peer is in KnownProxies/Networks; without `UseForwardedHeaders`, `RemoteIpAddress` is the proxy for every client (one bucket). IP-keyed partitions carry the doc's DoS/memory warning. +- Audit-and-purge: HN and Stack Exchange both reverse or demote by pattern and both document false positives (above); neither publishes thresholds. +- PoW: Laurie/Clayton and Anubis (above): attackers with solvers or botnets pay CPU; legitimate low-end devices pay too; a reusable cookie spreads one solve over many requests. .NET libraries require a replay store; in-memory stores are test-only/single-instance. Headless-farm cost: not gathered. +- Third-party challenge: CNIL Q17 names reCAPTCHA as requiring consent where the provider reuses data. + +## Where the current design's assumptions are contradicted by evidence + +1. The design lists proof-of-work as slowing scripts yet barely touching determined attackers; the cited literature agrees, but the design also says ALTCHA "needs no third party" and is "ALTCHA-style library served from Bifrost": ALTCHA has no official C# server library, only community packages that need a custom replay store for production. +2. Data Protection default key persistence is process-lifetime unless a profile/registry/volume exists; default key lifetime is 90 days, not one year. The one-year device cookie depends on retired keys being kept and `DataProtection__KeysPath` set (the doc's fact 22 flags the path; the doc does not state the key-loss consequence for tokens). +3. The design's /64 partition for IPv6 is described by practitioner sources as bypassable by one household holding a /56 or /48 and by privacy-extension rotation inside a /64; the opposite risk (aggregating above /64 on mobile) is documented as collateral damage. No evidence was found for the /24 IPv4 choice. +4. The design treats the device cookie as needing no consent notice; ICO and CNIL exempt security cookies only where security is the sole purpose and from the user's side, CNIL calls fraud-prevention trackers generally non-exempt (exception: securing an authentication mechanism), and ICO says users should still be informed. A vote-integrity cookie is not an authentication mechanism and was not addressed by either text found. +5. The design says large sites "rely on after-the-fact detection ... then discount or purge": confirmed for HN, Stack Exchange and (secondary) Reddit, but those systems also publish no thresholds, keep false-positive reversals, and Reddit/Product Hunt add vote fuzzing or voter-history weighting, which the design does not include. From e77d6220de036dcc912717dabe668e04060a8c4a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 05:16:45 +0200 Subject: [PATCH 051/155] Concept Mill votes target: re-verified body facts, authority map, cut order Imagination pass that admitted cut specs 0a through 7. Facts re-probed at Bifrost bf83a93 and on the host; the from-memory prior-art list is replaced by a citation of docs/research/anonymous-vote-stuffing-prior-art.md. Co-Authored-By: Claude Opus 5.5 --- docs/concept-mill-votes-target.md | 239 ++++++++++-------------------- 1 file changed, 75 insertions(+), 164 deletions(-) diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index b2b9272..91c92e6 100644 --- a/docs/concept-mill-votes-target.md +++ b/docs/concept-mill-votes-target.md @@ -1,217 +1,128 @@ # Concept Mill votes through Bifrost: map -Status: Imagination mapping pass, 2026-10-08, revised after the rulings `aetheria-release:ruling:concept-mill-votes-open` and `aetheria-release:ruling:concept-mill-votes-anonymous`. Committed 2026-10-10 as the target of the `concept-mill-votes` campaign; its invariants, rulings and cut specs are typed state in the eureka mind, and this file keeps the body facts and rationale. Where they differ, the mind is right. +Status: Imagination mapping pass 2026-10-08, revised after the rulings `aetheria-release:ruling:concept-mill-votes-open` and `aetheria-release:ruling:concept-mill-votes-anonymous`. Committed 2026-10-10 as the target of the `concept-mill-votes` campaign. Body facts re-verified and cut specs admitted 2026-10-10 by `imagination-mill-votes` against Bifrost `bf83a93`. Its invariants, rulings, questions and cut specs are typed state in the eureka mind; this file keeps the body facts and rationale. Where they differ, the mind is right. Operator's words: "I want to track upvotes and downvotes on there, if possible." Then: "Yggdrasil is the obvious place. Doesn't Bifrost have discussions and voting?" Rulings: "anyone can vote, votes are public (we can filter by members if we wanted to)", then "anonymous, too, I mean anyone". -Short answer: Bifrost has two voting-like things, and neither fits the Mill as built. Governance topics take support and objection only as free-text comment stances. Those live in a command-line CultCache file the web app never reads, and they are not tied to accounts. Motions have real one-account-one-vote records, but they are tier-weighted decisions that close by deadline. Bifrost is the right home, but the Mill needs a small new record in it that admits anonymous voters, which nothing in Bifrost does today. It also needs an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that has to be settled first. +Short answer: Bifrost has two voting-like things, and neither fits the Mill. Governance topics take support and objection only as free-text comment stances, in a command-line CultCache file the web app never reads, not tied to accounts. Motions have real one-account-one-vote records, but they are tier-weighted decisions that close by deadline. Bifrost is the right home, but the Mill needs a small new record in it that admits anonymous voters, which nothing in Bifrost does today, plus an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that is settled first. ## Body facts -Bifrost repo head: `160fac2` (2026-10-01). Paths are relative to `F:\Projects\Bifrost` unless named. +Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only). Paths are relative to `F:\Projects\Bifrost` unless named. Facts 1-13 were taken at `160fac2` on 2026-10-08; the source files they cite are unchanged at `bf83a93` (the two commits since touch only `docs/`). Facts 14 onward were re-probed on 2026-10-10 by `imagination-mill-votes`; scripts are in that agent's scratch directory (`ygg-probe*.sh`). ### What a topic is in code 1. Topics and comments exist only in a Node CLI, `tools/governance-threads.mjs`. Its store is a CultCache file, `.bifrost/governance-threads.cc` (`tools/governance-threads.mjs:18`), and its types are `bifrost.governance.topic` and `bifrost.governance.topic-comment` (`:48-111`). The commands are `open`, `comment`, `approve`, `promote`, `list`, `show` and `digest` (`:113-155`, `:582`). 2. A topic is an agent-dispatch work thread. It has `jurisdictionRepoName` (required), `jurisdictionAgentIdentity`, a status lifecycle `open → consensus_ready → approved → dispatched → closed` (`:52`), and `promote`, which renders the topic into an agent update request (`:324-405`). 3. Support and objection are comment stances, not votes. The valid set is `comment, proposal, support, objection, question, approval, summary, receipt` (`:53`). A comment has `authorKind` (default `"agent"`) and `authorId`, a free string from `--author` (`:232-241`). Nothing enforces one stance per author: each call writes a new `comment_` (`:233`). Retraction is not possible. `authorId` is not tied to a Bifrost `UserAccount`. -4. The web app has no topic type, no topic page and no topic store. It knows topics only as `GovernanceActivityReceipt` rows in Postgres, with kinds `TopicOpened/Commented/Approved/Promoted` (`src/Bifrost.Web/Domain/BifrostModels.cs:235-240`, `:1050-1066`; `Features/Bridge/GovernanceActivityReceiptService.cs`). -5. No topic store exists anywhere I looked. `F:\Projects\Bifrost\.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc`. That `find` ran under `sudo -n` with errors silenced, so the absence there is likely but not proven. +4. The web app has no topic type, page or store. It knows topics only as `GovernanceActivityReceipt` rows in Postgres, with kinds `TopicOpened/Commented/Approved/Promoted` (`src/Bifrost.Web/Domain/BifrostModels.cs:235-240`, `:1050-1066`; `Features/Bridge/GovernanceActivityReceiptService.cs`). +5. No topic store exists anywhere looked. `.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc` (ran under `sudo -n` with errors silenced, so likely but not proven). ### How motions and votes differ 6. Motions and votes are EF Core entities in Postgres: `Motion` (`BifrostModels.cs:665-700`) and `Vote` (`:702-721`, with `VoteChoice For/Against/Abstain` at `:110-115`). -7. A vote is one per account per motion: there is a unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting a vote upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`). -8. Votes are weighted. `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`). That weight is 0 unless the membership is `Active`, and otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`). Patron tier is the voting weight. +7. A vote is one per account per motion: unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`). +8. Votes are weighted: `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`), 0 unless the membership is `Active`, otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`). 9. A motion has a category with an approval threshold, `ClosesAtUtc`, and a pass/fail resolution (`MotionGovernanceService.cs:95-131`, `MotionCategoryPolicy.cs`). It is a decision, not a standing rating. ### Who can act -10. Every member surface is behind `ActiveMember`: `AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)` (`src/Bifrost.Web/Program.cs:57`). The handler succeeds only when `Membership.Status == Active` (`Features/Membership/ActiveMemberAuthorizationHandler.cs`, `Features/Shared/CurrentBifrostActor.cs:17`). The motions page and its vote handler are at `Pages/App/Motions/Index.cshtml.cs:56`. -11. An account becomes `Active` in one of three ways: - - bootstrap admin GitHub logins (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`); - - admin approval on the Members page (`Pages/App/Members/Index.cshtml.cs:92-105`); - - a Heimdall member-access claim, meaning the Discord cult-member role or an active Patreon tier (`Features/Heimdall/HeimdallAuthService.cs:158,186`). - - GitHub sign-in with an invitation lands in `PendingApproval`, and without one in `Authenticated` (`MembershipSynchronizationService.cs:61`). -12. Anyone can register natively, and doing so proves nothing. `POST /auth/bifrost/register` is anonymous (`Program.cs:286-316`). It takes an identity name and a display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). It asks for no credential, and there is no native sign-in route. A native account is a single cookie: as a voter it is indistinguishable from anonymous, and it is lost when the cookie expires. -13. No public read path exists: - - Anonymous routes are only health, readiness, auth, webhooks, the patron intake and the patronage checkout (`Program.cs:211-522`). - - The Eve governance surface requires `ActiveMember` (`Program.cs:473-488`). - - No CORS policy is configured anywhere in `src/Bifrost.Web` (grep for `Cors` returns nothing). +10. Every member surface is behind `ActiveMember`: `AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)` (`src/Bifrost.Web/Program.cs:57`). The handler succeeds only when `Membership.Status == Active` (`Features/Membership/ActiveMemberAuthorizationHandler.cs`, `Features/Shared/CurrentBifrostActor.cs:17`). Admin-only page handlers additionally check `Actor.CanManageMembership` and return `Forbid()` (`Pages/App/Members/Index.cshtml.cs:37-39,95-97`). +11. An account becomes `Active` by bootstrap admin GitHub login (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`), admin approval (`Pages/App/Members/Index.cshtml.cs:92-105`), or a Heimdall member-access claim (`Features/Heimdall/HeimdallAuthService.cs:158,186`). GitHub sign-in with an invitation lands in `PendingApproval`, without one in `Authenticated` (`MembershipSynchronizationService.cs:61`). +12. Anyone can register natively, and doing so proves nothing: `POST /auth/bifrost/register` is anonymous (`Program.cs:286-316`), takes an identity name and display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). No credential, no native sign-in route. As a voter a native account is indistinguishable from anonymous. +13. No public read path exists: anonymous routes are only health, readiness, auth, webhooks, patron intake and patronage checkout (`Program.cs:211-522`); the Eve governance surface requires `ActiveMember` (`Program.cs:473-488`); no CORS and no rate limiter are configured (`git grep -i -E 'AddCors|UseCors|RateLimit|ForwardedHeaders' bf83a93 -- 'src/*.cs'` returns nothing). ### Deployed versus repo -14. The live release is `/srv/bifrost/app/releases/20260723-daedcc4`, and `bifrost.service` is active. I checked both read-only over SSH alias `yggdrasil` on 2026-10-08. `https://bifrost.gamecult.org/healthz` and `/readyz` both return 200. -15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on `main`. It exists only on `origin/codex/bifrost-migration-metadata`, which branched from `bd3a5a9`. Compared with `daedcc4`, `main` has `cde3a78`, which deletes 369 lines of HTTP bridge endpoints from `Program.cs`. `main` also lacks the migration-metadata attribute lines `daedcc4` added to seven migrations. Production therefore runs code that `main` has deleted, and `main` may still carry the EF migration-discovery bug that `daedcc4` fixed. -16. Deploys are manual. `gamecult-ops/scripts/deploy-bifrost-alpha.sh` creates a timestamped release directory, and Idunn does not manage Bifrost (`gamecult-ops/runbooks/bifrost-alpha-deploy.md:162-164`). The inventory entry (`gamecult-ops/inventory.md:941-952`) records no revision. +14. Live release `/srv/bifrost/app/releases/20260723-daedcc4` (`readlink -f /srv/bifrost/app/current`, 2026-10-10 02:58Z), `bifrost.service` active since 2026-08-21 10:51 UTC, `ExecStart=/usr/bin/dotnet /srv/bifrost/app/current/Bifrost.Web.dll`, `EnvironmentFiles=/srv/bifrost/env/bifrost.env`, `ASPNETCORE_ENVIRONMENT=Production`, listening on `127.0.0.1:5080`. Earlier releases are bare UTC timestamps (`20260610073418` … `20260618144854`); the `-daedcc4` suffix was added by hand, the deploy script does not write it (fact 16). +15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on main: `git branch -a --contains daedcc4` lists only `origin/codex/bifrost-migration-metadata`, which is `bd3a5a9` (an ancestor of main) plus `daedcc4` alone. `git merge-tree --write-tree bf83a93 origin/codex/bifrost-migration-metadata` is clean. Main has `cde3a78`, which deleted the HTTP bridge routes `/bridge/actions/*`, `/dispatch/runs/*`, `/transport/receipts`, `/governance/receipts` and `POST /eve/governance/commands` (369 lines of `Program.cs`); main's `PlatformEndpointsTests.Removed_http_bridge_routes_are_not_mapped` pins their absence. +16. Deploys are manual: `gamecult-ops/scripts/deploy-bifrost-alpha.sh` (33 lines, gamecult-ops `1e95674`; the deploy files are unchanged since `44889b1`) unpacks `/srv/bifrost/bifrost-publish.tar` into `releases/$(date -u +%Y%m%d%H%M%S)`, repoints `current`, restarts. It takes no revision and records none. `runbooks/bifrost-alpha-deploy.md` step 3 publishes with `dotnet publish` from the operator's checkout `F:\Projects\Bifrost` (`:101-115`), step 2 lists the env keys (`:63-89`). Idunn does not manage Bifrost (`:162-164`). The inventory's "Bifrost alpha live state" block (`gamecult-ops/inventory.md:923-933`) records no revision. +17. No caller of the routes `cde3a78` deleted, over the whole retained nginx access log (2026-09-26 00:46 to 2026-10-10 02:58, 473,581 lines, all vhosts): zero requests to `/bridge/actions`, `/dispatch/runs`, `/transport/receipts`, `/governance/receipts` or `/eve/governance/commands`. The only `/governance/*` hits were ten scanner GETs on 2026-10-07 (`/governance/providers`, `/governance/budgets`, …, all 400). No systemd unit, timer or crontab on Yggdrasil names the bridge tools or `127.0.0.1:5080` except `bifrost.service` itself, and no non-nginx connection to `:5080` was established at probe time. The bifrost journal holds 1 line in 30 days, so the app's own logs cannot witness traffic. + +### Data Protection on the live host + +18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration. +19. The service's working directory resolves to the release directory: `readlink /proc//cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty). + +### Proxy and client address + +20. nginx sends `X-Real-IP`, `X-Forwarded-For` (`$proxy_add_x_forwarded_for`), `X-Forwarded-Proto` and `X-Forwarded-Host` (live `/etc/nginx/sites-enabled/bifrost*:12-19`), and proxies to `127.0.0.1:5080`. `Program.cs` never calls `UseForwardedHeaders` (fact 13 grep), so `RemoteIpAddress` is `127.0.0.1` for every request and any per-client limit puts every visitor in one bucket. + +### Code and test substrate + +21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key. +22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`). +23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`). +24. The shared layout loads HTMX from `https://unpkg.com/htmx.org@1.9.12` (`Pages/Shared/_Layout.cshtml:65`); no page posts with `hx-post` today. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`). +25. Verification on Yggdrasil: `ygg-verify.sh`'s `dotnet` alias is `mcr.microsoft.com/dotnet/sdk:10.0`, which has no .NET 8 runtime for this `net8.0` test project; passing the image name `mcr.microsoft.com/dotnet/sdk:8.0` works (any other value is used as an image name). At `bf83a93`, `dotnet test tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj -c Release --filter "FullyQualifiedName!~BridgeCliTests&FullyQualifiedName!~ProviderAdvertisementTests"` passes 29 of 29 (scratch log `bifrost-base-test-2.log`; that run's filter excluded only `BridgeCliTests`, and the two `ProviderAdvertisementTests` failed for want of Node). The other 37 tests shell out to Node: with no Node they fail to start the process; with Debian's Node 18 they fail on newer Node APIs (`bifrost-base-test-3.log`, 30 pass, 36 fail). Starfire has Node 24 and SDK 10.0.202 only. None of the cuts touch the Node tools. ### The Mill page -17. The Mill is being built uncommitted in worktree `F:\Projects\AetheriaLore-wt-concept-mill`. It consists of `Aetheria/Game Design/Concept Mill.md` (397 lines, hand-authored HTML in Markdown), `Aetheria/media/concept-mill/` (41 files) and a `site/quartz/styles/custom.scss` change. -18. Each card is `
`, and there are 19 today (`Concept Mill.md:12` for the first, `bison`). The slug is already independent of the display name: for example, `vasuki2` is shown as "Vasuki". The ship roll links to `#`. -19. The site has a pattern for client script. A Quartz component sets `afterDOMLoaded` (`site/quartz/components/AetheriaEpubReader.tsx:110`) and re-runs on the `nav` event (`site/quartz/components/scripts/aetheriaEpubReader.inline.ts:228`). Deploys go through `.github/workflows/deploy-quartz.yml` to GitHub Pages. - -### Anonymous-voter substrate - -20. Bifrost has no anonymous actor. Every write path resolves a `CurrentBifrostActor` with a `UserAccount` and throws without one (`MotionGovernanceService.cs:18,54`). Anonymous votes therefore need a voter identity that is not a `UserAccount`. -21. Bifrost sees no client IP. nginx sends `X-Real-IP` and `X-Forwarded-For` (`gamecult-ops/nginx/bifrost.gamecult.org.conf:15-16`), but `Program.cs` never calls `UseForwardedHeaders`, so `RemoteIpAddress` is the loopback proxy for every request. Until that is fixed, any per-IP limit puts every visitor in one bucket. -22. Data Protection keys can be persisted (`Program.cs:113-117`, `DataProtection:KeysPath`). A device token signed with them would stay valid across restarts and releases, provided the live env sets the path. Cut 0 checks this. +26. The Mill is committed and merged: AetheriaLore `origin/main` `6980087` ("Merge concept-mill-spa"). `Aetheria/Game Design/Concept Mill.md` is now 10 lines of front matter and prose; the viewer is the Quartz component `site/quartz/components/AetheriaConceptMill.tsx` with `scripts/aetheriaConceptMill.inline.ts` (417 lines) and `styles/aetheriaConceptMill.scss`. Its data source is `scripts/concept-mill/ships.yaml` (581 lines); `node scripts/concept-mill/build.mjs` writes the manifest `site/quartz/components/data/conceptMill.json` (embedded in the page) and render derivatives, skipping derivatives that already exist unless `--force` (`build.mjs:51-60`), and loads `js-yaml` and `sharp` from the sibling `F:\Projects\GameCult-Quartz` checkout (`:22-25`). +27. Concept identity is the ship `id` in `ships.yaml`; there are 27 ships today (`bison vasuki squatter raft gentle-giant shellback lamp-shy zera nakovalnya oboz shchit glushitel key-light shepherd rake dine-and-dash party-favors finders-keepers tattletale ghost-fare odd-job one-pass bulldog breakup-song 50-f live-wire homestead`). The build refuses duplicate ship ids (`build.mjs:94-96`). Each ship has one or more render variants with their own ids; the URL hash is `#ship` or `#ship/variant` (`aetheriaConceptMill.inline.ts:3,178-192`). The 2026-10-08 "19 card slugs" are gone. +28. The vote slot already exists and is switched off. `ships.yaml:22-27` holds `votes: { enabled: false, endpoint: null, bifrostUrl: null }`, copied into the manifest (`conceptMill.json:16-19`). When enabled, the script fetches `endpoint` once with `credentials: "omit"`, reads `tallies[ship.id]` as `{ up, down, members?: { up, down } }`, shows `▲ up ▼ down` (members not shown), and links `${bifrostUrl}/${ship.id}` as "Vote on Bifrost" (`inline.ts:163-174,284-287`). On fetch failure the slot shows `—`. Tallies are per ship, not per render. +29. The Aetheria site's origin is `https://aetheria.gamecult.org` (`site/quartz.config.ts:66` `baseUrl: "aetheria.gamecult.org"`). ## Rulings in force -- **Who may vote:** anyone, including anonymous visitors. There is no membership gate. -- **Votes are public:** up and down counts are shown, so downvotes are visible. -- **Members are a filter, not a separate system.** Each vote records the voter's class at cast time (anonymous, account, or Active member). Member-only views are a filter over the same rows. +The rulings are typed state: `aetheria-release:ruling:concept-mill-votes-open`, `aetheria-release:ruling:concept-mill-votes-anonymous`, and `concept-mill-votes:ruling:public-means-counts` (Self's defaulted reading that "public" means counts split by voter class, no per-voter identity). -Derived from these rulings, not asked separately: +Derived from them, not asked separately: - **Counts are raw: one voter, one mark.** Patron weight cannot apply to anonymous voters, and the member filter replaces it as the "members' opinion" view. -- **Concept art stays out of governance.** Mill votes are their own record, not governance topics or motions. Topics are agent-dispatch threads, and motions are tier-weighted, deadline-closing decisions (facts 2, 8, 9). Neither can admit an anonymous voter (fact 20). -- **Substrate is Postgres beside `Vote`,** to keep the optional foreign key to `UserAccount` in one transaction. "Bifrost web state is off CultCache" remains the standing doctrine debt it already is. This feature does not pay it down. - -Interpretation for Self to confirm, not a fork: "votes are public" is read as public counts, split by voter class. The export carries no per-voter identities. Anonymous voters have none to show, and publishing account names was not asked for. +- **Concept art stays out of governance.** Mill votes are their own record, not topics or motions (facts 2, 8, 9). Neither can admit an anonymous voter. +- **Substrate is Postgres beside `Vote`,** to keep the optional foreign key to `UserAccount` in one transaction. "Bifrost web state is off CultCache" remains standing doctrine debt; this feature does not pay it down. ## Authority map -- **Concept identity.** Owner: the Mill page in AetheriaLore. The card `id` slug is the key (fact 18). Bifrost stores keys and never invents one. Renaming a ship changes its title, never its key. -- **Concept subjects.** Owner: a Bifrost `ConceptSubject` record: `(collection, key)` unique, plus `title`, `registeredAt` and `retiredAt`. Writer: one idempotent admin registration command, fed from the Mill's card list and run by the render-round agent. The vote path cannot create subjects, because otherwise anonymous visitors could mint keys. -- **Voter identity.** Owner: a Bifrost `VoterKey` resolver, the one place that answers "who is casting this?": - - signed in: `account:`; - - otherwise: `device:`, where the id comes from a signed, HttpOnly, first-party device-token cookie that Bifrost issues on the vote page. - - The `VoterKey` resolver is not an owner of membership status. It reads `CurrentBifrostActor.IsActiveMember` at cast time. -- **Votes.** Owner: a `ConceptAppraisal` record with these fields: - - `subjectId` and `voterKey`, with `(subjectId, voterKey)` unique; - - `stance` (`Up` or `Down`) and `castAt`; - - `voterClass` (`Anonymous`, `Account` or `Member`), stamped at cast; - - `userAccountId`, nullable; - - `networkTag`, an HMAC of the client's /24 (IPv4) or /64 (IPv6) prefix, stored for audit and purge. - - The one writer is `ConceptAppraisalService` (Cast, Change, Retract, and a Purge for admins). Rate limiting happens before the service, and uniqueness is enforced inside it and by the index. -- **Tallies.** Derived only. Per subject, per stance, per voter class, they are computed from rows and never stored as counters. The "all" and "members" views are filters over the same rows. -- **Public tally export.** This is the JSON xenos boundary. It is one anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject: - - no voter keys, account ids or network tags; - - CORS allows exactly `https://aetheria.gamecult.org`, `GET` only, without credentials; - - `Cache-Control` is about 60 s. - - The export owns nothing. -- **The Mill page.** Read only. One fetch fills each card's counts, and a "Vote on Bifrost" link goes to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. That means no `SameSite=None`, no cross-site POST and no exposure to browser third-party-cookie blocking. +- **Concept identity.** Owner: the Mill's data in AetheriaLore, `scripts/concept-mill/ships.yaml`, ship `id` (fact 27). Bifrost stores keys and never invents one. Renaming a ship changes its name, never its key. A render variant is not a subject. +- **Concept subjects.** Owner: Bifrost `ConceptSubject` (`(Collection, Key)` unique, plus `Title`, `RegisteredAtUtc`, `RetiredAtUtc`). One writer, `ConceptSubjectRegistry`, reached from one admin page that takes the Mill's generated manifest (the owner's artifact) and applies an idempotent plan: add, rename, retire-on-explicit-choice, revive. The vote path cannot create subjects. +- **Voter identity.** Owner: `VoterIdentity.For(actor, deviceId)`, a pure function, plus a `VoterKeyResolver` that only reads or mints the device cookie and calls it: + - signed in: key `account:`, class `Member` if `CurrentBifrostActor.IsActiveMember` at cast time, else `Account`; + - otherwise: key `device:`, class `Anonymous`, where the id is a random GUID protected by Data Protection in an HttpOnly, Secure, SameSite=Lax, first-party cookie on `bifrost.gamecult.org`. + The resolver does not own membership; it reads it. When the cookie is minted is the open question `concept-mill-votes:question:device-cookie-consent`. +- **Votes.** Owner: `ConceptAppraisal` rows: `SubjectId`, `VoterKey` (`(SubjectId, VoterKey)` unique), `Stance` (`Up`/`Down`), `VoterClass` stamped at cast, `UserAccountId` nullable, `NetworkTag` and `CastAtUtc`. One writer, `ConceptAppraisalService` (Cast, Retract, Purge, plus read-only Tally and subject view). Rate limiting stands in front of it; uniqueness is enforced inside it and by the unique index, with a unique-violation on insert retried as a stance change. +- **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists. +- **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters. +- **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing. +- **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. +- **Client address.** `UseForwardedHeaders` trusting loopback only makes `RemoteIpAddress` the real client. It is a rate-limit bucket and an audit tag, never a vote key. +- **Key ring.** Data Protection keys live at a configured path with a fixed application name, so tokens survive restarts and deploys; production refuses to start without the path. - **Demotions:** - Governance topic support and objection are not vote owners. - `Vote.Weight` and patron tiers stay governance-only. - The static page owns no count. - - The client IP is not an identity: it is a rate-limit bucket and an audit tag, never a vote key. + - The client IP is not an identity. + - The content-root path is no longer the Data Protection discriminator; the fixed application name is. + - The `HOME`-default key ring is no longer an owner; the configured `DataProtection:KeysPath` is (pointed at the same directory, so existing keys are retained). ## Anti-stuffing defence (implementation duty) -Prior art on lightweight anonymous voting, from memory and not freshly searched: +Prior art: `docs/research/anonymous-vote-stuffing-prior-art.md` (Eyes, 2026-10-10, Bifrost `bf83a93`). It replaces the from-memory list this section used to carry. Its section "Where the current design's assumptions are contradicted" (`:92-98`) changed the design as follows: -- Strawpoll-style polls offer duplicate checks by IP, by browser cookie, or by sign-in. IP-only checks wrongly block shared networks, and cookie-only checks are reset by a private window. -- Proof-of-work challenges (Hashcash; today ALTCHA, mCaptcha, Friendly Captcha) make each vote cost CPU without any third party. They slow bulk scripts and barely touch a determined attacker with hardware. -- Third-party challenges (Cloudflare Turnstile, reCAPTCHA) are strong. They also send visitor data to an outside service and add an external dependency to the vote path. -- Large sites rely on after-the-fact detection more than on gates: they look for clusters by network, timing and new devices, then discount or purge. +1. **Device token: one vote per device per concept.** Kept. Its one-year life depends on persisted, retained keys and a stable discriminator (note `:65-69,95`; facts 18-19), so Cut 1 makes both configuration. Whether the cookie needs a notice or consent is the operator's question `device-cookie-consent` (note `:74-77,97`); the recommended option mints it only when a visitor casts a vote, with a one-line notice. +2. **Network rate limits.** Kept, in-box ASP.NET Core rate limiter, partitioned by `NetworkPrefix` for anonymous voters and by account when signed in. The prefix lengths are configuration, not design: the note found no evidence for a /24 IPv4 bucket, and per-/64 IPv6 buckets are spread across by delegated prefixes and privacy rotation while larger buckets catch mobile users (`:57-61,96`). Hands proposes defaults and limits; Soul probes them. Partition keys come only from `RemoteIpAddress` after loopback-only forwarded headers or from an account id, never from raw client input (note `:45`). +3. **Audit and purge.** Kept. The note confirms large sites rely on after-the-fact detection and also documents their false positives and unpublished thresholds (`:14-16,98`); Purge leaves an audit event so a correction is itself reviewable. +4. **Escalation, not built now:** proof of work. ALTCHA has no official .NET server, only community packages that need a production replay store (`:27,94`), and PoW is weak against determined attackers (`:34-37`). Not specified until audit shows stuffing. Turnstile and reCAPTCHA stay refused (third party on the vote path). -Recommendation: a layered defence that lives entirely inside Bifrost, with proof-of-work held back as an escalation. +Vote fuzzing and voter-history weighting (note `:98`) are not adopted: raw public counts are the ruling. -1. **Device token: one vote per device per concept.** On the first vote-page visit, Bifrost mints a random device id, signs it with Data Protection, and sets it as `bifrost_voter`: HttpOnly, Secure, SameSite=Lax, valid for one year. The vote key is `device:`. Clearing cookies resets it, and that is accepted and bounded by layer 2. -2. **Network rate limits.** Configure `UseForwardedHeaders`, trusting only loopback (fact 21 makes this a prerequisite). Then put the in-box ASP.NET Core rate limiter (`Microsoft.AspNetCore.RateLimiting`) on two paths, partitioned by network prefix: - - token minting: a small number of new devices per prefix per hour; - - vote casts: a modest number per prefix per minute. +Where it lives: Bifrost web code only. nginx gets nothing new (`limit_req` would be a second, unobservable owner of the same budget). The static site gets nothing. - Signed-in accounts are partitioned by account, not by network. The limits are configuration values, and the first values are Hands' to propose and Soul's to probe. -3. **Audit and purge.** Every anonymous row carries `networkTag` and `castAt`. An admin view lists subjects whose recent votes cluster on one tag. A Purge command removes rows by tag and time window, leaving an audit event. Public counts can then be corrected after the fact, without a gate that blocks honest shared networks. -4. **Escalation, not built now:** a self-hosted proof-of-work challenge on token minting, using an ALTCHA-style library served from Bifrost. It is switched on by configuration only if layer 3 shows stuffing. It needs no third party, so votes stay Bifrost-owned. Turnstile and reCAPTCHA are refused, because they add an outside owner to the vote path. +## Cut order and rationale -Where it lives: +The cut specs are typed state (`concept-mill-votes:cut_spec:cut-