Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
521570e8ad | ||
|
|
0c97560d0e | ||
|
|
e0a6af9c06 | ||
|
|
c1c970a84b | ||
|
|
acb4757f38 | ||
|
|
a2db07dffe | ||
|
|
2dea6c568b | ||
|
|
b53491f3aa | ||
|
|
32d63fe9ee | ||
|
|
ad16c26e21 | ||
|
|
7aec4961f6 | ||
|
|
0ddcc90be2 | ||
|
|
6512175482 | ||
|
|
c2668509b2 | ||
|
|
2d921adba2 |
20 changed files with 3831 additions and 8983 deletions
10
.github/workflows/cultmesh-portability.yml
vendored
10
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -8,12 +8,6 @@ on:
|
|||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
|
||||
# workflow_dispatch runs are never cancelled: a tag run carries a release.
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
|
||||
|
||||
jobs:
|
||||
reactive-and-resource-lifetime:
|
||||
name: ${{ matrix.os }} reactive semantics and leases
|
||||
|
|
@ -98,8 +92,8 @@ jobs:
|
|||
--logger "console;verbosity=minimal"
|
||||
|
||||
# The TypeScript side of the CultMesh authority rules. This job is the
|
||||
# only one that runs on Linux for main, codex/** and pull requests (and on
|
||||
# Windows too under workflow_dispatch): cultnet-interop.yml is windows-latest and dispatch-only, so
|
||||
# only one that runs on both Windows and Linux for main, codex/** and
|
||||
# pull requests: cultnet-interop.yml is windows-latest and main-only, so
|
||||
# until these steps existed the shared verifier's tests never ran on
|
||||
# Linux at all, and the browser client's never ran in CI on any OS.
|
||||
# packages/cultnet-ts verifies both vector files (content.test.ts judges both
|
||||
|
|
|
|||
6
.github/workflows/quic-native.yml
vendored
6
.github/workflows/quic-native.yml
vendored
|
|
@ -18,12 +18,6 @@ on:
|
|||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
|
||||
# workflow_dispatch runs are never cancelled: a tag run carries a release.
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,10 +29,10 @@ All documents are CultNet map-mode: every key written, nil where absent, closed
|
|||
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, deciders[], status, ruling, ruled_from[], order, options_hash}`.
|
||||
|
||||
- `fork_id`: the mind's `question` key, `<campaign>:question:<label>`.
|
||||
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
|
||||
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
|
||||
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `deciders`, `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
|
||||
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The list holds one to 64 ids** (Huginn's bound), on the card and in the per-call list alike (vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
|
||||
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `order` is unique among a Mind's cards (a list with two cards on one `order` is refused, naming both fork ids); `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members.
|
||||
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path; the Mind reads it and the card projection copies it. A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
|
||||
- Rules a schema cannot say, each with a negative vector: option ids are unique; `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members.
|
||||
|
||||
### Options hash (canonical bytes)
|
||||
|
||||
|
|
@ -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: 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. **`retracted` is part of the body**: the same `request_id` with `retracted: true` and then `false` (or the reverse) and every other field equal is `request_id_conflict`, for the illegal forms as for any other (vectors `R1_retracted_true_then_false_...`, `R1_retracted_false_then_true_...`). Each illegal form's own retry, byte for byte, replays its stored `bad_retraction` (vectors `R1_a_retraction_with_an_answer_...`, `R1_a_retraction_with_no_target_...`, `R1_a_plain_answer_with_a_target_...`). A body refused at step 1 or 2 binds nothing, so its id is free to reuse; so a target-less retraction resent with `retracted: false` (no answer, no retraction) is `body_invalid` at step 2, not a conflict. 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)
|
||||
|
||||
|
|
@ -61,14 +61,12 @@ The stamped `authority` field of a stored answer is an **attribution**: the role
|
|||
- `Counted`: the answer that counts. `of`, `retracts`, `code` nil.
|
||||
- `Revision`: a later passing answer, kept, not counted while the first stands. `of` names the counted answer at the time.
|
||||
- `Retraction`: the stored form of `retracted: true` plus `retracts`. `retracts` names the answer retracted; `choice` and `note` nil. The illegal pair (a retraction with no target, or a target with no retraction) cannot be written.
|
||||
- `Refused`: any refusal from steps 4 to 7 or R1 and R2, with its `code`, stored too. `choice` and `note` keep what the body carried, and so does `retracts`: a refused body that named a target (a retraction of any refusal code, or a plain answer carrying one) projects it as `n<seq>`, never nil; a refused body that named none has `retracts` nil. `of` is nil.
|
||||
- `Refused`: any refusal from steps 4 to 7 or R1 and R2, with its `code`, stored too. `choice` and `note` keep what the body carried.
|
||||
|
||||
`received_at` is Huginn's admission time; "first" means first by admission order (the `n<seq>` ordinal), never by `answered_at`. `claim_in_future` is true when `answered_at` is more than five minutes after `received_at`: accepted but flagged. It is a function of the two stored values, so the writer compares against the `received_at` it stores, at the precision it stores it (a flag decided against an unrounded clock and a stored second-truncated `received_at` disagrees with the document it sits in). **Not stored, and no answer id:** a body refused at step 1 or 2 (Bifrost; it never reaches Huginn), and a body naming an unknown fork (step 3; Huginn answers it from its stored forks, but with no question there is nothing to key a record under). Each gets a `refusal` and is dropped. A malformed body is therefore never stored, and neither is an unknown fork. Every refusal from step 4 on is stored. **`unavailable`** (503) is not a refusal and not in the refusal set: Bifrost answers it when the second door or Huginn did not answer after the auth steps and the body shape passed (a bad body is still refused first). **It says the outcome is NOT CONFIRMED, never that nothing was stored**: a timeout after the request reached Huginn may have stored it. It carries no answer id. The desk retries the same request with the same `request_id` and learns the outcome: the stored one if Huginn had it, a fresh one if not. It covers **calls 1 and 2 as well as the answer POST** when Huginn cannot be reached (Transport; a read has no outcome to confirm, so a retry is plain). `retryAfterSeconds` is nil when Bifrost has no hint.
|
||||
|
||||
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client.
|
||||
|
||||
**Huginn's digest, pinned in the vectors (`bodyDigests`; informative, not a wire document).** SHA-256 of one compact JSON object as `serde_json::to_string` writes it (UTF-8, no spaces), lowercase hex, keys in this order, `null` for a missing value, strings verbatim: `fork`, `options_hash`, `answered_at`, `choice`, `note`, `retracts` (the **full stored key** `<campaign>:desk_answer:<label>.n<seq>`: the door converts the wire's `n<seq>` before the digest), then, **only for the three illegal forms** (R1: a retraction carrying an answer, a retraction naming no target, a plain answer carrying a target), `illegal_retraction: true` and `retracted: true|false`. `request_id` is not in it. A plain answer and a legal retraction carry neither trailing key, so their digests are the ones Huginn main pins (`8a4d5b2e`, `9f807538`) and do not move. `9f807538` is pinned for a target no door produces (`Q1.1`); the golden for the real legal retraction is the door's form, the wire's `n1` converted to `Q1.n1` (`84576840`, vector `legal_retraction_door_form`, also main-valid). Source: Eureka's `desk-body-digest-order-20261110.md`, computed with `sha256sum`; the generator, the C# suite and the TypeScript suite recompute every digest from the input fields. **Status:** the three `retracted` goldens (`935cab1a`, `82b24ccc`, `34ef86ed`, and `79eee623` for form (i) with a null target) are the form of `cut-desk-digest-retracted.r2`, merged as Huginn #30. Eureka reports that these four and the door-form legal retraction (`84576840`) reproduce byte for byte on Huginn main `f0ca504` (the #30 fix); that run is hers, reported to us, and was not rerun here. Before #30, Huginn main (`7ea11b4`) wrote `illegal_retraction` alone, so forms (i) and (iii) collided there (`69074cc6`, pinned as `illegal_i_and_iii_alike_on_main`). The wire contract does not wait on it: the conflict scenarios are pinned in `scenarios`.
|
||||
|
||||
### Counting rules (in order; the first failure refuses with that code)
|
||||
|
||||
| step | rule | refusal | stored |
|
||||
|
|
@ -105,30 +103,30 @@ 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) 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` |
|
||||
| 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; every reader sees every fork's card and outcome, and which forks a caller may answer is the decider list.
|
||||
- **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: 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 typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy.
|
||||
- **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; 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..
|
||||
**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 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`).
|
||||
**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<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`).
|
||||
|
||||
**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.
|
||||
**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, 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.
|
||||
**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` (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.
|
||||
**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; 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.
|
||||
**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
|
||||
|
||||
|
|
@ -147,7 +145,7 @@ The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a
|
|||
| `lean` ("Self leans" mark) | none | `leans` (an id or nil), not hashed |
|
||||
| `flag` (the provisional line) | none | `flag` (a string or nil), not hashed |
|
||||
| `status` open or closed, `ruling` | derived from the resolution (status is never written): any resolution of the question closes it; the ruling is the one that answered it | `status`, `ruling`, both a projection (`ruling` is nil on an open card and on a card closed with no ruling) |
|
||||
| `order` | none (unique rank per Mind, from 0) | `order` |
|
||||
| `order` | none (admission ordinal) | `order` |
|
||||
| none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried |
|
||||
| none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` |
|
||||
| answer `{choice: key or null, note, question, updatedAt}`, one mutable document per question | none | `answer_request` then an append-only `desk_answer` |
|
||||
|
|
@ -188,10 +186,8 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th
|
|||
4. Whether `fork_unknown` and shape refusals should leave an audit record somewhere other than a `desk_answer`.
|
||||
5. Whether `leans` and `flag`, which the booth shows, should join the hash. The binding file's five members do not include them; a changed lean would not make an answer stale.
|
||||
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 `n<seq>` answer ordinal alone decided answer order.
|
||||
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 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`.
|
||||
|
|
|
|||
|
|
@ -104,11 +104,9 @@
|
|||
},
|
||||
"depends": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"leans": {
|
||||
|
|
@ -149,8 +147,7 @@
|
|||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"maxItems": 64
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
|
|
@ -579,14 +576,7 @@
|
|||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"type": "null"
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
|
|
@ -663,8 +653,7 @@
|
|||
"required": [
|
||||
"schemaVersion",
|
||||
"answer",
|
||||
"card",
|
||||
"counted_answer_id"
|
||||
"card"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
|
|
@ -682,16 +671,6 @@
|
|||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"counted_answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -714,7 +693,6 @@
|
|||
"body_unknown_field",
|
||||
"body_invalid",
|
||||
"fork_unknown",
|
||||
"fork_too_large",
|
||||
"insufficient_scope",
|
||||
"forbidden",
|
||||
"request_invalid",
|
||||
|
|
|
|||
|
|
@ -1121,25 +1121,6 @@ class CultCacheTests(unittest.TestCase):
|
|||
catalog_entry=catalog_entry,
|
||||
)
|
||||
|
||||
def test_pulling_a_synthetic_pre_v1_envelope_array_leaves_the_file_bytes_identical(self) -> None:
|
||||
try:
|
||||
import msgpack # type: ignore
|
||||
except ImportError:
|
||||
self.skipTest("msgpack optional dependency is not installed")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "legacy.cc"
|
||||
legacy = msgpack.packb(
|
||||
[{"key": "note:hello", "type": "note", "payload": {"title": "Hello"}, "storedAt": "2026-10-20T00:00:00Z"}],
|
||||
use_bin_type=True,
|
||||
)
|
||||
path.write_bytes(legacy)
|
||||
|
||||
envelopes = SingleFileMessagePackBackingStore(path).pull_all()
|
||||
|
||||
self.assertEqual([e.key for e in envelopes], ["note:hello"])
|
||||
self.assertEqual(path.read_bytes(), legacy)
|
||||
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["legacy.cc"])
|
||||
|
||||
def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings = define_document_type("settings", global_document=True)
|
||||
|
|
|
|||
|
|
@ -6136,27 +6136,6 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulling_a_synthetic_pre_v1_envelope_array_leaves_the_file_bytes_identical() -> Result<()> {
|
||||
// SYNTHETIC pre-v1 vector: no real file of this shape is known; the Rust envelope payload is binary.
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("legacy.cc");
|
||||
let legacy = rmp_serde::to_vec_named(&vec![CultCacheEnvelope {
|
||||
key: "note:hello".to_string(),
|
||||
r#type: "note".to_string(),
|
||||
payload: vec![0x90],
|
||||
stored_at: "2026-10-20T00:00:00Z".to_string(),
|
||||
schema_id: None,
|
||||
}])?;
|
||||
fs::write(&path, &legacy)?;
|
||||
|
||||
let envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?;
|
||||
|
||||
assert_eq!(envelopes.iter().map(|e| e.key.as_str()).collect::<Vec<_>>(), ["note:hello"]);
|
||||
assert_eq!(fs::read(&path)?, legacy);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_store_is_named_once_its_header_string_is_complete() -> Result<()> {
|
||||
let vector = fs::read(
|
||||
|
|
|
|||
|
|
@ -3,15 +3,6 @@
|
|||
All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Opening a legacy (pre-v1) store is a pure read: payloads that were not bytes are normalized in memory and the
|
||||
file is left as it was, so a codec that refuses the content cannot lose the old bytes. The next push, delete or
|
||||
flush rewrites the file as v1, merging what the open read.
|
||||
- `SingleFileStoreRead` no longer carries `repairedLegacyPayload`; no consumer used it.
|
||||
|
||||
## [0.15.0]
|
||||
|
||||
0.14.1 and 0.14.2 were never tagged or published; this entry carries them with the Unreleased work, and `cultcache-ts-v0.14.0` is the previous release.
|
||||
|
|
@ -50,6 +41,8 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
|||
- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers
|
||||
hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms
|
||||
apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock.
|
||||
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken
|
||||
under the lock.
|
||||
|
||||
### Dependencies
|
||||
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ That is the TypeScript version of the original CultCache trick:
|
|||
- the top-level MessagePack value is `[formatVersion, schemaCatalog, records]`
|
||||
- each record is `[key, schemaId, storedAt, payload]`
|
||||
- each catalog entry is `[schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, members]`
|
||||
- it still reads the older TS/Rust-style envelope array (object payloads are normalized in memory; opening never rewrites the file) and replaces it with a v1 snapshot on the next push, delete or flush
|
||||
- it still reads the older TS/Rust-style envelope array and rewrites repaired legacy payloads as v1 snapshots
|
||||
- it queues local writes in order, then takes the shared `<file>.lock` (`flock` on Unix, `LockFileEx` on Windows, via koffi, loaded on the first write) for each one, so C#, Rust and TypeScript writers exclude each other; the read a write merges from is taken under that lock
|
||||
- it fsyncs the staged file and, on Unix, the directory after the replace; on Windows it replaces with a POSIX-semantics rename and falls back to a write-through `MoveFileExW`, retrying briefly on sharing violations
|
||||
- it persists raw MessagePack payload bytes inside schema-addressed records
|
||||
|
|
|
|||
|
|
@ -47,11 +47,21 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
}
|
||||
|
||||
async pullAll(): Promise<CultCacheEnvelope[]> {
|
||||
// A pure read: the disk decides the header, and a legacy file is normalized in memory only. The file is rewritten
|
||||
// as v1 by the next push, delete or flush, which merges what this read returns; opening never replaces bytes the
|
||||
// consumer's codec has not yet accepted.
|
||||
// The disk decides the header: a file that is gone or legacy is not marked.
|
||||
this.#format = STORE_FORMAT_VERSION;
|
||||
const disk = this.#readDisk();
|
||||
this.#format = disk.format;
|
||||
if (disk.repairedLegacyPayload) {
|
||||
// The heal decides from its own read, taken under the store lock.
|
||||
await this.#enqueue(async () => {
|
||||
const again = this.#readDisk();
|
||||
this.#format = again.format;
|
||||
if (again.repairedLegacyPayload) {
|
||||
await this.#writeAll([], again.envelopes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return disk.envelopes;
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +158,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
export function readSingleFileStore(path: string): SingleFileStoreRead {
|
||||
const data = readStoreBytes(path);
|
||||
if (data === undefined) {
|
||||
return { absent: true, format: STORE_FORMAT_VERSION, envelopes: [] };
|
||||
return { absent: true, format: STORE_FORMAT_VERSION, envelopes: [], repairedLegacyPayload: false };
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -385,6 +395,8 @@ export type SingleFileStoreRead = {
|
|||
absent: boolean;
|
||||
format: StoreFormat;
|
||||
envelopes: CultCacheEnvelope[];
|
||||
// A legacy file whose payloads were not bytes: reading it repairs them, and the store rewrites the file.
|
||||
repairedLegacyPayload: boolean;
|
||||
};
|
||||
|
||||
function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent"> {
|
||||
|
|
@ -408,7 +420,7 @@ function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent">
|
|||
storedAt: record.storedAt,
|
||||
};
|
||||
});
|
||||
return { format: snapshot.format, envelopes };
|
||||
return { format: snapshot.format, envelopes, repairedLegacyPayload: false };
|
||||
}
|
||||
|
||||
const legacy = decodeLegacyEnvelopeArray(decoded);
|
||||
|
|
@ -416,8 +428,19 @@ function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent">
|
|||
throw new Error("CultCache file is not a recognized CultCache MessagePack store.");
|
||||
}
|
||||
|
||||
const normalized = legacy.map((entry) => ({ ...entry, payload: normalizePayload(entry.payload) }));
|
||||
return { format: STORE_FORMAT_VERSION, envelopes: envelopeArraySchema.parse(normalized).map((entry) => ({ ...entry, payload: documentPayloadFromBytes(entry.payload) })) as CultCacheEnvelope[] };
|
||||
let repairedLegacyPayload = false;
|
||||
const normalized = legacy.map((entry) => {
|
||||
const payload = normalizePayload(entry.payload);
|
||||
if (payload !== entry.payload) {
|
||||
repairedLegacyPayload = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
payload,
|
||||
};
|
||||
});
|
||||
return { format: STORE_FORMAT_VERSION, envelopes: envelopeArraySchema.parse(normalized).map((entry) => ({ ...entry, payload: documentPayloadFromBytes(entry.payload) })) as CultCacheEnvelope[], repairedLegacyPayload };
|
||||
}
|
||||
|
||||
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
|
||||
|
|
|
|||
|
|
@ -504,56 +504,46 @@ test("SingleFileMessagePackBackingStore refuses a record whose schema the catalo
|
|||
await assert.rejects(() => cache.pullAllBackingStores(), StoreUnreadableError);
|
||||
});
|
||||
|
||||
const legacyNoteType = defineDocumentType({
|
||||
type: "note",
|
||||
schema: z.object({ title: z.string(), body: z.string().optional() }),
|
||||
});
|
||||
test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => {
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
// SYNTHETIC pre-v1 file (no real one is known): an envelope array with object payloads.
|
||||
async function legacyNoteStore(second: unknown): Promise<{ storePath: string; bytes: Uint8Array }> {
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "legacy.msgpack");
|
||||
const bytes = encode([
|
||||
{ key: "note:hello", type: "note", payload: { title: "Hello", body: "world" }, storedAt: "2026-10-20T00:00:00Z" },
|
||||
{ key: "note:other", type: "note", payload: second, storedAt: "2026-10-20T00:00:00Z" },
|
||||
]);
|
||||
await writeFile(storePath, bytes);
|
||||
return { storePath, bytes };
|
||||
}
|
||||
const envelopePayload = {
|
||||
title: "Hello",
|
||||
body: "world",
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
storePath,
|
||||
encode([
|
||||
{
|
||||
key: "note:hello",
|
||||
type: "note",
|
||||
payload: envelopePayload,
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
test("a codec that refuses a synthetic pre-v1 store's content leaves the file byte-identical", async () => {
|
||||
const { storePath, bytes } = await legacyNoteStore({ title: 42 });
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(legacyNoteType)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await assert.rejects(() => cache.pullAllBackingStores());
|
||||
assert.deepEqual([...(await readFile(storePath))], [...bytes]);
|
||||
assert.deepEqual((await readdir(dirname(storePath))).sort(), ["legacy.msgpack"]);
|
||||
});
|
||||
|
||||
test("a synthetic pre-v1 store is read untouched and becomes v1 only when the consumer writes, keeping its other entries", async () => {
|
||||
const { storePath, bytes } = await legacyNoteStore({ title: "Other" });
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(legacyNoteType)
|
||||
.withDocumentType(noteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await cache.pullAllBackingStores();
|
||||
assert.deepEqual(cache.getRequired(legacyNoteType, "note:hello"), { title: "Hello", body: "world" });
|
||||
assert.deepEqual([...(await readFile(storePath))], [...bytes], "reading does not rewrite the file");
|
||||
assert.deepEqual(cache.getRequired(noteDocument, "note:hello"), envelopePayload);
|
||||
|
||||
await cache.put(legacyNoteType, "note:new", { title: "New" });
|
||||
assert.equal((decode(await readFile(storePath)) as unknown[])[0], "cultcache.store.v1");
|
||||
|
||||
const reopened = CultCache.builder()
|
||||
.withDocumentType(legacyNoteType)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
await reopened.pullAllBackingStores();
|
||||
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:hello"), { title: "Hello", body: "world" });
|
||||
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:other"), { title: "Other" });
|
||||
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:new"), { title: "New" });
|
||||
const rewritten = decode(await readFile(storePath)) as unknown[];
|
||||
assert.equal(rewritten[0], "cultcache.store.v1");
|
||||
const records = rewritten[2] as unknown[][];
|
||||
assert.equal(records.length, 1);
|
||||
assert.ok(records[0]?.[3] instanceof Uint8Array);
|
||||
});
|
||||
|
||||
test("CultCache rejects a second generic backing store", async () => {
|
||||
|
|
|
|||
|
|
@ -471,70 +471,44 @@ test("the store lock is released when the operation throws", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
// SYNTHETIC pre-v1 vector: no real file of this shape is known to exist. A top-level array of {key,type,payload,storedAt}
|
||||
// whose payload is not binary (an object, a Node Buffer JSON, an int array) is what the removed heal-on-open rewrote.
|
||||
const legacyObjectPayload = (): Uint8Array =>
|
||||
encode([{ key: "note:hello", type: "note", payload: { title: "Hello" }, storedAt: "2026-10-20T00:00:00Z" }]);
|
||||
|
||||
test("opening a synthetic pre-v1 store with object payloads returns them normalized and leaves the file untouched, even while another holder has the lock", async () => {
|
||||
test("an open that heals a legacy store waits for the store lock and heals from its own read", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "legacy.cc");
|
||||
const legacy = legacyObjectPayload();
|
||||
await writeFile(path, legacy);
|
||||
const store = new SingleFileMessagePackBackingStore(path);
|
||||
let healed: Promise<unknown> | undefined;
|
||||
let settled = false;
|
||||
await withStoreWriteLock(path, async () => {
|
||||
const [lockHeld] = await Promise.all([readdir(directory)]);
|
||||
const envelopes = await within(store.pullAll(), 5000, "an open does not wait for the write lock");
|
||||
assert.deepEqual(envelopes.map((entry) => entry.key), ["note:hello"]);
|
||||
assert.deepEqual(decode(documentPayloadBytes(envelopes[0].payload)), { title: "Hello" });
|
||||
assert.deepEqual([...(await readFile(path))], [...legacy]);
|
||||
assert.deepEqual((await readdir(directory)).sort(), lockHeld.sort(), "an open adds no lock or temp file");
|
||||
healed = store.pullAll().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await sleepFor(300);
|
||||
assert.equal(settled, false, "the heal must wait for the lock");
|
||||
assert.deepEqual([...(await readFile(path))], [...legacy], "the file keeps its legacy bytes while the lock is held elsewhere");
|
||||
});
|
||||
assert.deepEqual([...(await readFile(path))], [...legacy]);
|
||||
assert.deepEqual((await readdir(directory)).filter((name) => name !== "legacy.cc" && name !== "legacy.cc.lock"), []);
|
||||
await within(healed!, 5000, "the heal");
|
||||
assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1");
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("opening a synthetic pre-v1 store with Buffer-JSON and int-array payloads normalizes them in memory and leaves the file untouched", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "legacy.cc");
|
||||
const inner = encode({ title: "Hello" });
|
||||
const legacy = encode([
|
||||
{ key: "a", type: "note", payload: { type: "Buffer", data: [...inner] }, storedAt: "2026-10-20T00:00:00Z" },
|
||||
{ key: "b", type: "note", payload: [...inner], storedAt: "2026-10-20T00:00:00Z" },
|
||||
]);
|
||||
await writeFile(path, legacy);
|
||||
const envelopes = await new SingleFileMessagePackBackingStore(path).pullAll();
|
||||
assert.deepEqual(envelopes.map((entry) => entry.key), ["a", "b"]);
|
||||
for (const envelope of envelopes) {
|
||||
assert.deepEqual(decode(documentPayloadBytes(envelope.payload)), { title: "Hello" });
|
||||
}
|
||||
assert.deepEqual([...(await readFile(path))], [...legacy]);
|
||||
assert.deepEqual(await readdir(directory), ["legacy.cc"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("push and delete over a synthetic pre-v1 store write v1 and keep the other legacy entries, normalized payloads included", async () => {
|
||||
test("push and delete over a legacy store that needs healing finish instead of waiting on their own lock", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "legacy.cc");
|
||||
await writeFile(path, legacyObjectPayload());
|
||||
const store = new SingleFileMessagePackBackingStore(path);
|
||||
await within(store.push(entryOf("a")), 5000, "push");
|
||||
assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1");
|
||||
assert.deepEqual((await store.pullAll()).map((entry) => entry.key).sort(), ["a", "note:hello"]);
|
||||
await writeFile(path, legacyObjectPayload());
|
||||
await within(store.delete(entryOf("a")), 5000, "delete");
|
||||
assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1");
|
||||
const kept = await store.pullAll();
|
||||
assert.deepEqual(kept.map((entry) => entry.key), ["note:hello"]);
|
||||
assert.deepEqual(decode(documentPayloadBytes(kept[0].payload)), { title: "Hello" });
|
||||
const keys = (await store.pullAll()).map((entry) => entry.key);
|
||||
assert.deepEqual(keys, ["note:hello"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -723,7 +697,7 @@ test("a store file of an unrecognized shape is refused, and a store naming a mis
|
|||
}
|
||||
});
|
||||
|
||||
test("opening writes nothing: a missing store leaves no lock file, a legacy store with byte payloads keeps its bytes", async () => {
|
||||
test("opening a store that needs no healing writes nothing: a missing store leaves no lock file, a legacy store with byte payloads keeps its bytes", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const absent = join(directory, "nested", "absent.cc");
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ const cardRuleErrors = (c: Card): string[] => {
|
|||
if (!ids.includes(c.recommendation)) errors.push("recommendation is not an option");
|
||||
if (c.leans !== null && !ids.includes(c.leans)) errors.push("leans is not an option");
|
||||
if (new Set(c.deciders).size !== c.deciders.length) errors.push("duplicate deciders");
|
||||
if (c.depends.some((d) => Buffer.byteLength(d, "utf8") > 1000)) errors.push("a depends line is over 1000 UTF-8 bytes");
|
||||
if (c.status === "open" && c.ruling !== null) errors.push("an open card names a ruling");
|
||||
if (c.ruling === null && c.ruled_from.length > 0) errors.push("ruled_from without a ruling");
|
||||
if (c.options_hash !== optionsHash(c)) errors.push("options_hash is not the hash of the shown members");
|
||||
|
|
@ -128,36 +127,6 @@ test("fork card: options hash is SHA-256 of the canonical JSON, computed here, a
|
|||
}
|
||||
});
|
||||
|
||||
test("fork card: an answer result without counted_answer_id is refused by the schema", () => {
|
||||
const result = structuredClone(fork.scenarios.flatMap((sc: any) => sc.steps).find((st: any) => st.response?.answer).response);
|
||||
assert.ok(check(forkSchema, "answerResult", result));
|
||||
delete result.counted_answer_id;
|
||||
assert.ok(!check(forkSchema, "answerResult", result));
|
||||
});
|
||||
|
||||
// body_sha256 is Huginn's private "same body" digest, not a wire field: compact JSON, fixed key order, null for missing, the two illegal-form keys only when present.
|
||||
test("fork card: the body digest is SHA-256 of the compact JSON in the stated key order, and the fixed forms separate retracted true from false", () => {
|
||||
const byName = new Map<string, any>(fork.bodyDigests.map((d: { name: string }) => [d.name, d]));
|
||||
for (const d of fork.bodyDigests) {
|
||||
const o: Record<string, unknown> = {};
|
||||
for (const key of ["fork", "options_hash", "answered_at", "choice", "note", "retracts", "illegal_retraction", "retracted"]) {
|
||||
if ((key === "illegal_retraction" || key === "retracted") && d.input[key] === null) continue;
|
||||
o[key] = d.input[key];
|
||||
}
|
||||
const json = JSON.stringify(o);
|
||||
assert.equal(json, d.canonical, d.name);
|
||||
assert.equal(sha(Buffer.from(json, "utf8")), d.sha256, d.name);
|
||||
}
|
||||
assert.ok(byName.get("plain_answer_choice_A").sha256.startsWith("8a4d5b2e"));
|
||||
assert.ok(byName.get("legal_retraction").sha256.startsWith("9f807538"));
|
||||
assert.ok(byName.get("legal_retraction_door_form").sha256.startsWith("84576840"));
|
||||
assert.ok(byName.get("legal_retraction_door_form").input.retracts.endsWith(".n1"), "the door's form: the wire n<seq> converted to the full key");
|
||||
assert.ok(byName.get("illegal_i_retraction_with_an_answer_retracted_true").sha256.startsWith("935cab1a"));
|
||||
assert.ok(byName.get("illegal_iii_plain_answer_with_a_target_retracted_false").sha256.startsWith("82b24ccc"));
|
||||
assert.ok(byName.get("illegal_ii_no_answer_no_target_retracted_true").sha256.startsWith("34ef86ed"));
|
||||
assert.equal(new Set(fork.bodyDigests.map((d: { sha256: string }) => d.sha256)).size, fork.bodyDigests.length, "no two vectors share a digest");
|
||||
});
|
||||
|
||||
test("fork card: mutating any shown member, an option id included, changes the hash", () => {
|
||||
const card = fork.card.valid[0].message as Card;
|
||||
const base = optionsHash(card);
|
||||
|
|
@ -208,7 +177,6 @@ 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", () => {
|
||||
|
|
@ -263,11 +231,11 @@ function judge(start: Card, steps: { kind: string; body: Record<string, any> | n
|
|||
const store = (outcome: string, link: string | null, code: string | null) => {
|
||||
const a = b.answer ?? { choice: null, note: null };
|
||||
const doc = { schemaVersion: "gamecult.desk_answer.v0", answer_id: s.answer_id, fork_id: b.fork_id, outcome,
|
||||
of: outcome === "Revision" ? link : null, retracts: outcome === "Retraction" ? link : outcome === "Refused" ? b.retracts : null, code: outcome === "Refused" ? code : null,
|
||||
of: outcome === "Revision" ? link : null, retracts: outcome === "Retraction" ? link : null, code: outcome === "Refused" ? code : null,
|
||||
options_hash: b.options_hash, choice: a.choice, note: a.note, answered_at: b.answered_at, received_at: s.received_at,
|
||||
claim_in_future: SECONDS(b.answered_at) - SECONDS(s.received_at) > 300_000, answerer: s.answerer, authority: s.authority, request_id: b.request_id };
|
||||
answers.push(doc);
|
||||
const stored = { schemaVersion: "gamecult.fork.answer_result.v0", answer: doc, card: code === "stale_fork" ? card : null, counted_answer_id: counted() };
|
||||
const stored = { schemaVersion: "gamecult.fork.answer_result.v0", answer: doc, card: code === "stale_fork" ? card : null };
|
||||
seen.set(`${s.answerer}|${b.request_id}`, { body: structuredClone(b), response: structuredClone(stored) });
|
||||
return stored;
|
||||
};
|
||||
|
|
@ -275,7 +243,7 @@ function judge(start: Card, steps: { kind: string; body: Record<string, any> | n
|
|||
const down = s.door_down === true, reached = s.stored === true; // the second door failed to answer; "stored" says whether Huginn had the request by then
|
||||
const unavailable = () => ({ schemaVersion: "gamecult.fork.unavailable.v0", code: "unavailable", error: "unavailable: the outcome is not confirmed, try again with the same request_id", retryAfterSeconds: s.retry_after_seconds });
|
||||
// a stored desk_answer has no card, so a replayed stale_fork carries the CURRENT card (fork-card.md, request_id)
|
||||
const replay = (stored: any) => { const copy = structuredClone(stored); if (copy.answer.code === "stale_fork") copy.card = structuredClone(card); copy.counted_answer_id = counted(); return copy; };
|
||||
const replay = (stored: any) => { const copy = structuredClone(stored); if (copy.answer.code === "stale_fork") copy.card = structuredClone(card); return copy; };
|
||||
let response: unknown;
|
||||
if (s.answerer === null) response = door("unauthenticated");
|
||||
else if (s.active === false) response = door("forbidden");
|
||||
|
|
@ -388,17 +356,6 @@ const entryRuleErrors = (e: { card: Card; record: { fork_id: string; options_has
|
|||
return errors;
|
||||
};
|
||||
|
||||
// Order is a unique rank per Mind: the refusal names both fork ids, the first holder and the second.
|
||||
const orderErrors = (cards: { fork_id: string; order: number }[]): string[] => {
|
||||
const holder = new Map<number, string>();
|
||||
const errors: string[] = [];
|
||||
for (const c of cards) {
|
||||
if (holder.has(c.order)) errors.push(`order ${c.order} is shared by ${holder.get(c.order)} and ${c.fork_id}`);
|
||||
else holder.set(c.order, c.fork_id);
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
// The reference list judge: newest first by card order, status filter, cursor c<N> = cards below order N, a page of limit, counts over the whole set.
|
||||
const judgeList = (entries: { card: Card }[], ordinal: number, rq: { mind: string; status: string | null; cursor: string | null; limit: number }) => {
|
||||
const rows = [...entries].sort((a, b) => (b.card as any).order - (a.card as any).order);
|
||||
|
|
@ -473,9 +430,7 @@ test("fork transport: requests and entries validate and encode to the vector byt
|
|||
}
|
||||
}
|
||||
for (const v of t.listInvalid) {
|
||||
const refused = !check(forkSchema, "list", v.message) || orderErrors(v.message.forks.map((f: any) => f.card)).length > 0;
|
||||
assert.ok(refused, `${v.name}: ${v.why}`);
|
||||
if (v.refusal !== undefined) assert.deepEqual(orderErrors(v.message.forks.map((f: any) => f.card)), [v.refusal], v.name);
|
||||
assert.equal(check(forkSchema, "list", v.message), false, `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
});
|
||||
|
|
@ -483,7 +438,6 @@ test("fork transport: requests and entries validate and encode to the vector byt
|
|||
test("fork transport: every list request replays to the vector's response, newest first, paged by the cursor", () => {
|
||||
for (const sc of fork.transport.listScenarios) {
|
||||
for (const e of sc.entries) assert.deepEqual(entryRuleErrors(e), [], sc.name);
|
||||
assert.deepEqual(orderErrors(sc.entries.map((e: any) => e.card)), [], sc.name);
|
||||
let previous: { next_cursor: string | null } | null = null;
|
||||
for (const step of sc.steps) {
|
||||
const response = judgeList(sc.entries, sc.ordinal, step.request);
|
||||
|
|
@ -498,79 +452,6 @@ test("fork transport: every list request replays to the vector's response, newes
|
|||
}
|
||||
});
|
||||
|
||||
test("fork transport: order 0 is legal, no cursor names it, and the page ending at order 1 hands on to it", () => {
|
||||
const sc = fork.transport.listScenarios.find((x: any) => x.name === "order_zero_is_legal_but_no_cursor_names_it");
|
||||
assert.deepEqual(sc.steps.map((s: any) => s.response.next_cursor), ["c1", null]);
|
||||
assert.deepEqual(sc.steps[1].response.forks.map((f: any) => f.card.order), [0]);
|
||||
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>();
|
||||
|
|
|
|||
|
|
@ -233,10 +233,10 @@ def future(a, r):
|
|||
def da(outcome, link, code, b, server):
|
||||
a = b["answer"] or {"choice": None, "note": None}
|
||||
return {"schemaVersion": "gamecult.desk_answer.v0", "answer_id": server["answer_id"], "fork_id": b["fork_id"], "outcome": outcome,
|
||||
"of": link if outcome == "Revision" else None, "retracts": link if outcome == "Retraction" else (b["retracts"] if outcome == "Refused" else None), "code": code if outcome == "Refused" else None,
|
||||
"of": link if outcome == "Revision" else None, "retracts": link if outcome == "Retraction" else None, "code": code if outcome == "Refused" else None,
|
||||
"options_hash": b["options_hash"], "choice": a["choice"], "note": a["note"], "answered_at": b["answered_at"], "received_at": server["received_at"],
|
||||
"claim_in_future": future(b["answered_at"], server["received_at"]), "answerer": server["answerer"], "authority": server["authority"], "request_id": b["request_id"]}
|
||||
def result(answer, c=None): return {"schemaVersion": "gamecult.fork.answer_result.v0", "answer": answer, "card": c, "counted_answer_id": None} # sc() fills counted_answer_id
|
||||
def result(answer, c=None): return {"schemaVersion": "gamecult.fork.answer_result.v0", "answer": answer, "card": c}
|
||||
def record(counted, answers, h=H, fork=FID): return {"schemaVersion": "gamecult.fork.record.v0", "fork_id": fork, "options_hash": h, "counted_answer_id": counted, "answers": answers}
|
||||
T = lambda m, sec=5: f"2026-11-12T19:{m:02d}:{sec:02d}Z"
|
||||
def step(kind, body_=None, server=None, response=None, **kw):
|
||||
|
|
@ -247,18 +247,8 @@ def rf(b, server, code, c=None):
|
|||
a = da("Refused", None, code, b, server); return step("answer", b, server, result(a, c)), a
|
||||
def door(b, server, code): return step("answer", b, server, refusal_doc(code)), None
|
||||
def sc(name, c, items, counted, h=None):
|
||||
# answer_result.counted_answer_id: the counted answer after the step, derived from the answers stored so far under the card's current hash
|
||||
steps, answers, cur = [], [], c["options_hash"]
|
||||
def live(): return next((a["answer_id"] for a in answers if a["outcome"] in ("Counted", "Revision") and a["options_hash"] == cur
|
||||
and not any(r["outcome"] == "Retraction" and r["retracts"] == a["answer_id"] for r in answers)), None)
|
||||
for i in items:
|
||||
st = i[0] if isinstance(i, tuple) else i
|
||||
if isinstance(i, tuple) and i[1] is not None: answers.append(i[1])
|
||||
if st["kind"] == "card": cur = st["card"]["options_hash"]
|
||||
if st["kind"] == "answer" and (st["response"] or {}).get("schemaVersion") == "gamecult.fork.answer_result.v0":
|
||||
st = dict(st, response=dict(st["response"], counted_answer_id=live()))
|
||||
steps.append(st)
|
||||
assert live() == counted, (name, live(), counted)
|
||||
steps = [i[0] if isinstance(i, tuple) else i for i in items]
|
||||
answers = [i[1] for i in items if isinstance(i, tuple) and i[1] is not None]
|
||||
return (name, c, steps, record(counted, answers, h or c["options_hash"]))
|
||||
|
||||
x1 = body("no", NOTE); sx1 = srv("n1", OP, T(2))
|
||||
|
|
@ -341,24 +331,8 @@ scen = [
|
|||
sc("V11_retracting_an_answer_that_was_not_counted_changes_nothing", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1"), ok(RET("n3"), sr("n8"), "Retraction", "n3")], "n1"),
|
||||
sc("after_every_answer_is_retracted_a_fresh_answer_is_Counted_again", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), ok(x2, sx2, "Counted", None)], "n2"),
|
||||
]
|
||||
# The three illegal forms (R1) under fixed request ids (a fresh next_rid() here would move every later vector). A and C differ only in `retracted`.
|
||||
IL_A = dict(retraction("n1", rid="0000a001-0000-4000-8000-00000000a001"), answer={"choice": "no", "note": None}) # a retraction carrying an answer
|
||||
IL_B = dict(retraction("n1", rid="0000b001-0000-4000-8000-00000000b001"), retracts=None) # a retraction naming no target
|
||||
IL_C = dict(body("no", None, rid="0000a001-0000-4000-8000-00000000a001"), retracts="n1") # a plain answer carrying a target
|
||||
assert {k: v for k, v in IL_A.items() if k != "retracted"} == {k: v for k, v in IL_C.items() if k != "retracted"} and IL_A["retracted"] and not IL_C["retracted"]
|
||||
def replayed(first, server): return (step("answer", first[0]["body"], dict(server, answer_id="n9"), first[0]["response"]), None)
|
||||
for _n, _f in (("a_retraction_with_an_answer", IL_A), ("a_retraction_with_no_target", IL_B), ("a_plain_answer_with_a_target", IL_C)):
|
||||
_r = rf(_f, sr("n2"), "bad_retraction")
|
||||
scen.append(sc(f"R1_{_n}_is_a_stored_bad_retraction_and_a_retry_replays_it", CARD, [X1, _r, replayed(_r, sr("n2"))], "n1"))
|
||||
scen += [
|
||||
sc("a_replay_returns_the_counted_answer_id_current_at_the_retry_not_the_one_stored", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), REPLAY1], None),
|
||||
sc("R1_retracted_true_with_an_answer_and_no_target_is_a_stored_bad_retraction", CARD, [X1, rf(dict(IL_A, retracts=None), sr("n2"), "bad_retraction")], "n1"),
|
||||
sc("R1_retracted_true_then_false_under_one_request_id_is_request_id_conflict", CARD, [X1, rf(IL_A, sr("n2"), "bad_retraction"), door(IL_C, sr("n9"), "request_id_conflict")], "n1"),
|
||||
sc("R1_retracted_false_then_true_under_one_request_id_is_request_id_conflict", CARD, [X1, rf(IL_C, sr("n2"), "bad_retraction"), door(IL_A, sr("n9"), "request_id_conflict")], "n1"),
|
||||
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, "fork_too_large": 422,
|
||||
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,
|
||||
"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:
|
||||
|
|
@ -393,16 +367,6 @@ a5 = da("Counted", None, None, fbody(q5, "no", NOTE), srv("n1", OP, T(2)))
|
|||
a3 = da("Counted", None, None, fbody(q3, "yes", NOTE), srv("n1", OP, T(2)))
|
||||
r3 = da("Refused", None, "fork_closed", fbody(q3, "no", NOTE), srv("n2", HM, T(4), "Steward"))
|
||||
ENTRIES = [fentry(q5, [a5], "n1"), fentry(q4), fentry(q3, [a3, r3], "n1"), fentry(q2), fentry(q1)]
|
||||
q0 = fcard(0)
|
||||
ORDER0 = [fentry(fcard(3)), fentry(fcard(2)), fentry(fcard(1)), fentry(q0)] # a Mind whose oldest card has order 0
|
||||
q4b = dict(fcard(4), fork_id="gamecult-chat:question:q4b") # a second card on order 4 in the same Mind
|
||||
def order_shared(entries):
|
||||
seen, out = {}, []
|
||||
for e in entries:
|
||||
c = e["card"]
|
||||
if c["order"] in seen: out.append(f"order {c['order']} is shared by {seen[c['order']]} and {c['fork_id']}")
|
||||
else: seen[c["order"]] = c["fork_id"]
|
||||
return out
|
||||
def lreq(mind="gamecult-chat", status=None, cursor=None, limit=10): return {"schemaVersion": "gamecult.fork.list_request.v0", "mind": mind, "status": status, "cursor": cursor, "limit": limit}
|
||||
def judge_list(entries, ordinal, rq):
|
||||
rows = sorted(entries, key=lambda e: -e["card"]["order"])
|
||||
|
|
@ -419,33 +383,8 @@ LISTS = [
|
|||
{"name": "all_forks_one_page", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=50))]},
|
||||
{"name": "recorded_only_paged", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="recorded", limit=1)), lstep(ENTRIES, 412, lreq(status="recorded", cursor="c3", limit=1))]},
|
||||
{"name": "limit_zero_is_the_count_and_stamp_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=0))]},
|
||||
{"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}
|
||||
AUTH = [
|
||||
("list_with_desk_read", "list", TOK("bifrost-desk", "desk.read"), "ok"), ("read_with_desk_read", "read", TOK("bifrost-desk", "desk.read"), "ok"),
|
||||
|
|
@ -509,47 +448,14 @@ MINDCASES = [
|
|||
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)
|
||||
DECIDERS_64 = [f"{i:08x}-0000-4000-8000-{i:012x}" for i in range(1, 65)] # the card's `deciders` holds at most 64 (Huginn's bound)
|
||||
# body_sha256: Huginn's private "same body" digest (fork-card.md, "The mind's record"); no wire document carries it. Input per
|
||||
# /opt/commons/handoff/desk-body-digest-order-20261110.md (Eureka): SHA-256 of compact serde_json UTF-8, keys in this order, null for missing,
|
||||
# `retracts` the full stored key, request_id out. `illegal_retraction` and `retracted` are written only when the input has them (non-null).
|
||||
DIGEST_KEYS = ["fork", "options_hash", "answered_at", "choice", "note", "retracts", "illegal_retraction", "retracted"]
|
||||
def digest_bytes(i): return json.dumps({k: i[k] for k in DIGEST_KEYS if k in ("fork", "options_hash", "answered_at", "choice", "note", "retracts") or i.get(k) is not None},
|
||||
separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
DQ = {"fork": "eureka-state:question:Q1", "options_hash": "a" * 64, "answered_at": "2026-11-10T12:00:00Z"}
|
||||
DT = "eureka-state:desk_answer:Q1.n1"
|
||||
def dg(name, status, sha_expected, why, **kw):
|
||||
i = dict(DQ, choice=None, note=None, retracts=None, illegal_retraction=None, retracted=None); i.update(kw)
|
||||
b = digest_bytes(i); got = sha(b)
|
||||
assert got == sha_expected, (name, got)
|
||||
return {"name": name, "status": status, "why": why, "input": i, "canonical": b.decode("utf-8"), "sha256": got}
|
||||
MAIN, FIX = "main_7ea11b4_must_not_move", "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here"
|
||||
BODY_DIGESTS = [
|
||||
dg("plain_answer_choice_A", MAIN, "8a4d5b2ed0b3452bfed2f3a276b2828974e6824a3edcc643cfe9cfcdda34173a", "a plain answer: choice and note as sent, retracts null; no illegal or retracted key", choice="A"),
|
||||
dg("legal_retraction", MAIN, "9f8075382556898c0bea364ed7c6deb2a86d8c78288a1f34e06da6e9bf5f58a3", "a legal retraction as Huginn main pins it: retracts is `Q1.1`, a string no door produces (the key grammar is .n<seq>); kept for the frozen-form test", retracts="eureka-state:desk_answer:Q1.1"),
|
||||
dg("illegal_i_and_iii_alike_on_main", MAIN, "69074cc60829ef5edb12358746f7549ad08da97653d59359c945cea102f53565", "main writes `illegal_retraction: true` and no `retracted`, so (i) and (iii) collide: the defect the fix removes", choice="A", retracts=DT, illegal_retraction=True),
|
||||
dg("legal_retraction_door_form", MAIN, "84576840247830b9b75754b460dc4ea52883ca1e410f9f011521e0f69b99eee1", "a legal retraction as the door produces it: the wire's n1 converted to the full stored key Q1.n1 before the digest; the golden that stands for the real form (digest unaffected by .n<seq> vs .1)", retracts=DT),
|
||||
dg("illegal_ii_on_main", MAIN, "1a36c4aba0d1c30d0736f20243ebfdb84fe84a5eabd4f69709a096a32c3cc6e3", "(ii) no answer, no target, on main", illegal_retraction=True),
|
||||
dg("illegal_i_retraction_with_an_answer_retracted_true", FIX, "935cab1a0ae34606b809d8ebca7cffa2889d20b3585d2622d32153a4d6898af1", "(i) answer present, retracted true, target Q1.n1", choice="A", retracts=DT, illegal_retraction=True, retracted=True),
|
||||
dg("illegal_iii_plain_answer_with_a_target_retracted_false", FIX, "82b24cccb4ed2535a549f95f844fcd935074c3712b5b832f0b2cbd89c71fd9a6", "(iii) the same bytes as (i) with retracted false: another body, so request_id_conflict", choice="A", retracts=DT, illegal_retraction=True, retracted=False),
|
||||
dg("illegal_i_retraction_with_an_answer_and_no_target_retracted_true", "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here", "79eee623fd6712db6537a4867cedf54ca49ee4885416c2c8e4acec59f811b894", "(i) with the target null (the order doc: retracts set or null); Eureka re-derived it with sha256sum (order doc addendum)", choice="A", illegal_retraction=True, retracted=True),
|
||||
dg("illegal_ii_no_answer_no_target_retracted_true", FIX, "34ef86ed63b9318459965d4b85e794d2636ffc15736da866036b087c9c4a30eb", "(ii) no answer, no target, retracted true", illegal_retraction=True, retracted=True),
|
||||
]
|
||||
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.",
|
||||
"optionsHash": hashes,
|
||||
"bodyDigests": BODY_DIGESTS,
|
||||
"card": {"valid": [{"name": "operator_fork", "message": CARD, "messagePackHex": hx(CARD)},
|
||||
{"name": "leans_flag_closed_two_deciders", "message": card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0),
|
||||
"messagePackHex": hx(card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0))},
|
||||
{"name": "depends_at_both_caps", "message": card(depends=["a"] * 7 + ["\u00e9" * 500]), "messagePackHex": hx(card(depends=["a"] * 7 + ["\u00e9" * 500]))},
|
||||
{"name": "closed_without_a_ruling_the_question_was_withdrawn", "message": card(status="closed"), "messagePackHex": hx(card(status="closed"))},
|
||||
{"name": "deciders_at_the_cap_of_64", "message": card(deciders=DECIDERS_64), "messagePackHex": hx(card(deciders=DECIDERS_64))}],
|
||||
{"name": "closed_without_a_ruling_the_question_was_withdrawn", "message": card(status="closed"), "messagePackHex": hx(card(status="closed"))}],
|
||||
"invalid": [bad(n, w, m) for n, w, m in [
|
||||
("depends_nine_lines", "depends holds at most 8 lines (the stored question's limit)", card(depends=["a"] * 9)),
|
||||
("depends_line_1001_code_points", "a depends line is at most 1000 characters", card(depends=["a" * 1001])),
|
||||
("depends_line_1001_bytes", "rule: one byte over the limit (500 two-byte characters and one more; not expressible in the schema)", card(depends=["\u00e9" * 500 + "a"])),
|
||||
("depends_line_over_1000_bytes", "rule: a depends line is at most 1000 UTF-8 bytes (the stored Line limit; not expressible in the schema)", card(depends=["\u00e9" * 501])),
|
||||
("one_option", "two or more options", card(options=[OPT[0]], recommendation="yes")),
|
||||
("no_deciders", "a fork with no decider cannot be answered", card(deciders=[])),
|
||||
("null_recommendation", "every card recommends an option", dict(card(), recommendation=None)),
|
||||
|
|
@ -565,7 +471,6 @@ fork = {
|
|||
("open_card_names_a_ruling", "rule: only a closed card names a ruling (not expressible in the schema)", card(ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")),
|
||||
("ruled_from_without_a_ruling", "rule: ruled_from lists answers a ruling was admitted from, so it needs the ruling (not expressible in the schema)", card(status="closed", ruled_from=["n1"])),
|
||||
("options_hash_of_another_card", "rule: options_hash equals the hash of the shown members (not expressible in the schema)", dict(card(), options_hash=STALE_H)),
|
||||
("deciders_65", "deciders holds at most 64 (Huginn's bound)", card(deciders=DECIDERS_64 + ["00000041-0000-4000-8000-000000000041"])),
|
||||
]]},
|
||||
"request": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
|
||||
[("choice_and_note", x1), ("choice_alone", pick), ("note_alone_clear_pick", words), ("retraction", retraction("n1")),
|
||||
|
|
@ -592,7 +497,7 @@ fork = {
|
|||
"deskAnswer": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
|
||||
[("counted", CNT), ("revision", REV), ("retraction", da("Retraction", "n1", None, retraction("n1"), sr("n8"))),
|
||||
("refused_stale", da("Refused", None, "stale_fork", stale_body(), sx1)),
|
||||
("refused_closed_retraction_names_its_target", da("Refused", None, "fork_closed", retraction("n1"), sr("n9")))]],
|
||||
("refused_closed_retraction_keeps_no_target", da("Refused", None, "fork_closed", retraction("n1"), sr("n9")))]],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("counted_with_a_code", "only Refused carries a code", dict(CNT, code="fork_closed")),
|
||||
("refused_without_a_code", "Refused carries a reason code", dict(REF, code=None)),
|
||||
|
|
@ -601,14 +506,13 @@ fork = {
|
|||
("revision_naming_none", "a Revision names the counted answer", dict(REV, of=None)),
|
||||
("revision_that_also_retracts", "a variant carries only its own link (a retraction with no target, or a target with no retraction, cannot exist)", dict(REV, retracts="n1")),
|
||||
("retraction_naming_no_target", "a Retraction names the answer it retracts", dict(da("Retraction", "n1", None, retraction("n1"), sr("n8")), retracts=None)),
|
||||
("refused_naming_another_answer_as_of", "a Refused answer names no counted answer (`of` is a Revision's)", dict(REF, of="n1")),
|
||||
("refused_with_a_target_that_is_not_an_answer_id", "a Refused answer's retracts is nil or an answer id (n<seq>), never the stored key", dict(REF, retracts="cultlib-gaps:desk_answer:slot-mark-consent.n1")),
|
||||
("refused_naming_a_target", "Refused carries only its code", dict(REF, retracts="n1")),
|
||||
("retraction_carrying_a_choice", "a Retraction carries no choice or note", dict(da("Retraction", "n1", None, retraction("n1"), sr("n8")), choice="no")),
|
||||
("outcome_lower_case", "outcome tags are Counted, Revision, Retraction, Refused", dict(CNT, outcome="counted")),
|
||||
("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] + [{"name": "fork_too_large", "httpStatus": HTTP["fork_too_large"], "message": SHORT_PAGES[1]["steps"][0]["refusal"], "messagePackHex": SHORT_PAGES[1]["steps"][0]["refusalHex"]}],
|
||||
"doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL],
|
||||
"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,
|
||||
|
|
@ -636,15 +540,8 @@ fork = {
|
|||
"listInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("next_cursor_malformed", "a cursor is c<N>", dict(LISTS[0]["steps"][0]["response"], next_cursor="more")),
|
||||
("stamp_malformed", "a stamp is s<N>", dict(LISTS[0]["steps"][0]["response"], stamp="412")),
|
||||
("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1))]]
|
||||
+ [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])],
|
||||
("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],
|
||||
"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],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -178,47 +178,11 @@ namespace GameCult.Networking.Tests
|
|||
if (c["leans"] is not null && !ids.Contains(S(c["leans"]))) yield return "leans is not an option";
|
||||
var deciders = c["deciders"]!.AsArray().Select(S).ToList();
|
||||
if (deciders.Distinct().Count() != deciders.Count) yield return "duplicate deciders";
|
||||
if (c["depends"]!.AsArray().Any(d => Encoding.UTF8.GetByteCount(S(d)) > 1000)) yield return "a depends line is over 1000 UTF-8 bytes";
|
||||
if (S(c["status"]) == "open" && c["ruling"] is not null) yield return "an open card names a ruling";
|
||||
if (c["ruling"] is null && c["ruled_from"]!.AsArray().Count > 0) yield return "ruled_from without a ruling";
|
||||
if (S(c["options_hash"]) != HashOf(c)) yield return "options_hash is not the hash of the shown members";
|
||||
}
|
||||
|
||||
// body_sha256 is Huginn's private "same body" digest, not a wire field: compact JSON, fixed key order, null for missing, the two illegal-form keys only when present.
|
||||
[Test]
|
||||
public void ForkCard_AnAnswerResultWithoutCountedAnswerId_IsRefusedBySchema()
|
||||
{
|
||||
var result = Clone(Fork["scenarios"]!.AsArray().SelectMany(sc => sc!["steps"]!.AsArray()).First(st => st!["response"]?["answer"] is not null)!["response"])!.AsObject();
|
||||
Assert.That(Errors(ForkSchema, "answerResult", result), Is.Empty);
|
||||
result.Remove("counted_answer_id");
|
||||
Assert.That(Errors(ForkSchema, "answerResult", result), Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_BodyDigest_IsSha256OfTheCompactJsonInTheStatedKeyOrder_AndTheFixedFormsSeparateRetractedTrueFromFalse()
|
||||
{
|
||||
var options = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
var digests = Fork["bodyDigests"]!.AsArray().ToDictionary(d => S(d!["name"]), d => d!);
|
||||
foreach (var d in digests.Values)
|
||||
{
|
||||
var i = d["input"]!;
|
||||
var o = new JsonObject();
|
||||
foreach (var key in new[] { "fork", "options_hash", "answered_at", "choice", "note", "retracts", "illegal_retraction", "retracted" })
|
||||
if (key is "illegal_retraction" or "retracted" ? i[key] is not null : true) o[key] = Clone(i[key]);
|
||||
var json = o.ToJsonString(options);
|
||||
Assert.That(json, Is.EqualTo(S(d["canonical"])), S(d["name"]));
|
||||
Assert.That(Sha(Encoding.UTF8.GetBytes(json)), Is.EqualTo(S(d["sha256"])), S(d["name"]));
|
||||
}
|
||||
Assert.That(S(digests["plain_answer_choice_A"]["sha256"]), Does.StartWith("8a4d5b2e"));
|
||||
Assert.That(S(digests["legal_retraction"]["sha256"]), Does.StartWith("9f807538"));
|
||||
Assert.That(S(digests["legal_retraction_door_form"]["sha256"]), Does.StartWith("84576840"));
|
||||
Assert.That(digests["legal_retraction_door_form"]["input"]!["retracts"]!.ToString(), Does.EndWith(".n1"), "the door's form: the wire n<seq> converted to the full key");
|
||||
Assert.That(S(digests["illegal_i_retraction_with_an_answer_retracted_true"]["sha256"]), Does.StartWith("935cab1a"));
|
||||
Assert.That(S(digests["illegal_iii_plain_answer_with_a_target_retracted_false"]["sha256"]), Does.StartWith("82b24ccc"));
|
||||
Assert.That(S(digests["illegal_ii_no_answer_no_target_retracted_true"]["sha256"]), Does.StartWith("34ef86ed"));
|
||||
Assert.That(digests.Values.Select(d => S(d["sha256"])).Distinct().Count(), Is.EqualTo(digests.Count), "no two vectors share a digest");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkCard_OptionsHash_IsSha256OfTheCanonicalJson_ComputedHere_AndEveryShownMemberMovesIt()
|
||||
{
|
||||
|
|
@ -303,7 +267,6 @@ 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]
|
||||
|
|
@ -377,14 +340,14 @@ namespace GameCult.Networking.Tests
|
|||
var doc = new JsonObject
|
||||
{
|
||||
["schemaVersion"] = "gamecult.desk_answer.v0", ["answer_id"] = S(s["answer_id"]), ["fork_id"] = Clone(b["fork_id"]), ["outcome"] = outcome,
|
||||
["of"] = outcome == "Revision" ? link : null, ["retracts"] = outcome == "Retraction" ? link : outcome == "Refused" ? Clone(b["retracts"]) : null, ["code"] = outcome == "Refused" ? code : null,
|
||||
["of"] = outcome == "Revision" ? link : null, ["retracts"] = outcome == "Retraction" ? link : null, ["code"] = outcome == "Refused" ? code : null,
|
||||
["options_hash"] = Clone(b["options_hash"]), ["choice"] = Clone(a?["choice"]), ["note"] = Clone(a?["note"]), ["answered_at"] = Clone(b["answered_at"]),
|
||||
["received_at"] = Clone(s["received_at"]),
|
||||
["claim_in_future"] = (DateTimeOffset.Parse(S(b["answered_at"])) - DateTimeOffset.Parse(S(s["received_at"]))).TotalSeconds > 300,
|
||||
["answerer"] = Clone(s["answerer"]), ["authority"] = Clone(s["authority"]), ["request_id"] = Clone(b["request_id"])
|
||||
};
|
||||
answers.Add(Clone(doc));
|
||||
var stored = new JsonObject { ["schemaVersion"] = "gamecult.fork.answer_result.v0", ["answer"] = doc, ["card"] = code == "stale_fork" ? Clone(card) : null, ["counted_answer_id"] = Counted() };
|
||||
var stored = new JsonObject { ["schemaVersion"] = "gamecult.fork.answer_result.v0", ["answer"] = doc, ["card"] = code == "stale_fork" ? Clone(card) : null };
|
||||
seen[S(s["answerer"]) + "|" + S(b["request_id"])] = (Clone(b)!, Clone(stored)!);
|
||||
return stored;
|
||||
}
|
||||
|
|
@ -395,7 +358,7 @@ namespace GameCult.Networking.Tests
|
|||
var reached = s["stored"] is not null;
|
||||
JsonNode Unavailable() => new JsonObject { ["schemaVersion"] = "gamecult.fork.unavailable.v0", ["code"] = "unavailable", ["error"] = "unavailable: the outcome is not confirmed, try again with the same request_id", ["retryAfterSeconds"] = Clone(s["retry_after_seconds"]) };
|
||||
// a stored desk_answer has no card, so a replayed stale_fork carries the CURRENT card (fork-card.md, request_id)
|
||||
JsonNode Replay(JsonNode stored) { var copy = Clone(stored)!; if (S(copy["answer"]!["code"] ?? "") == "stale_fork") copy["card"] = Clone(card); copy["counted_answer_id"] = Counted(); return copy; }
|
||||
JsonNode Replay(JsonNode stored) { var copy = Clone(stored)!; if (S(copy["answer"]!["code"] ?? "") == "stale_fork") copy["card"] = Clone(card); return copy; }
|
||||
JsonNode response;
|
||||
if (s["answerer"] is null) response = Door("unauthenticated");
|
||||
else if (s["active"] is JsonValue activeValue && !activeValue.GetValue<bool>()) response = Door("forbidden");
|
||||
|
|
@ -533,20 +496,6 @@ namespace GameCult.Networking.Tests
|
|||
if ((record["counted_answer_id"] is null ? null : S(record["counted_answer_id"])) != counted) yield return "counted_answer_id is not derived";
|
||||
}
|
||||
|
||||
// Order is a unique rank per Mind: the refusal names both fork ids, the first holder and the second.
|
||||
private static List<string> OrderErrors(IEnumerable<JsonNode?> cards)
|
||||
{
|
||||
var holder = new Dictionary<long, string>();
|
||||
var errors = new List<string>();
|
||||
foreach (var c in cards)
|
||||
{
|
||||
var order = c!["order"]!.GetValue<long>();
|
||||
if (holder.TryGetValue(order, out var first)) errors.Add($"order {order} is shared by {first} and {S(c["fork_id"])}");
|
||||
else holder[order] = S(c["fork_id"]);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
// The reference list judge: newest first by card order, status filter, cursor c<N> = cards below order N, a page of limit, counts over the whole set.
|
||||
private static JsonNode JudgeList(JsonArray entries, long ordinal, JsonNode request)
|
||||
{
|
||||
|
|
@ -651,9 +600,7 @@ namespace GameCult.Networking.Tests
|
|||
}
|
||||
foreach (var v in t["listInvalid"]!.AsArray())
|
||||
{
|
||||
var shared = OrderErrors(v!["message"]!["forks"]!.AsArray().Select(f => f!["card"]));
|
||||
Assert.That(Errors(ForkSchema, "list", v["message"]).Count > 0 || shared.Count > 0, Is.True, $"{S(v["name"])}: {S(v["why"])}");
|
||||
if (v["refusal"] is not null) Assert.That(shared, Is.EqualTo(new[] { S(v["refusal"]) }), S(v["name"]));
|
||||
Assert.That(Errors(ForkSchema, "list", v!["message"]), Is.Not.Empty, $"{S(v["name"])}: {S(v["why"])}");
|
||||
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
|
||||
}
|
||||
}
|
||||
|
|
@ -666,7 +613,6 @@ namespace GameCult.Networking.Tests
|
|||
var name = S(sc!["name"]);
|
||||
var entries = sc["entries"]!.AsArray();
|
||||
foreach (var e in entries) Assert.That(EntryRuleErrors(e!), Is.Empty, name);
|
||||
Assert.That(OrderErrors(entries.Select(e => e!["card"])), Is.Empty, name);
|
||||
JsonNode? previous = null;
|
||||
foreach (var step in sc["steps"]!.AsArray())
|
||||
{
|
||||
|
|
@ -682,100 +628,6 @@ namespace GameCult.Networking.Tests
|
|||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForkTransport_OrderZero_IsLegal_NoCursorNamesIt_AndThePageEndingAtOrderOneHandsOnToIt()
|
||||
{
|
||||
var sc = Fork["transport"]!["listScenarios"]!.AsArray().Single(x => S(x!["name"]) == "order_zero_is_legal_but_no_cursor_names_it")!;
|
||||
var steps = sc["steps"]!.AsArray();
|
||||
Assert.That(S(steps[0]!["response"]!["next_cursor"]), Is.EqualTo("c1"));
|
||||
Assert.That(steps[1]!["response"]!["next_cursor"], Is.Null);
|
||||
Assert.That(steps[1]!["response"]!["forks"]!.AsArray().Select(f => f!["card"]!["order"]!.GetValue<long>()), Is.EqualTo(new[] { 0L }));
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
# sprite-silhouette
|
||||
|
||||
Turns a sprite's alpha channel into a simplified polygon in percent coordinates, for CSS
|
||||
`shape-outside: polygon()`, so text wraps around the figure instead of its rectangle. Zero
|
||||
dependencies: Node (tested on 24; `node:zlib` does the PNG inflate). Entry point: `silhouette.mjs`.
|
||||
Live and tested; there is no packaging or release for it, it is a repo tool.
|
||||
|
||||
## First call
|
||||
|
||||
```
|
||||
node tools/sprite-silhouette/silhouette.mjs /opt/commons/cast-art/poses --out silhouettes.json
|
||||
node --test tools/sprite-silhouette/silhouette.test.mjs
|
||||
```
|
||||
|
||||
The first argument is a poses directory holding `catalogue.json` (the cast-art catalogue); the
|
||||
tool reads it and every PNG it names, and writes nothing there. Output is a JSON object keyed by
|
||||
pose id, `mind/label` in catalogue order (`void/neutral`, `eureka/probe`, ...):
|
||||
|
||||
```json
|
||||
{ "void/neutral": { "width": 605, "height": 461,
|
||||
"points": [[42.98, 6.72], [49.59, 8.24], "..."],
|
||||
"css": "polygon(42.98% 6.72%, 49.59% 8.24%, ...)" } }
|
||||
```
|
||||
|
||||
Use the `css` string as the value of `shape-outside` on a floated image of the same aspect ratio
|
||||
(percentages are of the image's box):
|
||||
|
||||
```css
|
||||
img.pose { float: left; shape-outside: polygon(42.98% 6.72%, 49.59% 8.24%, ...); shape-margin: 0.5em; }
|
||||
```
|
||||
|
||||
An empty sprite (nothing at or above the threshold) gets `points: []` and `css: "none"`.
|
||||
|
||||
From code: `silhouette(pngBuffer, { threshold, tolerance })` in `silhouette.mjs`.
|
||||
|
||||
## Options
|
||||
|
||||
- `--threshold 0.35`: a pixel is inside when `alpha/255 >= threshold` (0.35 is the Void mock-up's value).
|
||||
- `--tolerance 1.5`: Douglas-Peucker distance in source pixels; the simplified polygon stays within
|
||||
this of the traced edge in both directions (tested). 0 keeps every corner.
|
||||
- `--out file`: write there instead of stdout.
|
||||
|
||||
## What it does and does not trace
|
||||
|
||||
Threshold, then the outer boundary of the largest 8-connected shape, by walking the pixel edges
|
||||
(so vertices sit on pixel corners and the walk is exact), then simplification of that closed ring.
|
||||
Holes are ignored (wrapping text goes around a figure, not through its gaps), and smaller
|
||||
disconnected islands (a floating hand, a stray sparkle) are dropped: `shape-outside` takes one
|
||||
polygon. The contour is a staircase of pixel edges, so below about 1 px the tolerance only
|
||||
reproduces the staircase; there is no sub-pixel interpolation of the alpha ramp.
|
||||
|
||||
Input: 8-bit non-interlaced PNG (RGBA, grey+alpha, palette with tRNS, or opaque RGB/grey, which is
|
||||
a rectangle). Anything else is refused with its name.
|
||||
|
||||
## Prior art
|
||||
|
||||
- **Marching squares** (Lorensen and Cline's cubes, 2D case) interpolates along the alpha ramp for
|
||||
sub-pixel contours and one pass emits every contour, holes included. Not used: the pose art is
|
||||
anti-aliased over a pixel or two, a 1.5 px tolerance swallows that, and we want exactly one outer ring.
|
||||
- **Potrace** (Selinger, 2003) fits optimal polygons and then Bezier curves to a bitmap. CSS
|
||||
`polygon()` has no curves, so only its idea of an optimal polygon applies; this tool keeps the
|
||||
simpler greedy Douglas-Peucker and measures its error directly.
|
||||
- **Douglas-Peucker** (1973; Ramer 1972) is the simplifier: keep the vertex farthest from the chord
|
||||
if it is beyond the tolerance, recurse. Its guarantee (every dropped vertex within the tolerance
|
||||
of the result) is what the tests assert. It is not topology-preserving; on all 99 current poses at
|
||||
the defaults no output self-intersects (checked when this was written, not asserted by the suite).
|
||||
- **Boundary following** (Moore-neighbour / square tracing) is the contour walk, with the
|
||||
8-connected rule so diagonally touching pixels stay one shape.
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
// Minimal PNG reader: the alpha plane only. 8-bit, non-interlaced (every pose in cast-art is RGBA8).
|
||||
// Anything else is refused by name, never guessed at.
|
||||
import { inflateSync } from "node:zlib";
|
||||
|
||||
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
||||
|
||||
/** @returns {{ width: number, height: number, alpha: Uint8Array }} alpha is row-major, 0..255. */
|
||||
export function decodeAlpha(buffer) {
|
||||
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");
|
||||
let header = null;
|
||||
let palette = null;
|
||||
let trns = null;
|
||||
const idat = [];
|
||||
for (let at = 8; at + 8 <= buffer.length; ) {
|
||||
const length = buffer.readUInt32BE(at);
|
||||
const type = buffer.toString("latin1", at + 4, at + 8);
|
||||
const body = buffer.subarray(at + 8, at + 8 + length);
|
||||
if (body.length !== length) throw new Error(`truncated ${type} chunk`);
|
||||
at += 12 + length;
|
||||
if (type === "IHDR") {
|
||||
header = { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], color: body[9], interlace: body[12] };
|
||||
} else if (type === "PLTE") palette = body;
|
||||
else if (type === "tRNS") trns = body;
|
||||
else if (type === "IDAT") idat.push(body);
|
||||
else if (type === "IEND") break;
|
||||
}
|
||||
if (!header) throw new Error("PNG has no IHDR");
|
||||
const { width, height, depth, color, interlace } = header;
|
||||
if (depth !== 8) throw new Error(`unsupported PNG bit depth ${depth} (8 only)`);
|
||||
if (interlace !== 0) throw new Error("unsupported PNG: interlaced");
|
||||
const channels = CHANNELS[color];
|
||||
if (!channels) throw new Error(`unsupported PNG color type ${color}`);
|
||||
if (color === 3 && !palette) throw new Error("indexed PNG has no PLTE");
|
||||
|
||||
const raw = inflateSync(Buffer.concat(idat));
|
||||
const stride = width * channels;
|
||||
if (raw.length !== (stride + 1) * height) throw new Error("PNG pixel data has the wrong length");
|
||||
|
||||
const alpha = new Uint8Array(width * height);
|
||||
let prev = new Uint8Array(stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[y * (stride + 1)];
|
||||
const line = Uint8Array.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
||||
unfilter(filter, line, prev, channels);
|
||||
for (let x = 0; x < width; x++) {
|
||||
const p = x * channels;
|
||||
let a = 255;
|
||||
if (color === 6) a = line[p + 3];
|
||||
else if (color === 4) a = line[p + 1];
|
||||
else if (color === 3) a = trns && line[p] < trns.length ? trns[line[p]] : 255;
|
||||
else if (trns && color === 0 && line[p] === trns.readUInt16BE(0)) a = 0;
|
||||
else if (trns && color === 2 && line[p] === trns.readUInt16BE(0) && line[p + 1] === trns.readUInt16BE(2) && line[p + 2] === trns.readUInt16BE(4)) a = 0;
|
||||
alpha[y * width + x] = a;
|
||||
}
|
||||
prev = line;
|
||||
}
|
||||
return { width, height, alpha };
|
||||
}
|
||||
|
||||
function unfilter(filter, line, prev, bpp) {
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const a = i >= bpp ? line[i - bpp] : 0;
|
||||
const b = prev[i];
|
||||
const c = i >= bpp ? prev[i - bpp] : 0;
|
||||
let add;
|
||||
switch (filter) {
|
||||
case 0: add = 0; break;
|
||||
case 1: add = a; break;
|
||||
case 2: add = b; break;
|
||||
case 3: add = (a + b) >> 1; break;
|
||||
case 4: {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
||||
break;
|
||||
}
|
||||
default: throw new Error(`bad PNG filter ${filter}`);
|
||||
}
|
||||
line[i] = (line[i] + add) & 255;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Sprite alpha silhouette -> simplified polygon in percent coordinates for CSS shape-outside: polygon().
|
||||
// First call: node tools/sprite-silhouette/silhouette.mjs /opt/commons/cast-art/poses > silhouettes.json
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { decodeAlpha } from "./png.mjs";
|
||||
import { largestContour, mask, simplifyRing } from "./trace.mjs";
|
||||
|
||||
export const DEFAULTS = { threshold: 0.35, tolerance: 1.5 };
|
||||
|
||||
const pct = (v, total) => Math.round((v / total) * 10000) / 100;
|
||||
|
||||
/** @returns {{ width: number, height: number, points: number[][], css: string }} points are [x%, y%]; css is "none" for an empty sprite. */
|
||||
export function silhouette(png, { threshold = DEFAULTS.threshold, tolerance = DEFAULTS.tolerance } = {}) {
|
||||
const { width, height, alpha } = decodeAlpha(png);
|
||||
const ring = largestContour(mask(alpha, threshold), width, height);
|
||||
if (!ring) return { width, height, points: [], css: "none" };
|
||||
const points = simplifyRing(ring, tolerance).map(([x, y]) => [pct(x, width), pct(y, height)]);
|
||||
const css = `polygon(${points.map(([x, y]) => `${x}% ${y}%`).join(", ")})`;
|
||||
return { width, height, points, css };
|
||||
}
|
||||
|
||||
/** Pose ids are `mind/label`, in catalogue order; the value is the path under the poses root. */
|
||||
export function poseFiles(catalogue) {
|
||||
const files = {};
|
||||
for (const [mind, entry] of Object.entries(catalogue.minds)) {
|
||||
for (const group of [entry.shared, entry.signatures]) {
|
||||
for (const [label, path] of Object.entries(group ?? {})) files[`${mind}/${label}`] = path;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function silhouettes(root, options) {
|
||||
const catalogue = JSON.parse(readFileSync(join(root, "catalogue.json"), "utf8"));
|
||||
const out = {};
|
||||
for (const [id, path] of Object.entries(poseFiles(catalogue))) out[id] = silhouette(readFileSync(join(root, path)), options);
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
let out = null, root = null;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === "--threshold") options.threshold = Number(argv[++i]);
|
||||
else if (argv[i] === "--tolerance") options.tolerance = Number(argv[++i]);
|
||||
else if (argv[i] === "--out") out = argv[++i];
|
||||
else if (argv[i].startsWith("--")) throw new Error(`unknown option ${argv[i]}`);
|
||||
else root = argv[i];
|
||||
}
|
||||
if (!root || !(options.threshold > 0 && options.threshold <= 1) || !(options.tolerance >= 0)) {
|
||||
console.error("usage: silhouette.mjs <poses-dir> [--threshold 0.35] [--tolerance 1.5] [--out file.json]");
|
||||
process.exit(2);
|
||||
}
|
||||
const text = JSON.stringify(silhouettes(root, options), null, 1) + "\n";
|
||||
if (out) writeFileSync(out, text);
|
||||
else process.stdout.write(text);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(process.argv.slice(2));
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { decodeAlpha } from "./png.mjs";
|
||||
import { silhouette, silhouettes } from "./silhouette.mjs";
|
||||
import { largestContour, mask, maxDistanceToRing, simplifyRing } from "./trace.mjs";
|
||||
|
||||
// An RGBA8 PNG built from an alpha function, every row encoded with the given PNG filter.
|
||||
function crc32(buf) {
|
||||
let c, crc = ~0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = (crc ^ buf[i]) & 255;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
||||
crc = (crc >>> 8) ^ c;
|
||||
}
|
||||
return ~crc >>> 0;
|
||||
}
|
||||
function chunk(type, body) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(body.length, 0);
|
||||
head.write(type, 4, "latin1");
|
||||
const tail = Buffer.alloc(4);
|
||||
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
|
||||
return Buffer.concat([head, body, tail]);
|
||||
}
|
||||
function png(width, height, alphaAt, { filter = 0 } = {}) {
|
||||
const rows = [];
|
||||
let prev = Buffer.alloc(width * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const line = Buffer.alloc(width * 4);
|
||||
for (let x = 0; x < width; x++) line.set([200, 10 * (x % 20), 30, alphaAt(x, y)], x * 4);
|
||||
const out = Buffer.alloc(width * 4 + 1);
|
||||
out[0] = filter;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const a = i >= 4 ? line[i - 4] : 0, b = prev[i], c = i >= 4 ? prev[i - 4] : 0;
|
||||
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
const pred = [0, a, b, (a + b) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? b : c][filter];
|
||||
out[i + 1] = (line[i] - pred) & 255;
|
||||
}
|
||||
rows.push(out);
|
||||
prev = line;
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr), chunk("IDAT", deflateSync(Buffer.concat(rows))), chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
const disc = (cx, cy, r) => (x, y) => ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2 <= r * r ? 255 : 0);
|
||||
|
||||
test("decodes alpha through every PNG row filter", () => {
|
||||
const f = (x, y) => (x * 7 + y * 13) % 256;
|
||||
for (const filter of [0, 1, 2, 3, 4]) {
|
||||
const { width, height, alpha } = decodeAlpha(png(9, 7, f, { filter }));
|
||||
assert.deepEqual([width, height], [9, 7]);
|
||||
for (let y = 0; y < 7; y++) for (let x = 0; x < 9; x++) assert.equal(alpha[y * 9 + x], f(x, y), `filter ${filter} at ${x},${y}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses what it cannot read", () => {
|
||||
assert.throws(() => decodeAlpha(Buffer.from("nope")), /not a PNG/);
|
||||
const bad = png(2, 2, () => 255);
|
||||
bad[24] = 16; // IHDR bit depth
|
||||
assert.throws(() => decodeAlpha(bad), /bit depth 16/);
|
||||
});
|
||||
|
||||
test("a fully transparent sprite has no shape", () => {
|
||||
const s = silhouette(png(8, 8, () => 0));
|
||||
assert.deepEqual(s.points, []);
|
||||
assert.equal(s.css, "none");
|
||||
});
|
||||
|
||||
test("a fully opaque sprite is its own bounding box", () => {
|
||||
const s = silhouette(png(8, 4, () => 255));
|
||||
assert.deepEqual(s.points, [[0, 0], [100, 0], [100, 100], [0, 100]]);
|
||||
assert.equal(s.css, "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)");
|
||||
});
|
||||
|
||||
test("threshold is alpha/255 >= 0.35: 89 is out, 90 is in", () => {
|
||||
assert.deepEqual(silhouette(png(4, 4, () => 89)).points, []);
|
||||
assert.equal(silhouette(png(4, 4, () => 90)).points.length, 4);
|
||||
assert.deepEqual(silhouette(png(4, 4, () => 89), { threshold: 0.3 }).points.length, 4);
|
||||
});
|
||||
|
||||
test("simplified shape stays within tolerance of the traced edge, both ways, and is smaller", () => {
|
||||
const { width, height, alpha } = decodeAlpha(png(120, 100, disc(60, 50, 40)));
|
||||
const ring = largestContour(mask(alpha, 0.35), width, height);
|
||||
for (const tolerance of [0.5, 1.5, 4]) {
|
||||
const simple = simplifyRing(ring, tolerance);
|
||||
assert.ok(simple.length < ring.length && simple.length >= 4);
|
||||
assert.ok(maxDistanceToRing(ring, simple) <= tolerance + 1e-9, `ring to simplified at ${tolerance}`);
|
||||
assert.ok(maxDistanceToRing(simple, ring) <= tolerance + 1e-9, `simplified to ring at ${tolerance}`);
|
||||
}
|
||||
// And the traced edge is the true edge to within a pixel: every ring vertex sits within 1px of the disc's circle.
|
||||
for (const [x, y] of ring) assert.ok(Math.abs(Math.hypot(x - 60, y - 50) - 40) <= 1.0, `${x},${y}`);
|
||||
});
|
||||
|
||||
test("only the outside is traced: a hole is ignored", () => {
|
||||
const ring = (x, y) => (Math.max(Math.abs(x - 10), Math.abs(y - 10)) <= 8 && Math.max(Math.abs(x - 10), Math.abs(y - 10)) > 3 ? 255 : 0);
|
||||
const s = silhouette(png(20, 20, ring), { tolerance: 0 });
|
||||
const xs = s.points.map((p) => p[0]), ys = s.points.map((p) => p[1]);
|
||||
assert.deepEqual([Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)], [10, 95, 10, 95]);
|
||||
assert.equal(s.points.length, 4);
|
||||
});
|
||||
|
||||
test("pixels that only touch diagonally are one shape; a smaller island is dropped", () => {
|
||||
const dots = (x, y) => (x === y && x < 4 ? 255 : 0);
|
||||
const diag = silhouette(png(8, 8, dots), { tolerance: 0 });
|
||||
const xs = diag.points.map((p) => p[0]);
|
||||
assert.equal(Math.max(...xs), 50); // x 0..4 of 8
|
||||
const island = (x, y) => ((x < 5 && y < 5) || (x === 8 && y === 8) ? 255 : 0);
|
||||
const s = silhouette(png(10, 10, island));
|
||||
assert.deepEqual(s.points, [[0, 0], [50, 0], [50, 50], [0, 50]]);
|
||||
});
|
||||
|
||||
test("deterministic: same bytes, same polygon; coordinates are percent within the image", () => {
|
||||
const bytes = png(90, 70, disc(45, 35, 30));
|
||||
const a = silhouette(bytes), b = silhouette(Buffer.from(bytes));
|
||||
assert.deepEqual(a, b);
|
||||
for (const [x, y] of a.points) assert.ok(x >= 0 && x <= 100 && y >= 0 && y <= 100);
|
||||
assert.match(a.css, /^polygon\((\d+(\.\d+)?% \d+(\.\d+)?%)(, \d+(\.\d+)?% \d+(\.\d+)?%){3,}\)$/);
|
||||
});
|
||||
|
||||
const POSES = "/opt/commons/cast-art/poses";
|
||||
test("every pose in the shared cast-art catalogue gets a polygon", { skip: !existsSync(`${POSES}/catalogue.json`) }, () => {
|
||||
const all = silhouettes(POSES);
|
||||
const ids = Object.keys(all);
|
||||
assert.ok(ids.length > 90 && ids.includes("void/neutral"));
|
||||
for (const id of ids) assert.ok(all[id].points.length >= 4 && all[id].points.length < 400, id);
|
||||
assert.deepEqual(silhouettes(POSES), all);
|
||||
});
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
// Silhouette of an alpha plane: threshold -> outer contour of the largest 8-connected shape -> Douglas-Peucker.
|
||||
// Coordinates are pixel-corner lattice points: x in 0..width, y in 0..height.
|
||||
|
||||
/** A pixel is inside when alpha/255 >= threshold. */
|
||||
export function mask(alpha, threshold) {
|
||||
const m = new Uint8Array(alpha.length);
|
||||
for (let i = 0; i < alpha.length; i++) m[i] = alpha[i] / 255 >= threshold ? 1 : 0;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Outer contour (lattice vertices at turns, clockwise on screen) of the 8-connected component with the most pixels, or null. */
|
||||
export function largestContour(m, width, height) {
|
||||
const seen = new Uint8Array(m.length);
|
||||
let best = null;
|
||||
for (let start = 0; start < m.length; start++) {
|
||||
if (!m[start] || seen[start]) continue;
|
||||
// Raster order: `start` is the component's first pixel, so the pixels above and up-left of it are empty
|
||||
// or belong to another component.
|
||||
let count = 0;
|
||||
const stack = [start];
|
||||
seen[start] = 1;
|
||||
while (stack.length) {
|
||||
const p = stack.pop();
|
||||
count++;
|
||||
const px = p % width, py = (p / width) | 0;
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
const x = px + dx, y = py + dy;
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
const q = y * width + x;
|
||||
if (m[q] && !seen[q]) { seen[q] = 1; stack.push(q); }
|
||||
}
|
||||
}
|
||||
if (!best || count > best.count) best = { count, start };
|
||||
}
|
||||
return best && traceOuter(m, width, height, best.start % width, (best.start / width) | 0);
|
||||
}
|
||||
|
||||
// Directions: 0 east, 1 south, 2 west, 3 north. The shape is kept on the walker's right, so an
|
||||
// outer boundary comes out clockwise; at each corner a filled left-ahead pixel turns left (which
|
||||
// joins pixels that only touch diagonally), else a filled right-ahead pixel goes straight, else right.
|
||||
const DX = [1, 0, -1, 0], DY = [0, 1, 0, -1];
|
||||
const AHEAD_LEFT = [[0, -1], [0, 0], [-1, 0], [-1, -1]];
|
||||
const AHEAD_RIGHT = [[0, 0], [-1, 0], [-1, -1], [0, -1]];
|
||||
|
||||
function traceOuter(m, width, height, px, py) {
|
||||
const at = (x, y) => x >= 0 && y >= 0 && x < width && y < height && m[y * width + x] === 1;
|
||||
const vertices = [];
|
||||
let x = px, y = py, dir = 3, first = true; // arrive at the start corner heading north, so it is counted as a turn
|
||||
for (;;) {
|
||||
const left = at(x + AHEAD_LEFT[dir][0], y + AHEAD_LEFT[dir][1]);
|
||||
const right = at(x + AHEAD_RIGHT[dir][0], y + AHEAD_RIGHT[dir][1]);
|
||||
const turn = left ? 3 : right ? 0 : 1;
|
||||
const next = (dir + turn) & 3;
|
||||
// Back at the start corner and about to leave east: the ring is closed.
|
||||
if (!first && x === px && y === py && next === 0) break;
|
||||
first = false;
|
||||
if (turn !== 0) vertices.push([x, y]);
|
||||
dir = next;
|
||||
x += DX[dir]; y += DY[dir];
|
||||
}
|
||||
return vertices;
|
||||
}
|
||||
|
||||
/** Douglas-Peucker on a closed ring: split at the vertex farthest from the first, simplify both halves. */
|
||||
export function simplifyRing(ring, tolerance) {
|
||||
if (ring.length <= 3 || tolerance <= 0) return ring.slice();
|
||||
let far = 0, best = -1;
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
const d = (ring[i][0] - ring[0][0]) ** 2 + (ring[i][1] - ring[0][1]) ** 2;
|
||||
if (d > best) { best = d; far = i; }
|
||||
}
|
||||
const a = dp(ring.slice(0, far + 1), tolerance);
|
||||
const b = dp(ring.slice(far).concat([ring[0]]), tolerance);
|
||||
return a.slice(0, -1).concat(b.slice(0, -1));
|
||||
}
|
||||
|
||||
function dp(points, tolerance) {
|
||||
const keep = new Uint8Array(points.length);
|
||||
keep[0] = keep[points.length - 1] = 1;
|
||||
const stack = [[0, points.length - 1]];
|
||||
while (stack.length) {
|
||||
const [lo, hi] = stack.pop();
|
||||
let worst = -1, at = -1;
|
||||
for (let i = lo + 1; i < hi; i++) {
|
||||
const d = distanceToSegment(points[i], points[lo], points[hi]);
|
||||
if (d > worst) { worst = d; at = i; }
|
||||
}
|
||||
if (worst > tolerance) { keep[at] = 1; stack.push([lo, at], [at, hi]); }
|
||||
}
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
export function distanceToSegment(p, a, b) {
|
||||
const vx = b[0] - a[0], vy = b[1] - a[1];
|
||||
const len2 = vx * vx + vy * vy;
|
||||
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / len2));
|
||||
return Math.hypot(p[0] - (a[0] + t * vx), p[1] - (a[1] + t * vy));
|
||||
}
|
||||
|
||||
/** Largest distance from any point of ring `a`'s edges' vertices to ring `b`'s edges. */
|
||||
export function maxDistanceToRing(points, ring) {
|
||||
let worst = 0;
|
||||
for (const p of points) {
|
||||
let d = Infinity;
|
||||
for (let i = 0; i < ring.length; i++) d = Math.min(d, distanceToSegment(p, ring[i], ring[(i + 1) % ring.length]));
|
||||
worst = Math.max(worst, d);
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
Loading…
Reference in a new issue