Compare commits

..
Author SHA1 Message Date
soul
521570e8ad Fork card: depends are the stored question's prose lines, verbatim
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 14m40s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 15s
`huginn schema question` types depends as up to 8 Lines, not references;
Huginn PR #29 (desk.rs:87) hashes them as stored. The page said each was a
campaign:kind:label rendering of a reference. Hash vectors are unchanged
(their depends strings are ids written as text).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:35:27 +00:00
soul
0c97560d0e Fork card: Soul fixes (decode coverage, byte-built sameResponse, claim boundary, digest covers retracted)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
- mindRoutes: a percent-encoded configured Mind resolves once (%6eibu), a
  double-encoded one does not; `.`, `..` are unknown; the C# judge decodes
  strictly so invalid UTF-8 is a miss by rule, not by luck.
- sameResponse pairs are compared as bytes built from the spec's refusal rule,
  not from the vector against itself.
- claim_in_future: exactly five minutes is not flagged, five minutes and half
  a second is, from the stored received_at.
- the digest that decides "same body" must cover `retracted` too.
- Dropped the judge rule that the new ruled_from-needs-a-ruling rule subsumes.
- Mapping table says what closes a card and when `ruling` is nil.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:33:38 +00:00
soul
e0a6af9c06 Fork card: the mind's record is projected (answer id n<seq>, snake_case codes), same body is Huginn's alone, claim_in_future follows the stored received_at
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
From the read of Huginn PR #29 at 3a16772: its record names answers by the
full document id and spells refusal codes in PascalCase, keeps body_sha256
beside the fields, and decides claim_in_future against an unrounded clock.
The page now says what the second door projects, that no digest is a field
of the document, and what a digest must cover to be "same body".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:21:16 +00:00
soul
c1c970a84b Fork card transport: the Mind id rides in the path; V14 asserted on every scenario
Routes are GET /api/desk/{mind}/forks, GET /api/desk/{mind}/forks/{fork_id}
and POST /api/desk/{mind}/answers with fork_id still in the body (Heimdall's
file 4.2 and 4.3a). Spec: the {mind} segment rules M1 to M5 (fixed configured
list, never joined into a path, unknown Mind is the unknown-fork refusal with
the same bytes, the route's Mind must hold the fork_id, scopes not per Mind,
the decider list is Bifrost-to-Huginn only, marked Bifrost/Huginn work).
Schema: read_request carries `mind`. Vectors: transport.mindRoutes (47 cases,
sameResponse pairs, the socket a judge would open), scenarios for a body that
names `mind` or `deciders` (body_unknown_field), read_request with the Mind.
Both judges replay them; both also assert on every scenario that a request id
bound by another account never returns that account's answer id or document
(V14), with `of` allowed to name the counted answer. Open choice 11: an
unreachable known Mind answers unavailable, an unknown one fork_unknown.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:21:01 +00:00
soul
acb4757f38 Fork card: a closed card may name no ruling (withdrawn or superseded question)
Step 6 reads status, and the mind closes a question on any resolution, not
only a ruling (Heimdall's file, step 6: "resolved or withdrawn: fork_closed";
Huginn PR #29 desk.rs:161). The rule "a closed card names its ruling" made a
withdrawn question an invalid card. Now only an open card may not name a
ruling, and ruled_from needs a ruling. Vectors: a valid withdrawn card, and
negatives for an open card with a ruling and ruled_from without one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:18:14 +00:00
soul
a2db07dffe Fork card: replayed stale_fork returns the current card; identical body from another account; same body includes answered_at
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 15m55s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Heimdall 1.3e Q1-Q6, V12-V18: vectors for an identical body from another
account (own Counted, never the first account's Refused), only answered_at
changed, a forbidden or door-refused or unknown-fork request binding no id, an
uppercase id. Both judges replay a stale_fork with the current card.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:43:31 +00:00
soul
2dea6c568b Fork card: Differences names request_id as this page's addition
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:39:01 +00:00
soul
b53491f3aa Fork card: unavailable is not confirmed, request_id makes a retry safe, forbidden precedes a bad body
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
unavailable now says the outcome is NOT CONFIRMED (a timeout after Huginn had
the request may have stored it). The answer request and the stored desk_answer
carry a client-set request_id: a retry with the same id and body returns the
stored outcome and never adds a Revision; the same id with another body is
request_id_conflict (409). A non-Active member with a bad body gets forbidden
first. Scenarios: unavailable-then-retry (stored and not stored), replayed
Refused, conflict, same id from another account, forbidden before the body.
Both judges follow; vectors regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:38:15 +00:00
soul
32d63fe9ee Fork card transport: forbidden (403) for a non-Active member; unavailable covers list and read
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Door refusals gain `forbidden`, judged after scope and before Huginn is asked.
The Transport section states unavailable (503) also answers list and read when
Huginn is down; auth and membership are judged first so it never hides a
refusal. Nine new auth cases pin both; both judges follow.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:31:11 +00:00
soul
ad16c26e21 Fork card: transport (list, read, answer; desk.read and desk.answer), note limit in UTF-8 bytes, unavailable
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Adds the Transport section: three routes, request and response documents
(list_request, list, read_request, entry), the stamp and cursor, auth in order
(bearer, audience bifrost-desk, scope), the MAS token and votes.* tokens
refused, Huginn's outcome returned unchanged, a stale refusal carrying the
current card; every part marked as owed by Bifrost or Huginn. Note limit is
4000 UTF-8 bytes (a rule beside the looser maxLength) with ASCII, multibyte,
astral and combining vectors. unavailable (503) is a document of its own,
outside the refusal set, stores nothing, and is raised after body shape and
before the door. Vectors regenerated; both judges replay lists, auth, notes and
the unavailable scenarios.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:26:50 +00:00
soul
7aec4961f6 Governance suites: judges follow the contracts (no ruled, ruled_from on open card, UTF-16 reason rule); validator counts code points
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Generator, C# judge and TS judge drop `ruled`, add the open-card ruled_from
rule and the reason UTF-16 rule. MiniJsonSchemaValidator counted UTF-16 units
for minLength/maxLength where JSON Schema and Ajv count code points, so the
two judges refused different strings; it now counts runes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:20:04 +00:00
soul
0ddcc90be2 Vote API: reauthentication_required, held, highStakes; Standard scope, trimming, auth per route, unbuilt limits
Closed refusal set gains reauthentication_required (401, sealed commit on a
highStakes motion, Bifrost #37 D2) and held (503, the voting brake, D5);
motion and list item carry highStakes, fixed at open. Spec text: the 413 cap
and per-account 429 are not on Bifrost's member API yet; commitment mismatch
(plain 400) and contended reveal (WrongPhase 409) must be split first; Bifrost
trims candidate and reason today and must stop; Standard motions are not
covered here; auth is stated per route now and after #37 with an opaque bearer
on the wire. A reason is counted in UTF-16 units: the schema alone cannot say
it, so a rule and two astral vectors do.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:20:04 +00:00
soul
6512175482 Fork card: drop ruled, add V10a, attribution is not authority, unknown fork and malformed body are not stored
A ruling that answers a fork closes it (Eureka and Heimdall, 2026-11-10), so a
retraction after a ruling is fork_closed and there is no `ruled` code (R3 and
V10 go; V10a replaces them). An open card now cannot carry ruled_from. The
stamped `authority` on a stored answer is an attribution; authority exists only
where a ruling cites a counted answer. The page says an unknown fork and a
malformed body are refused and not stored (Huginn answers fork_unknown; it does
not key a record). Note length is counted in code points.

Vectors are regenerated; the generator and suites follow in the next commits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:20:04 +00:00
soul
c2668509b2 GovernanceVectorTests: Clone keeps a nil node nil (null choice and note in stored answers)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:08:37 +00:00
soul
2d921adba2 CultNet governance contracts: vote API (sealed motions) and fork-desk card, with schemas, specs, vectors and C#/TS conformance
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
contracts/cultnet/governance: vote-api.{md,schema.json}, fork-card.{md,schema.json}, vectors for both (positive, negative per refusal, byte-exact MessagePack), generator scripts/gen-governance-vectors.py. Fork rules follow /opt/commons/handoff/fork-desk-answer-binding-20261110.md (options_hash canonical JSON with NFC, desk_answer tagged outcome, retraction, second door).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:07:37 +00:00
20 changed files with 3831 additions and 8983 deletions

View file

@ -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

View file

@ -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

View file

@ -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`.

View file

@ -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",

View file

@ -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)

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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 () => {

View file

@ -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");

View file

@ -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>();

View file

@ -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],
},

View file

@ -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()
{

View file

@ -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.

View file

@ -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;
}
}

View file

@ -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));

View file

@ -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);
});

View file

@ -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;
}