20 KiB
Desk feed: per-Mind open forks for a signed-in member (design, no code)
Status: proposal, 2026-11-11, Heimdall (Imagination pass) against origin/main e8e8fcc. Docs only; nothing here is built, ruled or deployed.
Labels: FACT = read at the cited place. INFERENCE = my reading. UNREACHED = I could not read it.
Reads: src/Bifrost.Web/Features/Desk/* (main), docs/ratatoskr-voting-auth.md (PR #37, e172869), /opt/commons/handoff/fork-desk-answer-binding-20261110.md, Ratatoskr fbec032 (src/app/features/desk/*), tools/persona-matrix-mouth.mjs, docs/bridge.md. UNREACHED: CultLib fork-card.md (no checkout here; I rely on the Desk code that pins it at de8a062a), Huginn's desk_wire.rs (PR #31 is merged; the list/read half of the door wire is not defined in anything I read).
1. What is asked, and the finding that shapes it
Ratatoskr's Desk row per Mind is empty "until Bifrost feeds it" (FACT src/app/features/desk/source.ts: deskFor(mind): ForkList | undefined, slice 2 "replaces this with Bifrost's list reader"). The page already decodes gamecult.fork.list.v0 (forkContract.ts).
Finding: the feed route is already built. GET /api/desk/{mind}/forks?status=open&limit=N is on main (PR #40, hardened #42-#45): list, per-Mind socket door, open_count, stamp, notes stripped for non-deciders. It serves nothing today because its two ports have no production implementation (DeskPorts.cs: IDeskCredential, IDeskDoor; MapDesk fails startup if either is missing). So the smallest honest feed is not a new endpoint. It is the existing list route, read-only, with its two ports filled and a token that carries only desk.read. A second route would need a second contract, vectors and owner for no gain.
2. The endpoint
GET https://<bifrost>/api/desk/{mind}/forks?status=open&limit=50[&cursor=c<N>], one call per Mind (the client already asks per Mind).limit=0returns counts and stamp only (FACT binding file, route table), usable for a badge.- Response:
gamecult.fork.list.v0(ForkList:mind,stamp,open_count,recorded_count,forks[]ofForkEntry,next_cursor). JSON, and why: Ratatoskr is a browser client, the xenos boundary; the contract (CultLibfork-card.md, Transport) fixes JSON at the HTTP edge with every key written, and the documents are typed C# records (DeskDocuments.cs) mirroring CultLib's. Bifrost to Huginn is the typed door, not JSON. - The row the UI needs is derived by the client from each entry:
card.fork_id,card.title, age (section 5), and the count isopen_count. The page "derives nothing" (FACTforkContract.tsheader), so it takesopen_countas the one count. - Not offered: any POST (
/api/desk/{mind}/answersstays unreachable to this client because its token has nodesk.answer, so it is refused 403insufficient_scopeat step 4 of the pipeline, before the body), any vote, any write.
3. Auth: what a signed-in member can call today, and what the feed reuses
FACT today: a signed-in member holds only the cookie __Host-bifrost_session; the default scheme is Cookie (Program.cs:189-200); no AddValidation exists, so no access token authenticates any /api/* route; the desk handler is .AllowAnonymous() and judges its own bearer (DeskEndpoints.cs, MapDesk), and the cookie scheme cannot authenticate /api/desk/*. A signed-in member can call nothing on the desk today. Chat.gamecult.org never holds the __Host- cookie, so "the Heimdall session" cannot be sent by the browser to Bifrost from chat.
So the session is used as PR #37 uses it: the sign-in leg of an OIDC code+PKCE flow (/connect/authorize resumes on the Heimdall-backed session, FACT OidcEndpoints.cs:44-70, which admits any account that is not Suspended and has an identity: membership is neither required nor written, so the sign-in leg does not prove Active. It works for desk.read only because the desk re-checks Active on every request) mints a short access token. Reuse, exactly:
- Token: PR #37's second public PKCE client (Ratatoskr) and OpenIddict validation as a non-default scheme (built as
OidcDeskCredentialon OpenIddict's fixed scheme nameOpenIddict.Validation.AspNetCore; the labelBifrostBearerin PR #37 section 5 names the role, not the scheme string; handlerIDeskCredentialproduction impl, the credential port; H4 in the desk cut map, a label used only there). Request scopedesk.readonly, audiencebifrost-desk. Nodesk.answer, novotes.*. - Judgment: unchanged
DeskEndpoints.HandleAsync: audience, scope, thenOidcEndpoints.ActiveMemberIdAsync(db, sub)re-read every request (non-Active gets 403forbidden). - CORS: existing
DeskCors(exactlyDesk:AllowedOrigin, GET and POST, no credentials). Rejected: a cookie-authenticated same-origin JSON route (works for a Bifrost page, not for the chat origin; a second auth path on the same data), and letting chat present its Matrix/MAS token (Bifrost does not validate it; INFERENCE per PR #37 section 1).
4. Where the data comes from (compare, recommend one)
Huginn is the truth. A fork is a question record with options, a derived status, deciders and a server-computed options_hash (FACT binding file, "Cards are a read-only projection"). Bifrost holds no fork store, no cache, no hash (FACT cut map authority map). Everything else is a cache or an unrelated plane. Options:
- (a) A small Huginn endpoint Bifrost calls. This is already the plan of record, not a new thing:
IDeskDoor.ListAsync(socket, context, request)over the per-Mind peer-checked unix socket,context= {Mind, authority, subject, deciders} built by Bifrost alone (DeskPorts.cs). Huginn returns the list "as it projects it for this viewer". - (b) A file the persona mouth tails or writes. FACT the mouth holds one
cursor.ccper Persona and it stores only the Matrix/syncpositionnext_batch(persona-matrix-mouth.mjs~:491, :906-912).chat_pendingis Matrix deliveries to a Persona over loopback RUDP (credential-derived connection id), served to Huginn, which is the client; the mouth reads Matrix, not Huginn. It holds no fork, no question and no Huginn client. A file route means a new writer inside the Matrix actuator that copies private records out of the mind into the chat plane, a second owner of "what a member may see", a Node process rewriting Rust's projection, and staleness equal to its write period. The mouth would gain a job it was split away from. - (c) Bifrost reads Huginn directly with a CultNet selection (what
huginn open <campaign>/huginn blocked <spec>do). Rejected: those return every in-force question and follow-up of a campaign (epiphany.pipeline.question.v2,follow_up.v2), most of which are not forks. Bifrost would need a general read of the mind and would decide fork-hood and visibility itself. Also Bifrost has no Huginn client at all (FACT: no CultNet/RUDP/unix-socket client in C#;UnixDomainSocketEndPointappears nowhere).
Recommend (a), as the existing desk door, list request only. Reasons:
- Auth: the door is peer-credential checked (the receiver's uid); Bifrost passes the verified subject; the member's token never reaches Huginn.
- Staleness: none added. A live read,
Cache-Control: no-store(FACT), with Huginn'sstamp(s<N>, same stamp same state) as the change signal; a client polls, orlimit=0for the cheap probe. Bifrost may cache by stamp later; it need not. - Failure: per Mind and per call. A dead socket, timeout or unknown outcome is 503
unavailablewithRetry-After(FACTWriteUnavailableAsync); the other Minds' rows are unaffected because the client asks per Mind. The mouth being down is irrelevant. Nothing is served from a stale copy that could mislead. - Mouth overlap: none, which is the point. Chat plane (mouth) and fork plane (door) stay two owners.
- Not yet existing:
UnixSocketDeskDoor(the door port's production impl; H5 in the desk cut map) and the Huginn side of the list wire. Eureka owns that wire (binding file section 5, "The door wire is Eureka's"). I found no definition of the list/read request bytes; this note does not invent one.
5. Meaning of "open" and "age"
- open = the card's
statusisopenas Huginn derives it (ForkCard.status;?status=openfilter). Bifrost does not decide it. The count isopen_countof the same response, so the number and the rows come from one stamp.recorded_count(closed with a ruling) is not part of the feed. - age = now minus when the question was raised. The contract cannot give this today. FACT
ForkCardhasorder(a position),raised_in(a reference) and no timestamp; answers carryanswered_at/received_atbut an unanswered fork has none. Ruled 2026-11-11 by Libby (CultLib steward): the card gainsraised_at, a REQUIRED key whose value is an RFC3339 string or nil (the card goes from 16 to 17 keys; its hex changes). Huginn emits the question's admission time, or nil when it has none. Bifrost's record gains one field that must accept string or nil. Age derives fromraised_atand is computed by the client; Bifrost computes nothing; nil means age unknown and the row shows no age. Until the contract lands the feed ships without age (the fallback loses nothing else).
6. D1 (options_hash) and the feed
Two different "D1"s exist: the ruling cited for this feed is the fork-desk answer-binding D1 (2026-11-10: options_hash covers title, context, options with ids, recommendation and depends; fork_id is bound beside it). PR #37 also has a D1 (consent). This section is the first; section 7 the second.
- Feed rows carry the card as Huginn returns it,
options_hashincluded. Bifrost never computes or corrects a hash (FACT binding file). A row's identity is(fork_id, options_hash): an edit to a shown title changes the hash, so the row changes with it. - The feed shows title, which the hash covers, so feed and detail cannot disagree about it.
- Age is not decision content. Ruled by Libby (X2, my earlier "inside the hash" option is withdrawn):
raised_atstays OUT ofoptions_hash, which keeps exactly five members (title, context, options, recommendation, depends), so the hash digests and Void'soptionsHash.tsvectors do not change. Only the card's own bytes change, by the added key.
7. PR #37 decisions, what this feed needs
| id | decision (brief) | for the feed |
|---|---|---|
| D1 | explicit consent for votes.cast, or first-party implicit |
not needed: a desk.read grant casts and changes nothing; implicit read consent (PR #37 section 7) |
| D2 | re-authentication at cast for high-stakes motions | not needed: no cast |
| D3 | token lifetime | decided (PR #37 section 14 D3): no refresh token; desk.read-only requests get a 60-minute access token, so a polling feed needs no redirect per 10 minutes. Per-audience lifetime is not built; probe 7 is owed |
| D4 | where a member lists and revokes grants | not needed while the grant is read-only; revisit with desk.answer |
| D5 | runtime brake | decided (section 14 D5): the existing marker-file brake, held by the steward. It is NOT per-client and does not cover /api/desk until the guard's matching is extended; that and the desk-only deployment are the code PR's to settle |
| D6 | rate limits | decided (section 14 D6): 120 requests/min per account for desk.read, in configuration. A 429 has no refusal code in the contract (Q3 of the desk cut map): not decided there |
| D7 | order against the MAS rollout | decided (section 14 D7): independent; the Ratatoskr client is seeded only when configured. The feed still needs Bifrost OIDC live, which it is not (PR #37 section 1) |
Already ruled: the answer-binding D1 above, visibility ruling c (any desk.read holder sees cards and outcomes; notes only for deciders), read with Bifrost's requirement that the door returns only cards this member may see, D2/answer-binding (decider list authored in Bifrost admin, Mind read-only).
8. Privacy: what a member may see per Mind
- Filter owner: Huginn. The list and read calls carry the viewer's
subjectand that Mind'sdeciders(noauthority; an answer carries it); Huginn projects "for this viewer". The policy is in Huginn'sdocs/desk-feed-door.md("Listing", Huginn main 79d954c or later) and is not copied here: not listed unless a listing record is in force, deciders see every showable fork, a hidden fork reads asfork_unknown. That gate is not live on Huginn main yet (cut-desk-feed-listing), so no Mind that holds private questions goes behind a live receiver before it lands. Bifrost holds NO audience rule: no filter on which forks appear, no per-card visibility rule, no copy of the policy (two copies would drift). Bifrost's two new acts are the per-request Active check and thedesk.readbearer (below), beside two already built: it nils everynotefor a non-decider (DeskNotes.Strip), and it resolves{mind}only against the configured list (unknown Mind and unknown fork return the same 404 bytes, so Minds are not probeable). - Fields safe for a member:
fork_id,title,options,recommendation,depends,status,order,options_hash, counts,stamp, andraised_at(string or nil). Not safe, and not in the feed:note(stripped), the decider list beyond ids already on the card, anything from the mind that is not a fork card (follow-ups, findings, research, persona state, room text). - Answerer ids (X3, closed by the steward in PR #37 section 14):
ForkEntry.record.answers[]carries each answerer's account id (a BifrostUserAccount.Id) to anydesk.readholder; ruling c permits cards and outcomes, but a member list of who answered what is more than a feed needs. The feed does not needrecordat all. SUPERSEDED by Eureka's rulingeureka-substrate:ruling:desk-read-visibility-withheld-ids(Huginn #56, 79d954c): the door, not Bifrost, withholds decider ids and answerer ids from a non-decider and returns counts. Bifrost masks nothing it cannot read (the card is opaque to it) and keeps no answerer mask; it still nilsnotefor a non-decider. Until Huginn's cut and the CultLib change land, the door sends ids whole, so no receiver goes live on a Mind before then. - Content privacy: a fork's
contextandtitleare Mind-authored prose and may name people or private matter. The door is the only gate: Bifrost requires the list wire to carry only cards the member may see, passes them through; the door withholds answerer and decider ids. The audience policy is Huginn's door spec's, not here. The feed ships no free text Bifrost wrote. - Consent and receipts: reading creates no member record, so no receipt and no consent object; the member's act is the token grant (read-only, implicit). Answer receipts and the append-only record belong to the answer path and stay out. Audit: log client id and
sub, not the response. The training-notice receipt gates the chat token only (Meta's ruling, 2026-11-11): a desk token issues for a member with no receipt.
9. Failure modes
Bearer unjudgeable: 503 unavailable (not a refusal of the caller). Not Active: 403. Unknown Mind: 404 fork_unknown. Oversize fork: 422 fork_too_large with a cursor to continue (FACT #42/#44; the client must handle it, not render nothing). Token expired: 401; the client restarts the redirect (D3). Door down: 503; the Desk row shows "unavailable, retry", never an empty list (an empty list must mean zero open forks). Huginn emits a decider list outside 1..64 for a configured Mind: 503 (config fault, FACT). Ratatoskr's source is synchronous; it needs a fetch-and-hold layer that keeps the last good stamp and marks it stale, which is Void's.
10. Tests that would prove it (consumer side)
- Ratatoskr, against a stubbed list: a
ForkListfixture from the vendored vectors renders the right rows and count; a 503 renders "unavailable", not an empty desk;open_count0 with an empty page renders "none open". - Bifrost,
DeskWebApplicationFactory+FakeDeskDoor: a token withdesk.readlists (200); no token 401; wrong audience 401; adesk.read-only token POSTing an answer is 403insufficient_scopewith the door never called; a non-Active subject is 403 with the door never called; a cookie alone gets 401. - Privacy: a non-decider's response has every
notenil (key present); Bifrost masks noanswerer(Huginn's door withholds ids); a decider keeps notes; an unknown Mind and an unknown fork return identical bytes; Bifrost relays exactly the cards the door returns (a door double returning N cards yields N rows, none dropped or added) and applies no audience filter of its own. - Hash: a row's
options_hashequals the vector value, and Ratatoskr'soptionsHash.tsrecomputed over the rendered title/context/options matches it (pins D1 end to end). - Probes owed by PR #37 section 11 (items 1, 3, 5): public client exchange, audience+scope enforced, bearer on
/Appstill refused. - Door contract: a
UnixSocketDeskDooragainst a Huginn double returnsListed,NotListed,ForkTooLarge, and an exception maps to 503.
11. Size estimate (lines, include tests)
| file | lines |
|---|---|
Bifrost Features/Desk/UnixSocketDeskDoor.cs (list only; read/answer added by the answer cut) |
~180 |
Bifrost Features/Oidc/* bearer validation, desk.read scope, audience, second client seed (shared with PR #37) |
~150 |
Bifrost IDeskCredential production impl (validates the bearer, returns DeskToken) + wiring in Program.cs; no audience or visibility logic |
~60 |
| Bifrost sweep revoking the Ratatoskr/desk client's authorizations for subjects failing the chat-tier test (PR #37 section 8; derived from membership, no outbound POST) | ~80 |
Bifrost OidcGuard match extended to /api/desk (D5 brake) and the desk-only deployment settlement |
~30 |
Bifrost per-audience token lifetime at authorize (D3: 60 minutes for desk.read-only; probe 7 first) |
~30 |
Bifrost DeskNotes extension: mask answerer for non-deciders (dropped: the door withholds ids) |
0 |
Bifrost re-vendor tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json (17-key card, new CultLib commit) + DeskVectors.cs:9,11 pin + ForkCard 17th member RaisedAt, string or nil (DeskDocuments.cs:64-80) + DeskDocumentTests/DeskScenarioTests updates |
~60 |
CultLib fork-card.md + schema + card vectors for raised_at (17-key card; options_hash vectors unchanged) |
~40 |
Huginn: list wire handler and raised_at (Eureka's) |
not estimated |
| Ratatoskr list reader + token redirect + stale holder (Void's) | ~250 |
| Bifrost tests (items 2, 3, 6) | ~250 |
This doc and docs/oidc-issuer.md note |
~40 |
Subtraction: no new route, no store, no migration, no DTO type beyond one field. The two shared ports (door, bearer) are the cost, and the answer path needs them anyway.
12. Open decisions
- X1 (CultLib, ruled 2026-11-11, Libby):
raised_atis a required card key, RFC3339 string or nil; 16 to 17 keys. Huginn's emission of it is Eureka's, still to build. - X2 (withdrawn):
raised_atis out ofoptions_hash, digests unchanged (Libby). - Nothing in this note is open for Bifrost's own design. Decided and recorded elsewhere:
raised_at(Libby, CultLib ruling 2026-11-11, X1/X2 closed); answerer ids (X3: withheld by Huginn's door, rulingdesk-read-visibility-withheld-ids, supersedes the Bifrost mask); PR #37 D1-D7 (section 14); card visibility (the Huginn door's policy,docs/desk-feed-door.mdin Huginn; Bifrost holds no audience rule). - Owed by others: Eureka owns Huginn's emission of
raised_at, the listing gate (cut-desk-feed-listing) and the ops root-run that binds the sockets; the door wire is on Huginn main and its golden fixtures are the contract forUnixSocketDeskDoor.