skip to the page

Answering a held change

The Gate gives one of three answers, and only two of them are answers. Yes applies the change. No refuses it. The third one — ask a person — is not a decision at all: it is the runtime saying this is somebody's to make, and then waiting.

This page is about that somebody. What they are looking at, what they need to know before they click, and what actually happens when they do.

On Your first change you played both parts: you asked for something, the Gate held it, and you said yes yourself, in the same box, seconds later. That is the loop with the waiting taken out of it. Here it is put back — because on a deployment the person who asks and the person who answers are different people, and the gap between them is where everything on this page happens.

Try it again on the page below. Press Demote the page's heading and watch nothing happen to it.

The smallest tree that renderslive · rendered through the runtime

Hello from a tree

Nothing here was written as markup.

Propose a change:

Every one of these goes through the same pipeline a model’s answer would: interpreted, analysed, weighed, judged, applied, appended. The log is real and it is in this tab — reload the page and the example is back as it was.

show the tree
{
  "kind": "element",
  "id": "n_firsttree5",
  "type": "loom.page",
  "props": {
    "fills": true,
    "width": "readable",
    "loom:theme": {
      "palette": "minimal",
      "fontPack": "minimal-sans",
      "stylePreset": "precise"
    }
  },
  "children": [
    {
      "kind": "element",
      "id": "n_firsttree2",
      "type": "loom.heading",
      "props": {
        "level": 1
      },
      "children": [
        {
          "kind": "text",
          "id": "n_firsttree1",
          "value": "Hello from a tree"
        }
      ]
    },
    {
      "kind": "element",
      "id": "n_firsttree4",
      "type": "loom.prose",
      "props": {},
      "children": [
        {
          "kind": "text",
          "id": "n_firsttree3",
          "value": "Nothing here was written as markup."
        }
      ]
    }
  ]
}
A page with a heading and a sentence. Every node names a registered primitive and carries props that primitive declared.

The change was planned, judged and then set aside. It is waiting for a person.

Where a waiting change waits

Not on the page. A page's history is a list of changes that were applied, and a change nobody has answered was not applied — putting it there would make the history a list of two different things.

Not in the browser either. The person who eventually says yes may be somebody else, on another day, and a browser that held the change would have to send it back when they did — which is the one thing a visitor's browser is never allowed to do.

So it waits on your server, in a hold store: a small table of changes that have been judged and not yet answered, each under the id of the proposal that made it. Loom gives you one the same way it gives you a place to keep pages.

import { postgresHoldStore } from "@jam-overture/loom/postgres"

const holds = postgresHoldStore(db)

const waiting = await holds.forTree(treeId)

forTree is the whole of the read side. It gives you everything waiting on one page, oldest first, and it is what a review screen is built out of.

What a person is owed before they decide

A held change is not a diff to approve. Somebody asked for something in their own words, something planned what that would mean, and the Gate decided it was not going to do that on its own authority. A reviewer needs all three, or they are guessing.

Here is a real queue, built while this page was being built: two changes asked for by a colleague, waiting on the example page above.

The page is at revision 2. 1 of these changes can still be applied; 1 cannot.

  • “make the title smaller” — ravi

    cannot be applied

    The plan. One configure. The heading's level prop goes from 1 to 2, and nothing else moves.

    Why it is waiting. More than an instruction may apply on its own — touches protected loom.heading

    stakes-above-ceiling · stakes high · confidence 1 · judged against revision 1

  • “tighten up the opening line” — ravi

    can still be applied

    The plan. One configure. The opening sentence goes to the small size.

    Why it is waiting. The planner was not sure enough — interpreter confidence 0.5 is below 0.7

    confidence-below-minimum · stakes low · confidence 0.5 · judged against revision 2

Both are waiting, and they are waiting for completely different reasons.

The first is about what the change does. This site says its headings matter, so reconfiguring one is more than a plain instruction gets to do on its own. A reviewer answering it is being asked do we want this, and they can answer it by reading it.

The second is about how sure the planner was. It is a small change to a sentence, nothing protected anywhere near it — and the thing that planned it said it was about half confident. A reviewer answering that is being asked something else entirely: is this actually what was meant? The change is harmless; the guess behind it may not be right.

