skip to the page

When something looks wrong

Every page before this one is about building. This one is about the Tuesday morning six weeks later, when the thing is live, people have been asking it for changes, and something is off.

There are three questions you will actually ask, and each has one call that answers it:

  • Who put that there? Nobody remembers agreeing to it, and you want a name.
  • Is the page still what its history says? It looks wrong and you cannot tell whether anything is broken or you are misremembering.
  • Somebody was asked to approve a change and nothing happened. It has been sitting there for days.

None of the three needs a table you have to add, a field you have to set, or a service you have to run. All three are answered from the log, which has been filling up since your first change, and each one is a function you can call from a script.

The page underneath everything below is this one:

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.

Three changes were made to a copy of it as this documentation was being built, by two people:

  1. dana added a card at the bottom, with a sentence inside it.
  2. ravi made the opening sentence quieter.
  3. dana moved her card to the top.

Every number, name and machine-written sentence on this page came out of that history. Nothing below was typed by hand.

Who put this here?

A card is on the page. You did not put it there, and neither did the person sitting next to you.

Nothing on that card says who added it. A node in a Loom tree carries a type, some props and its children — there is no author field, and adding one would be the second copy of a fact the log already holds. So the answer is derived: walk the log backwards from now, and the first change that put this node on the page is the one that put it there.

That is one call:

import { attributeTree } from "@jam-overture/loom/store"

const attributed = await attributeTree(store, page)

It takes the tree you are looking at rather than an id, which matters more than it sounds like it should: you are asking about the page on somebody's screen, and re-reading the page inside the call could answer about a revision that arrived while you were asking.

Here is what it says about the page above.

On the pageHow it got thereAsked for?Since then
loom.pagewas on the page before the log starts—nothing since
loom.carddana, at r1yes, by namemoved by dana at r3
loom.prose — “Published while the page was live.”dana, at r1no — it came with something elsenothing since
loom.heading — “Hello from a tree”was on the page before the log starts—nothing since
loom.prose — “Nothing here was written as markup.”was on the page before the log starts—configured by ravi at r2

Read the second and third rows together, because the difference between them is the whole reason this is worth having.

Dana asked for a card. She got a card with a sentence in it. Both nodes were put there by revision 1, and only one of them was ever mentioned: an insert carries a whole subtree, so a change that names one node can place five. "Dana added a card" and "dana added the sentence inside the card she added" are different sentences, and only one of them is fair. The Asked for? column is which.

The answer is bounded, and says when it ran out

A page that has been edited for two years has a log nothing should read all of to answer a question about one card. So the walk has a budget — five pages of log by default — and it stops when it runs out.

Asked for { pages: 1 } against the same page, the walk read back as far as revision 3 and stopped with 5 of those rows still unanswered. It says so, in that many undetermined results, rather than calling them seeded — the node was placed at or before the revision it names.

This is the third answer, and the one a hand-written version of this function would get wrong. When the budget runs out, the honest thing to say is I do not know, not nobody changed it — the second is a sentence that credits the seed with somebody's work. examinedTo is the number that makes it useful: the node was placed at or before that revision, and asking again with a bigger budget will say where.

Is the page still what its history says?

A store keeps two things: the log, which is every change that was ever applied, and the snapshot, which is the page as it stands. The snapshot exists so that showing somebody a page does not mean replaying its whole history first.

Two copies of one truth can disagree. When they do, the log is the one that is right — and the function that finds out is:

import { auditSnapshot } from "@jam-overture/loom/store"

const audited = await auditSnapshot(store, treeId, seed)

seed is the page at revision 0, before anything was applied. It is a parameter rather than something the store hands you, and that is a real limitation rather than an oversight: a store that has been compacted may no longer have revision 0, and an audit that started from the snapshot it is checking would be starting from the answer.

Run it on a schedule, or in a test. Never on a request — it folds a whole log, which is exactly the work the snapshot exists to avoid.

There are three things it can say.

  • The page is what its history says

    agrees

    Nothing is wrong. The log replays to exactly the page that is being served.

    What the audit reported

    outcome: "agrees", at revision 3

    What you do. Nothing. This is the answer you want from a scheduled audit, and the reason to run one on a schedule is so that the day it says something else, you find out from a job rather than from a reader.

  • The page is not what its history says

    diverged

    Somebody changed the page without going through the runtime — a hand-run UPDATE, or a restore that put one table back and not the other.

    What the audit reported

    loom.card (n_ops13) differs: props

    What you do. The log is the one that is right. Read the differences, decide whether the edit was wanted, and if it was, ask for it properly so it lands as a revision with a name on it.

  • The history cannot be read

    unreplayable

    The log itself cannot be folded: an entry is missing, or one of them no longer applies to the tree the entry before it produced.

    What the audit reported

    revision-gap: expected 2, found 3

    What you do. Stop and get the log back before anything else. This is the one failure where the snapshot may be the only intact copy of the page, and an audit cannot tell you whether it is right.

The middle one is the one to look at twice. "Diverged" on its own is not something you can act on — it tells you the page and its history disagree and leaves you to find out how. So the audit carries both trees, and compareTrees turns them into the line above: which node, and what about it differs. That is a sentence you can take to whoever ran the update.

Somebody was asked, and nothing happened

When the Gate says ask a person, the change is not applied and it is not thrown away — it waits in a store of its own, keyed by the proposal's id. To see what is waiting for a page:

const waiting = await holds.forTree(treeId)

Oldest first, each carrying the Gate's reason. That is your review queue, and building one is ten lines in your own app.

Now the part nobody expects.

  1. The Gate holds ravi’s change and puts it in the queue. 1 waiting, judged against revision 3.
  2. Dana’s change lands while it waits. The page is now at revision 4.
  3. Somebody answers yes. The write ends not-written, and the runtime says:t_firsttree1 moved on: the delta applies to revision 3, but head is 4
  4. The queue holds 0 changes. Custody ended when it was answered, because a change that can never apply again is not left in a queue for somebody to try a second time.

A held change names the revision it was judged against, so a change that waited while the page moved on can never apply again. It is not stale pending a retry — it is dead, and answering it is how you find out. The runtime does not hold it back for a person to try a second time: custody ends, the queue goes back to empty, and what comes back is revision-conflict with both numbers in it.

What Loom will not do about any of this

Three things, and each one is a decision rather than a gap.

It will not repair a diverged snapshot. An audit tells you the log and the page disagree and stops there. Whether the edit somebody made by hand was wanted is not a question a framework can answer, and a runtime that quietly overwrote one copy with the other would be destroying evidence in an incident.

It will not expire a held change. Nothing times a hold out, and the queue will keep showing changes that can no longer apply. How long a person gets to answer is your policy, not the runtime's — and a queue you never look at is a signal about your process rather than a bug in the store.

None of these run on their own. There is no background job, no scheduler, nothing on a request path. auditSnapshot in a nightly script and attributeTree behind an admin screen is the whole of what a deployment typically does with them, and both are ordinary code you write in the language you were already writing.