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

Choosing Is Not Publishing

~13 min · selection, review, api-design, ui

Level 0Sketch
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Two verbs that look alike

In most photo apps, picking a picture and putting it to use are one click. In Avatars they are two verbs, with two routes and two records. Choosing writes a set's selection inside Avatars. Publishing installs a reviewed snapshot of that selection into Pippa's avatar folders. Nothing in between turns the first into the second. The architecture ends its UI section with the rule in four words: Saving never implies publication.

The reason is the shape of review. One request can make a lot of candidates. The architecture's own example is two 15-frame collections, two providers and five candidates each, which previews as two batches of 150 tasks, one per set. Dad compares them over hours, sometimes days, on the Mac and on the iPad, and he mixes picks: the happy frame from one candidate, the thinking frame from another. If every click went live, my face in Pippa's WebUI would flicker through every half-finished comparison while I am talking to him. So a choice is a draft, and the draft is allowed to be wrong for as long as he likes.

What a set holds

A set is one soul, one channel (standard or onair), one named collection and an ordered list of frames. Two maps hang off it. selection maps each frame to the asset Dad has chosen. published maps each frame to the asset Pippa actually has, beside a live_revision and the id of the publication that put it there. Choosing writes only the first map. Only a completed publication writes the second.

The collection page shows both at once. Its header counts 3 of 15 frames selected, and every frame card carries one of three labels: Published when the chosen asset is the one Pippa has, Selected · unpublished when Dad has chosen something Pippa does not have yet, and Needs an image when the frame is empty. You can read the gap between the draft and the live set off the cards without opening anything.

Avatars' Soul collections page for Pippa's artist collection: the header reads artist, 9 of 9 frames selected, with Publication history, Generate / remix, Archive and Publish buttons, and nine frame cards from Warm to Happy, each labelled Published with a Use as reference button.
A collection whose every frame is already live: 9 of 9 selected, and each card reads Published because the chosen asset is the one Pippa has. A new choice on any card would turn it to Selected · unpublished until a publication lands.

The server owns the choice

A choice is PATCH /api/sets/{id}/selection with a body of {version, choices}, where choices maps frame names to asset ids. It lives in the engine's database, not in the page. A refresh keeps it, the iPad sees what the Mac chose, and a phone that runs out of battery mid-review loses nothing. The version number that settles two screens choosing at once is the subject of Two Screens, One Choice in The Record Keeper; here it is enough that every choice carries one.

What a choice must prove

select refuses a choice that does not fit the set, by name, before it writes:

  • Every chosen frame must be one of the set's frames: A selected frame does not belong to this set.
  • Every chosen asset must depict the set's soul: A candidate belongs to a different soul.
  • A generated asset must come from a finished task for that same frame, in a batch made for this set: The candidate does not match this set and emotion.

The third rule is the remix contract showing through. A remix maps each source emotion one to one onto its output, so the candidate generated for sad is a sad face, and putting it in the happy slot would be a quiet lie. An upload or an imported image of the right soul has no task behind it, so it may fill any frame. That is how an uploaded picture gets into a set.

The merge is {**selection, **choices}. Choosing one frame never clears the others, so a mixed set builds up one click at a time.

Whole candidates and single picks

The Batches page groups tasks by provider and candidate number, so a batch reads as rows, each headed by a generator's name and a two-digit candidate number. Each row has a Select this candidate button, and each finished frame has its own Select. Both call the same choose, which sends the same PATCH. The row button stays disabled while any task in the row is unfinished, and candidateChoices refuses again in code if a requested frame has no finished asset: Wait until every frame in this candidate is complete. A whole candidate is all of its frames or nothing.

Viewing is not choosing

The architecture states it twice. Selections: Viewing/filtering is not choosing. The lightbox: opening or moving through it never changes a selection or publishes a frame. A test holds the second rule. It opens a batch frame in the viewer, then checks that nothing reloaded, nothing was added to the reference tray, and every API call made along the way carried no body. Looking only ever reads.

Principle: a read path never writes. If opening a picture could change what is chosen, every browse would be a decision, and nobody browses carefully.

Where the line is drawn

