From c21af113cfcd4556156dbfd93a27e939c090d5aa Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 7 Nov 2026 09:52:39 +0000 Subject: [PATCH 1/4] docs: feedback receiver design (draft, do not merge) Design for the receiver half of Ratatoskr's Mind feedback pages: records, session check, account-kind lookup, delivery as quoted data, erase redaction and gaps. Design only; nothing implemented. Co-Authored-By: Claude Sonnet 5.5 --- docs/feedback-receiver.md | 208 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/feedback-receiver.md diff --git a/docs/feedback-receiver.md b/docs/feedback-receiver.md new file mode 100644 index 0000000..fc4d17f --- /dev/null +++ b/docs/feedback-receiver.md @@ -0,0 +1,208 @@ +# Feedback receiver + +Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:chat-s3533-3533`. It is the receiver half of +Ratatoskr's Mind feedback pages (Ratatoskr PR #24, `docs/mind-feedback-pages.md` at 8e2ace76) under Heimdall's draft +rulings 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): `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; the brief's summary of them (receiver verifies the Heimdall session, append-only answer and +close-request records in its own store, delivery as a framed message through the mouth's pending/ack path, the page +polling Huginn Query) is what I designed against. Where source disagrees with it, section 2 says so. + +## 1. What the receiver owns + +| Concern | Owner | Receiver's part | +|---|---|---| +| A question, its status, hides, rulings | the owning Mind, in her own mind (Huginn) | reads them to build the projection; writes none | +| An answer, a close request, a report | the author's act | verifies who, stamps `answerer`, `kind`, `time`, appends it | +| What `kind` an account is | Bifrost (`Membership`, later `Role`) | reads it at answer time and records it on the record | +| Delivery to the Mind | the receiver, at least once | keeps the record until the Mind acks | +| Hiding, closing, ruling | the owning Mind | **never**; a hide only changes what the projection serves | +| Redaction on erase | Bifrost's erase, executed here | withdraws the text and name from the store (section 6) | + +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.112) is filled in one place, `syncOnce` + (l.303), from Matrix `/sync` events. No function accepts a delivery from elsewhere. +- **A request is non-consuming; an ack consumes.** `chat_pending_request` carries `afterSeq`; the mouth answers + `pendingAfter(afterSeq)` (l.331-333) paged to 256 KiB (`pageOf`, l.428-445). `chat_ack` carries `throughSeq`; `ack` + drops every delivery at or below it (l.326-329). The endpoint accepts exactly these kinds (l.139) and answers a + request for another Persona with a typed `UnknownPersona` refusal. +- **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.316-324), so a + restart before the ack re-delivers from Matrix. `seqCounter` is persisted whenever a round made deliveries (l.311), so + sequence numbers never repeat across a restart. +- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.45) `syncOnce` stops reading (l.263). A truncated round leaves the + cursor where it was (l.304). +- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.436-440); 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` refuses a non-loopback host (l.124). It is another process + from Bifrost.Web, which has no way to hand the mouth anything. +- **The delivery tuple is closed.** `deliveryTuple` and `parseDelivery` are exactly eleven fields, with no source or + kind field (`persona-chat-documents.mjs` l.117-135). 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: + +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 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`. +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. + +Options considered for delivery: + +| | What | For | Against | +|---|---|---|---| +| A | Inject records into `pending` as synthetic Matrix deliveries | no wire change | forgeable (2); the ack couples to Matrix batches; a restart loses them (3) | +| **B** | **New kinds on the same endpoint:** `gamecult.persona.feedback_pending_request.v1`, `feedback_pending.v1`, `feedback_ack.v1`, with their own seq space and cursor, reading the receiver's store | typed, unforgeable, no change to the v1 and v2 chat tuples, the Matrix cursor logic untouched, the existing request/ack shape the consumer already knows | the consumer must learn three kinds; `checkEnvelope`'s closed list (l.139) grows | +| C | The receiver posts to a Matrix room as a bot | no consumer change | Matrix sender is the wrong identity authority; spam and forgery; the Mind sees it as chat | + +Recommended: **B**. Confidence medium: B changes the consumer, which I cannot see. + +## 3. Records + +One store, owned by the receiver, written only by it. Append-only through the API: nothing edits or deletes a record, +except a redaction (section 6). Every record has an `id`, a receiver-assigned `seq` per owning Mind (the delivery +sequence), and `time` (when the receiver accepted it). + +| Record | Fields | Notes | +|---|---|---| +| `answer` | `questionId`, `owner`, `optionId?`, `text?` (at most 2000 characters, R4), `answerer` (Bifrost account id), `kind`, `nameAtAnswer`, `clientRequestId` | at least one of `optionId`/`text`; an unknown option id is refused; the client's request id makes a retry idempotent, as `txnId = request_id` does in the mouth | +| `close_request` | `questionId`, `requester`, `kind` | a request only; refused if `kind` is `guest` or the account is not `active` at that moment (R4c) | +| `report` | `targetId`, `reporter`, `kind`, `reason` | delivered to the Mind as input; never shown, never counted (R6b) | +| `redaction` | `targetId`, `cause` (`erased`), `time` | section 6 | +| delivery state | per owning Mind: highest seq acked; per record: `delivered`, `failed` | a cursor, not a record the page reads | + +Invariants: `answerer`, `kind`, `time` and `nameAtAnswer` come from the verified session and Bifrost state, never from +the request body. The latest record per account is that account's standing answer, derived at read time. A record is +refused if the question is not on the page, is hidden, or is settled. A refused submission leaves nothing behind. + +## 4. Session check + +Bifrost already verifies Heimdall tokens: `HeimdallTokenVerifier.VerifyAsync` (`Features/Heimdall/HeimdallTokenVerifier.cs`) +checks EdDSA against Heimdall's JWKS (a cache refreshed every 30 s, valid at most an hour), then `iss` equals Heimdall, +`aud` and `app.slug` equal the app slug, `typ` is `heimdall_access`, `nbf` and `exp` within 60 s of skew, and +`sub == account_id`. The receiver reuses this and checks nothing the client says about itself. + +What source does not give me: + +- **The audience.** The verifier demands `aud == options.AppSlug`, Bifrost's slug. Whether Ratatoskr can obtain a Heimdall + access token for that audience is not in this repo. Ratatoskr's chat session is a Matrix and OIDC session (Bifrost is + the issuer for MAS, `Features/Oidc`), not a Heimdall one. +- **A bearer endpoint.** Bifrost.Web has cookie sign-in (`Program.cs` l.167-173) and no endpoint that takes a Heimdall + bearer token. `VerifyAsync` also takes a `payloadAccountId` that only the sign-in response supplies, so a bearer path + needs a small change to how that cross-check is done. +- **Who the browser page is.** If the feedback page is a Ratatoskr view, the question is which credential its fetches carry. + +Two routes, for Eureka and Void to rule on: (1) a bearer endpoint family on Bifrost.Web accepting a Heimdall +access token for the Bifrost audience; (2) the OIDC access token Bifrost already issues to chat, verified by Bifrost +itself. Route 2 needs no Heimdall token from Ratatoskr and ties the answer to the same account the chat session already +has. I lean to 2, with low confidence: I did not read the OIDC token's claims for this. + +## 5. Account-kind lookup + +- The account is found by `UserAccount.HeimdallAccountId` (`HeimdallAuthService.cs` l.278) or, under route 2, by the OIDC + subject, which is `UserAccount.Id`. **`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`: under PR #9 that expression also requires + `Role == Member` and would exclude guests. +- **`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. How a Mind answers (a Heimdall session 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 to `GitHubLogin` + (`CurrentBifrostActor.cs` l.12-16) 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 on the typed kinds of section 2 (option B). A feedback record is therefore never a + `chat_pending` delivery and a Matrix sender cannot produce one. +- The record carries its fields separately: `recordId`, `questionId`, `kind`, `nameAtAnswer`, `answerer`, `time`, `optionId`, + `text`. The **consumer 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. +- **Untrusted by kind.** Text from `guest`, `user` and `patron` is untrusted input; so is `member` text, which differs only in + weight. The Mind records a ruling from an answer, not an action. +- **Ack means delivered to her conversation**, not read and not ruled on. Delivery is at-least-once, so the consumer + dedupes by `recordId`. +- **A record that will not encode is marked `failed` and stays visible**, never acked past silently (the opposite of + `pageOf`). The cap and backpressure of the mouth 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 at all. + +## 7. Projection read + +The page polls the receiver, and the receiver reads the Mind's side through Huginn (RUDP, as PR #24 section 3 says). That +resolves a seam between sources: my brief said the page polls Huginn Query, while PR #24 says the client never talks to +Huginn. I follow PR #24. + +- Questions, their audience, status, rulings and hide acts come from the Mind's mind. Answers come from the receiver's store. +- **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 redaction + +Bifrost's erase (PR #9) blanks the display name and keeps the account id as a tombstone. The receiver's part: + +1. Bifrost erase appends `redaction(cause: erased)` for every record the account authored, then the account is tombstoned. + The two stores are not one transaction, so erase is crash-replayable: the erase intent is recorded in Postgres first and + the receiver applies it idempotently, as `docs/bridge.md` describes for the persona-feedback pump. +2. A redacted record keeps `id`, `questionId`, `kind`, `time` and, for an option answer, `optionId` (not personal). + `text`, `nameAtAnswer` and `clientRequestId` are removed from the store and the projection. The row shows + "erased account". +3. **I propose no content hash.** R4e suggested one. An unsalted hash of a short free-text answer can confirm a guessed + text, so I withdraw that: keep nothing of the text. +4. The Mind is told by a `redaction` record on the delivery path, a request she may honour in her own memory. Erase does not + reach what she already holds, the same limit as `guest-accounts.md` states for chat copies. +5. "Append-only" is the receiver's API contract, not a property of the file. Backups and any witness copy need a stated + purge or expiry, or redaction is a promise the backups break. Unresolved. + +## 8. Where it lives + +Bifrost.Web is ASP.NET with Postgres as its store of record and no CultCache writer (`CultCache` appears only in a +comment in `BifrostModels.cs`); the mouth and the persona-feedback pump are Node and read CultCache. + +| | Storage | Delivery | +|---|---|---| +| R1 (recommended) | Postgres rows in Bifrost.Web, append-only by construction, `.cc` witness owed as for every Bifrost table (`jurisdiction-map.md`) | the Node chat endpoint reads a loopback-only Bifrost.Web endpoint and serves the section 2 kinds | +| R2 | a CultCache `.cc` written from C# | the Node endpoint reads the file read-only with its own cursor, as `persona-feedback serve` does with its delivery store | + +R1 matches where session, account and role already are, and adds no new C# dependency. R2 matches the swarm's state +doctrine, and adds a C# CultCache writer nothing here has. Eureka's call; confidence low. + +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`, not `persona_feedback`. + +## 9. Gaps, plainly + +- `gamecult.bifrost.account.v1` and `Membership.Role` are unbuilt (PR #9, open, design only). Without them `kind` is + derived from `Status`, and no guest exists. +- **Deploy is behind main.** + - The deployed mouth is `/srv/bifrost/persona-chat/runtime/current`, release `e396305`. Its `persona-matrix-mouth.mjs` is + byte-identical to `e396305`'s (sha256 `91f6dd49...`); main adds 30 lines (the media-line change) to the file. + - The deployed web app is `/srv/bifrost/app/current` at `67072b2`, 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 Huginn kinds are Eureka's:** the opt-in with `audience`, `closeGrants`, the close request as the Mind's input, + hide and unhide, and what the receiver reads to build the projection. This doc names what the receiver needs; it proposes no kinds. +- The consumer of `chat_pending` (heartbeat) is outside this repo, and option B needs it to learn three kinds. +- The credential Ratatoskr presents (section 4) is open, as is how a `mind` answers. +- The mapping of Postgres or `.cc` backups to redaction (section 7a, item 5) is open. +- Heimdall PR #6 (the answer-collector consent doc) is not read; whether guest answers are collected for anything is R4f's + default, "no", unchecked against it. +- A Synapse or Matrix side is not involved: the receiver writes nothing to Matrix. -- 2.47.3 From 4aa627b32b8b43cb45abdd3f17971c8d6a1ee382 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 7 Nov 2026 09:52:49 +0000 Subject: [PATCH 2/4] docs: feedback receiver: correct mouth line references Co-Authored-By: Claude Sonnet 5.5 --- docs/feedback-receiver.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/feedback-receiver.md b/docs/feedback-receiver.md index fc4d17f..684f6a9 100644 --- a/docs/feedback-receiver.md +++ b/docs/feedback-receiver.md @@ -32,17 +32,17 @@ Verified in `tools/persona-matrix-mouth.mjs` and `tools/persona-chat-documents.m - **Pending is Matrix mail only, held in memory.** `pending` (mouth l.112) is filled in one place, `syncOnce` (l.303), from Matrix `/sync` events. No function accepts a delivery from elsewhere. - **A request is non-consuming; an ack consumes.** `chat_pending_request` carries `afterSeq`; the mouth answers - `pendingAfter(afterSeq)` (l.331-333) paged to 256 KiB (`pageOf`, l.428-445). `chat_ack` carries `throughSeq`; `ack` - drops every delivery at or below it (l.326-329). The endpoint accepts exactly these kinds (l.139) and answers a + `pendingAfter(afterSeq)` (l.331-333) paged to 256 KiB (`pageOf`, l.431-447). `chat_ack` carries `throughSeq`; `ack` + drops every delivery at or below it (l.326-329). The endpoint accepts exactly these kinds (l.468) and answers a request for another Persona with a typed `UnknownPersona` refusal. - **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.316-324), so a restart before the ack re-delivers from Matrix. `seqCounter` is persisted whenever a round made deliveries (l.311), so sequence numbers never repeat across a restart. - **Backpressure.** At 500 unacked (`MAX_PENDING`, l.45) `syncOnce` stops reading (l.263). A truncated round leaves the cursor where it was (l.304). -- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.436-440); the caller's +- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.437); 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` refuses a non-loopback host (l.124). It is another process +- **The endpoint is loopback RUDP.** `startChatEndpoint` refuses a non-loopback host (l.453). It is another process from Bifrost.Web, which has no way to hand the mouth anything. - **The delivery tuple is closed.** `deliveryTuple` and `parseDelivery` are exactly eleven fields, with no source or kind field (`persona-chat-documents.mjs` l.117-135). A delivery cannot say it is not a Matrix message. @@ -62,7 +62,7 @@ Options considered for delivery: | | What | For | Against | |---|---|---|---| | A | Inject records into `pending` as synthetic Matrix deliveries | no wire change | forgeable (2); the ack couples to Matrix batches; a restart loses them (3) | -| **B** | **New kinds on the same endpoint:** `gamecult.persona.feedback_pending_request.v1`, `feedback_pending.v1`, `feedback_ack.v1`, with their own seq space and cursor, reading the receiver's store | typed, unforgeable, no change to the v1 and v2 chat tuples, the Matrix cursor logic untouched, the existing request/ack shape the consumer already knows | the consumer must learn three kinds; `checkEnvelope`'s closed list (l.139) grows | +| **B** | **New kinds on the same endpoint:** `gamecult.persona.feedback_pending_request.v1`, `feedback_pending.v1`, `feedback_ack.v1`, with their own seq space and cursor, reading the receiver's store | typed, unforgeable, no change to the v1 and v2 chat tuples, the Matrix cursor logic untouched, the existing request/ack shape the consumer already knows | the consumer must learn three kinds; `checkEnvelope`'s closed list (l.468) grows | | C | The receiver posts to a Matrix room as a bot | no consumer change | Matrix sender is the wrong identity authority; spam and forgery; the Mind sees it as chat | Recommended: **B**. Confidence medium: B changes the consumer, which I cannot see. -- 2.47.3 From fa46ef3c0b72c5590c7e64d98898f8d99cdf0a97 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 7 Nov 2026 09:57:49 +0000 Subject: [PATCH 3/4] docs: feedback receiver: cite Ratatoskr PR #24 at b146000d, route 2 claims check, CultCache file, erase and backup rules Co-Authored-By: Claude Sonnet 5.5 --- docs/feedback-receiver.md | 256 ++++++++++++++++++++++---------------- 1 file changed, 148 insertions(+), 108 deletions(-) diff --git a/docs/feedback-receiver.md b/docs/feedback-receiver.md index 684f6a9..b95bc03 100644 --- a/docs/feedback-receiver.md +++ b/docs/feedback-receiver.md @@ -1,26 +1,29 @@ # Feedback receiver -Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:chat-s3533-3533`. It is the receiver half of -Ratatoskr's Mind feedback pages (Ratatoskr PR #24, `docs/mind-feedback-pages.md` at 8e2ace76) under Heimdall's draft -rulings on guests and hiding (`/opt/commons/handoff/heimdall-feedback-pages-rulings.md`, cited as R4a to R6f). Draft; do not merge. +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): `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; the brief's summary of them (receiver verifies the Heimdall session, append-only answer and -close-request records in its own store, delivery as a framed message through the mouth's pending/ack path, the page -polling Huginn Query) is what I designed against. Where source disagrees with it, section 2 says so. +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 status, hides, rulings | the owning Mind, in her own mind (Huginn) | reads them to build the projection; writes none | -| An answer, a close request, a report | the author's act | verifies who, stamps `answerer`, `kind`, `time`, appends it | -| What `kind` an account is | Bifrost (`Membership`, later `Role`) | reads it at answer time and records it on the record | -| Delivery to the Mind | the receiver, at least once | keeps the record until the Mind acks | +| 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 | -| Redaction on erase | Bifrost's erase, executed here | withdraws the text and name from the store (section 6) | +| 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`. @@ -57,69 +60,105 @@ What follows from it: 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. -Options considered for delivery: +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: -| | What | For | Against | -|---|---|---|---| -| A | Inject records into `pending` as synthetic Matrix deliveries | no wire change | forgeable (2); the ack couples to Matrix batches; a restart loses them (3) | -| **B** | **New kinds on the same endpoint:** `gamecult.persona.feedback_pending_request.v1`, `feedback_pending.v1`, `feedback_ack.v1`, with their own seq space and cursor, reading the receiver's store | typed, unforgeable, no change to the v1 and v2 chat tuples, the Matrix cursor logic untouched, the existing request/ack shape the consumer already knows | the consumer must learn three kinds; `checkEnvelope`'s closed list (l.468) grows | -| C | The receiver posts to a Matrix room as a bot | no consumer change | Matrix sender is the wrong identity authority; spam and forgery; the Mind sees it as chat | - -Recommended: **B**. Confidence medium: B changes the consumer, which I cannot see. +- **It cannot be a variant inside `chat_pending`.** `parseDelivery` accepts exactly eleven fields + (`persona-chat-documents.mjs` l.125-126) and `pageOf` encodes each delivery with `deliveryTuple` (l.117), 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:** 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 and `MAX_PENDING` are untouched. The endpoint's closed kind list + (l.468) grows by three. Confidence medium: the consumer, which I cannot see, must learn them. +- 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). ## 3. Records -One store, owned by the receiver, written only by it. Append-only through the API: nothing edits or deletes a record, -except a redaction (section 6). Every record has an `id`, a receiver-assigned `seq` per owning Mind (the delivery -sequence), and `time` (when the receiver accepted it). +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 `.a`, N the receiver's per-question +sequence; I use that for answers and the same shape for the others. | Record | Fields | Notes | |---|---|---| -| `answer` | `questionId`, `owner`, `optionId?`, `text?` (at most 2000 characters, R4), `answerer` (Bifrost account id), `kind`, `nameAtAnswer`, `clientRequestId` | at least one of `optionId`/`text`; an unknown option id is refused; the client's request id makes a retry idempotent, as `txnId = request_id` does in the mouth | -| `close_request` | `questionId`, `requester`, `kind` | a request only; refused if `kind` is `guest` or the account is not `active` at that moment (R4c) | -| `report` | `targetId`, `reporter`, `kind`, `reason` | delivered to the Mind as input; never shown, never counted (R6b) | -| `redaction` | `targetId`, `cause` (`erased`), `time` | section 6 | +| `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`, `time` and `nameAtAnswer` come from the verified session and Bifrost state, never from -the request body. The latest record per account is that account's standing answer, derived at read time. A record is -refused if the question is not on the page, is hidden, or is settled. A refused submission leaves nothing behind. +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. Session check +## 4. Credential and claims check -Bifrost already verifies Heimdall tokens: `HeimdallTokenVerifier.VerifyAsync` (`Features/Heimdall/HeimdallTokenVerifier.cs`) -checks EdDSA against Heimdall's JWKS (a cache refreshed every 30 s, valid at most an hour), then `iss` equals Heimdall, -`aud` and `app.slug` equal the app slug, `typ` is `heimdall_access`, `nbf` and `exp` within 60 s of skew, and -`sub == account_id`. The receiver reuses this and checks nothing the client says about itself. +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 source does not give me: +### What the token carries (`OidcEndpoints.cs` l.35-79, `docs/oidc-issuer.md`) -- **The audience.** The verifier demands `aud == options.AppSlug`, Bifrost's slug. Whether Ratatoskr can obtain a Heimdall - access token for that audience is not in this repo. Ratatoskr's chat session is a Matrix and OIDC session (Bifrost is - the issuer for MAS, `Features/Oidc`), not a Heimdall one. -- **A bearer endpoint.** Bifrost.Web has cookie sign-in (`Program.cs` l.167-173) and no endpoint that takes a Heimdall - bearer token. `VerifyAsync` also takes a `payloadAccountId` that only the sign-in response supplies, so a bearer path - needs a small change to how that cross-check is done. -- **Who the browser page is.** If the feedback page is a Ratatoskr view, the question is which credential its fetches carry. +| 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 | -Two routes, for Eureka and Void to rule on: (1) a bearer endpoint family on Bifrost.Web accepting a Heimdall -access token for the Bifrost audience; (2) the OIDC access token Bifrost already issues to chat, verified by Bifrost -itself. Route 2 needs no Heimdall token from Ratatoskr and ties the answer to the same account the chat session already -has. I lean to 2, with low confidence: I did not read the OIDC token's claims for this. +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.cs` l.51; + no reference tokens, no lifetime override anywhere in `src`). 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.Issuer` is `Host:PublicBaseUrl` (l.60-68). A token from any other issuer cannot be opened, so there is nothing to compare. +- **Audience.** None is set: no `SetAudiences` or `SetResources` appears in `src`, so the token carries no `aud` of 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.** `sub` must parse as a Guid, name an existing `UserAccount`, and that account's `Status` must be + `Active` at answer time. This is the reading `IsStillActiveMemberAsync` makes (`OidcEndpoints.cs` l.121-132), but **not** through + `IsActiveMember`: under PR #9 that expression also requires `Role == Member` and would exclude guests, and PR #9 itself moves the OIDC tests + to `Status == Active` for 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 + +1. **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.cs` l.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. +2. **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`). + Same `sub`, same checks, a purpose-limited scope the receiver can require. It changes the one-client rule in `oidc-issuer.md`, the seeder's delete + of other clients, the need to validate access tokens outside the userinfo endpoint (the web project references only `OpenIddict.Server.AspNetCore` + and `OpenIddict.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-s3549` says route 2 without naming which. +3. **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 own `Status == Active` read at every submission is the check that covers it. ## 5. Account-kind lookup -- The account is found by `UserAccount.HeimdallAccountId` (`HeimdallAuthService.cs` l.278) or, under route 2, by the OIDC - subject, which is `UserAccount.Id`. **`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). +- 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`: under PR #9 that expression also requires - `Role == Member` and would exclude guests. +- **An answer needs `Status == Active`**, read at answer time, not `IsActiveMember` (section 4). On main the authorize endpoint + already refuses a non-Active account a chat token (`OidcEndpoints.cs` l.50-53), so under route 2 only an Active account can answer + until PR #9 changes that test: `Authenticated` and `PendingApproval` accounts have no token to answer with. - **`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. How a Mind answers (a Heimdall session of its own, or a path through +- **`mind` is 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 to `GitHubLogin` (`CurrentBifrostActor.cs` l.12-16) and then to the literal "Guest". The receiver stores `nameAtAnswer` from @@ -128,81 +167,82 @@ has. I lean to 2, with low confidence: I did not read the OIDC token's claims fo ## 6. Delivery as quoted data -- The receiver delivers each record on the typed kinds of section 2 (option B). A feedback record is therefore never a - `chat_pending` delivery and a Matrix sender cannot produce one. -- The record carries its fields separately: `recordId`, `questionId`, `kind`, `nameAtAnswer`, `answerer`, `time`, `optionId`, - `text`. The **consumer 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. -- **Untrusted by kind.** Text from `guest`, `user` and `patron` is untrusted input; so is `member` text, which differs only in - weight. The Mind records a ruling from an answer, not an action. -- **Ack means delivered to her conversation**, not read and not ruled on. Delivery is at-least-once, so the consumer - dedupes by `recordId`. -- **A record that will not encode is marked `failed` and stays visible**, never acked past silently (the opposite of - `pageOf`). The cap and backpressure of the mouth 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 at all. +- 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`. ## 7. Projection read -The page polls the receiver, and the receiver reads the Mind's side through Huginn (RUDP, as PR #24 section 3 says). That -resolves a seam between sources: my brief said the page polls Huginn Query, while PR #24 says the client never talks to -Huginn. I follow PR #24. +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 audience, status, rulings and hide acts come from the Mind's mind. Answers come from the receiver's store. +- 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 redaction +## 7a. Erase -Bifrost's erase (PR #9) blanks the display name and keeps the account id as a tombstone. The receiver's part: +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: -1. Bifrost erase appends `redaction(cause: erased)` for every record the account authored, then the account is tombstoned. - The two stores are not one transaction, so erase is crash-replayable: the erase intent is recorded in Postgres first and - the receiver applies it idempotently, as `docs/bridge.md` describes for the persona-feedback pump. -2. A redacted record keeps `id`, `questionId`, `kind`, `time` and, for an option answer, `optionId` (not personal). - `text`, `nameAtAnswer` and `clientRequestId` are removed from the store and the projection. The row shows - "erased account". -3. **I propose no content hash.** R4e suggested one. An unsalted hash of a short free-text answer can confirm a guessed - text, so I withdraw that: keep nothing of the text. -4. The Mind is told by a `redaction` record on the delivery path, a request she may honour in her own memory. Erase does not - reach what she already holds, the same limit as `guest-accounts.md` states for chat copies. -5. "Append-only" is the receiver's API contract, not a property of the file. Backups and any witness copy need a stated - purge or expiry, or redaction is a promise the backups break. Unresolved. +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.** +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.** "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 -Bifrost.Web is ASP.NET with Postgres as its store of record and no CultCache writer (`CultCache` appears only in a -comment in `BifrostModels.cs`); the mouth and the persona-feedback pump are Node and read CultCache. +**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: -| | Storage | Delivery | -|---|---|---| -| R1 (recommended) | Postgres rows in Bifrost.Web, append-only by construction, `.cc` witness owed as for every Bifrost table (`jurisdiction-map.md`) | the Node chat endpoint reads a loopback-only Bifrost.Web endpoint and serves the section 2 kinds | -| R2 | a CultCache `.cc` written from C# | the Node endpoint reads the file read-only with its own cursor, as `persona-feedback serve` does with its delivery store | +- 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 serve` reads its delivery store and the mouth keeps `cursor.cc`; +- session check, account state and role stay in Bifrost.Web (Postgres), where they already are. -R1 matches where session, account and role already are, and adds no new C# dependency. R2 matches the swarm's state -doctrine, and adds a C# CultCache writer nothing here has. Eureka's call; confidence low. +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`, not `persona_feedback`. +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.v1` and `Membership.Role` are unbuilt (PR #9, open, design only). Without them `kind` is - derived from `Status`, and no guest exists. +- **`gamecult.bifrost.account.v1` and `Membership.Role` are unbuilt** (PR #9, open, design only). Without them `kind` is derived from `Status`, 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`, release `e396305`. Its `persona-matrix-mouth.mjs` is - byte-identical to `e396305`'s (sha256 `91f6dd49...`); main adds 30 lines (the media-line change) to the file. - - The deployed web app is `/srv/bifrost/app/current` at `67072b2`, 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. + - The deployed mouth is `/srv/bifrost/persona-chat/runtime/current`, release `e396305`. Its `persona-matrix-mouth.mjs` is byte-identical to + `e396305`'s (sha256 `91f6dd49...`); main adds 30 lines (the media-line change) to the file. + - The deployed web app is `/srv/bifrost/app/current` at `67072b2`, 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 Huginn kinds are Eureka's:** the opt-in with `audience`, `closeGrants`, the close request as the Mind's input, - hide and unhide, and what the receiver reads to build the projection. This doc names what the receiver needs; it proposes no kinds. -- The consumer of `chat_pending` (heartbeat) is outside this repo, and option B needs it to learn three kinds. -- The credential Ratatoskr presents (section 4) is open, as is how a `mind` answers. -- The mapping of Postgres or `.cc` backups to redaction (section 7a, item 5) is open. -- Heimdall PR #6 (the answer-collector consent doc) is not read; whether guest answers are collected for anything is R4f's - default, "no", unchecked against it. -- A Synapse or Matrix side is not involved: the receiver writes nothing to Matrix. +- **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. +- **How a Mind answers is open.** Bifrost has no `Mind` role; 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. -- 2.47.3 From aa8c28a8737b8efe2ef9f18f795a1eb3a2bec7e4 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 14:39:15 +0000 Subject: [PATCH 4/4] docs: feedback receiver: re-verify the mouth facts against main 55b74275 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 --- docs/feedback-receiver.md | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/feedback-receiver.md b/docs/feedback-receiver.md index b95bc03..d022e33 100644 --- a/docs/feedback-receiver.md +++ b/docs/feedback-receiver.md @@ -6,7 +6,7 @@ Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:ch 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): `tools/persona-matrix-mouth.mjs`, `tools/persona-chat-documents.mjs`, +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 @@ -32,23 +32,23 @@ authority and never becomes `answerer`. Verified in `tools/persona-matrix-mouth.mjs` and `tools/persona-chat-documents.mjs`: -- **Pending is Matrix mail only, held in memory.** `pending` (mouth l.112) is filled in one place, `syncOnce` - (l.303), from Matrix `/sync` events. No function accepts a delivery from elsewhere. +- **Pending is Matrix mail only, held in memory.** `pending` (mouth l.123) is filled in one place, `syncOnce` + (l.285), from Matrix `/sync` events. No function accepts a delivery from elsewhere. - **A request is non-consuming; an ack consumes.** `chat_pending_request` carries `afterSeq`; the mouth answers - `pendingAfter(afterSeq)` (l.331-333) paged to 256 KiB (`pageOf`, l.431-447). `chat_ack` carries `throughSeq`; `ack` - drops every delivery at or below it (l.326-329). The endpoint accepts exactly these kinds (l.468) and answers a + `pendingAfter(afterSeq)` (l.383) paged to 256 KiB (`pageOf`, l.483-498). `chat_ack` carries `throughSeq`; `ack` + drops 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 typed `UnknownPersona` refusal. -- **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.316-324), so a - restart before the ack re-delivers from Matrix. `seqCounter` is persisted whenever a round made deliveries (l.311), so +- **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. `seqCounter` is persisted whenever a round made deliveries (l.346), so sequence numbers never repeat across a restart. -- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.45) `syncOnce` stops reading (l.263). A truncated round leaves the - cursor where it was (l.304). -- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.437); the caller's +- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.53) `syncOnce` stops 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.** `pageOf` logs 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.** `startChatEndpoint` refuses a non-loopback host (l.453). It is another process +- **The endpoint is loopback RUDP.** `startChatEndpoint` refuses 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.** `deliveryTuple` and `parseDelivery` are exactly eleven fields, with no source or - kind field (`persona-chat-documents.mjs` l.117-135). A delivery cannot say it is not a Matrix message. +- **The delivery tuple is closed.** `deliveryTuple` and `parseDelivery` are exactly twelve fields (the twelfth, added since `bf8ff62`, is an optional stored-image reference), with no source or + kind field (`persona-chat-documents.mjs` l.120-160). The pending answer is now `gamecult.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. @@ -64,12 +64,16 @@ PR #24 section 3 names the carrier: a new delivery document, `gamecult.persona.f the mouth's pending/ack path. Source adds one constraint on how it rides: - **It cannot be a variant inside `chat_pending`.** `parseDelivery` accepts exactly eleven fields - (`persona-chat-documents.mjs` l.125-126) and `pageOf` encodes each delivery with `deliveryTuple` (l.117), so a different + (`persona-chat-documents.mjs` l.149, twelve fields) and `pageOf` encodes each delivery with `deliveryTuple` (l.120), 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:** 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 and `MAX_PENDING` are untouched. The endpoint's closed kind list - (l.468) grows by three. Confidence medium: the consumer, which I cannot see, must learn them. + (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 chat `ack` now has Matrix side effects: it posts the Persona's public `m.read` + receipt per room (`readUpTo`, l.372-381) and releases stored images (`images?.acked`). 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 (`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 `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). @@ -232,7 +236,7 @@ receiver's records and kinds use `feedback_page` and `feedback_delivery`, not `p 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`, release `e396305`. Its `persona-matrix-mouth.mjs` is byte-identical to - `e396305`'s (sha256 `91f6dd49...`); main adds 30 lines (the media-line change) to the file. + `e396305`'s (sha256 `91f6dd49...`); main has since added the image path (cut A), a public `m.read` receipt 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/current` at `67072b2`, 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. -- 2.47.3