The mouth moved since bf8ff62: the pending answer is chat_pending.v2 with a
twelve-field delivery, a chat ack now posts a public m.read receipt and
releases stored images, and presence follows the brake. Update the line
references and the gaps, and record why a feedback ack must be its own kind.
No design change; the OIDC and account files are unchanged on main.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
23 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).
Draft; do not merge.
Read at Bifrost bf8ff62 (main), then section 2 and the gaps re-verified at main 55b74275 (the mouth line numbers, the delivery tuple, the ack path and the pending kind all moved; the OIDC, account and CurrentBifrostActor files did not): tools/persona-matrix-mouth.mjs, tools/persona-chat-documents.mjs,
tools/persona-feedback*.mjs, src/Bifrost.Web/Features/Heimdall/*, Features/Shared/CurrentBifrostActor.cs,
Program.cs, Domain/BifrostModels.cs; the deployed trees under /srv/bifrost. 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.
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 | drops the text and name (section 7a) |
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:
- Pending is Matrix mail only, held in memory.
pending(mouth l.123) is filled in one place,syncOnce(l.285), from Matrix/syncevents. No function accepts a delivery from elsewhere. - A request is non-consuming; an ack consumes.
chat_pending_requestcarriesafterSeq; the mouth answerspendingAfter(afterSeq)(l.383) paged to 256 KiB (pageOf, l.483-498).chat_ackcarriesthroughSeq;ackdrops every delivery at or below it (l.374-381). The endpoint accepts exactly these kinds (l.520) and answers a request for another Persona with a typedUnknownPersonarefusal. - At-least-once. The Matrix cursor is persisted only when every delivery of a batch is acked (
commit, l.351-359), so a restart before the ack re-delivers from Matrix.seqCounteris persisted whenever a round made deliveries (l.346), so sequence numbers never repeat across a restart. - Backpressure. At 500 unacked (
MAX_PENDING, l.53)syncOncestops reading (l.294). A truncated round leaves the cursor where it was (l.305, l.339). - A delivery that will not encode is skipped, not held.
pageOflogs and omits it (l.489-491); 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.
startChatEndpointrefuses a non-loopback host (l.505). It is another process from Bifrost.Web, which has no way to hand the mouth anything. - The delivery tuple is closed.
deliveryTupleandparseDeliveryare exactly twelve fields (the twelfth, added sincebf8ff62, is an optional stored-image reference), with no source or kind field (persona-chat-documents.mjsl.120-160). The pending answer is nowgamecult.persona.chat_pending.v2. A delivery cannot say it is not a Matrix message. - The consumer is not in this repo. The mouth's header (l.8-9) says the heartbeat polls with a non-consuming request and decides who runs. I could not confirm that from source.
What follows from it:
- Feedback cannot ride
pendingas it is. There is no inlet, and the cursor logic ties acks to Matrix batches. - A framed text delivery is forgeable. Any Matrix member can write a line that looks like a frame, and the tuple has no
field to tell the Mind which one is real. The frame must be a typed difference in the wire, not a convention in
text. - 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.parseDeliveryaccepts exactly eleven fields (persona-chat-documents.mjsl.149, twelve fields) andpageOfencodes each delivery withdeliveryTuple(l.120), so a different tuple in that page breaks every current consumer, and a text frame intextis forgeable by any Matrix member. - Option B, kept: the delivery document rides its own request, page and ack kinds on the same endpoint
(
feedback_pending_request,feedback_pending,feedback_ack), with its own seq space and a durable cursor over the receiver's store. The Matrix kinds, the Matrix cursor andMAX_PENDINGare untouched. The endpoint's closed kind list (l.520) grows by three. Confidence medium: the consumer, which I cannot see, must learn them. - Why main's changes reinforce B (re-verified at
55b74275). A chatacknow has Matrix side effects: it posts the Persona's publicm.readreceipt per room (readUpTo, l.372-381) and releases stored images (images?.acked). A feedback ack must do neither, so it cannot share thechat_ackkind or itsackfunction. The chat pending answer is already versioned (chat_pending.v2, twelve-field tuple), so the lesson of the image change is that 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
pendingas 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).
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) |
| delivery state | per owning Mind: highest seq acked; per record: delivered, failed |
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: Ratatoskr presents the OIDC access token Bifrost already issues to chat, and Bifrost verifies it itself. This section records what Bifrost source says about that token, because PR #24 lists the claims check as open.
What the token carries (OidcEndpoints.cs l.35-79, docs/oidc-issuer.md)
| Claim | Value | In the access token | Use by the receiver |
|---|---|---|---|
sub |
UserAccount.Id, formatted D |
always | answerer, and the only claim the receiver reads |
preferred_username |
NormalizedBifrostIdentity (the Matrix localpart, minted once) |
with scope profile |
never: it is the chat handle |
name |
DisplayName, else BifrostIdentity |
with scope profile |
never: it is stale at issue time and can be a handle. The name comes from account state (section 5) |
| scopes | copied from the request: openid, profile |
yes | ignored |
No email, no Heimdall id, no GitHub id and no membership detail is in it. The GitHub login and the Heimdall account id are
not claims, so neither can become answerer or a display name by way of the token.
What the receiver checks
- Validity and expiry. The access token is an encrypted OpenIddict token (
AddEncryptionKey,OidcServiceCollectionExtensions.csl.51; no reference tokens, no lifetime override anywhere insrc). Only Bifrost can open it, so validity, issuer binding and expiry are OpenIddict's own checks at validation time. The lifetime is OpenIddict's default, which I believe is one hour but have not verified. There is no refresh token (oidc-issuer.md), so a session ends at expiry and the client signs in again. - Issuer.
server.IssuerisHost:PublicBaseUrl(l.60-68). A token from any other issuer cannot be opened, so there is nothing to compare. - Audience. None is set: no
SetAudiencesorSetResourcesappears insrc, so the token carries noaudof Bifrost's choosing. The receiver cannot check an audience, and a token is not bound to the feedback endpoint. That is a weakness of route 2 as it stands: any access token this issuer has given any client would be accepted here. A scope for this purpose (below) narrows it. - The subject is an active account, read now.
submust parse as a Guid, name an existingUserAccount, and that account'sStatusmust beActiveat answer time. This is the readingIsStillActiveMemberAsyncmakes (OidcEndpoints.csl.121-132), but not throughIsActiveMember: under PR #9 that expression also requiresRole == Memberand would exclude guests, and PR #9 itself moves the OIDC tests toStatus == Activefor the same reason.
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 submission whose body carries 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 that route 2 has to answer
- The token reaches only MAS. Exactly one OIDC client is registered, a confidential one for MAS, authorization code with PKCE and a
client secret (
OidcClientSeeder.csl.28-44), and every other application row is deleted at startup (l.56-68). MAS holds the access token; Ratatoskr, a Matrix client, gets a Matrix session from MAS, not a Bifrost token. Nothing in this repo gives Ratatoskr a Bifrost access token. - Possible readings of "the token chat already has":
- 2a. Ratatoskr as a second client of Bifrost's issuer, public, authorization code with PKCE, and its own scope (for example
feedback). Samesub, same checks, a purpose-limited scope the receiver can require. It changes the one-client rule inoidc-issuer.md, the seeder's delete of other clients, the need to validate access tokens outside the userinfo endpoint (the web project references onlyOpenIddict.Server.AspNetCoreandOpenIddict.EntityFrameworkCore, not the validation package), and gives sign-in every hour with no refresh. - 2b. The Matrix token Ratatoskr already holds, checked by asking the homeserver who it is. No new client, but it hands the receiver a bearer token good for the person's whole Matrix account. I recommend against.
- I lean to 2a, confidence low: I could not read MAS's side, and
chat-s3549says route 2 without naming which.
- 2a. Ratatoskr as a second client of Bifrost's issuer, public, authorization code with PKCE, and its own scope (for example
- The logout reconciler does not cover a second client. It lists the authorizations of the MAS client (
oidc-issuer.md), so a demoted member's feedback token would live to its expiry. The receiver's ownStatus == Activeread at every submission is the check that covers it.
5. Account-kind lookup
- The account is found by the token's
sub, which isUserAccount.Id(section 4).answererisUserAccount.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.csl.5-10, l.335). The role field andgamecult.bifrost.account.v1are in PR #9 and unbuilt. Until then,kindcan only be derived:Activeismember,AuthenticatedandPendingApprovalareuser, and nobody is aguest. 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, notIsActiveMember(section 4). On main the authorize endpoint already refuses a non-Active account a chat token (OidcEndpoints.csl.50-53), so under route 2 only an Active account can answer until PR #9 changes that test:AuthenticatedandPendingApprovalaccounts have no token to answer with. kindis read from Bifrost's database, never from the token: the session carries identity only.mindis not a Bifrost account in main or PR #9. How a Mind authenticates to answer (a credential of its own, or a path through the receiver) is open in both specs.- The display name must not be
CurrentBifrostActor.DisplayName. It falls back toGitHubLogin(CurrentBifrostActor.csl.12-16) and then to the literal "Guest". The receiver storesnameAtAnswerfromMemberProfile.NicknameorUserAccount.DisplayNameonly, 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.v1document on the typed kinds of section 2. A feedback record is therefore never achat_pendingdelivery 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 putstextin a data position only: attributed, quoted, never in an instruction position, with the author'skindstated (R4d). The receiver does no keyword screening. - She admits the text-free
feedback_answerinto 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 samerecordId, which Huginn answersAlreadyAdmitted. So an ack means admitted, a stronger meaning than the mouth's chat ack, which means only taken. - Untrusted by kind. Text from
guest,userandpatronis untrusted input; so ismembertext, 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
failedand stays visible, never acked past silently (the opposite ofpageOf). 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_hideof reasonErasedAuthor.
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-freefeedback_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
kindagainst the question'saudienceat 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. The receiver's part, as PR #24 section 7 fixes it:
- 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.mddescribes for the persona-feedback pump. - For every record the account authored the receiver drops the text and the name and keeps record id, kind and time only.
choice,weightPermille,answererandclientRequestIdare dropped too. The row shows "erased account". No content hash is kept. - The receiver delivers an erase notice, and the Persona admits a
feedback_hideof reasonErasedAuthor: the Mind's acknowledgement, not a moderation call. - 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.
- Backups. "Append-only" is the receiver's API contract, not a property of the file; erasing the live file leaves the text in every backup of it. A stated purge or expiry for the receiver's backups is required before any deploy, so erased text does not survive in them. The number is Eureka's and Heimdall's to set; this doc names none. Until it exists, redaction is a promise the backups break.
8. Where it lives
A CultCache file, owned and written only by the receiver (Eureka's pick, PR #24 section 7a). Bifrost.Web is ASP.NET with Postgres as its
store of record and has no CultCache writer (CultCache appears only in a comment in BifrostModels.cs); the mouth and the persona-feedback pump
are Node and read CultCache. So the shape that fits what exists:
- the receiver writes the file (a C# CultCache writer is new to Bifrost.Web, or a Node writer behind a loopback call; I did not decide which, and
it is Eureka's), only it writes, and the Node chat endpoint reads it read-only with its own delivery cursor in a separate file, the way
persona-feedback servereads its delivery store and the mouth keepscursor.cc; - session check, account state and role stay in Bifrost.Web (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, plainly
gamecult.bifrost.account.v1andMembership.Roleare unbuilt (PR #9, open, design only). Without themkindis derived fromStatus, and no guest exists.- Route 2 has no client yet. The one OIDC client is MAS, so no Bifrost access token reaches Ratatoskr today (section 4). Route 2 needs a second client or another reading; the token has no audience; a validation package is not referenced; there is no refresh.
- Deploy is behind main.
- The deployed mouth is
/srv/bifrost/persona-chat/runtime/current, releasee396305. Itspersona-matrix-mouth.mjsis byte-identical toe396305's (sha25691f6dd49...); main has since added the image path (cut A), a publicm.readreceipt posted on every chat ack, and presence set from the brake, which are 82 changed lines in the mouth and a twelfth delivery field. - The deployed web app is
/srv/bifrost/app/currentat67072b2, 129 commits behind main. That includes the OIDC issuer and back-channel logout. I cannot tell from here which of those are live beyond that directory. - Anything in this design runs on a release that does not exist yet.
- The deployed mouth is
- The Huginn kinds are Eureka's. PR #24 names
feedback_listing,feedback_answer,feedback_close_request,feedback_hide, owned byhuginn-feedback-kinds-proposal.md, which I could not read. PR #24 also notes the proposal'sfeedback_answerlists acontent_hashthat must go. Their dependency order is Huginn readers, then the Persona organ, then this receiver, so nothing here can ship first. - How a Mind answers is open. Bifrost has no
Mindrole; neither main nor PR #9 gives a Mind an account. - The consumer of the mouth's pending path (the heartbeat) is outside this repo, and option B needs the Persona side to learn three kinds.
- Backup purge or expiry is required before deploy and unset (section 7a, item 5).
- Erase is bounded by what a Mind already holds (section 7a, item 4).
- Unread: Eureka's receiver notes in #minds,
huginn-feedback-kinds-proposal.md, MAS's side of the session, and Heimdall PR #6 (the answer-collector consent doc; 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.