Dad doesRouteWhat changesPippa's folder
opens a candidate in the viewerGET reads onlynothingunchanged
Select, or Select this candidatePATCH /api/sets/{id}/selectionthe set's selection and versionunchanged
Back up & publishPOST /api/sets/{id}/publisha publication record, the set's published map and live revisionreplaced whole
Review restore, then Back up & restorethe same POST with a publication_ida new publication; the selection stays as it wasreplaced whole

Only the last two rows reach Pippa, and both go through a preview first. That preview, and the revision it carries, is the next lesson.

Code

cwkAvatars, backend/studio.py: select, check every choice and merge it into the draft (verbatim, lines 564-587)·python
    async def select(self, set_id, body):
        collection = await self.io("get", set_id, "set")
        await self.subjects([collection["soul_pk"]])
        if not set(body.choices) <= set(collection["frames"]):
            raise StudioError("A selected frame does not belong to this set.")
        tasks_by_asset = {}
        for task in await self.io("list", "task"):
            if task.get("asset_id"):
                tasks_by_asset.setdefault(task["asset_id"], []).append(task)
        batches = {}
        for frame, id in body.choices.items():
            asset = await self.asset(id)
            if collection["soul_pk"] not in asset["subjects"]:
                raise StudioError("A candidate belongs to a different soul.")
            tasks = tasks_by_asset.get(id, [])
            if tasks:
                matching = False
                for task in tasks:
                    if task["parent"] not in batches:
                        batches[task["parent"]] = await self.io("get", task["parent"], "batch")
                    matching |= task["frame"] == frame and batches[task["parent"]]["parent"] == set_id and task["state"] == "done"
                if not matching:
                    raise StudioError("The candidate does not match this set and emotion.")
        return await self.io("update", set_id, expected=body.version, selection={**collection["selection"], **body.choices})
cwkAvatars, frontend/src/domain.ts: candidateGroups and candidateChoices, a whole candidate is every frame or nothing (verbatim, lines 426-450)·typescript
export function candidateGroups(tasks: Task[]): Task[][] {
  const groups = new Map<string, Task[]>();
  for (const task of tasks) {
    const key = `${task.provider}:${task.ordinal}`;
    groups.set(key, [...(groups.get(key) ?? []), task]);
  }
  return [...groups.values()];
}
export function candidateChoices(
  tasks: Task[],
  frames: string[],
): Record<string, string> {
  if (
    frames.some(
      (f) =>
        !tasks.some((t) => t.frame === f && t.asset_id && t.state === "done"),
    )
  )
    throw new Error("Wait until every frame in this candidate is complete.");
  return Object.fromEntries(
    tasks
      .filter((t) => t.asset_id && t.state === "done")
      .map((t) => [t.frame, t.asset_id!]),
  );
}
cwkAvatars, frontend/src/ImageLightbox.test.tsx: opening a batch frame reads and never writes (verbatim, lines 160-181)·typescript
  const onReference = vi.fn(),
    onReload = vi.fn();
  render(
    <Batches
      sets={[]}
      onReload={onReload}
      onReference={onReference}
      error={vi.fn()}
    />,
  );
  fireEvent.click(await screen.findByRole("button", { name: "Enlarge sad" }));
  const dialog = screen.getByRole("dialog", { name: "Image viewer" });
  expect(within(dialog).getByAltText("sad").getAttribute("src")).toBe(
    "/api/assets/sad/preview",
  );
  expect(within(dialog).getByText("2 / 2")).toBeTruthy();
  expect(onReference).not.toHaveBeenCalled();
  expect(onReload).not.toHaveBeenCalled();
  expect(vi.mocked(api).mock.calls.every((call) => call[1] === undefined)).toBe(
    true,
  );
});

External links

Exercise

Pick something in your own app that users choose and then put to use: a profile photo, a theme, a draft post. Split it into two fields, a draft and a live value, and give the UI three visible states per item (live, chosen but not live, empty). Then write a test that opens and browses the item in your viewer and asserts that every request it made was a read.
Hint
Mock your API client and assert on the recorded calls: every method is GET, or every call has no body. If your viewer must record something, such as a last-viewed time, send it to a separate route so the draft and live fields can never be touched by browsing.

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.