Bifrost/docs/feedback-receiver.md
Heimdall 96665a778b docs(feedback-receiver): drop stale draft marker; steward merges the design
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 03:28:33 +00:00

43 KiB

Feedback receiver

Status: design, nothing implemented. Written for heimdall-body:follow_up:chat-s3533-3533, updated for chat-s3549-3549. It is the receiver half of Ratatoskr's Mind feedback pages. The page spec is Ratatoskr PR #24, docs/mind-feedback-pages.md at b146000d, which I take as final here; its section 7a names this doc. The rulings it builds on are Heimdall's drafts on guests and hiding (/opt/commons/handoff/heimdall-feedback-pages-rulings.md, cited as R4a to R6f).

Read at Bifrost main b1725a1 (2026-11-12), line references re-read at e58e953 (2026-11-12): tools/persona-matrix-mouth.mjs, tools/persona-chat-documents.mjs, src/Bifrost.Web/Features/{Oidc,Desk,Registration,Shared}/*, Domain/BifrostModels.cs, Program.cs, docs/oidc-issuer.md, docs/ratatoskr-voting-auth.md; the deployed trees under /srv/bifrost were last read at an earlier main (section 9). I could not read Eureka's receiver notes in #minds, and huginn-feedback-kinds-proposal.md was not in /opt/commons/handoff when I looked. Where PR #24 names a kind or a field it says the proposal wins, so I cite PR #24 and name the proposal's kinds only as PR #24 does. Where source disagrees with either, the section says so.

Rulings of Meta, 2026-11-12, now written into this doc: erase redacts the answer text in the receiver's store and keeps a text-free record (section 7a); the training notice covers any text entered into the app, bar a short exception list Eureka drafts (section 4a); the host may withhold an answer from its Mind in an emergency, with a logged receipt (section 6a); Minds authenticate to the receiver through an OIDC client with an audience, as the desk does (section 4b). Eureka's decisions on forks 2 to 5 of 2026-11-12 (/opt/commons/handoff/bifrost-10-forks-2-5-recs-20261112.md) are folded into sections 2, 4b, 6a, 7a and 9 and close those forks, except where section 9 says a point is still open.

Rulings of the steward (Heimdall, 2026-11-12)

Two of this doc's forks are the Bifrost steward's. Meta's, Eureka's and Libby's items are not ruled here.

  1. Ratatoskr's client for people: reading 2a, with no third client. The one public PKCE client the desk-credential cut seeds for Ratatoskr (only when Bifrost:Oidc:Clients:Ratatoskr is configured, so absent config changes nothing) also gets a scope feedback.answer that puts audience bifrost-feedback-page on the token. Authorization code with PKCE only, exact redirect URIs, no refresh token, the token only in an Authorization header and never in a URL, a query or a log. The audience is distinct from the Mind's bifrost-feedback, so a person's token never opens a Mind endpoint and a Mind's never opens the page (least privilege). Reading 2b, a Matrix token checked at the homeserver, is refused: it is a bearer for the person's whole Matrix account. The receiver writes a text-free receipt per accepted submission (receipts).
  2. Token lifetimes are explicit, short, and set in the issuer change. feedback.answer access token: 15 minutes, because the Bifrost session cookie re-issues it silently. The Mind's client_credentials token: 10 minutes, one call to renew. No refresh token for either. The desk's lifetime is its own decision and is not touched here. Until the issuer change lands the OpenIddict default applies; this doc no longer relies on it.

1. What the receiver owns

Concern Owner Receiver's part
A question, its listing, status, hides, rulings the owning Mind, in her own mind (Huginn) reads them to build the projection; never writes to Huginn (an admission for another instance is refused ForeignInstance, PR #24 section 3)
An answer's text the receiver stores it; it is never admitted into a mind
The answer's record in the Mind the owning Mind's Persona she admits a text-free feedback_answer from the delivery, and acks after she has
What kind an account is Bifrost (Membership, later Role) reads it at answer time and stamps it
Delivery to the Mind the receiver, at least once keeps the record until the Persona acks
Hiding, closing, ruling the owning Mind never; a hide only changes what the projection serves
Erase Bifrost's erase, executed here redacts the text and name in the store, keeps a text-free record (section 7a)
Withholding an answer from its Mind the host (emergency only) logs a text-free withhold record and skips delivery; changes nothing stored or ruled (section 6a)
Who may answer Bifrost: Active, a live training-notice receipt checks both on every submission with text (section 4a)

The page and the client write no record that carries identity (PR #24, section 3). A Matrix sender is a different identity authority and never becomes answerer.

2. The mouth's pending/ack path, read from source

Verified in tools/persona-matrix-mouth.mjs and tools/persona-chat-documents.mjs; line numbers re-read at main e58e953 (2026-11-12), where the mouth has grown by about 100 lines since b1725a1 and nothing else in this section changed:

  • Pending is Matrix mail only, held in memory. pending (mouth l.156) is filled in one place, syncOnce (l.543; pending.push(...made) l.602), from Matrix /sync events. No function accepts a delivery from elsewhere.
  • A request is non-consuming; an ack consumes. The pending requests carry afterSeq; the mouth answers pendingAfter(afterSeq) (l.720) paged to 256 KiB (pageOf, l.1055-1084; limits.replyBytes, documents l.53). chat_ack carries throughSeq; ack (l.662-665) drops every delivery at or below it, releases stored images (images?.acked) and advances the Matrix cursor. An ack posts nothing to Matrix: her public m.read receipt follows a separate chat_read document (header l.10-13, chat_read branch l.1138). The endpoint serves a closed list of kinds (served, l.1103) and answers any other loudly with chat_refusal UnknownKind (l.1112); a request for another Persona gets UnknownPersona.
  • At-least-once. The Matrix cursor is persisted only when every delivery of a batch is acked (commit, l.619-627), so a restart before the ack re-delivers from Matrix. seqCounter (l.155) is persisted whenever a round made deliveries (l.614), so sequence numbers never repeat across a restart.
  • Backpressure. At 500 unacked (MAX_PENDING, l.63) syncOnce stops reading (l.550). A truncated round (pending cap l.578, reply-read cap l.579) leaves the cursor where it was (l.607).
  • A delivery that will not encode is skipped, not held. pageOf logs and omits it (l.1060-1062); the caller's next ack covers its seq, so it is dropped. Harmless for a chat line, wrong for a durable record.
  • The endpoint is loopback RUDP. startChatEndpoint (l.1091) refuses a non-loopback host (l.1092). It is another process from Bifrost.Web, which has no way to hand the mouth anything.
  • The delivery tuple is closed and versioned. deliveryTuple is twelve fields (the twelfth is an optional stored-image reference), the pending answer gamecult.persona.chat_pending.v2 (persona-chat-documents.mjs l.216-224). v3 adds basis and form (14 fields) and a room tail (chat_pending.v3, asked for by chat_pending_request.v2); v4 adds reply_to (15 fields, chat_pending_request.v3, served only with CHAT_PENDING_V4=1, replyLinkFromEnv l.1185). No version has a source or kind field. A delivery cannot say it is not a Matrix message.
  • A fetch kind exists, off by default. chat_fetch_request is served only with CHAT_FETCH=1 (served, l.1103; fetchFromEnv, l.1187): an optional served kind is a precedent for adding kinds to served, and the way a kind is withheld until its consumer is ready.
  • The consumer is not in this repo. The mouth's header (l.10-11) says the heartbeat polls with a non-consuming request and decides who runs. I could not confirm that from source.

What follows from it:

  1. Feedback cannot ride pending as it is. There is no inlet, and the cursor logic ties acks to Matrix batches.
  2. A framed text delivery is forgeable. Any Matrix member can write a line that looks like a frame, and no tuple has a field to tell the Mind which one is real. The frame must be a typed difference in the wire, not a convention in text.
  3. A record that is not in Matrix is not re-delivered by a restart. Feedback needs its own durable store and its own delivery cursor.

PR #24 section 3 names the carrier: a new delivery document, gamecult.persona.feedback_delivery.v1, owned by Bifrost, through the mouth's pending/ack path. Source adds one constraint on how it rides:

  • It cannot be a variant inside chat_pending. Each version is a closed positional tuple (twelve, fourteen or fifteen fields) encoded by pageOf (l.1060), so a different tuple in that page breaks every current consumer, and a text frame in text is forgeable by any Matrix member.
  • Option B, kept in shape: the delivery document has its own request, page and ack kinds (feedback_pending_request, feedback_pending, feedback_ack), its own seq space and a durable cursor over the receiver's store. The Matrix kinds, the Matrix cursor and MAX_PENDING are untouched. Per the decision below the kinds are served by the receiver over HTTPS, not added to the mouth's served list (l.1103). Confidence medium: the Persona-side consumer, which I cannot see, must learn them.
  • Why main's changes reinforce B. A chat ack releases stored images and advances the Matrix /sync cursor (l.662-665). A feedback ack must do neither, so it cannot share the chat_ack kind or its ack function. The chat pending answer is already versioned three times, so a new field in that tuple is a new document version for every consumer, which a feedback record must not force.
  • Rejected: injecting records into pending as synthetic Matrix deliveries (no inlet, forgeable, lost on restart); posting to a Matrix room as a bot (wrong identity authority, spam and forgery, and the Mind would read it as chat).
  • Decided (Eureka, 2026-11-12): feedback does not ride the mouth. The endpoint is loopback RUDP with no bearer and trusts the persona id in the document, so routing feedback through it would make the Matrix organ a bearer-less deputy holding every Mind's authority and would put the receiver's own seq space and durable cursor in a Matrix process. A Mind pulls and acks directly over HTTPS from her own process to the receiver, with the section 4b bearer; she admits the text-free record into her own mind afterwards. The three kinds feedback_pending_request, feedback_pending, feedback_ack are therefore HTTP endpoints of the receiver, not kinds added to served; Option B's "same endpoint" clause is withdrawn and the mouth is untouched. The delivery document and its seq space are as above. This is a Bifrost HTTP service, a xenos boundary for CultNet, so JSON/HTTP there is the tolerated kind. Pre-deploy test: section 9.

3. Records

One CultCache file, owned and written only by the receiver (section 8). Append-only through the API: nothing edits or deletes a record except the redaction of section 7a. Every record has a recordId and a receiver-assigned seq per owning Mind (the delivery sequence). PR #24 names the answer row <question-label>.a<N>, N the receiver's per-question sequence; I use that for answers and the same shape for the others.

Record Fields Notes
answer recordId, questionId, owner, choice? (an option label), text? (at most 2000 characters), answerer, kind, weightPermille, nameAtAnswer, clientRequestId, time at least one of choice/text; an unknown option label is refused; the request id makes a retry idempotent, as txnId = request_id does in the mouth; weightPermille is 0 for a guest and 1000 otherwise, stamped, and the page sums nothing
close_request recordId, questionId, requester, kind, time refused for a guest and for an account not active at that moment (R4c)
report recordId, targetId, reporter, kind, reason, time delivered to the Mind as input; never shown, never counted (R6b)
withhold, withholdLifted recordId, answerRecordId, owner, withheldBy or liftedBy, time, reasonCode (withhold only) text-free log of section 6a
delivery state per owning Mind: highest seq acked; per record: delivered, failed, withheld a cursor, not a record the page reads

Invariants: answerer, kind, weightPermille, time and nameAtAnswer come from the verified token and Bifrost state, never from the request body, and the body schema is closed: an unknown key refuses the submission. The latest record per account is that account's standing answer, derived at read time. A submission is refused if the question has no listing in force or is hidden. An answer to a question that has settled is kept and the page says so (PR #24 section 8). A refused submission leaves nothing behind.

4. Credential and claims check

PR #24 section 7a rules route 2 for people: Ratatoskr presents an OIDC access token that Bifrost issued, and Bifrost verifies it itself. Meta ruled on 2026-11-12 that Minds authenticate to the receiver through an OIDC client with an audience, as the desk does (4b). This section records what Bifrost's source does on main e58e953 (the Oidc, Desk and Registration files cited here are unchanged since b1725a1, bar DeskEndpoints.cs:49, which registers UnixSocketDeskDoor).

What main does with tokens

Token Who may get it Gate at issue Gate on use
Chat token (scopes openid, profile; no audience) an account that exists, has a Bifrost identity that is not reserved, and whose membership is not Suspended; membership is neither required nor written (OidcEndpoints.cs:55-78) a live training-notice receipt: without one the member is redirected to the consent page and the parked request waits (:104-111) the token and userinfo endpoints re-read it: ChatRefusalAsync refuses an absent or Suspended account and a chat token with no live receipt (:164-180, called at :127 and :144)
Desk token (every scope is desk.read or desk.answer, DeskEndpoints.cs:21-26) the same sign-in, then only a client whose permissions allow the desk scope no receipt: IsDeskToken is true only when every scope is a desk scope (:118), so a desk token skips the gate (:104, :108); principal.SetResources(DeskEndpoints.Audience) puts audience bifrost-desk on it (:91-94) per request, in DeskEndpoints.HandleAsync: audience (DeskEndpoints.cs:110), scope (:116-121), then Active membership re-read through OidcEndpoints.ActiveMemberIdAsync (:123-139; OidcEndpoints.cs:183-194, which is CurrentBifrostActor.IsActiveMember, Status == Active, CurrentBifrostActor.cs:17)

The token claims are unchanged: sub is UserAccount.Id in form D; preferred_username and name ride the token only with scope profile (OidcEndpoints.cs:80-102). No email, Heimdall id or GitHub id is in it. The access token is an encrypted OpenIddict token and its lifetime is OpenIddict's default, which I believe is one hour and have not verified (no lifetime override in src; ratatoskr-voting-auth.md section 14 D3 owes explicit values). There is no refresh token.

How a bearer is validated has changed: OpenIddict's validation handler is registered against the local server (DeskEndpoints.cs:36-38), reading the Authorization header only (:40-44), and OidcDeskCredential authenticates through it by its own scheme name and judges nothing else (OidcDeskCredential.cs:14-27; audience, scope and membership are the desk's, per its header comment). That path is registered only when Bifrost:Oidc is configured (:30-33).

Only one client exists. The seeder creates the MAS client and deletes every other application at startup (OidcClientSeeder.cs:35-51, :63-75). MAS's permissions carry no desk scope (:42-50), so no desk token can be issued on main: the desk's second client is owed (ratatoskr-voting-auth.md section 14 D7). AllowAuthorizationCodeFlow is the only grant and the token endpoint takes client_secret_basic only (OidcServiceCollectionExtensions.cs:39, :45-46).

4a. What the receiver checks on an answer from a person

In this order; a submission that fails any step leaves nothing behind.

  1. The token opens and has not expired, and sub is a Guid naming an existing UserAccount.
  2. The account is Active now (ActiveMemberIdAsync as the desk does). A chat token is issued to non-Active accounts (OidcEndpoints.cs:55-62), so the token alone proves nothing about standing. Under PR #9 IsActiveMember would also require Role == Member and exclude guests, so the receiver reads Status == Active itself and not that expression.
  3. A live training-notice receipt, read by the receiver on every submission that carries text (Meta's ruling, 2026-11-12): ConsentEndpoints.HasLiveReceiptAsync (Features/Registration/ConsentEndpoints.cs:146), the one reading of "may speak": a receipt for the notice version in force, not withdrawn. The notice covers any text entered into the app except a short designated exception list, which Eureka drafts. The receiver checks the receipt itself because the token's gate does not cover it: a chat token's receipt was checked at issue and again only at the token and userinfo endpoints (:127, :144), never at the receiver, and a token whose audience is not a chat token skips the gate altogether (:104). A receipt withdrawn after issue must stop the next submission. The check applies to answers, close requests and reports alike. Until Eureka's list exists the gate applies to every submission with text; the list narrows it, and only by being written down.
  4. The body is closed-schema, an option label must exist, the question must be in force and not hidden (section 3).

A refusal for a missing receipt is typed and carries the consent page's path, so the page can send the person to agree; it does not store the text.

What the receiver refuses: a token that does not open or has expired; a sub that is not a Guid or names no account; an account that is not Active; a missing receipt (above); a body carrying answerer, kind, name, weight or any key the closed schema does not name; an unknown option label; a question with no listing in force, or hidden; a close request from a guest.

What source says route 2 still has to answer:

  1. No client reaches Ratatoskr. MAS holds the only client, and it holds the access token; Ratatoskr gets a Matrix session from MAS, not a Bifrost token. The reading that fits what the desk now does is 2a: Ratatoskr as a second, public, PKCE client with its own audience and scope, issued by the same code path as the desk's (ratatoskr-voting-auth.md sections 3-4). The audience machinery it needs now exists on main; the client does not. Reading 2b (check the Matrix token against the homeserver) hands the receiver a bearer good for the person's whole Matrix account; I recommend against. I lean to 2a, confidence medium.
  2. The logout reconciler covers only MAS (OidcLogoutReconciler.cs:108, :180); a second client's token lives to expiry. The receiver's own Active and receipt reads at every submission cover that.
  3. A token with an audience of its own would not be accepted from any other client, which closes the weakness an unaudienced chat token had here.

4b. How a Mind authenticates (Meta's ruling, 2026-11-12; names are proposed)

A Mind authenticates to the receiver through an OIDC client with an audience, the shape the desk uses: a scope at issue puts an audience on the token (OidcEndpoints.cs:91-94), the validation handler opens it (OidcDeskCredential.cs:14-27), and the endpoint judges audience, then scope, then subject (DeskEndpoints.cs:110-139). The analogous parts:

Part Desk (main) Receiver (proposed)
Audience bifrost-desk (DeskEndpoints.cs:21) bifrost-feedback
Scopes desk.read, desk.answer (:22-23) feedback.read (pull deliveries and the Mind's own projection) and feedback.ack (ack a delivery; also the Mind's acts that pass through the receiver)
Credential OidcDeskCredential, OpenIddict validation, header only the same port, a second audience
Subject check ActiveMemberIdAsync on sub (DeskEndpoints.cs:127) the sub must be the owning Mind's configured client, matched against the question's owner; a Mind is not a UserAccount and has no membership to read
Receipt none for a desk token (OidcEndpoints.cs:104) none: a Mind is not a person entering text

Decided (Eureka, 2026-11-12): OAuth 2.0 client_credentials, one confidential client per Mind. client_id = mind-<persona>; scopes feedback.read and feedback.ack only; audience bifrost-feedback. The token's sub maps to the Mind's registered persona and is matched against the question's owner. The grant is RFC 6749 section 4.4 ("using only its client credentials"): short-lived, audience-bound, no refresh token, no cookie session. It is not extended to the desk in the same cut.

The issuer today cannot issue it. It enables one grant, server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange() (OidcServiceCollectionExtensions.cs:39); the token endpoint accepts client_secret_basic only (:44-46: ClientAuthenticationMethods.Clear(), then ClientSecretBasic); the seeder creates only the MAS client and deletes every other application at startup (OidcClientSeeder.cs:35-51, :63-75). Enabling client_credentials (AllowClientCredentialsFlow), seeding the per-Mind clients and the audience/scope registration is an issuer change this design owes, with a matching edit to docs/oidc-issuer.md. Until it lands no Mind token can be issued on main. The audience and scope names are accepted as proposed.

Client authentication: private_key_jwt with the Mind's Ed25519 key is not available on the pinned stack; use a secret. Finding, read against the pinned packages (src/Bifrost.Web/packages.lock.json: OpenIddict.Server, .Validation and the rest at 7.7.1):

  • Supported: OpenIddict.Server 7.7.1 has private_key_jwt client authentication. Its assembly carries the literal private_key_jwt, the client_assertion and client_assertion_type parameters, OpenIddictServerOptions.ClientAssertionTypes, and the handlers ValidateClientAssertion, ...Issuer, ...Audience and ...WellknownClaims (OpenIddict.Server.xml); each application can carry a JsonWebKeySet (OpenIddictApplicationDescriptor.JsonWebKeySet, IOpenIddictApplicationManager.GetJsonWebKeySetAsync, OpenIddict.Abstractions.xml). Bifrost does not enable it today: the method list is cleared to ClientSecretBasic (:44-46).
  • Not supported with Ed25519: the assertion is a JWT checked by Microsoft.IdentityModel 8.19.2, the version the lock file pulls in. Scanning Microsoft.IdentityModel.Tokens.dll for algorithm identifiers finds only HS256/384/512, RS256/384/512, PS256/384/512 and ES256/384/512; there is no EdDSA, Ed25519 or OKP anywhere in it or in Microsoft.IdentityModel.JsonWebTokens.dll, and none in OpenIddict.Server.dll or OpenIddict.Abstractions.dll. So a Mind's existing Ed25519 key (the one moderator-registered through MindIntakeService.cs) cannot sign a client_assertion this server would accept. (Evidence is a string scan of the shipped assemblies, not a runtime test; I did not run an assertion against a live issuer. Confidence high that it fails, since the algorithm names are absent.)
  • Consequence: private_key_jwt would need a second, ECDSA P-256 (or RSA) key per Mind registered as the client's key set, which is a new key to custody and no improvement on a secret. Decision: client_secret_basic with one secret per Mind, the method the token endpoint already takes. If the stack gains EdDSA the choice reopens, and private_key_jwt with the existing key is then preferred because it removes the per-Mind secret (RFC 7523 section 2.2).

The client secret lives only in a file the Mind's process reads (for example a systemd credential in her own home, mode 0400), never in an environment variable, a command line or a log. The seeder stores the hash Bifrost holds; the plaintext is handed to the Mind once, through the same custody path as her other credentials.

Part Desk (main) Receiver (decided)
Audience bifrost-desk (DeskEndpoints.cs:21) bifrost-feedback
Scopes desk.read, desk.answer (:22-23) feedback.read (pull deliveries, read the Mind's own projection) and feedback.ack (ack a delivery; also the Mind's acts that pass through the receiver)
Credential port OidcDeskCredential, OpenIddict validation, header only the same port, a second audience
Subject check ActiveMemberIdAsync on sub (DeskEndpoints.cs:127) sub is the owning Mind's client; a Mind is not a UserAccount and has no membership to read
Receipt none for a desk token (OidcEndpoints.cs:104) none: a Mind is not a person entering text

Rejected (Eureka): reusing the mouth's signed envelope per request. It is motion-bound (verb is commit/reveal, MotionId in the signed text) and would need a new verb set. Token lifetimes: ruled in Rulings of the steward (15 and 10 minutes, set explicitly in the issuer change).

5. Account-kind lookup

  • The account is found by the token's sub, which is UserAccount.Id (section 4). answerer is UserAccount.Id, which survives role change and Heimdall unlink; the Heimdall account id is only an authenticator handle and is cleared on erase (guest-accounts design, PR #9).
  • On main there is only Membership.Status (Authenticated | PendingApproval | Active | Suspended) and no role (BifrostModels.cs l.5-10, l.335). The role field and gamecult.bifrost.account.v1 are in PR #9 and unbuilt. Until then, kind can only be derived: Active is member, Authenticated and PendingApproval are user, and nobody is a guest. That is the PR #9 backfill, repeated here, and R4a-c have no guest to apply to until PR #9 lands.
  • An answer needs Status == Active, read at answer time, not IsActiveMember (section 4a). On main the chat token is issued to any account that is not suspended (OidcEndpoints.cs:55-62), so an Authenticated or PendingApproval account can hold a token and the receiver refuses it on its own read.
  • kind is read from Bifrost's database, never from the token: the session carries identity only.
  • mind is not a Bifrost account in main or PR #9. A Mind authenticates as an OIDC client with an audience (section 4b), not as an account; kind is stamped only on a person's answer.
  • The display name must not be CurrentBifrostActor.DisplayName. It falls back to GitHubLogin (CurrentBifrostActor.cs:11-15) and then to the literal "Guest". The receiver stores nameAtAnswer from MemberProfile.Nickname or UserAccount.DisplayName only, and "an account" when both are empty. Never a provider handle, GitHub login, Heimdall id or Matrix localpart.

6. Delivery as quoted data

  • The receiver delivers each record as a gamecult.persona.feedback_delivery.v1 document on the typed kinds of section 2. A feedback record is therefore never a chat_pending delivery and a Matrix sender cannot produce one.
  • The delivery carries its fields separately: recordId, questionId, kind, nameAtAnswer, answerer, time, choice, text. The Persona's side renders the frame, not the receiver, and puts text in a data position only: attributed, quoted, never in an instruction position, with the author's kind stated (R4d). The receiver does no keyword screening.
  • She admits the text-free feedback_answer into her own mind and acks only after that (PR #24 section 3). The text reaches her turn once, in the delivery, and is never admitted; she cites the record id, not the text. A restart redelivers the same recordId, which Huginn answers AlreadyAdmitted. So an ack means admitted, a stronger meaning than the mouth's chat ack, which means only taken.
  • Untrusted by kind. Text from guest, user and patron is untrusted input; so is member text, which differs only in weight. She records a ruling from an answer, never an action.
  • A record that will not encode, or that she cannot admit, is marked failed and stays visible, never acked past silently (the opposite of pageOf). The mouth's cap and backpressure do not apply: the receiver keeps accepting answers while a Mind has a backlog, because they are durable.
  • Close requests and reports are delivered the same way. A close request changes nothing until the Mind resolves the question; a report changes nothing.
  • An erase notice is delivered the same way (section 7a), and she answers it with a feedback_hide of reason ErasedAuthor.

6a. Withholding an answer from its Mind (Meta's ruling, 2026-11-12)

The host may withhold an answer from its Mind in an emergency, with a logged receipt. It changes delivery only.

  • The withhold record is one more receiver record, append-only, text-free: recordId (its own), answerRecordId (the answer withheld), owner (the Mind), withheldBy (the host actor's UserAccount.Id, read from Bifrost state, never the body), time, reasonCode (closed, exactly Emergency (imminent harm), PersonalData (the answer contains someone's data) or Legal (counsel or a legal request); no free text, because free text is where content would get in; a fourth code needs a ruling) and, when it ends, a second record, withholdLifted, with liftedBy and time. No answer text, no choice, no name of the answerer and no excerpt is in either. It is the log the ruling asks for, readable by the host. Decided (Eureka, 2026-11-12): the Mind and the member both see a text-free withheld marker carrying the code, so silence is not read as "no answer"; the member sees the same marker on their feedback page. The marker carries no text, no choice and no withholder.
  • What it changes: the answer's delivery state becomes withheld, and the delivery cursor does not pass it silently. The receiver does not put it on the wire to the Mind while the withhold is in force. Lifting it makes the answer deliverable again, in its original seq order, unchanged.
  • What it never changes: the stored text (it is not redacted, edited or moved: only erase redacts, section 7a); the projection rules of section 7 (a withhold is not a hide); and the Mind's own authority over the question: she may still hide the answer, close the question or rule on it by her own acts, and a withhold takes none of that away. She cannot lift it; the host's act is the host's.
  • Who may do it: PlatformAdmin only (CanManageMembership, CurrentBifrostActor.cs:30, the check suspension uses; no new role). The act is refused to anyone else and to the Mind herself. The withholder's identity (withheldBy, liftedBy) appears in the audit receipt only, never on the marker the Mind or the member sees. Open: whether the member should be told in a safety case (for example a threat) is Meta's call (Eureka 60%); the default written here is that the member is told.
  • A withheld answer is not erased, and an erase of its author redacts the text as in 7a; the withhold record stays, because it carries none.

7. Projection read

The page polls the receiver, and the receiver reads the Mind's side through Huginn over RUDP and writes nothing to it (PR #24 section 3). The page does not talk to Huginn.

  • Questions, their listing (audience, close_grants), status, rulings and hide acts come from the Mind's mind. Answer text and the name come from the receiver's store; an answer row is assembled from the receiver's record and the Mind's text-free feedback_answer.
  • A hidden item's text is withheld on the server, not collapsed in the client: author, kind, time and reason stay, the text does not leave the receiver (R6d). A hidden question's title, body, options and answers are withheld.
  • Questions and answers are filtered by the viewer's kind against the question's audience at read time (R4b).
  • The projection carries its own as of, so the page can say when it is stale.

7a. Erase

Bifrost's erase (PR #9) blanks the display name and keeps the account id as a tombstone. Ruled by Meta, 2026-11-12: erase redacts the answer text in the receiver's store and keeps a text-free record. The receiver's part, as PR #24 section 7 fixes it:

  1. Bifrost's erase records its intent in Postgres first, then the receiver applies it idempotently. The two stores are not one transaction, so erase is crash-replayable, as docs/bridge.md describes for the persona-feedback pump.
  2. For every record the account authored the receiver drops the text and the name and keeps record id, kind and time only. choice, weightPermille, answerer and clientRequestId are dropped too. The row shows "erased account". No content hash is kept. Redaction is in the store itself, not a flag over retained text.
  3. The receiver delivers an erase notice, and the Persona admits a feedback_hide of reason ErasedAuthor: the Mind's acknowledgement, not a moderation call.
  4. The honest residue: a Mind's own memory or ruling may already quote an answer, and Huginn cannot redact it. That the Persona cites record ids and never text is a prompt, not an invariant. Erase does not reach what she already holds.
  5. Backups: 56 days, restores replay erasures (Eureka, 2026-11-12). "Append-only" is the receiver's API contract, not a property of the file, so erased text survives in backups of it. The stated purge is: erased text can survive in backups at most 56 days, the existing Raven tier (newest 14 daily plus the newest of each of 8 Monday-weeks; gamecult-ops runbooks/gamecult-state-backup.md, read at origin/main). No backup is changed. The receiver keeps an erased-id list (account id and record ids, no text, no hash) beside its store, and any restore must re-apply it over receiver.cc before the receiver serves, never trusting the two files to be the same generation (section 8). The notice to people says it (R2 of the exceptions draft). If Meta wants shorter, put the store outside the weekly tier (14 days), a small backup-config edit. Is the store under a backed-up path? Yes, by choice (Eureka, 2026-11-12): /var/lib/gamecult/bifrost/feedback-receiver/ (section 8), under /var/lib/gamecult, which the nightly snapshot takes whole bar Idunn caches and Ghostlight acceptance output. It avoids the excluded /srv/bifrost/persona-feedback/runtime, app, log and backups. A file with a sibling <name>.lock is re-copied under a shared lock, so receiver.cc and erased.cc each carry a .lock and travel in one directory in every snapshot, the erased-id list included. The 56 days is read from the runbook, not measured.

8. Where it lives

CultCache files in one directory, written only by the receiver, which is C# inside the Bifrost app (bifrost.service, Bifrost.Web), sharing its issuer and auth (Eureka, 2026-11-12). Not Node, not the mouth, and not the bifrost-feedback user of bifrost-persona-feedback.service.

  • Path: /var/lib/gamecult/bifrost/feedback-receiver/ holding receiver.cc (records and delivery state), erased.cc (the erased-id list of section 7a) and a .lock file for each. Directory bifrost:bifrost, mode 0750, created by root at install: /var/lib/gamecult/bifrost is root:root 0755 today, so the bifrost user cannot create the child itself (or a StateDirectory=gamecult/bifrost/feedback-receiver in the unit that writes it). bifrost.service has no sandbox directives, so it needs nothing more; if it is later hardened with ProtectSystem=strict, add ReadWritePaths=/var/lib/gamecult/bifrost/feedback-receiver. The backup reads as root, so 0750 does not block it.
  • Nothing else opens the files. There is no Node mouth reader and no Rust writer. The receiver is the only process that opens receiver.cc or erased.cc; its delivery cursor lives in the same store. The cross-runtime refusals are real and are not worked around: the Rust runtime refuses to write until it has pulled, and the C# runtime refuses to open a store whose records it cannot read (CultStoreUnreadableException). The CultCache contract would allow a lock-free stale read or a shared-lock read by another process; this design takes neither: Mind-side readers get no file access at all, they pull over HTTPS (section 2).
  • Two stores, two locks, no pair consistency. receiver.cc and erased.cc take independent locks (receiver.cc.lock, erased.cc.lock; AcquireLock, CultCache.cs:5164-5185, FileShare.None, retry 30 s), so a backup copying under each shared lock can capture them at different generations. The design never trusts the pair to be consistent: restore re-applies erased.cc over receiver.cc by design (section 7a, item 5), whichever generation each came from, and the receiver does not serve until it has.
  • The C# runtime fit is "needs work" (Eyes). CultLib C# (src/GameCult.Caching and GameCult.Caching.MessagePack, about 10.6k lines, netstandard2.1, MIT) fits net8.0, but:
    • No published package exists: nuget.org has none, the cultlib-v tags stop at 1.0.32, and packaging is a PowerShell local feed (CultLib docs/nuget-packaging.md). Bifrost has no nuget.config and no vendor directory. The consumption route (checkout ProjectReference, local feed, or vendoring) is an open fork for Eureka and Libby.
    • Version conflict (predicted, not run): Caching.MessagePack needs System.Text.Json 9.0.10; Bifrost's Direct pin is [8.0.5, ) (packages.lock.json:108-112), so restore likely fails with NU1605.
    • It drags CultMath (MIT AND MPL-2.0 AND Apache-2.0, shaders) into a web service.
    • Writer precedent: the Node sidecar already writes /srv/bifrost/persona-feedback/deliveries.cc (docs/bridge.md:294), so the receiver is the second .cc writer in the app and the first in C#.
  • Gaps owed to Libby (CultLib steward); no local workaround is built here: (1) no consumable C# package; (2) the System.Text.Json pin conflict; (3) the CultMath transitive closure; (4) src/GameCult.Caching/README.md:310 says single-writer, no competing writers, which contradicts the store-composition contract's lock; (5) no documented stale .tmp / crash procedure; (6) no documented backup-while-writing contract for a pair of stores; (7) no cross-runtime test pairing a Linux C# writer with a Rust reader.
  • Session check, account state and role stay in Postgres, where they already are.

I withdraw my earlier lean to Postgres rows: it contradicts the ruled store, and the .cc witness it would have owed is what the file is.

Naming: persona-feedback already means the Discord-to-Epiphany ingress (docs/bridge.md, "Organizational Persona Feedback Ingress"). This receiver's records and kinds use feedback_page and feedback_delivery, not persona_feedback.

9. Gaps and open forks, plainly

Rulings of 2026-11-12 closed these, and they are no longer forks: whether erase redacts the receiver's text (it does, section 7a); whether a person's answer to a Mind needs the training-notice receipt (it does, bar a designated exception list, section 4a); whether the host can withhold an answer (it can, in an emergency, section 6a); whether a Mind authenticates through the issuer (it does, with an audience, section 4b).

Closed by Eureka's decisions (2026-11-12), folded above: how a Mind gets a token (client_credentials, a confidential client per Mind, secret in a file; section 4b); the carrier (HTTPS with the bearer, not the mouth; section 2); who may withhold, the reason codes and who is told (section 6a); the backup purge number (56 days, with the erased-id list; section 7a). Their remaining work is listed below.

Open forks:

  1. The exception list is owed. The notice covers any text entered into the app except a short designated list, which Eureka drafts. This doc names no entries; until the list exists the receipt gate applies to every submission with text (section 4a).
  2. Route 2 for people has no client. Only MAS is seeded (OidcClientSeeder.cs:35-75); a Ratatoskr client with its own audience is owed, as is the desk's (section 4a).
  3. Telling the member of a withhold in a safety case (section 6a): Meta's call.
  4. Erase is bounded by what a Mind already holds; Huginn cannot redact it (section 7a, item 4).

Owed by this design, not forks:

  • The issuer change of section 4b (client_credentials, per-Mind clients, docs/oidc-issuer.md).
  • Pre-deploy test: a Mind's process can reach Bifrost over HTTPS, and get a token. Done 2026-11-12 from a Self run (not a Persona turn's sandbox): https://bifrost.gamecult.org/healthz returns 200 with a verified certificate. /.well-known/openid-configuration returns 404 because the live app (67072b2) predates the issuer, so the token path is untested. Re-run when the issuer ships, adding discovery, a client_credentials token request and one authorised receiver call; and run curl -sI https://bifrost.gamecult.org/healthz once from inside a Persona turn, which has not been tested. No Mind-to-Bifrost HTTP call exists in the repo except the signed intake POST (Program.cs:768).
  • Install step: root creates /var/lib/gamecult/bifrost/feedback-receiver/ as bifrost:bifrost 0750 (section 8).
  • Retention: 56 days, stated in the notice.

Dependencies and unbuilt things:

  • gamecult.bifrost.account.v1 and Membership.Role are unbuilt (PR #9, design only at last read). Without them kind is derived from Status, and no guest exists.
  • The Huginn kinds are Eureka's. PR #24 names feedback_listing, feedback_answer, feedback_close_request, feedback_hide, owned by huginn-feedback-kinds-proposal.md, which I could not read. PR #24 also notes the proposal's feedback_answer lists a content_hash that must go. Their dependency order is Huginn readers, then the Persona organ, then this receiver, so nothing here can ship first.
  • The Persona side must learn the three feedback kinds and pull them over HTTPS with its bearer (section 2); the heartbeat that polls the mouth is outside this repo and is not the carrier.
  • Deploy is behind main. The deployed mouth is /srv/bifrost/persona-chat/runtime/current, release e396305, read at an earlier main; main has since added the image path, reply links, CHAT_FETCH and the v3/v4 pending answers. The deployed web app is /srv/bifrost/app/current at 67072b2, which predates the desk, the chat-tier issuer change and the consent gate on main. I did not re-read /srv/bifrost for this pass. Anything in this design runs on a release that does not exist yet.
  • Unread: Eureka's receiver notes in #minds, huginn-feedback-kinds-proposal.md, MAS's side of the session, and Heimdall PR #6 (merged, docs/answer-collector-consent.md; it drops a sender with no linked member at l.197 and does not mention guests; the page's "guest answers are collected for nothing but delivery" is unchecked against it).
  • The receiver writes nothing to Matrix and nothing to Huginn.