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
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>
This commit is contained in:
parent
e0a6af9c06
commit
0c97560d0e
5 changed files with 562 additions and 155 deletions
File diff suppressed because one or more lines are too long
|
|
@ -65,7 +65,7 @@ The stamped `authority` field of a stored answer is an **attribution**: the role
|
|||
|
||||
`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`, `answered_at` and `retracts` (every field but `request_id` and the stamps), is computed by Huginn and never read from a client.
|
||||
**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.
|
||||
|
||||
### Counting rules (in order; the first failure refuses with that code)
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ Calls 1 and 2 are enough for a read-only desk. Every response is the pinned Mess
|
|||
|
||||
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
|
||||
|
||||
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid`; `listScenarios` (five scenarios over one Mind's five cards: paged open-only, recorded-only, all, `limit` 0, an empty Mind, each response with its bytes, the cursor of page two being page one's `next_cursor`); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 47 cases over the three routes: bad segments opening nothing, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
|
||||
**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 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.
|
||||
|
||||
|
|
@ -144,7 +144,7 @@ The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a
|
|||
| `rec` (a key) | `recommended` (a label) | `recommendation` (an id), never nil |
|
||||
| `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) | `status`, `ruling`, both a projection |
|
||||
| `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 (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` |
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ const cardRuleErrors = (c: Card): string[] => {
|
|||
if (new Set(c.deciders).size !== c.deciders.length) errors.push("duplicate deciders");
|
||||
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.status === "open" && c.ruled_from.length > 0) errors.push("an open card cites an answer in ruled_from");
|
||||
if (c.options_hash !== optionsHash(c)) errors.push("options_hash is not the hash of the shown members");
|
||||
return errors;
|
||||
};
|
||||
|
|
@ -408,10 +407,12 @@ test("fork transport: the mind segment is only compared with the configured list
|
|||
for (const [a, b] of config.sameResponse) {
|
||||
const x = byName.get(a)!, y = byName.get(b)!;
|
||||
assert.equal(x.code, y.code);
|
||||
assert.equal(refusals.get(x.code).messagePackHex, refusals.get(y.code).messagePackHex, `${a} and ${b} answer with the same bytes`);
|
||||
const built = (code: string) => hexOf({ schemaVersion: "gamecult.fork.refusal.v0", code, error: `${code}: ${TAILS[code]}` });
|
||||
assert.equal(built(x.code), built(y.code), `${a} and ${b} answer with the same bytes, built from the spec's rule`);
|
||||
assert.equal(built(x.code), refusals.get(x.code).messagePackHex, a);
|
||||
}
|
||||
const all = [...byName.values()];
|
||||
assert.ok(all.filter((v) => v.code === "fork_unknown" && v.opened === null).length >= 27, "every bad segment opens nothing");
|
||||
assert.ok(all.filter((v) => v.code === "fork_unknown" && v.opened === null).length >= 39, "every bad segment opens nothing");
|
||||
assert.equal(new Set(all.filter((v) => v.storedIn !== null).map((v) => v.storedIn)).size, 2, "an answer is stored in the route's Mind only, and both Minds take one");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ def refusal_doc(code): return {"schemaVersion": "gamecult.fork.refusal.v0", "cod
|
|||
def unavailable_doc(retry=30): return {"schemaVersion": "gamecult.fork.unavailable.v0", "code": "unavailable", "error": "unavailable: the outcome is not confirmed, try again with the same request_id", "retryAfterSeconds": retry}
|
||||
def future(a, r):
|
||||
import datetime as dt
|
||||
p = lambda x: dt.datetime.strptime(x, "%Y-%m-%dT%H:%M:%SZ")
|
||||
p = lambda x: dt.datetime.fromisoformat(x.replace("Z", "+00:00"))
|
||||
return (p(a) - p(r)).total_seconds() > 300
|
||||
def da(outcome, link, code, b, server):
|
||||
a = b["answer"] or {"choice": None, "note": None}
|
||||
|
|
@ -309,6 +309,8 @@ scen = [
|
|||
sc("V5_receiver_order_decides_not_answered_at", CARD, [ok(x2, srv("n1", OP, T(2)), "Counted", None), ok(x1, srv("n2", OP, T(3)), "Revision", "n1")], "n1"),
|
||||
sc("V5_reversed_arrival_reverses_the_result", CARD, [ok(x1, srv("n1", OP, T(2)), "Counted", None), ok(x2, srv("n2", OP, T(3)), "Revision", "n1")], "n1"),
|
||||
sc("a_second_decider_answer_is_recorded_as_a_Revision", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1")], "n1"),
|
||||
sc("a_claim_exactly_five_minutes_ahead_is_not_flagged", CARD, [ok(body("no", NOTE, at="2026-11-12T19:07:05Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("a_claim_five_minutes_and_half_a_second_ahead_is_flagged_from_the_stored_received_at", CARD, [ok(body("no", NOTE, at="2026-11-12T19:07:05.500Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("a_claim_more_than_five_minutes_ahead_is_accepted_and_flagged", CARD, [ok(body("no", NOTE, at="2026-11-12T19:09:00Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("V6_a_matrix_edit_redaction_or_reaction_changes_nothing", CARD, [X1] + MATRIX, "n1"),
|
||||
sc("V7_a_choice_that_is_not_an_option_is_Refused_choice_invalid", CARD, [rf(body("maybe", None), sx1, "choice_invalid")], None),
|
||||
|
|
@ -427,9 +429,9 @@ def mcase(name, route, seg, fork=None, tok=None):
|
|||
code, opened, stored = mind_route(route, seg, fork, tok)
|
||||
return {"name": name, "route": route, "mind": seg, "fork_id": fork, "token": tok, "expect": code, "httpStatus": 200 if code == "ok" else HTTP[code], "opened": opened, "stored_in": stored}
|
||||
BAD_SEGMENTS = [("dot_dot", "../x"), ("slash", "a/b"), ("encoded_slash", "a%2fb"), ("encoded_dot_dot", "%2e%2e%2fx"), ("nul", "eureka%00"), ("four_kb", "a" * 4096),
|
||||
("other_case", "GAMECULT-CHAT"), ("trailing_space", "nibu%20"), ("invalid_utf8", "%ff")]
|
||||
("other_case", "GAMECULT-CHAT"), ("trailing_space", "nibu%20"), ("invalid_utf8", "%ff"), ("dot", "."), ("dots", ".."), ("double_encoded", "%256eibu")]
|
||||
MINDCASES = [
|
||||
mcase("list_a_configured_mind", "list", "gamecult-chat"), mcase("list_another_configured_mind", "list", "nibu"),
|
||||
mcase("list_a_percent_encoded_configured_mind_is_decoded_once", "list", "%6eibu"), mcase("list_a_configured_mind", "list", "gamecult-chat"), mcase("list_another_configured_mind", "list", "nibu"),
|
||||
mcase("read_a_fork_the_mind_holds", "read", "gamecult-chat", FID), mcase("answer_a_fork_the_mind_holds_is_stored_in_that_mind", "answer", "gamecult-chat", FID),
|
||||
mcase("answer_on_the_other_mind", "answer", "nibu", "nibu:question:glow-order"),
|
||||
] + [mcase(f"M1_{route}_{n}_is_unknown_and_opens_nothing", route, seg, FID if route != "list" else None) for route in ("list", "read", "answer") for n, seg in BAD_SEGMENTS] + [
|
||||
|
|
|
|||
|
|
@ -180,7 +180,6 @@ namespace GameCult.Networking.Tests
|
|||
if (deciders.Distinct().Count() != deciders.Count) yield return "duplicate deciders";
|
||||
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["status"]) == "open" && c["ruled_from"]!.AsArray().Count > 0) yield return "an open card cites an answer in ruled_from";
|
||||
if (S(c["options_hash"]) != HashOf(c)) yield return "options_hash is not the hash of the shown members";
|
||||
}
|
||||
|
||||
|
|
@ -524,13 +523,25 @@ namespace GameCult.Networking.Tests
|
|||
return huginnDown ? "unavailable" : "ok";
|
||||
}
|
||||
|
||||
private static string PercentDecode(string s)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
for (var i = 0; i < s.Length;)
|
||||
{
|
||||
if (s[i] == '%' && i + 2 < s.Length && Uri.IsHexDigit(s[i + 1]) && Uri.IsHexDigit(s[i + 2])) { bytes.Add(Convert.ToByte(s.Substring(i + 1, 2), 16)); i += 3; continue; }
|
||||
var rune = System.Text.Rune.GetRuneAt(s, i);
|
||||
bytes.AddRange(Encoding.UTF8.GetBytes(rune.ToString())); i += rune.Utf16SequenceLength;
|
||||
}
|
||||
return new UTF8Encoding(false, true).GetString(bytes.ToArray()); // throws DecoderFallbackException (an ArgumentException) on invalid UTF-8
|
||||
}
|
||||
|
||||
// The {mind} segment is decoded once and compared, ordinally, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment").
|
||||
private static (string Code, string? Opened, string? StoredIn) MindRoute(JsonNode config, string route, string segment, string? fork, JsonNode? token)
|
||||
{
|
||||
var auth = AuthJudge(route, token, false);
|
||||
if (auth != "ok") return (auth, null, null);
|
||||
string mind;
|
||||
try { mind = Uri.UnescapeDataString(segment); } catch (UriFormatException) { return ("fork_unknown", null, null); }
|
||||
try { mind = PercentDecode(segment); } catch (ArgumentException) { return ("fork_unknown", null, null); } // bytes that are not UTF-8 are a miss
|
||||
var minds = config["minds"]!.AsObject();
|
||||
if (!minds.TryGetPropertyValue(mind, out var socket)) return ("fork_unknown", null, null);
|
||||
if (route != "list" && !config["forks"]![mind]!.AsArray().Any(f => S(f) == fork)) return ("fork_unknown", S(socket), null);
|
||||
|
|
@ -562,9 +573,10 @@ namespace GameCult.Networking.Tests
|
|||
{
|
||||
var a = byName[S(pair![0])]; var b = byName[S(pair[1])];
|
||||
Assert.That(a.Code, Is.EqualTo(b.Code));
|
||||
Assert.That(S(refusals[a.Code]["messagePackHex"]), Is.EqualTo(S(refusals[b.Code]["messagePackHex"])), $"{S(pair[0])} and {S(pair[1])} answer with the same bytes");
|
||||
string Built(string code) => HexOf(new JsonObject { ["schemaVersion"] = "gamecult.fork.refusal.v0", ["code"] = code, ["error"] = $"{code}: {Tails[code]}" });
|
||||
Assert.That(Built(a.Code), Is.EqualTo(Built(b.Code)).And.EqualTo(S(refusals[a.Code]["messagePackHex"])), $"{S(pair[0])} and {S(pair[1])} answer with the same bytes, built from the spec's rule");
|
||||
}
|
||||
Assert.That(byName.Values.Count(v => v.Code == "fork_unknown" && v.Opened is null), Is.GreaterThanOrEqualTo(27), "every bad segment opens nothing");
|
||||
Assert.That(byName.Values.Count(v => v.Code == "fork_unknown" && v.Opened is null), Is.GreaterThanOrEqualTo(39), "every bad segment opens nothing");
|
||||
Assert.That(byName.Values.Where(v => v.StoredIn is not null).Select(v => v.StoredIn).Distinct().Count(), Is.EqualTo(2), "an answer is stored in the route's Mind only, and both Minds take one");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue