Only Dad changes the record
The addendum's action contract opens with one short sentence: only Dad changes the record. When Dad asks for a change, or agrees to one I suggested, I end my reply with one fenced block labelled sizzle-actions and tell him the button applies it. The contract's last line closes the loop: never say a change is saved, because Dad's button saves it. That line matters. If I said "saved" and the button were never pressed, my words and his tree would disagree, and he would have no way to know which one to believe.
| Action | Fields | Allowed in | Button, on both clients |
|---|---|---|---|
correct | target, value, note | plate threads only | Correct the reading: … |
adopt | class_id | any thread | Adopt into my tree |
rename | class_id, name | any thread | Rename to “…” |
link | class_id | plate threads only | Tie this plate to it |
variant | parent_id, name, overrides, note | any thread | Create my version “…” |
verdict | class_id, verdict | any thread | Save my verdict |
The web's Talk.tsx and the phone's Models.swift spell the buttons the same way, and the Swift comment gives the reason: both clients offer the same change the same way.
Cutting the block out of the reply
split_actions finds the first fenced sizzle-actions block with a non-greedy pattern compiled with re.S, so the block may span lines. Everything outside it, before and after, becomes the answer Dad reads. The inside is parsed as JSON. A parse failure keeps the answer and proposes nothing. A single object is accepted as a list of one, and anything that is neither an object nor a list yields no actions. The engine test checks both halves: a reply with a block comes back as its Korean sentence plus one action, and a plain reply comes back unchanged with an empty list.
First check, when I propose
_record_answer looks at the first six proposals and passes each one to Sizzle.validate_action, which normalizes the action or raises, and writes nothing. Its rules are short:
- An action must be an object with a known
type. correctandlinkbelong to a plate's thread. In a class or kitchen thread they are refused.- Every
class_idandparent_idmust name a class that exists in the mirror. - A variant derives from a dish or another variant, never from a principle, and needs a name and a non-empty list of overrides.
- A correction needs a value, a rename needs a name, and a verdict needs its text.
An action that fails is dropped with one log line, and the turn is recorded as turn.answered holding only the actions that passed. Nothing is repaired or guessed. The engine test proposes two adopts, one for a real principle and one for an id spelled out of the letters BAD, and the recorded turn keeps exactly the first. When I quote an id that names no class, Dad simply sees no button for it, and that is the right outcome. A guessed id would attach his word to the wrong class.
Second check, when Dad presses
The button sends almost nothing. Both clients post to /api/turns/{turn_id}/actions/{index}/apply with an empty body. The engine reads the action from its own record by position, so a client can point at a proposal and can never rewrite one. apply_action then works in order:
- It finds the turn and checks that the index is in range.
- If that index was already applied, it returns the stored result and records nothing. The test applies the same action twice and gets the same result back.
- It runs
validate_actionagain, against the record as it is now and the rules as the running code has them. - It calls the domain methods the engine's own routes are built on:
correct,adopt,rename,link,derive_variant, orset_verdict, which isrevise_classwith only the verdict. - It records
action.appliedwith the turn, the index and the result.
Those five steps are not one transaction. apply_action takes no lock across them, and the route is a plain def, so FastAPI runs each press in a worker thread of its own. Step 2's check and step 5's record are two separate moments. Two presses that arrive together can both pass step 2 and both apply, which for a variant means two variants. A crash after step 4 and before step 5 leaves the change in the journal with no action.applied behind it, and the next press applies it again. The once-only answer holds for a repeat that comes after the first apply has finished, which is what the test checks. A lock has to start before step 2 to close the race. Step 1 fetches the turn as a snapshot, applied marks included, so two presses that both fetched before either applied would each check a stale copy, lock or no lock. Holding the engine's re-entrant write lock from step 1 through step 5, so the turn is read inside it, would close the race. The crash window needs more: the change and its applied mark written as one event.
The domain methods carry checks of their own, and one is stricter than the first pass. derive_variant keeps only overrides that name both a role and a replacement, and refuses the variant if none survive. A variant made this way is born with origin dad and status adopted, because Dad pressed the button, and in a plate's thread it is also tied to that plate. A link adopts its class if the class was still a proposal. A correct queues the plate for a new reading and records the turn's id as the correction's source.
What the checks do not judge
Validation guards ids and thread kinds. It does not judge meaning. A correction's target is trimmed to 80 characters and falls back to note, and any text passes. The plate page overlays only a summary target or a dish:<n>:name_ko, name_original or cuisine target on the current reading, while every correction rides into the next reading as Dad's fact. The last check is a person: Dad reads the button's words before he presses.
After a press inside the Sidekick frame, the conversation posts a sizzle:changed message to the page around it, and that page reloads. Track 7 covers the frame.