Docs: desk feed visibility is Huginn's door policy; cite the written door spec #69

Open
heimdall wants to merge 3 commits from heimdall/desk-docs-door-spec into main
2 changed files with 14 additions and 14 deletions

View file

@ -22,7 +22,7 @@ Ratatoskr's Desk row per Mind is empty "until Bifrost feeds it" (FACT `src/app/f
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:
1. Token: PR #37's second public PKCE client (Ratatoskr) and `BifrostBearer` OpenIddict validation (PR #37 section 5, handler `IDeskCredential` production impl, the credential port; H4 in the desk cut map, a label used only there). Request scope `desk.read` only, audience `bifrost-desk`. No `desk.answer`, no `votes.*`.
1. Token: PR #37's second public PKCE client (Ratatoskr) and OpenIddict validation as a non-default scheme (built as `OidcDeskCredential` on OpenIddict's fixed scheme name `OpenIddict.Validation.AspNetCore`; the label `BifrostBearer` in PR #37 section 5 names the role, not the scheme string; handler `IDeskCredential` production impl, the credential port; H4 in the desk cut map, a label used only there). Request scope `desk.read` only, audience `bifrost-desk`. No `desk.answer`, no `votes.*`.
2. Judgment: unchanged `DeskEndpoints.HandleAsync`: audience, scope, then `OidcEndpoints.ActiveMemberIdAsync(db, sub)` re-read every request (non-Active gets 403 `forbidden`).
3. CORS: existing `DeskCors` (exactly `Desk: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).
@ -70,11 +70,11 @@ Already ruled: the answer-binding D1 above, visibility ruling c (any `desk.read`
## 8. Privacy: what a member may see per Mind
- **Filter owner: Huginn.** The door call carries the viewer (`subject`, `authority`, that Mind's `deciders`); Huginn projects "for this viewer". Bifrost REQUIRES of the door that the list wire carry only cards this member may see; the policy and its wording are Eureka's to write in the door spec (not yet written, so nothing here quotes it; the intended policy is that a card is hidden unless the door says a member may see it). 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 three new acts are the per-request Active check, the `desk.read` bearer, and answerer masking on what the door returns (below), beside two already built: it nils every `note` for 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).
- **Filter owner: Huginn.** The list and read calls carry the viewer's `subject` and that Mind's `deciders` (no `authority`; an answer carries it); Huginn projects "for this viewer". The policy is in Huginn's `docs/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 as `fork_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 the `desk.read` bearer (below), beside two already built: it nils every `note` for 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`, and `raised_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 Bifrost `UserAccount.Id`) to any `desk.read` holder; ruling c permits cards and outcomes, but a member list of who answered what is more than a feed needs. The feed does not need `record` at all. Decided: Bifrost masks `answerer` for what it serves (small, consistent with `DeskNotes`) until a record-free list exists.
- **Content privacy:** a fork's `context` and `title` are 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 and masks answerer ids. The audience policy is Eureka's to put in the door spec, 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.
- **Answerer ids (X3, closed by the steward in PR #37 section 14):** `ForkEntry.record.answers[]` carries each answerer's account id (a Bifrost `UserAccount.Id`) to any `desk.read` holder; ruling c permits cards and outcomes, but a member list of who answered what is more than a feed needs. The feed does not need `record` at all. SUPERSEDED by Eureka's ruling `eureka-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 nils `note` for 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 `context` and `title` are 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
@ -84,7 +84,7 @@ Bearer unjudgeable: 503 `unavailable` (not a refusal of the caller). Not Active:
1. Ratatoskr, against a stubbed list: a `ForkList` fixture from the vendored vectors renders the right rows and count; a 503 renders "unavailable", not an empty desk; `open_count` 0 with an empty page renders "none open".
2. Bifrost, `DeskWebApplicationFactory` + `FakeDeskDoor`: a token with `desk.read` lists (200); no token 401; wrong audience 401; a `desk.read`-only token POSTing an answer is 403 `insufficient_scope` with the door never called; a non-Active subject is 403 with the door never called; a cookie alone gets 401.
3. Privacy: a non-decider's response has every `note` nil (key present); every `answerer` of a non-decider is masked; a decider keeps both; 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.
3. Privacy: a non-decider's response has every `note` nil (key present); Bifrost masks no `answerer` (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.
4. Hash: a row's `options_hash` equals the vector value, and Ratatoskr's `optionsHash.ts` recomputed over the rendered title/context/options matches it (pins D1 end to end).
5. Probes owed by PR #37 section 11 (items 1, 3, 5): public client exchange, audience+scope enforced, bearer on `/App` still refused.
6. Door contract: a `UnixSocketDeskDoor` against a Huginn double returns `Listed`, `NotListed`, `ForkTooLarge`, and an exception maps to 503.
@ -99,7 +99,7 @@ Bearer unjudgeable: 503 `unavailable` (not a refusal of the caller). Not Active:
| 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 | ~15 |
| 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 |
@ -113,5 +113,5 @@ Subtraction: no new route, no store, no migration, no DTO type beyond one field.
- **X1** (CultLib, ruled 2026-11-11, Libby): `raised_at` is 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_at` is out of `options_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 masking (X3, steward, PR #37 section 14); PR #37 D1-D7 (section 14); card visibility (the Huginn door's policy, in its spec; Bifrost holds no audience rule).
- Owed by others: Eureka owns the list/read door wire, Huginn's emission of `raised_at`, and the visibility policy; this feed is blocked on them and on PR #37's bearer.
- **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, ruling `desk-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.md` in 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 for `UnixSocketDeskDoor`.

View file

@ -39,7 +39,7 @@ Audience: access tokens for these scopes carry audience `bifrost-votes` (INFEREN
## 5. The bearer surface
- Register OpenIddict validation (local server) as a **named, non-default** authentication scheme `BifrostBearer`. FACT the default scheme is Cookie (`Program.cs:189-200`), so every existing endpoint stays cookie-only and a bearer token cannot reach `/App`, `/api/sealed-motions` or `/eve` (a test pins it).
- Register OpenIddict validation (local server) as a **named, non-default** authentication scheme `BifrostBearer` (the desk's credential is built on OpenIddict's fixed scheme name `OpenIddict.Validation.AspNetCore`; keep that name, and read `BifrostBearer` as the role). FACT the default scheme is Cookie (`Program.cs:189-200`), so every existing endpoint stays cookie-only and a bearer token cannot reach `/App`, `/api/sealed-motions` or `/eve` (a test pins it).
- New group `app.MapGroup("/api/votes")` with a policy `ActiveMember + scope + audience` whose `AuthenticationSchemes` is `BifrostBearer` only. A request that also carries the Bifrost session cookie is judged on the bearer alone, so the surface has no ambient credential: this is what removes CSRF from it (section 9).
- **Actor resolution is a new job.** FACT `CurrentBifrostActorAccessor` builds the actor from cookie claims (`BifrostIdentity`, `HeimdallAccountId`, GitHub `NameIdentifier`) and returns Anonymous when none are present (`Features/Shared/CurrentBifrostActorAccessor.cs:33-46`). A bearer principal has only `sub`, so existing services would see an anonymous actor. FACT `OidcEndpoints.ActiveMemberIdAsync` already resolves by `sub` and re-reads membership (`OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`; e8e8fcc). Proposal: one accessor path "principal with `sub` and `BifrostBearer` → load `UserAccount` + `Membership` by id", reusing that reading, and the `/api/votes` handlers call the existing services (`MotionGovernanceService.CastVoteAsync`, `SealedBallotService.CommitAsync/RevealAsync`) with that actor. No second vote implementation.
- Endpoints (names are a proposal, shapes belong to the cut spec): `GET /api/votes/motions`, `GET /api/votes/motions/{id}`, `PUT /api/votes/motions/{id}/ballot` `{choice, comment?}` (Standard), `POST …/commit` and `…/reveal` for sealed, `GET …/result`. FACT today's Standard cast returns silently when the motion is not open and throws on an unknown id (`MotionGovernanceService.cs:57-61`); the API wraps it with typed errors (404, 409 not open) rather than copying that.
@ -50,7 +50,7 @@ Audience: access tokens for these scopes carry audience `bifrost-votes` (INFEREN
1. Ratatoskr voting module starts login: generates `state`, `nonce`, PKCE verifier/challenge (S256), stores them in `sessionStorage` for the one tab, navigates to `GET /connect/authorize` with `client_id`, exact `redirect_uri`, `scope=openid profile votes.read votes.cast`, `response_type=code`.
2. FACT authorize without a session challenges to `/auth/sign-in` and resumes via a short `request_uri` (`OidcEndpoints.cs:44-51`); with a session it admits any account that is not Suspended and has an identity (chat tier; membership is neither required nor written), otherwise `access_denied` (`OidcEndpoints.cs:53-70`). So the sign-in leg does not prove Active: the `/api/votes` and `/api/desk` surfaces re-check Active on every request, which is what makes this safe. Heimdall sign-in and the Bifrost session happen on the Bifrost host exactly as for MAS.
3. Without a live notice receipt, Bifrost first redirects to its training-notice consent page and resumes the parked request (`OidcEndpoints.cs:99-102`, e8e8fcc; not a grant to the client). Then, for cast scopes only, the per-client grant screen (section 7, D1); `desk.read` alone has none. Then 302 to `https://chat.gamecult.org/<redirect path>?code=…&state=…`.
3. For a chat token without a live notice receipt (a desk or vote token needs none: the receipt gates the chat token only, Meta's ruling 2026-11-11), Bifrost first redirects to its training-notice consent page and resumes the parked request (`OidcEndpoints.cs:99-102`, e8e8fcc; not a grant to the client). Then, for cast scopes only, the per-client grant screen (section 7, D1); `desk.read` alone has none. Then 302 to `https://chat.gamecult.org/<redirect path>?code=…&state=…`.
4. The module checks `state`, immediately removes `code` and `state` from the address bar (`history.replaceState`), POSTs `/connect/token` (form: `grant_type=authorization_code`, `code`, `code_verifier`, `client_id`, `redirect_uri`) cross-origin. FACT the token endpoint re-reads the account and refuses with `invalid_grant` when `ChatRefusalAsync` says so: account missing, Suspended, or no live notice receipt (`OidcEndpoints.cs:107-120`, `ChatRefusalAsync` `:151`). It does not test Active; the per-request Active check is the new surfaces' own (section 14 D3).
5. The access token lives in a JS variable. Not `localStorage`, not `sessionStorage`, not IndexedDB. A page reload logs the module out of Bifrost votes (not out of chat) and the next vote action restarts step 1; with an existing Bifrost session it is two redirects and no typing.
@ -181,10 +181,10 @@ Decided by the steward of Bifrost (the Persona), recorded by Heimdall (Self). Sc
- Keeps: exit, because removing the configuration section deletes the client and its tokens at next start (once built); no receipts needed beyond the audit rows above.
- Holder: the steward of Bifrost.
**Decided with the feed.** The Desk feed never carries answerer account ids (Bifrost masks `answerer` for what it serves; this closes X3 of `docs/desk-feed-design.md`).
**Decided with the feed.** The Desk feed never carries answerer account ids (the door withholds them from a non-decider, `eureka-substrate:ruling:desk-read-visibility-withheld-ids`; Bifrost masks nothing it cannot read, and this supersedes the Bifrost mask that closed X3 of `docs/desk-feed-design.md`).
**Flags to other stewards.**
- Eureka (stated, not decided): the Huginn door list and read wire, including the audience/visibility policy and its wording in the door spec (not yet written; see the end of this section); the host path of the D5 marker.
- Eureka: the Huginn door list and read wire and its audience policy are written in Huginn's `docs/desk-feed-door.md` (Huginn main 79d954c or later, with golden fixtures); the host path of the D5 marker is still open. The listing gate that implements the audience policy is NOT live on Huginn main (cut `cut-desk-feed-listing`): until it lands, the door returns every showable question to every reader, so no Mind that holds private questions goes behind a live receiver.
- Libby (CultLib steward), ruled 2026-11-11: ForkCard gains `raised_at`, which stays OUT of `options_hash` (that hash keeps its five members: title, context, options, recommendation, depends, so digests are unchanged) and is a REQUIRED card key, an RFC3339 string or nil; the card goes from 16 to 17 keys and its hex changes. This closes X1 and X2 of `docs/desk-feed-design.md` (X2 withdrawn).
**Who sees which card: Huginn's door (Eureka), not Bifrost.** This is Bifrost's requirement of the door, not a quotation of any door spec (none is written yet). Bifrost assumes the door returns only cards this member may see, and applies no audience rule of its own. Eureka owns the policy and its wording in the door spec; the intended policy is that a card is hidden unless the door says a member may see it. The default-audience question (X4 of the desk feed note) is therefore not open for Bifrost and was never a CultLib matter. Bifrost adds three new acts on the desk path: the per-request Active membership check, the `desk.read` bearer (audience `bifrost-desk`), and masking `answerer` ids on what the door returns; they sit beside the two already built (`DeskNotes.Strip` and the configured-Mind 404). Bifrost does NOT: filter by audience, hold any per-card visibility rule, or keep a second copy of the policy (two copies would drift).
**Who sees which card: Huginn's door (Eureka), not Bifrost.** The policy is written in Huginn's `docs/desk-feed-door.md` ("Listing"), and this note holds no copy of it: a fork is shown to a reader who is not on the call's `deciders` only while a listing record for it is in force; a decider sees every showable fork; a hidden fork reads as `fork_unknown`, with the same bytes as a fork that does not exist. Bifrost applies no audience rule of its own: it relays exactly what the door returns. Reads carry `subject` and `deciders` (no `authority`; that is only on an answer), so anyone who must see unlisted forks is put on the Mind's decider list in Bifrost's admin. Bifrost adds three acts on the desk path: the per-request Active membership check, the `desk.read` bearer (audience `bifrost-desk`), and nilling notes for a non-decider; they sit beside the two already built (the note strip for non-deciders and the configured-Mind 404). Bifrost does NOT filter by audience, hold any per-card visibility rule, or keep a second copy of the policy (two copies would drift). The card is opaque to Bifrost (its `raised_at` key and order are being re-pinned in CultLib), so it is relayed as the door sent it.