From 5b1f55c30ccc082ba6a2c8d729f258071a8974b8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 02:07:51 +0200 Subject: [PATCH] Rule PA.f170: an opaque per-question token, and delete the version comparison Co-Authored-By: Claude Opus 5 --- .../architecture/ghostlight-play-agent-cut.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/architecture/ghostlight-play-agent-cut.md b/docs/architecture/ghostlight-play-agent-cut.md index 6694ca1..7ba3cf7 100644 --- a/docs/architecture/ghostlight-play-agent-cut.md +++ b/docs/architecture/ghostlight-play-agent-cut.md @@ -3329,6 +3329,47 @@ Interpreter, the elaborator sweep and lenses. Cut 11 deleted from the wire, and the vendored lowering echoes exactly one number: `document.version`. So the fix depends on what the lowering can carry. A probe answers that before Self rules. +- **PA.f170's probe and ruling (Imagination, Sonnet, then Self, under the + standing go, 2026-09-23).** The probe drove the real lowering at the + pinned revision and established: + - A binding with no matching node anywhere is **not** captured: + `findAuthoredBindingValue` walks the live tree. + - A node whose renderer ignores `props.value` **can** carry an authored + value that is captured and never appears in the DOM. No `hidden` trick + is needed, and the lowering supports none. + - Simpler still: a button's own `props.action` object is spread verbatim + into the payload, so any field the server puts there rides back with no + binding machinery at all. + - `routeHint` is hardcoded to `sourceVersion` and `transport`; nothing + else passes through. + - `web/src/main.ts` is ours and only hand-builds one intent (the auth + completion), so changing the shipped client is possible but is a + different kind of change from shaping the served document. + - Whatever rides back is still blocked by `PlayPayload`'s + `deny_unknown_fields` and by `unwrap_bindings` refusing a mixed + envelope. The wire allows it; the Rust schema is a second gate. + - It is DOM-invisible, not network-invisible: the value is in the served + JSON, like `sourceVersion`. A player cannot type into it, because the + intent is read from the in-memory tree. + - **Ruling (Self):** + - Each question mints an **opaque random token** and the row keeps it. + It is not the `QuestionId` and carries no turn id, round, slot, + subject or any other world handle: an answer's handle, and nothing + else. Invariant 8 holds, because a player reading it in devtools + learns nothing about the world, and forging one only answers their + own open question. + - The play button carries it in `props.action`, the channel that needs + no binding machinery. + - `PlayPayload` gains the token. An answer whose token is absent or + does not match the open question's is refused as stale. + - **`question_surface_version` and the version comparison are deleted** + (PA.f161, PA.f175). The token is the one owner of "is this answer for + this question", and a second, weaker test of the same thing is the + split authority this pass keeps finding. + - PA.f168's rule becomes: the bindings envelope merges with the + command's own action fields, and an **unknown** sibling is still + refused. The action spread and the envelope arrive together, so a + blanket refusal would break every command. - **PA.f171 (Cut 11, medium, fix):** the gate has no drift protection. Removing PA.f164's authored `value` leaves the fixture test green, because the recording carries the value in its own payload. The live