From c1c970a84b5723fa3299a03249c01f299bd6bcaf Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 10 Nov 2026 16:21:01 +0000 Subject: [PATCH] Fork card transport: the Mind id rides in the path; V14 asserted on every scenario Routes are GET /api/desk/{mind}/forks, GET /api/desk/{mind}/forks/{fork_id} and POST /api/desk/{mind}/answers with fork_id still in the body (Heimdall's file 4.2 and 4.3a). Spec: the {mind} segment rules M1 to M5 (fixed configured list, never joined into a path, unknown Mind is the unknown-fork refusal with the same bytes, the route's Mind must hold the fork_id, scopes not per Mind, the decider list is Bifrost-to-Huginn only, marked Bifrost/Huginn work). Schema: read_request carries `mind`. Vectors: transport.mindRoutes (47 cases, sameResponse pairs, the socket a judge would open), scenarios for a body that names `mind` or `deciders` (body_unknown_field), read_request with the Mind. Both judges replay them; both also assert on every scenario that a request id bound by another account never returns that account's answer id or document (V14), with `of` allowed to name the counted answer. Open choice 11: an unreachable known Mind answers unavailable, an unknown one fork_unknown. Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet/governance/fork-card-vectors.json | 1008 ++++++++++++++++- contracts/cultnet/governance/fork-card.md | 35 +- .../cultnet/governance/fork-card.schema.json | 4 + .../test/governance-vectors.test.ts | 57 + scripts/gen-governance-vectors.py | 49 +- .../GovernanceVectorTests.cs | 68 ++ 6 files changed, 1204 insertions(+), 17 deletions(-) diff --git a/contracts/cultnet/governance/fork-card-vectors.json b/contracts/cultnet/governance/fork-card-vectors.json index e127812f..233b90bd 100644 --- a/contracts/cultnet/governance/fork-card-vectors.json +++ b/contracts/cultnet/governance/fork-card-vectors.json @@ -2475,9 +2475,10 @@ "name": "read_one", "message": { "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "gamecult-chat", "fork_id": "gamecult-chat:question:slot-mark-consent" }, - "messagePackHex": "82AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74" + "messagePackHex": "83AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74" } ], "invalid": [ @@ -2486,9 +2487,29 @@ "why": "a read names a fork", "message": { "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "gamecult-chat", "fork_id": "" }, - "messagePackHex": "82AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A7666F726B5F6964A0" + "messagePackHex": "83AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A7666F726B5F6964A0" + }, + { + "name": "mind_empty", + "why": "a read names a Mind", + "message": { + "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "", + "fork_id": "gamecult-chat:question:slot-mark-consent" + }, + "messagePackHex": "83AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A46D696E64A0A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74" + }, + { + "name": "mind_missing", + "why": "map-mode writes every key; the Mind is the route's", + "message": { + "schemaVersion": "gamecult.fork.read_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent" + }, + "messagePackHex": "82AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74" } ] }, @@ -5210,6 +5231,835 @@ ] } ], + "mindRoutes": { + "minds": { + "gamecult-chat": "huginn-gamecult-chat.sock", + "nibu": "huginn-nibu.sock" + }, + "forks": { + "gamecult-chat": [ + "gamecult-chat:question:q1", + "gamecult-chat:question:q2", + "gamecult-chat:question:slot-mark-consent" + ], + "nibu": [ + "nibu:question:glow-order" + ] + }, + "cases": [ + { + "name": "list_a_configured_mind", + "route": "list", + "mind": "gamecult-chat", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "list_another_configured_mind", + "route": "list", + "mind": "nibu", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "read_a_fork_the_mind_holds", + "route": "read", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "answer_a_fork_the_mind_holds_is_stored_in_that_mind", + "route": "answer", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-gamecult-chat.sock", + "stored_in": "gamecult-chat" + }, + { + "name": "answer_on_the_other_mind", + "route": "answer", + "mind": "nibu", + "fork_id": "nibu:question:glow-order", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": "nibu" + }, + { + "name": "M1_list_dot_dot_is_unknown_and_opens_nothing", + "route": "list", + "mind": "../x", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_slash_is_unknown_and_opens_nothing", + "route": "list", + "mind": "a/b", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_encoded_slash_is_unknown_and_opens_nothing", + "route": "list", + "mind": "a%2fb", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_encoded_dot_dot_is_unknown_and_opens_nothing", + "route": "list", + "mind": "%2e%2e%2fx", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_nul_is_unknown_and_opens_nothing", + "route": "list", + "mind": "eureka%00", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_four_kb_is_unknown_and_opens_nothing", + "route": "list", + "mind": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_other_case_is_unknown_and_opens_nothing", + "route": "list", + "mind": "GAMECULT-CHAT", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_trailing_space_is_unknown_and_opens_nothing", + "route": "list", + "mind": "nibu%20", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_invalid_utf8_is_unknown_and_opens_nothing", + "route": "list", + "mind": "%ff", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_dot_dot_is_unknown_and_opens_nothing", + "route": "read", + "mind": "../x", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_slash_is_unknown_and_opens_nothing", + "route": "read", + "mind": "a/b", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_encoded_slash_is_unknown_and_opens_nothing", + "route": "read", + "mind": "a%2fb", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_encoded_dot_dot_is_unknown_and_opens_nothing", + "route": "read", + "mind": "%2e%2e%2fx", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_nul_is_unknown_and_opens_nothing", + "route": "read", + "mind": "eureka%00", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_four_kb_is_unknown_and_opens_nothing", + "route": "read", + "mind": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_other_case_is_unknown_and_opens_nothing", + "route": "read", + "mind": "GAMECULT-CHAT", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_trailing_space_is_unknown_and_opens_nothing", + "route": "read", + "mind": "nibu%20", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_invalid_utf8_is_unknown_and_opens_nothing", + "route": "read", + "mind": "%ff", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_dot_dot_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "../x", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_slash_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "a/b", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_encoded_slash_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "a%2fb", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_encoded_dot_dot_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "%2e%2e%2fx", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_nul_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "eureka%00", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_four_kb_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_other_case_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "GAMECULT-CHAT", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_trailing_space_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "nibu%20", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_invalid_utf8_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "%ff", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M2_read_unknown_mind_known_fork", + "route": "read", + "mind": "ghost", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M2_read_known_mind_unknown_fork", + "route": "read", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:ghost", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "M2_answer_unknown_mind_known_fork", + "route": "answer", + "mind": "ghost", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M2_answer_known_mind_unknown_fork", + "route": "answer", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:ghost", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "M2_list_unknown_mind", + "route": "list", + "mind": "ghost", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", + "route": "answer", + "mind": "nibu", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "M3_answer_on_gamecult_chat_with_a_fork_nibu_holds", + "route": "answer", + "mind": "gamecult-chat", + "fork_id": "nibu:question:glow-order", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "M4_desk_read_lists_every_mind", + "route": "list", + "mind": "nibu", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "M4_desk_read_reads_every_mind", + "route": "read", + "mind": "nibu", + "fork_id": "nibu:question:glow-order", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "M4_desk_answer_cannot_list_any_mind", + "route": "list", + "mind": "gamecult-chat", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "insufficient_scope", + "httpStatus": 403, + "opened": null, + "stored_in": null + }, + { + "name": "M4_desk_answer_cannot_list_the_other_mind", + "route": "list", + "mind": "nibu", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "insufficient_scope", + "httpStatus": 403, + "opened": null, + "stored_in": null + }, + { + "name": "M4_desk_read_cannot_answer_on_any_mind", + "route": "answer", + "mind": "nibu", + "fork_id": "nibu:question:glow-order", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "insufficient_scope", + "httpStatus": 403, + "opened": null, + "stored_in": null + }, + { + "name": "auth_comes_before_the_mind_lookup_no_bearer", + "route": "list", + "mind": "ghost", + "fork_id": null, + "token": null, + "expect": "unauthenticated", + "httpStatus": 401, + "opened": null, + "stored_in": null + }, + { + "name": "auth_comes_before_the_mind_lookup_scope", + "route": "answer", + "mind": "ghost", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "insufficient_scope", + "httpStatus": 403, + "opened": null, + "stored_in": null + }, + { + "name": "auth_comes_before_the_mind_lookup_non_active", + "route": "read", + "mind": "ghost", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": false + }, + "expect": "forbidden", + "httpStatus": 403, + "opened": null, + "stored_in": null + } + ], + "sameResponse": [ + [ + "M2_read_unknown_mind_known_fork", + "M2_read_known_mind_unknown_fork" + ], + [ + "M2_answer_unknown_mind_known_fork", + "M2_answer_known_mind_unknown_fork" + ], + [ + "M2_list_unknown_mind", + "M2_read_known_mind_unknown_fork" + ], + [ + "M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", + "M2_answer_known_mind_unknown_fork" + ] + ] + }, "authCases": [ { "name": "list_with_desk_read", @@ -6046,6 +6896,160 @@ }, "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" }, + { + "name": "M5_a_body_naming_deciders_is_an_unknown_key_the_decider_list_is_never_the_bodys", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "deciders": [ + "11111111-2222-4333-8444-555555555555" + ] + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_unknown_field", + "error": "body_unknown_field: the body is a closed shape" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answers": [] + }, + "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + }, + { + "name": "M1_a_body_naming_a_mind_is_an_unknown_key_the_mind_is_the_routes", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "mind": "nibu" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_unknown_field", + "error": "body_unknown_field: the body is a closed shape" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answers": [] + }, + "recordHex": "85AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + }, { "name": "an_unknown_key_is_refused_the_body_is_closed", "card": { diff --git a/contracts/cultnet/governance/fork-card.md b/contracts/cultnet/governance/fork-card.md index e72ff43a..17b3efea 100644 --- a/contracts/cultnet/governance/fork-card.md +++ b/contracts/cultnet/governance/fork-card.md @@ -50,7 +50,7 @@ Vectors `optionsHash`: the seven of the binding file (`heimdall_A..G`), computed ### answer_request -`{schemaVersion, fork_id, options_hash, answer, retracted, retracts, answered_at, request_id}`. **`request_id`** is set by the client, a lowercase D-format UUID, one per intended answer, kept across retries; the first stored answer that carries it binds it to the stamped `answerer`. "Same body" means every field equal, `answered_at` included, so a client resends identical bytes. A **retry with the same `request_id` and the same body returns the stored outcome unchanged** (a `Refused` one included; a replayed `stale_fork` carries the **current** card, because a stored `desk_answer` has no card field and no storage is added for one) and **never adds a Revision**; the same `request_id` with a different body is `request_id_conflict` (409, a refusal, nothing stored). The same id from another account is another request, judged on its own: it neither conflicts with nor replays the first account's outcome, even when the bytes are identical (vectors V14: an off-list account's `Refused`, then a decider's identical body is its own `Counted`). A body refused at step 1 or 2 binds nothing, so its id is free to reuse. The lookup comes after steps 1 and 2 and before the fork is looked up, so a retry gets its stored outcome even if the card has since changed. `answer` is `{choice, note}` (at least one non-nil) or nil on a retraction. `choice` is an option id; `note` is the person's words, verbatim, 1 to 4000 **UTF-8 bytes**, not blank. A schema counts characters, not bytes, so its `maxLength: 4000` (code points) is only the looser bound and both suites enforce the byte limit as a rule: 4000 ASCII bytes pass; 1000 astral characters (4000 bytes) pass and 1001 (4004) fail; `e` plus a combining acute is 3 bytes, so 1333 of them and an `x` pass and 1334 fail; 1999 two-byte characters plus three ASCII (4001 bytes) fail. A note over the limit is `body_invalid`. `answered_at` is the client's claim. **A request never carries `answerer` or `authority`**: both are stamped by Huginn (`answerer` from the verified subject the receiver passes; `authority` from the door's configuration). A body that names either key at any depth is refused whole, never stripped (`body_forbidden_field`); any other unknown key is `body_unknown_field`. The request type Bifrost sends over the second door is this document; it has no field for either key, so a client cannot supply them by construction, and Bifrost's scan is the first line, not the only one. How Huginn receives the verified subject is the door's authenticated context, not a body field (open choice 3). In map-mode `retracted` is always written (the binding file's optional `retracted` becomes `false`). +`{schemaVersion, fork_id, options_hash, answer, retracted, retracts, answered_at, request_id}`. **`request_id`** is set by the client, a lowercase D-format UUID, one per intended answer, kept across retries; the first stored answer that carries it binds it to the stamped `answerer`. "Same body" means every field equal, `answered_at` included, so a client resends identical bytes. A **retry with the same `request_id` and the same body returns the stored outcome unchanged** (a `Refused` one included; a replayed `stale_fork` carries the **current** card, because a stored `desk_answer` has no card field and no storage is added for one) and **never adds a Revision**; the same `request_id` with a different body is `request_id_conflict` (409, a refusal, nothing stored). The same id from another account is another request, judged on its own: Y reusing X's `request_id` with a body identical to X's gets **Y's own new answer, with its own answer id, stamped Y**; X's stored outcome (a `Refused` one included) is never returned in its place, and Y's request neither conflicts with it nor replays it. An `of` that names the counted answer is allowed, because answer ids are not secret between deciders (vectors V14: an off-list X's `Refused`, then a decider Y's identical body is its own `Counted`; X's `Counted`, then Y's identical body is its own `Revision` whose `of` names X's answer, and X's later retry still returns X's answer). Both suites assert it on every scenario: a response's `answerer` is the step's account, and an id first bound by another account never yields that account's answer id or document. A body refused at step 1 or 2 binds nothing, so its id is free to reuse. The lookup comes after steps 1 and 2 and before the fork is looked up, so a retry gets its stored outcome even if the card has since changed. `answer` is `{choice, note}` (at least one non-nil) or nil on a retraction. `choice` is an option id; `note` is the person's words, verbatim, 1 to 4000 **UTF-8 bytes**, not blank. A schema counts characters, not bytes, so its `maxLength: 4000` (code points) is only the looser bound and both suites enforce the byte limit as a rule: 4000 ASCII bytes pass; 1000 astral characters (4000 bytes) pass and 1001 (4004) fail; `e` plus a combining acute is 3 bytes, so 1333 of them and an `x` pass and 1334 fail; 1999 two-byte characters plus three ASCII (4001 bytes) fail. A note over the limit is `body_invalid`. `answered_at` is the client's claim. **A request never carries `answerer` or `authority`**: both are stamped by Huginn (`answerer` from the verified subject the receiver passes; `authority` from the door's configuration). A body that names either key at any depth is refused whole, never stripped (`body_forbidden_field`); any other unknown key is `body_unknown_field`. The request type Bifrost sends over the second door is this document; it has no field for either key, so a client cannot supply them by construction, and Bifrost's scan is the first line, not the only one. How Huginn receives the verified subject is the door's authenticated context, not a body field (open choice 3). In map-mode `retracted` is always written (the binding file's optional `retracted` becomes `false`). ### desk_answer (the stored kind) @@ -94,29 +94,37 @@ A ruling may carry `authority: Operator` only if it cites (`ruling_cite.answer_i ## Transport -Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-spec-20261110.md`) and Heimdall's binding file, `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, section 1.3c, which is the source for the second door. **Everything in this section is spec text for Bifrost to build; no route, scope or audience below exists in Bifrost today.** Heimdall is writing the same routes and scopes into his file; his file named none when this was written, so the paths and the audience name `bifrost-desk` are this page's proposal and any difference is his to rule. The scope names `desk.read` and `desk.answer` are his. +Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-spec-20261110.md`) and Heimdall's binding file, `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, section 1.3c, which is the source for the second door. **Everything in this section is spec text for Bifrost to build; no route, scope or audience below exists in Bifrost today.** The routes (with the `{mind}` segment), the scope names `desk.read` and `desk.answer`, the audience `bifrost-desk` and the auth order are Heimdall's rulings (his file, sections 4.1 to 4.3a); this page writes them as documents and vectors. **Authorities.** Cards are projected **read-only from Huginn**: Bifrost never authors a card, a ruling or a status, and the desk never writes one. Bifrost verifies the bearer, resolves the actor from its `sub` (a `UserAccount.Id`), and for an answer forwards a typed `DeskAnswer` on the peer-checked second door to Huginn, then returns **Huginn's outcome unchanged**: the stored `desk_answer` in an `answer_result`, or its refusal. The wire of the second door is **pending**: Eureka's cut spec follows her record PR, so this page defines the documents the desk sees and not the bytes between Bifrost and Huginn (open choice 3). | call | route (proposal) | request | response | token | |---|---|---|---|---| -| 1 list | `GET /api/desk/forks?mind=&status=&cursor=&limit=` | `list_request` as the query string | `list` | audience `bifrost-desk`, scope `desk.read` | -| 2 read | `GET /api/desk/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) | `desk.read` | -| 3 answer | `POST /api/desk/answers` | `answer_request` as the body | `answer_result` (200 for a stored outcome of any kind, `Refused` included; the desk reads `outcome` and `code`, not the status), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` | +| 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` | +| 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 a stored outcome of any kind, `Refused` included; the desk reads `outcome` and `code`, not the status), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` | + +**The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`). + +- **M1.** `{mind}` is looked up in a **fixed configured list** of Mind ids, each mapped in configuration to that Mind's socket on the peer-checked door. The segment is percent-decoded once and compared, ordinally, with the list's ids: no case folding, trimming or Unicode normalisation, and a decoding failure is a miss. It is **never joined into a path, a socket name or a query**, so a value off the list opens nothing (`../x`, `a/b`, `a%2fb`, `eureka%00`, a 4 KB string, `GAMECULT-CHAT` for `gamecult-chat`, a trailing space, bytes that are not UTF-8: all unknown, 404, no socket opened). +- **M2.** An unknown Mind gets **the same refusal as an unknown fork**: `fork_unknown`, 404, the same bytes (the document's `error` names neither the Mind nor the fork). The list of Minds is not probeable. Auth is judged first, so a caller without a valid token, scope or Active membership learns nothing about which Minds exist. +- **M3.** On the POST the route's Mind must hold `fork_id`: step 3 is judged against **that Mind's** stored forks, so a `fork_id` the route's Mind does not hold is `fork_unknown` and nothing is stored, in either Mind. A fork cannot be answered through another Mind's route. A body that names `mind` is `body_unknown_field`: the Mind is the route's. +- **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; which forks a caller may see or answer is the card's audience and decider list. +- **M5. The decider list reaches Huginn as a typed request on the same peer-checked door, sent only by Bifrost, from the admin-set list.** It is never taken from a Mind and never from a request body: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the document and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. 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. -**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the Mind's instance slug. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`** (the admission ordinal). `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`: 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 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`** (the admission ordinal). `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`: 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 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`). -**Read.** `entry` for one fork. 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). Which cards and answers a viewer may see follows the card's audience (PR #24 section 5); a fork the viewer may not see is `fork_unknown`, not a different refusal. +**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). Which cards and answers a viewer may see follows the card's audience (PR #24 section 5); a fork the viewer may not see is `fork_unknown`, not a different refusal. -**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. **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 a `request_id_conflict`. `unavailable` promises nothing either way. +**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. **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`; `listScenarios` (five scenarios over one Mind's five cards: paged open-only, recorded-only, all, `limit` 0, an empty Mind, 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. 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`; `listScenarios` (five scenarios over one Mind's five cards: paged open-only, recorded-only, all, `limit` 0, an empty Mind, 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; 47 cases over the three routes: bad segments opening nothing, 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 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 `unavailable` answer. **Huginn must still build** the second door's wire 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. +**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 request, sent from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the decider-list request it receives (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. ## Old format, the mind's record, and what maps to what @@ -162,9 +170,9 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th - `Refused` stores every refusal from steps 4 to 7 and R1 and R2 (his section 1.3b); only steps 1 and 2 and `fork_unknown` are not stored. The binding file does not say whether `fork_unknown` is stored; it cannot be keyed without a question, so it is not (open choice 4). - The binding file's `choice_valid` has no named refusal code; this page names it `choice_invalid`. `fork_known` is `fork_unknown`. A type-invalid body is `body_invalid`. - `ruled` (his R3 and V10) is dropped: a ruling that answers a fork closes it, so the case is `fork_closed` (V4, V10a). His file drops it too. -- Heimdall's section 1.3e (Q1 to Q6, V12 to V18) is the source for `request_id`; this page matches it, with two notes. Q1's open item is settled by his ruling: a replayed `stale_fork` returns the current card. In V14 a decider's identical body is a `Revision` whose `of` necessarily names the first account's counted answer id (the record shows it to every decider), so "never contains X's answer_id" holds for the replayed outcome, not for `of`; the vector with an off-list first account pins the replay case with a `Counted`. +- Heimdall's section 1.3e (Q1 to Q6, V12 to V18) is the source for `request_id`; this page matches it, with two notes. Q1's open item is settled by his ruling: a replayed `stale_fork` returns the current card. In V14 a decider's identical body is a `Revision` whose `of` necessarily names the first account's counted answer id (the record shows it to every decider), so "never contains X's answer_id" holds for Y's answer id and document, not for `of` (Heimdall: `of` naming the counted answer is allowed); the vector with an off-list first account pins the case with a `Counted`, which has no `of`. - `request_id` (on the request and the stored `desk_answer`) and `request_id_conflict` are this page's: his file has no idempotency key, and a retry after `unavailable` needs one. Eureka's `desk_answer` kind and Huginn's request type must carry it. -- Transport (routes, scopes, list, read, `unavailable`) is this page's; his file has none yet. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid. +- Transport: the routes, the `{mind}` segment, the scopes, the audience and the auth order are his rulings (4.1 to 4.3a); the documents (list, read, `unavailable`) are this page's. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid. - The recommendation is never nil. The binding file's earlier vectors D and E used a nil recommendation; its updated D and E use `"a"`, as here. - Hash vectors: A, B, C, D, E, F and G of the binding file are reproduced digest for digest by the Python generator, the C# suite and the TypeScript suite (the generator prints `same` for each; none differs). @@ -178,5 +186,6 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th 6. What the person sees of a retracted counted answer when the next answer counts: this contract only says the count moves. 7. `received_at` is not in the binding file's stored fields; it is kept for `claim_in_future` and could be dropped if the ordinal alone decides order. 8. The stamped `authority` field is an attribution, not an authority (above); renaming it `attribution` would say so, at the price of a new key the door must also refuse in a body. -9. The audience name `bifrost-desk` and the three routes are this page's proposal beside Heimdall's scope names; his file will rule them. The claims Bifrost verifies are PR #24's open item 5. +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. diff --git a/contracts/cultnet/governance/fork-card.schema.json b/contracts/cultnet/governance/fork-card.schema.json index 9e800b8e..f51745c8 100644 --- a/contracts/cultnet/governance/fork-card.schema.json +++ b/contracts/cultnet/governance/fork-card.schema.json @@ -838,12 +838,16 @@ "additionalProperties": false, "required": [ "schemaVersion", + "mind", "fork_id" ], "properties": { "schemaVersion": { "const": "gamecult.fork.read_request.v0" }, + "mind": { + "$ref": "#/$defs/id" + }, "fork_id": { "$ref": "#/$defs/id" } diff --git a/packages/cultnet-ts/test/governance-vectors.test.ts b/packages/cultnet-ts/test/governance-vectors.test.ts index 0e80b0f1..b6861852 100644 --- a/packages/cultnet-ts/test/governance-vectors.test.ts +++ b/packages/cultnet-ts/test/governance-vectors.test.ts @@ -285,6 +285,24 @@ test("fork card: every scenario replays to the vector's responses and record, by } }); +test("fork card: a request id belongs to its account; another account's identical body gets its own answer, never the first account's outcome", () => { + let crossed = 0; + for (const sc of fork.scenarios) { + const first = new Map(); // request_id -> the account that first got a stored result and what it got + for (const s of sc.steps) { + if (s.kind !== "answer" || !s.response?.answer) continue; + assert.equal(s.response.answer.answerer, s.server.answerer, `${sc.name}: a result is stamped with the account that asked`); + const prior = first.get(s.body.request_id); + if (!prior) { first.set(s.body.request_id, { answerer: s.server.answerer, result: s.response }); continue; } + if (prior.answerer === s.server.answerer) continue; + crossed++; + assert.notEqual(s.response.answer.answer_id, prior.result.answer.answer_id, `${sc.name}: its own answer id`); + assert.notDeepEqual(s.response, prior.result, `${sc.name}: the first account's outcome is never returned`); + } + } + assert.ok(crossed >= 3, "the vectors carry the cross-account cases"); +}); + test("fork card: a body refused at the door or naming an unknown fork is never stored", () => { const door = new Set(["unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "forbidden", "request_id_conflict"]); let doorRefusals = 0; @@ -358,6 +376,45 @@ const authJudge = (route: string, token: { audience: string; scopes: string[]; a return huginnDown ? "unavailable" : "ok"; }; +// The {mind} segment is decoded once and compared, exactly, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment"). +type MindResult = { code: string; opened: string | null; storedIn: string | null }; +const mindRoute = (config: any, route: string, segment: string, fork: string | null, token: any): MindResult => { + const auth = authJudge(route, token, false); + if (auth !== "ok") return { code: auth, opened: null, storedIn: null }; + let mind: string; + try { mind = decodeURIComponent(segment); } catch { return { code: "fork_unknown", opened: null, storedIn: null }; } + if (!Object.prototype.hasOwnProperty.call(config.minds, mind)) return { code: "fork_unknown", opened: null, storedIn: null }; + const opened: string = config.minds[mind]; + if (route !== "list" && !config.forks[mind].includes(fork)) return { code: "fork_unknown", opened, storedIn: null }; + return { code: "ok", opened, storedIn: route === "answer" ? mind : null }; +}; + +test("fork transport: the mind segment is only compared with the configured list; an unknown mind is the unknown fork refusal", () => { + const config = fork.transport.mindRoutes; + const refusals = new Map(fork.doorRefusals.map((r: { name: string }) => [r.name, r])); + const byName = new Map(); + for (const c of config.cases) { + const got = mindRoute(config, c.route, c.mind, c.fork_id, c.token); + byName.set(c.name, got); + assert.equal(got.code, c.expect, c.name); + assert.equal(got.opened, c.opened, `${c.name}: the socket opened`); + assert.equal(got.storedIn, c.stored_in, `${c.name}: where an answer was stored`); + assert.equal(c.httpStatus, got.code === "ok" ? 200 : refusals.get(got.code).httpStatus, c.name); + if (got.code === "fork_unknown") { + const error: string = refusals.get("fork_unknown").message.error; + assert.ok(!error.includes(c.mind) && !(c.fork_id !== null && error.includes(c.fork_id)), `${c.name}: the refusal names neither the Mind nor the fork`); + } + } + for (const [a, b] of config.sameResponse) { + const x = byName.get(a)!, y = byName.get(b)!; + assert.equal(x.code, y.code); + assert.equal(refusals.get(x.code).messagePackHex, refusals.get(y.code).messagePackHex, `${a} and ${b} answer with the same bytes`); + } + const all = [...byName.values()]; + assert.ok(all.filter((v) => v.code === "fork_unknown" && v.opened === null).length >= 27, "every bad segment opens nothing"); + assert.equal(new Set(all.filter((v) => v.storedIn !== null).map((v) => v.storedIn)).size, 2, "an answer is stored in the route's Mind only, and both Minds take one"); +}); + test("fork transport: requests and entries validate and encode to the vector bytes, and every negative is refused", () => { const t = fork.transport; for (const [section, def] of [["listRequest", "listRequest"], ["readRequest", "readRequest"], ["entry", "entry"]] as const) { diff --git a/scripts/gen-governance-vectors.py b/scripts/gen-governance-vectors.py index 4aa3781d..be7b8fd9 100644 --- a/scripts/gen-governance-vectors.py +++ b/scripts/gen-governance-vectors.py @@ -278,6 +278,8 @@ scen = [ sc("V1_answerer_in_the_body_is_refused_at_the_door_and_stores_nothing", CARD, [door(dict(x1, answerer="x"), sx1, "body_forbidden_field")], None), sc("V1_authority_nested_in_answer_is_refused", CARD, [door(dict(x1, answer={"choice": "no", "note": None, "authority": "Operator"}), sx1, "body_forbidden_field")], None), sc("V1_both_are_refused_not_stripped", CARD, [door(dict(x1, answerer=OP, authority="Operator"), sx1, "body_forbidden_field")], None), + sc("M5_a_body_naming_deciders_is_an_unknown_key_the_decider_list_is_never_the_bodys", CARD, [door(dict(x1, deciders=[ST]), sx1, "body_unknown_field")], None), + sc("M1_a_body_naming_a_mind_is_an_unknown_key_the_mind_is_the_routes", CARD, [door(dict(x1, mind="nibu"), sx1, "body_unknown_field")], None), sc("an_unknown_key_is_refused_the_body_is_closed", CARD, [door(dict(x1, mood="happy"), sx1, "body_unknown_field")], None), sc("an_answer_with_neither_choice_nor_note_is_invalid", CARD, [door(body(answer={"choice": None, "note": None}), sx1, "body_invalid")], None), sc("a_non_retraction_without_an_answer_is_invalid", CARD, [door(body(answer=None), sx1, "body_invalid")], None), @@ -403,6 +405,46 @@ AUTH = [ ("auth_is_judged_before_huginn_is_asked", "list", None, "unauthenticated", True), ("membership_is_judged_before_huginn_is_asked", "read", TOK("bifrost-desk", "desk.read", active=False), "forbidden", True), ] +# ---------- transport: the {mind} segment (Heimdall's file 4.3a, M1 to M4) ---------- +import urllib.parse +MINDS = {"gamecult-chat": "huginn-gamecult-chat.sock", "nibu": "huginn-nibu.sock"} +MIND_FORKS = {"gamecult-chat": ["gamecult-chat:question:q1", "gamecult-chat:question:q2", FID], "nibu": ["nibu:question:glow-order"]} +def auth_code(route, tok): + if tok is None or tok["audience"] != "bifrost-desk": return "unauthenticated" + if ("desk.answer" if route == "answer" else "desk.read") not in tok["scopes"]: return "insufficient_scope" + return "forbidden" if not tok["active"] else "ok" +def mind_route(route, seg, fork, tok): + code = auth_code(route, tok) + if code != "ok": return code, None, None + try: mind = urllib.parse.unquote(seg, errors="strict") + except UnicodeDecodeError: mind = None + if mind not in MINDS: return "fork_unknown", None, None # never joined into a path; no socket is opened + if route != "list" and fork not in MIND_FORKS[mind]: return "fork_unknown", MINDS[mind], None + return "ok", MINDS[mind], mind if route == "answer" else None +DESK_READ, DESK_ANSWER = TOK("bifrost-desk", "desk.read"), TOK("bifrost-desk", "desk.answer") +def mcase(name, route, seg, fork=None, tok=None): + tok = None if tok == "none" else tok or (DESK_ANSWER if route == "answer" else DESK_READ) + code, opened, stored = mind_route(route, seg, fork, tok) + return {"name": name, "route": route, "mind": seg, "fork_id": fork, "token": tok, "expect": code, "httpStatus": 200 if code == "ok" else HTTP[code], "opened": opened, "stored_in": stored} +BAD_SEGMENTS = [("dot_dot", "../x"), ("slash", "a/b"), ("encoded_slash", "a%2fb"), ("encoded_dot_dot", "%2e%2e%2fx"), ("nul", "eureka%00"), ("four_kb", "a" * 4096), + ("other_case", "GAMECULT-CHAT"), ("trailing_space", "nibu%20"), ("invalid_utf8", "%ff")] +MINDCASES = [ + mcase("list_a_configured_mind", "list", "gamecult-chat"), mcase("list_another_configured_mind", "list", "nibu"), + mcase("read_a_fork_the_mind_holds", "read", "gamecult-chat", FID), mcase("answer_a_fork_the_mind_holds_is_stored_in_that_mind", "answer", "gamecult-chat", FID), + mcase("answer_on_the_other_mind", "answer", "nibu", "nibu:question:glow-order"), +] + [mcase(f"M1_{route}_{n}_is_unknown_and_opens_nothing", route, seg, FID if route != "list" else None) for route in ("list", "read", "answer") for n, seg in BAD_SEGMENTS] + [ + mcase("M2_read_unknown_mind_known_fork", "read", "ghost", FID), mcase("M2_read_known_mind_unknown_fork", "read", "gamecult-chat", "gamecult-chat:question:ghost"), + mcase("M2_answer_unknown_mind_known_fork", "answer", "ghost", FID), mcase("M2_answer_known_mind_unknown_fork", "answer", "gamecult-chat", "gamecult-chat:question:ghost"), + mcase("M2_list_unknown_mind", "list", "ghost"), + mcase("M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", "answer", "nibu", FID), mcase("M3_answer_on_gamecult_chat_with_a_fork_nibu_holds", "answer", "gamecult-chat", "nibu:question:glow-order"), + mcase("M4_desk_read_lists_every_mind", "list", "nibu", tok=DESK_READ), mcase("M4_desk_read_reads_every_mind", "read", "nibu", "nibu:question:glow-order", tok=DESK_READ), + mcase("M4_desk_answer_cannot_list_any_mind", "list", "gamecult-chat", tok=DESK_ANSWER), mcase("M4_desk_answer_cannot_list_the_other_mind", "list", "nibu", tok=DESK_ANSWER), + mcase("M4_desk_read_cannot_answer_on_any_mind", "answer", "nibu", "nibu:question:glow-order", tok=DESK_READ), + mcase("auth_comes_before_the_mind_lookup_no_bearer", "list", "ghost", tok="none"), mcase("auth_comes_before_the_mind_lookup_scope", "answer", "ghost", FID, tok=DESK_READ), + mcase("auth_comes_before_the_mind_lookup_non_active", "read", "ghost", FID, tok=TOK("bifrost-desk", "desk.read", active=False)), +] +SAME_RESPONSE = [["M2_read_unknown_mind_known_fork", "M2_read_known_mind_unknown_fork"], ["M2_answer_unknown_mind_known_fork", "M2_answer_known_mind_unknown_fork"], + ["M2_list_unknown_mind", "M2_read_known_mind_unknown_fork"], ["M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", "M2_answer_known_mind_unknown_fork"]] def bad(name, why, m, **kw): return dict({"name": name, "why": why, "message": m}, **kw) fork = { "provenance": "Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md (Heimdall), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Bytes spec-authored with msgpack-python (scripts/gen-governance-vectors.py); every runtime re-encodes `message` and must match messagePackHex. options_hash follows fork-card.md 'Options hash'; the vectors named heimdall_* are that file's, with its digests.", @@ -481,9 +523,11 @@ fork = { ("status_all", "status is open, recorded or nil", lreq(status="all")), ("cursor_malformed", "a cursor is c", lreq(cursor="next")), ("cursor_zero", "a cursor names a positive order", lreq(cursor="c0")), ("mind_empty", "a list names a Mind", lreq(mind="")), ("unknown_key", "closed document", dict(lreq(), sort="oldest"))]]}, - "readRequest": {"valid": [{"name": "read_one", "message": {"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": FID}, "messagePackHex": hx({"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": FID})}], + "readRequest": {"valid": [{"name": "read_one", "message": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": FID}, "messagePackHex": hx({"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": FID})}], "invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [ - ("fork_id_empty", "a read names a fork", {"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": ""})]]}, + ("fork_id_empty", "a read names a fork", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ""}), + ("mind_empty", "a read names a Mind", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "", "fork_id": FID}), + ("mind_missing", "map-mode writes every key; the Mind is the route's", {"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": FID})]]}, "entry": {"valid": [{"name": n, "message": e, "messagePackHex": hx(e)} for n, e in [("open_no_answers", ENTRIES[1]), ("open_counted", ENTRIES[0]), ("closed_with_ruling_and_a_refusal", ENTRIES[2])]], "invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [ ("entry_without_record", "an entry holds the card and its record", {k: v for k, v in ENTRIES[0].items() if k != "record"}), @@ -496,6 +540,7 @@ fork = { ("stamp_malformed", "a stamp is s", dict(LISTS[0]["steps"][0]["response"], stamp="412")), ("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1))]], "listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS], + "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], }, "scenarios": [{"name": n, "card": c, "optionsHash": c["options_hash"], "steps": steps, "record": r, "recordHex": hx(r)} for n, c, steps, r in scen], diff --git a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs index 83a7d0d9..a8ebc4e3 100644 --- a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs +++ b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs @@ -395,6 +395,30 @@ namespace GameCult.Networking.Tests return (responses, record); } + [Test] + public void ForkCard_ARequestIdBelongsToItsAccount_AnotherAccountsIdenticalBodyGetsItsOwnAnswerNeverTheFirstOutcome() + { + var crossed = 0; + foreach (var sc in Fork["scenarios"]!.AsArray()) + { + var first = new Dictionary(); // request_id -> the account that first got a stored result and what it got + foreach (var step in sc!["steps"]!.AsArray()) + { + if (S(step!["kind"]) != "answer" || step["response"]?["answer"] is null) continue; + var who = S(step["server"]!["answerer"]); + var result = step["response"]!; + Assert.That(S(result["answer"]!["answerer"]), Is.EqualTo(who), $"{S(sc["name"])}: a result is stamped with the account that asked"); + var rid = S(step["body"]!["request_id"]); + if (!first.TryGetValue(rid, out var prior)) { first[rid] = (who, result); continue; } + if (prior.Answerer == who) continue; + crossed++; + Assert.That(S(result["answer"]!["answer_id"]), Is.Not.EqualTo(S(prior.Result["answer"]!["answer_id"])), $"{S(sc["name"])}: its own answer id"); + Assert.That(JsonNode.DeepEquals(result, prior.Result), Is.False, $"{S(sc["name"])}: the first account's outcome is never returned"); + } + } + Assert.That(crossed, Is.GreaterThanOrEqualTo(3), "the vectors carry the cross-account cases"); + } + [Test] public void ForkCard_EveryScenario_ReplaysToTheVectorsResponsesAndRecord_BytesIncluded() { @@ -500,6 +524,50 @@ namespace GameCult.Networking.Tests return huginnDown ? "unavailable" : "ok"; } + // The {mind} segment is decoded once and compared, ordinally, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment"). + private static (string Code, string? Opened, string? StoredIn) MindRoute(JsonNode config, string route, string segment, string? fork, JsonNode? token) + { + var auth = AuthJudge(route, token, false); + if (auth != "ok") return (auth, null, null); + string mind; + try { mind = Uri.UnescapeDataString(segment); } catch (UriFormatException) { return ("fork_unknown", null, null); } + var minds = config["minds"]!.AsObject(); + if (!minds.TryGetPropertyValue(mind, out var socket)) return ("fork_unknown", null, null); + if (route != "list" && !config["forks"]![mind]!.AsArray().Any(f => S(f) == fork)) return ("fork_unknown", S(socket), null); + return ("ok", S(socket), route == "answer" ? mind : null); + } + + [Test] + public void ForkTransport_MindSegment_IsOnlyComparedWithTheConfiguredList_AnUnknownMindIsTheUnknownForkRefusal() + { + var config = Fork["transport"]!["mindRoutes"]!; + var refusals = Fork["doorRefusals"]!.AsArray().ToDictionary(r => S(r!["name"]), r => r!); + var byName = new Dictionary(); + foreach (var c in config["cases"]!.AsArray()) + { + var got = MindRoute(config, S(c!["route"]), S(c["mind"]), c["fork_id"] is null ? null : S(c["fork_id"]), c["token"]); + byName[S(c["name"])] = got; + Assert.That(got.Code, Is.EqualTo(S(c["expect"])), S(c["name"])); + Assert.That(got.Opened, Is.EqualTo(c["opened"] is null ? null : S(c["opened"])), $"{S(c["name"])}: the socket opened"); + Assert.That(got.StoredIn, Is.EqualTo(c["stored_in"] is null ? null : S(c["stored_in"])), $"{S(c["name"])}: where an answer was stored"); + Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(got.Code == "ok" ? 200 : refusals[got.Code]["httpStatus"]!.GetValue()), S(c["name"])); + if (got.Code == "fork_unknown") + { + var error = S(refusals["fork_unknown"]["message"]!["error"]); + Assert.That(error.Contains(S(c["mind"])), Is.False, $"{S(c["name"])}: the refusal does not name the Mind"); + Assert.That(c["fork_id"] is not null && error.Contains(S(c["fork_id"])), Is.False, $"{S(c["name"])}: the refusal does not name the fork"); + } + } + foreach (var pair in config["sameResponse"]!.AsArray()) + { + var a = byName[S(pair![0])]; var b = byName[S(pair[1])]; + Assert.That(a.Code, Is.EqualTo(b.Code)); + Assert.That(S(refusals[a.Code]["messagePackHex"]), Is.EqualTo(S(refusals[b.Code]["messagePackHex"])), $"{S(pair[0])} and {S(pair[1])} answer with the same bytes"); + } + Assert.That(byName.Values.Count(v => v.Code == "fork_unknown" && v.Opened is null), Is.GreaterThanOrEqualTo(27), "every bad segment opens nothing"); + Assert.That(byName.Values.Where(v => v.StoredIn is not null).Select(v => v.StoredIn).Distinct().Count(), Is.EqualTo(2), "an answer is stored in the route's Mind only, and both Minds take one"); + } + [Test] public void ForkTransport_RequestsAndEntriesValidateEncodeToTheVectorBytes_AndEveryNegativeIsRefused() {