A screen that printed the word held over both would be hiding the only thing that tells a reviewer what job they are doing. Every hold carries the Gate's own reason, in a code and in a sentence, and both are on the cards above.

The one thing the queue cannot tell you

Look at the badge on each card again.

One of those two changes can never be applied, no matter who says yes. It is not broken, nobody did anything wrong, and there is nothing in the queue that says so.

Here is what happened. A change is judged against the page as it was at that moment — a specific revision, the one the asker was looking at. While it sat waiting, somebody else's change landed. The page moved. The plan that was made describes a page that no longer exists.

A hold records the revision it was judged against, so the comparison is there to be made. It is one read and one equality:

const page = await store.head(treeId)

const rows =
  waiting.ok && page.ok
    ? waiting.value.held.map((hold) => ({
        hold,
        stillAnswerable: hold.baseRevision === page.value.revision,
      }))
    : []

baseRevision is the revision the change was judged against. page.revision is where the page has got to. If they are the same, the change can still land. If they are not, it cannot, and no amount of answering will change that.

What saying yes does

Three answers, given in one sitting on the queue above:

What the reviewer clickedThe callWhat happenedWhat the runtime said
yes to the newer oneconfirmHeldapplied, and the page movedrevision 2 → 3, 1 still waitingapplied at revision 3
yes to the older oneconfirmHeldnothing applied, and the change is gone from the queuerevision 3 → 3, 0 still waitingt_firsttree1 moved on: the delta applies to revision 1, but head is 3
no to a thirddiscardHeldnothing applied, and nothing was recorded against the pagerevision 3 → 3, 0 still waitingp_answers71 is out of custody, and no revision was written

The first row is the ordinary one, and it is what a yes button calls: confirmHeld, naming the proposal and whoever answered it. A person says yes, the change applies, the page advances by one revision, and the history has their name on it as the one who answered.

The second row is the one to read twice. Saying yes to the older change did not apply it — it could not — and it did not put it back in the queue either. The change is simply gone. Custody ends on the attempt.

That last part is deliberate and it is worth saying plainly: a change that cannot be applied is dead, not stale. Leaving it in the queue would invite a second person to read it, decide, click, and be told the same thing. So the runtime lets go of it the moment somebody tries. The cost is that a reviewer can spend their attention on a change that was never going to land — which is why the badge in the section above is worth the three lines it costs you.

The third row is a person saying no, which is discardHeld. Nothing is applied and no revision is written. It is the cheapest thing that happens on this page and the most informative: the Gate was willing to offer this change and a human did not want it, which is the only signal that tells a policy that is too permissive from one that is calibrated.

Saying yes is not a rubber stamp

A confirmation is not a way past the Gate. When a person says yes, the change is judged again — the policy is resolved again, the change is assessed again, and the verdict is the one that holds now.

Most of the time that second look is uneventful and reaches the same place. It is not uneventful when the rules changed while the change was waiting:

When it was held

loom-docs asked a person: stakes-above-ceiling

touches protected loom.heading

When somebody said yes

loom-docs-tightened refused it outright: stakes-at-refusal-floor

touches protected loom.heading

The page stayed at revision 0, and nothing is left waiting.

The same change, the same damage, two different verdicts — because the deployment decided in between that changes to its protected primitives were not to be offered at all. A person clicked yes and the runtime said no, and it was right to.

Read the two halves of that block together and you will notice something a screen has to work around: the sentence is identical. touches protected loom.heading is what the Gate says when it asks a person and what it says when it refuses outright, because the sentence describes the damage and the verdict is a separate field. A reviewer shown only the sentence cannot tell why the answer changed. Show the reason code beside it, as the blocks on this page do.

What your screen owes the person using it

Four things, and none of them is a diff viewer:

  1. What was asked, in the asker's words. The utterance is on the intent the hold carries. A reviewer deciding without it is reviewing a plan with no idea what it was trying to do.
  2. Why it is waiting. The reason code, not only its sentence — the two sections above are what happens when you pick one.
  3. Whether it can still be applied. Your three lines. Sort by it if you like; a reviewer's attention is the scarce thing here.
  4. A way to say no. A queue with only a yes button is a queue that will be answered yes.

And one thing your screen does not owe anybody: a way to override the Gate. Confirming is answering the question the Gate asked. It is not overruling it.