Skip to content
C.W.K.
Lesson 03 of 05 · published

Pippa Proposes, Dad Applies, Validated Twice

~15 min · actions, validation, idempotency, agency

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

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.

ActionFieldsAllowed inButton, on both clients
correcttarget, value, noteplate threads onlyCorrect the reading: …
adoptclass_idany threadAdopt into my tree
renameclass_id, nameany threadRename to “…”
linkclass_idplate threads onlyTie this plate to it
variantparent_id, name, overrides, noteany threadCreate my version “…”
verdictclass_id, verdictany threadSave 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.
  • correct and link belong to a plate's thread. In a class or kitchen thread they are refused.
  • Every class_id and parent_id must 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:

  1. It finds the turn and checks that the index is in range.
  2. 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.
  3. It runs validate_action again, against the record as it is now and the rules as the running code has them.
  4. It calls the domain methods the engine's own routes are built on: correct, adopt, rename, link, derive_variant, or set_verdict, which is revise_class with only the verdict.
  5. It records action.applied with 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.

Principle: Let the model name a change and let the server own it. The client points at a stored proposal by position, the server checks it again at the moment of writing, and a repeat that comes after the apply has finished gets the stored result.

Code

cwkSizzle, backend/threads.py: the answer and the proposed actions, split apart (verbatim, lines 50-61)·python
def split_actions(text: str) -> tuple[str, list[Any]]:
    match = ACTIONS_BLOCK.search(text or "")
    if not match:
        return (text or "").strip(), []
    answer = (text[: match.start()] + text[match.end():]).strip()
    try:
        actions = json.loads(match.group(1).strip() or "[]")
    except json.JSONDecodeError:
        return answer, []
    if isinstance(actions, dict):
        actions = [actions]
    return answer, actions if isinstance(actions, list) else []
cwkSizzle, backend/threads.py: the first check, six at most, invalid ones dropped (verbatim, lines 153-162)·python
    def _record_answer(self, turn: dict[str, Any], content: str, emotion: str) -> None:
        answer, proposed = split_actions(content)
        actions = []
        for action in proposed[:6]:
            try:
                actions.append(self.sizzle.validate_action(turn["thread_key"], action))
            except DomainError as exc:
                logger.info("dropping an invalid action in turn %s: %s", turn["id"], exc)
        self.sizzle.record("turn.answered", {"id": turn["id"], "answer": answer, "emotion": emotion,
                                             "actions": actions})
cwkSizzle, backend/domain.py: the second check and the apply, once per action (verbatim, lines 276-305)·python
    def apply_action(self, turn_id: str, index: int) -> dict[str, Any]:
        turn = self.store.turn(turn_id)
        if turn is None:
            raise DomainError("turn not found")
        if not 0 <= index < len(turn["actions"]):
            raise DomainError("no such action")
        if str(index) in turn["applied"]:
            return turn["applied"][str(index)]["result"]
        key = turn["thread_key"]
        action = self.validate_action(key, turn["actions"][index])
        encounter_id = key.split(":", 1)[1] if key.startswith("encounter:") else None
        kind = action["type"]
        if kind == "correct":
            result = self.correct(encounter_id, target=action["target"], value=action["value"],
                                  note=action["note"], source_turn=turn_id)
        elif kind == "adopt":
            result = {"class_id": self.adopt(action["class_id"])["id"]}
        elif kind == "rename":
            result = {"class_id": self.rename(action["class_id"], action["name"])["id"]}
        elif kind == "link":
            self.link(encounter_id, action["class_id"])
            result = {"class_id": action["class_id"]}
        elif kind == "verdict":
            result = {"class_id": self.set_verdict(action["class_id"], action["verdict"])["id"]}
        else:
            variant = self.derive_variant(action["parent_id"], name=action["name"], overrides=action["overrides"],
                                          note=action["note"], source_encounter=encounter_id)
            result = {"class_id": variant["id"]}
        self.record("action.applied", {"turn_id": turn_id, "index": index, "result": result})
        return result

External links

Exercise

Add a propose-then-apply path to an assistant in your app. The model ends a reply with a fenced block of typed actions. Your server parses the block, caps the count, validates each action against the current store and keeps only the valid ones with the turn. An apply endpoint takes the turn id and an index, returns the stored result when that index was already applied, validates again, applies through your normal domain methods and records the apply. Test it with one unknown id and with the same apply sent twice.
Hint
Never accept the action body from the client at apply time, and hold one lock from reading the turn through the record of the apply, so the already-applied check reads fresh state inside it, or two presses that overlap both pass the check. Where your validator and your domain methods disagree about what is valid, let the domain method be the stricter one, because it is the last line before the write.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.