Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07a07e857 | ||
|
|
9394be3dd3 | ||
|
|
17c70d290d | ||
|
|
01cc741278 |
6 changed files with 1369 additions and 7 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -105,7 +105,7 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp
|
|||
| call | route (proposal) | request | response | token |
|
||||
|---|---|---|---|---|
|
||||
| 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` |
|
||||
| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) | `desk.read` |
|
||||
| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) or `fork_too_large` (422) | `desk.read` |
|
||||
| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` |
|
||||
|
||||
**The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`).
|
||||
|
|
@ -118,15 +118,15 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp
|
|||
|
||||
Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled).
|
||||
|
||||
**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document) and `request_id_conflict` are added to the door refusals.
|
||||
**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list and read; see List and Read). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`, each carrying the client response's bytes). The `unavailable` text's "same request_id" retry hint is for answers; reads and lists carry no request_id..
|
||||
|
||||
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
|
||||
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c<order of the last entry returned>` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order` (`fork_too_large: fork <fork_id> (order <n>) alone is past the response budget`). The client continues the list with cursor `c<n>`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c<n>` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
|
||||
|
||||
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
|
||||
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and carries no order and no cursor, because there is nothing to continue with: `fork_too_large: fork <fork_id> alone is past the response budget` (vector `transport.readTooLarge`; the cap is in no vector). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
|
||||
|
||||
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
|
||||
|
||||
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by <first fork id> and <second fork id>`); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
|
||||
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by <first fork id> and <second fork id>`); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `readTooLarge` (one case: a read whose entry is past the cap, refused `fork_too_large` 422 with the read-form text and its bytes); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
|
||||
|
||||
**Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service.
|
||||
|
||||
|
|
@ -193,3 +193,5 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th
|
|||
9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.)
|
||||
10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`).
|
||||
11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it.
|
||||
12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it.
|
||||
13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`.
|
||||
|
|
|
|||
|
|
@ -714,6 +714,7 @@
|
|||
"body_unknown_field",
|
||||
"body_invalid",
|
||||
"fork_unknown",
|
||||
"fork_too_large",
|
||||
"insufficient_scope",
|
||||
"forbidden",
|
||||
"request_invalid",
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ test("fork card: documents validate and encode to the vector bytes", () => {
|
|||
assert.ok(check(forkSchema, "refusal", v.message), v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
assert.equal(fork.doorRefusals.find((r: { name: string }) => r.name === "fork_too_large").httpStatus, 422);
|
||||
});
|
||||
|
||||
test("fork card: every negative is refused by the schema or a stated rule", () => {
|
||||
|
|
@ -504,6 +505,72 @@ test("fork transport: order 0 is legal, no cursor names it, and the page ending
|
|||
assert.equal(check(forkSchema, "cursor", "c0"), false);
|
||||
});
|
||||
|
||||
test("fork transport: a page may stop short of limit, and a first entry past the budget is fork_too_large (422) continued below its order", () => {
|
||||
const sc = fork.transport.listShortPages;
|
||||
assert.deepEqual(sc.map((x: any) => x.name), ["a_short_page_resumes_below_its_last_entry", "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "fork_too_large_on_order_zero_leaves_nothing_below"]);
|
||||
assert.ok((forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum.includes("fork_too_large"));
|
||||
const covered: number[] = [];
|
||||
for (const s of sc) {
|
||||
let last: string | null | undefined = undefined;
|
||||
s.steps.forEach((step: any, i: number) => {
|
||||
if (last !== undefined) assert.equal(step.request.cursor, last, `${s.name}: the step follows the cursor or the refused order`);
|
||||
const full = judgeList(s.entries, s.ordinal, step.request);
|
||||
if (step.response !== undefined) {
|
||||
const r = step.response as any, n = r.forks.length;
|
||||
assert.ok(check(forkSchema, "list", r), s.name);
|
||||
assert.equal(hexOf(r), step.responseHex, s.name);
|
||||
assert.deepEqual(r.forks, full.forks.slice(0, n), `${s.name}: a prefix of the unbudgeted page`);
|
||||
assert.ok(n >= 1, s.name);
|
||||
assert.equal(r.next_cursor, full.next_cursor !== null || n < full.forks.length ? `c${r.forks[n - 1].card.order}` : null, s.name);
|
||||
assert.equal(r.stamp, full.stamp); assert.equal(r.open_count, full.open_count); assert.equal(r.recorded_count, full.recorded_count);
|
||||
covered.push(...r.forks.map((f: any) => f.card.order));
|
||||
last = r.next_cursor;
|
||||
} else {
|
||||
const r = step.refusal, first = full.forks[0].card as any;
|
||||
assert.ok(check(forkSchema, "refusal", r), s.name);
|
||||
assert.equal(hexOf(r), step.refusalHex, s.name);
|
||||
assert.equal(r.code, "fork_too_large");
|
||||
assert.equal(step.httpStatus, 422);
|
||||
assert.equal(step.fork_id, first.fork_id); assert.equal(step.order, first.order);
|
||||
assert.equal(r.error, `fork_too_large: fork ${first.fork_id} (order ${first.order}) alone is past the response budget`);
|
||||
assert.ok(step.request.limit >= 1, "limit 0 never refuses");
|
||||
if (first.order >= 1) { assert.ok(i + 1 < s.steps.length, s.name); last = `c${first.order}`; assert.ok(check(forkSchema, "cursor", last)); }
|
||||
else { assert.equal(i, s.steps.length - 1, `${s.name}: order 0 is the end`); assert.equal(check(forkSchema, "cursor", "c0"), false); last = undefined; }
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(sc[0].steps.map((x: any) => x.response.next_cursor), ["c2", null]);
|
||||
assert.deepEqual(sc[0].steps.map((x: any) => x.response.forks.length), [2, 2]);
|
||||
assert.deepEqual([sc[1].steps[0].order, sc[2].steps[1].order], [3, 0]);
|
||||
assert.deepEqual(covered, [3, 2, 1, 0, 2, 1, 0, 1]);
|
||||
});
|
||||
|
||||
test("fork transport: a read whose entry is past the cap is fork_too_large (422), named by fork id alone", () => {
|
||||
const doorStatus = fork.doorRefusals.find((r: any) => r.name === "fork_too_large").httpStatus;
|
||||
assert.equal(fork.transport.readTooLarge.length, 1);
|
||||
for (const c of fork.transport.readTooLarge) {
|
||||
const held = c.entries.find((e: any) => e.card.fork_id === c.request.fork_id);
|
||||
assert.ok(held !== undefined && check(forkSchema, "readRequest", c.request), c.name);
|
||||
assert.ok(check(forkSchema, "refusal", c.refusal), c.name);
|
||||
assert.equal(c.refusal.code, "fork_too_large");
|
||||
assert.equal(c.refusal.error, `fork_too_large: fork ${held.card.fork_id} alone is past the response budget`);
|
||||
assert.ok(!c.refusal.error.includes("order"), "a read names no order");
|
||||
assert.equal(hexOf(c.refusal), c.refusalHex, c.name);
|
||||
assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, doorStatus);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: a door request_id_conflict is a fault on read and list (503 unavailable) and a refusal only on the answer", () => {
|
||||
assert.deepEqual(fork.transport.doorFaults.map((c: any) => c.route), ["read", "list", "answer"]);
|
||||
for (const c of fork.transport.doorFaults) {
|
||||
const source = c.route === "answer" ? fork.doorRefusals.find((r: any) => r.name === c.doorCode) : fork.unavailable[0];
|
||||
assert.equal(c.responseHex, source.messagePackHex, c.name);
|
||||
assert.equal(hexOf(c.response), c.responseHex, c.name);
|
||||
assert.equal(c.httpStatus, source.httpStatus, c.name);
|
||||
assert.ok(check(forkSchema, c.route === "answer" ? "refusal" : "unavailable", c.response), c.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => {
|
||||
const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
|
||||
const seen = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -358,7 +358,7 @@ scen += [
|
|||
sc("R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", CARD, [X1, rf(IL_B, sr("n2"), "bad_retraction"), door(dict(IL_B, retracted=False), sr("n9"), "body_invalid")], "n1"),
|
||||
]
|
||||
REASON_CODES = ["stale_fork", "not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted"]
|
||||
HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400,
|
||||
HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400, "fork_too_large": 422,
|
||||
"stale_fork": 409, "not_a_decider": 403, "fork_closed": 409, "choice_invalid": 400, "bad_retraction": 400, "already_retracted": 409}
|
||||
STORED_REFUSALS = []
|
||||
for reason in REASON_CODES:
|
||||
|
|
@ -422,6 +422,28 @@ LISTS = [
|
|||
{"name": "order_zero_is_legal_but_no_cursor_names_it", "ordinal": 9, "entries": ORDER0, "steps": [lstep(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c1", limit=3))]},
|
||||
{"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]},
|
||||
]
|
||||
# A page may stop short of `limit` when the next entry would pass the receiver's response budget; a list whose first
|
||||
# entry alone passes it is refused `fork_too_large` (422). The budget is not in the vector: a step records what the receiver answered, and the
|
||||
# judges check it against the unbudgeted page (a nonempty strict prefix, resumed below its last entry) or against the entry the page would open with.
|
||||
def too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} (order {c['order']}) alone is past the response budget"}
|
||||
def read_too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} alone is past the response budget"}
|
||||
READ_TOO_LARGE = [{"name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", "ordinal": 9, "entries": ORDER0,
|
||||
"request": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ORDER0[0]["card"]["fork_id"]},
|
||||
"refusal": read_too_large_doc(ORDER0[0]["card"]), "refusalHex": hx(read_too_large_doc(ORDER0[0]["card"])), "httpStatus": HTTP["fork_too_large"], "fork_id": ORDER0[0]["card"]["fork_id"]}]
|
||||
def short_step(entries, ordinal, rq, keep):
|
||||
full = judge_list(entries, ordinal, rq); page = full["forks"][:keep]
|
||||
assert 0 < keep < len(full["forks"])
|
||||
r = dict(full, forks=page, next_cursor=f"c{page[-1]['card']['order']}"); return {"request": rq, "response": r, "responseHex": hx(r)}
|
||||
def large_step(entries, ordinal, rq):
|
||||
c = judge_list(entries, ordinal, rq)["forks"][0]["card"]; r = too_large_doc(c)
|
||||
return {"request": rq, "refusal": r, "refusalHex": hx(r), "httpStatus": HTTP["fork_too_large"], "fork_id": c["fork_id"], "order": c["order"]}
|
||||
SHORT_PAGES = [
|
||||
{"name": "a_short_page_resumes_below_its_last_entry", "ordinal": 9, "entries": ORDER0, "steps": [short_step(ORDER0, 9, lreq(limit=3), 2), lstep(ORDER0, 9, lreq(cursor="c2", limit=3))]},
|
||||
{"name": "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "ordinal": 9, "entries": ORDER0,
|
||||
"steps": [large_step(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c3", limit=3))]},
|
||||
{"name": "fork_too_large_on_order_zero_leaves_nothing_below", "ordinal": 9, "entries": ORDER0[2:],
|
||||
"steps": [short_step(ORDER0[2:], 9, lreq(limit=5), 1), large_step(ORDER0[2:], 9, lreq(cursor="c1", limit=5))]},
|
||||
]
|
||||
DUP_LIST = judge_list(ENTRIES + [fentry(q4b)], 412, lreq(limit=50))
|
||||
assert order_shared(DUP_LIST["forks"]) == ["order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b"]
|
||||
TOK = lambda aud, *sc, active=True: {"audience": aud, "scopes": list(sc), "active": active}
|
||||
|
|
@ -586,7 +608,7 @@ fork = {
|
|||
("no_answerer", "answerer is stamped on every stored answer", dict(CNT, answerer="")),
|
||||
]]},
|
||||
"results": [{"name": r["name"], "httpStatus": r["httpStatus"], "message": r["message"], "messagePackHex": hx(r["message"])} for r in STORED_REFUSALS],
|
||||
"doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL],
|
||||
"doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL] + [{"name": "fork_too_large", "httpStatus": HTTP["fork_too_large"], "message": SHORT_PAGES[1]["steps"][0]["refusal"], "messagePackHex": SHORT_PAGES[1]["steps"][0]["refusalHex"]}],
|
||||
"unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))},
|
||||
{"name": "second_door_down_no_hint", "httpStatus": 503, "message": unavailable_doc(None), "messagePackHex": hx(unavailable_doc(None))}],
|
||||
"rulingCites": cites,
|
||||
|
|
@ -618,6 +640,11 @@ fork = {
|
|||
+ [dict(bad("two_cards_share_one_order", "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", DUP_LIST),
|
||||
messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])],
|
||||
"listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS],
|
||||
"listShortPages": SHORT_PAGES,
|
||||
"readTooLarge": READ_TOO_LARGE,
|
||||
"doorFaults": [dict(c, response=r, responseHex=hx(r)) for c, r in
|
||||
[({"name": f"{x}_door_answers_request_id_conflict_the_client_sees_unavailable", "route": x, "doorCode": "request_id_conflict", "httpStatus": 503}, unavailable_doc(30)) for x in ("read", "list")]
|
||||
+ [({"name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", "httpStatus": HTTP["request_id_conflict"]}, refusal_doc("request_id_conflict"))]],
|
||||
"mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE},
|
||||
"authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(Errors(ForkSchema, "refusal", v!["message"]), Is.Empty, S(v["name"]));
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
Assert.That(Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == "fork_too_large")!["httpStatus"]!.GetValue<int>(), Is.EqualTo(422));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -692,6 +693,89 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create("c0")), Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_AShortPage_AndForkTooLarge422_ContinuedBelowItsOrder()
|
||||
{
|
||||
var sc = Fork["transport"]!["listShortPages"]!.AsArray();
|
||||
Assert.That(sc.Select(x => S(x!["name"])), Is.EqualTo(new[] { "a_short_page_resumes_below_its_last_entry", "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "fork_too_large_on_order_zero_leaves_nothing_below" }));
|
||||
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!;
|
||||
Assert.That(schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S), Does.Contain("fork_too_large"));
|
||||
var covered = new List<long>();
|
||||
foreach (var s in sc)
|
||||
{
|
||||
var entries = s!["entries"]!.AsArray(); var steps = s["steps"]!.AsArray(); string? last = null; var follow = false;
|
||||
for (var i = 0; i < steps.Count; i++)
|
||||
{
|
||||
var step = steps[i]!; var name = S(s["name"]);
|
||||
if (follow) Assert.That(step["request"]!["cursor"] is null ? null : S(step["request"]!["cursor"]), Is.EqualTo(last), name);
|
||||
var full = JudgeList(entries, s["ordinal"]!.GetValue<long>(), step["request"]!);
|
||||
var fullForks = full["forks"]!.AsArray();
|
||||
if (step["response"] is not null)
|
||||
{
|
||||
var r = step["response"]!; var forks = r["forks"]!.AsArray(); var n = forks.Count;
|
||||
Assert.That(Errors(ForkSchema, "list", r), Is.Empty, name);
|
||||
Assert.That(HexOf(r), Is.EqualTo(S(step["responseHex"])), name);
|
||||
Assert.That(n, Is.GreaterThanOrEqualTo(1), name);
|
||||
for (var k = 0; k < n; k++) Assert.That(JsonNode.DeepEquals(forks[k], fullForks[k]), Is.True, $"{name}: a prefix of the unbudgeted page");
|
||||
var o = forks[n - 1]!["card"]!["order"]!.GetValue<long>();
|
||||
if (full["next_cursor"] is not null || n < fullForks.Count) Assert.That(S(r["next_cursor"]), Is.EqualTo($"c{o}"), name); else Assert.That(r["next_cursor"], Is.Null, name);
|
||||
Assert.That(S(r["stamp"]), Is.EqualTo(S(full["stamp"]))); Assert.That(r["open_count"]!.GetValue<int>(), Is.EqualTo(full["open_count"]!.GetValue<int>())); Assert.That(r["recorded_count"]!.GetValue<int>(), Is.EqualTo(full["recorded_count"]!.GetValue<int>()));
|
||||
covered.AddRange(forks.Select(f => f!["card"]!["order"]!.GetValue<long>()));
|
||||
last = r["next_cursor"] is null ? null : S(r["next_cursor"]); follow = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var r = step["refusal"]!; var first = fullForks[0]!["card"]!;
|
||||
Assert.That(Errors(ForkSchema, "refusal", r), Is.Empty, name);
|
||||
Assert.That(HexOf(r), Is.EqualTo(S(step["refusalHex"])), name);
|
||||
Assert.That(S(r["code"]), Is.EqualTo("fork_too_large")); Assert.That(step["httpStatus"]!.GetValue<int>(), Is.EqualTo(422));
|
||||
Assert.That(S(step["fork_id"]), Is.EqualTo(S(first["fork_id"]))); Assert.That(step["order"]!.GetValue<long>(), Is.EqualTo(first["order"]!.GetValue<long>()));
|
||||
Assert.That(S(r["error"]), Is.EqualTo($"fork_too_large: fork {S(first["fork_id"])} (order {first["order"]!.GetValue<long>()}) alone is past the response budget"));
|
||||
Assert.That(step["request"]!["limit"]!.GetValue<int>(), Is.GreaterThanOrEqualTo(1));
|
||||
var order = first["order"]!.GetValue<long>();
|
||||
if (order >= 1) { Assert.That(i + 1, Is.LessThan(steps.Count), name); last = $"c{order}"; follow = true; Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create(last)), Is.Empty); }
|
||||
else { Assert.That(i, Is.EqualTo(steps.Count - 1), $"{name}: order 0 is the end"); follow = false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert.That(covered, Is.EqualTo(new long[] { 3, 2, 1, 0, 2, 1, 0, 1 }));
|
||||
Assert.That(sc[0]!["steps"]!.AsArray().Select(x => x!["response"]!["forks"]!.AsArray().Count), Is.EqualTo(new[] { 2, 2 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_AReadWhoseEntryIsPastTheCap_IsForkTooLarge422_NamedByForkIdAlone()
|
||||
{
|
||||
var doorStatus = Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == "fork_too_large")!["httpStatus"]!.GetValue<int>();
|
||||
var cases = Fork["transport"]!["readTooLarge"]!.AsArray();
|
||||
Assert.That(cases.Count, Is.EqualTo(1));
|
||||
foreach (var c in cases)
|
||||
{
|
||||
var held = c!["entries"]!.AsArray().Single(e => S(e!["card"]!["fork_id"]) == S(c["request"]!["fork_id"]))!;
|
||||
Assert.That(Errors(ForkSchema, "readRequest", c["request"]), Is.Empty, S(c["name"]));
|
||||
Assert.That(Errors(ForkSchema, "refusal", c["refusal"]), Is.Empty, S(c["name"]));
|
||||
Assert.That(S(c["refusal"]!["code"]), Is.EqualTo("fork_too_large"));
|
||||
Assert.That(S(c["refusal"]!["error"]), Is.EqualTo($"fork_too_large: fork {S(held["card"]!["fork_id"])} alone is past the response budget"));
|
||||
Assert.That(S(c["refusal"]!["error"]), Does.Not.Contain("order"), "a read names no order");
|
||||
Assert.That(HexOf(c["refusal"]), Is.EqualTo(S(c["refusalHex"])), S(c["name"]));
|
||||
Assert.That(c["httpStatus"]!.GetValue<int>(), Is.EqualTo(422)); Assert.That(c["httpStatus"]!.GetValue<int>(), Is.EqualTo(doorStatus));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_ADoorRequestIdConflict_IsAFaultOnReadAndList_AndARefusalOnlyOnTheAnswer()
|
||||
{
|
||||
var cases = Fork["transport"]!["doorFaults"]!.AsArray();
|
||||
Assert.That(cases.Select(c => S(c!["route"])), Is.EqualTo(new[] { "read", "list", "answer" }));
|
||||
foreach (var c in cases)
|
||||
{
|
||||
var source = S(c!["route"]) == "answer" ? Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == S(c["doorCode"]))! : Fork["unavailable"]![0]!;
|
||||
Assert.That(S(c["responseHex"]), Is.EqualTo(S(source["messagePackHex"])), S(c["name"]));
|
||||
Assert.That(HexOf(c["response"]), Is.EqualTo(S(c["responseHex"])), S(c["name"]));
|
||||
Assert.That(c["httpStatus"]!.GetValue<int>(), Is.EqualTo(source["httpStatus"]!.GetValue<int>()), S(c["name"]));
|
||||
Assert.That(Errors(ForkSchema, S(c["route"]) == "answer" ? "refusal" : "unavailable", c["response"]), Is.Empty, S(c["name"]));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_Auth_BearerThenAudienceThenScope_AVotesTokenTouchesNothing()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue