A send queued at the room gate passed the brake check at entry only, so a
brake placed while it waited still let the PUT through. The check now also
runs inside the gate, just before the PUT, and answers a typed Brake refusal.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Removes the bare PUT; the one PUT now lives inside the room gate. v1 stays served unchecked through the same gate.
Wire: chat_send_request.v2 (basis) and chat_send_receipt.v2 (newer); RoomMoved is v2 only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-room in-memory history map is gone: a restart emptied it and /sync resumed from the
cursor, so every room (a DM first) lost its past. When a delivery is built the mouth now reads
the room's timeline from the homeserver as that Persona (GET /messages dir=b, or the thread's
root plus /relations m.thread for a threaded message), keeps m.room.message from everyone
including her own, oldest first, ending before the delivered event. Newest lines are kept to a
16 KiB byte budget (each clipped to limits.contextBytes), at most the 20 lines the delivery
tuple carries. One fetch per room or thread per sync round. A failed fetch gives empty context
and a redacted log line; the delivery still goes out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SLUG is the Persona slug upper-cased with "." and "-" turned into "_". Lists are
deduplicated; an absent variable gives shared rooms only; a malformed project alias,
or two Personas mapping to one variable, is refused at start. The send path's
UnknownRoom check already reads the mouth's own list, so another Persona's project
room is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two Persona turns running at once no longer take each other's session. Requests run in order per client, not across clients.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>