Reviews — pausing for a human decision

Reviews — pausing for a human decision #

Pause a run for a person's judgement: write a question, deliver the link it mints, then await the answer. The run stops, the person decides, and the run resumes exactly where it left off with every handle and binding it had — minutes or days later, holding nothing open in the meantime.

Reach for one only when a judgement is genuinely needed. A value you can compute, extract, or default with a ?: set-if-empty field is not one.

how-it-works #

asks = connect ask

q = write asks-[:Check]-> { Prompt: "Pursue this company?" }

the-families #

Every family takes Prompt (the question) and an optional Detail (a longer explanation shown under it), plus the parameters below.

Spell a parameter out as a literal and the answer narrows while you write it: a Choose over ["ship", "hold"] answers one of exactly those two, and comparing it to anything else is caught then and there; Answer Type: "number" answers a number. Build the options at run time instead and the answer is plain text — still correct, just unnarrowed. A literal Fields narrows the same way, into one field per name.

q = write asks-[:Form]-> {
  Prompt: "Fill in the missing details"
  Fields: ["Company", "Contact", "Budget"]
}

a = await first(q-[:Response]->)
write company-[:Notes]-> {
  Title:   "Intake details"
  Payload: a.Budget
}

Build those names at run time and there is nothing to read a field off — the editor warns you on save, and the answer stays a.Answer, one structured value carrying them all. A structured value is opaque. Pass it on into a structured field, but you cannot pick a piece out of it, compare it, or branch on it.

So when you need a value to act on — a name to look up, a number to write into a typed field — reach for Provide, Choose, Select, or a Form whose Fields you spelled out.

delivering-and-awaiting #

q = write asks-[:Check]-> { Prompt: "Pursue this company?" }

deals = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (deals == null) { fail("no #deals channel") }
write deals-[:Messages]-> { Message: "New company — pursue it? ${q.Url}" }

answer = await first(q-[:Response]->)
if (answer.Answer) {
  write crm-[:Companies]-> { unique by (FUZZY Name), Name: `Company Name` }
}

the-pick-idiom #

Letting a person pick a live record — one company out of the several you found — needs no special family. Compose it: offer the names, then filter the graph on the answer.

found = crm-[c:Companies WHERE c.Categories == "Lead"]-> {
  return c.Name
}

q = write asks-[:Choose]-> {
  Prompt: "Which company did they mean?"
  Options: found
}

a = await first(q-[:Response]->)
pick = only(crm-[co:Companies WHERE co.Name == a.Answer]->)
if (pick == null) { fail("no company named ${a.Answer}") }
write pick-[:Notes]-> { Title: "Chosen", Content: "Picked from ${count(found)} candidates." }

Options gathered at run time are ordinary — the answer is plain text rather than one of a known set, which is what it honestly is. The answer names the record; the lookup after it keys on that name.

timeout-and-escalation #

Give an unattended question a deadline by racing the wait for it against a sleep and proceeding with whichever settles first. There is no fallback clause — a deadline, a reminder, and an escalation are all composed from those two pieces:

deals = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (deals == null) { fail("no #deals channel") }

r = race(
  () => {
    a = await first(q-[:Response]->)
    return a.Answer
  },
  () => {
    await sleep(6h)
    write deals-[:Messages]-> { Message: "Still waiting: ${q.Url}" }
    await sleep(2d)
  },
)

decision = r[0]
if (decision == null) {
  write deals-[:Messages]-> { Message: "Nobody decided in time — holding." }
}
if (decision == true) {
  write crm-[:Companies]-> { unique by (FUZZY Name), Name: `Company Name` }
}

several-at-once #

Put one decision in front of several places at once by minting one callback, delivering it everywhere, then awaiting it once: whoever taps first settles it, wherever they were. A button in a channel people already read is the lightest way to put a decision in front of them, so that is what each channel gets:

approve = callback({ })

chat-[ch:Channels WHERE ch.Topic == "approvals"]-> {
  write ch-[:Messages]-> {
    Message: "Approve this?"
    Blocks: [ … ]              # one control carrying "${approve.id}"
  }
}

r = race(
  () => {
    tap = await first(approve-[:Called]->)
    return tap.At
  },
  () => { await sleep(1d) },
)

if (r[0] == null) { … }      # nobody tapped inside the day

One callback in several places settles on the first tap but says nothing about where it came from — every place carried the same control, so there is nothing to tell them apart. When that matters, give each place its own callback and its own arm. An arm is written where you already know the place, so it can hand that back along with the answer:

deals = callback({ })
board = callback({ })

`Deals Channel` = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (`Deals Channel` == null) { fail("no #deals channel") }
`Board Channel` = only(chat-[b:Channels WHERE b.Name == "board"]->)
if (`Board Channel` == null) { fail("no #board channel") }

write `Deals Channel`-[:Messages]-> { Message: "Approve this?", Blocks: [ … ] }   # carries "${deals.id}"
write `Board Channel`-[:Messages]-> { Message: "Approve this?", Blocks: [ … ] }   # carries "${board.id}"

r = race(
  () => {
    tap = await first(deals-[:Called]->)
    return { channel: "deals", at: tap.At }
  },
  () => {
    tap = await first(board-[:Called]->)
    return { channel: "board", at: tap.At }
  },
)

answered = r[0]
if (answered != null) {
  general = only(chat-[g:Channels WHERE g.Name == "general"]->)
  if (general == null) { fail("no #general channel") }
  write general-[:Messages]-> { Message: "approved in #${answered.channel} at ${answered.at}" }
}

Wait on different things at once by giving each its own function, and take every answer rather than the first with all, which runs them at once:

r = all(
  () => {
    b = await first(budget-[:Response]->)
    return b.Answer
  },
  () => {
    l = await first(legal-[:Response]->)
    return l.Answer
  },
)

if (r[0] && r[1]) { … }

in-chat-answers #

Response is an ordinary writable edge, so anything that can write can answer — a button included. Mint a callback per answer and give each control the callback's id; a tap then resolves the question in place, with no page hop.

q   = write asks-[:Check]-> { Prompt: "Pursue this company?" }
yes = callback({ write q-[:Response]-> { Answer: true } })
no  = callback({ write q-[:Response]-> { Answer: false } })

The controls carry "${yes.id}" and "${no.id}" — which field on a control holds it is that system's own business, and its chapter says. Keep q.Url in the message text as well, so anyone who cannot tap can still respond.

A callback also has a url — the same block of work reached the other way. The id is what a control carries; the url is what you hand out where there are no controls: an email, a text message, a system whose messages are plain prose.

Opening that url does not act. It shows a page saying what is about to happen and waits for the person to confirm, so a client that fetches links in the background, or a scanner following every link in a message, can never decide on someone's behalf.

how-often-a-callback-can-be-used #

A callback is used once by default: the first person to act settles it, and anyone arriving after is told it is closed. A second argument changes that:

nudge = callback({ … }, { once: false })
today = callback({ … }, { ttl: 4h })

once: false makes it repeatable — every use runs the block again. ttl stops it working after that long. Both are bounded by the run that minted it, which is the shorter leash: a ttl longer than the run's own life changes nothing.

a-value-supplied-when-someone-acts #

Some controls have nothing to pre-wire, because what the person supplies does not exist until they act — a date picker, a text box. Write the deferred work as a closure — (<parameters>) => { … }, a body run later that holds everything in scope where you wrote it — and the supplied value arrives bound to the parameter's name:

booked = callback((day: <date>) => {
  write ch-[:Messages]-> { Message: "Booked for ${day}." }
})

A parameter is a plain value the other side hands back — <text>, <number>, <boolean>, <date>, <datetime>, <json>, <file>. It can never be a record position: nothing out there can hand back a row from a connected system, so that is refused while you write it. Anything already known when the control goes out is not a parameter — put it in the body, which captures everything in scope.

The same values come back on the Called landing, one field per parameter, alongside At.

deferring-work-by-name #

A callback's closure can call a NAMED function, fixing the arguments now:

function `Chase`(c: <crm-[:Companies]->>) {
  write c-[:Notes]-> { Title: "Chased", Content: "Nudged again." }
}

again = callback(() => `Chase`(company), { once: false })

Whatever the closure takes as a parameter is what the acting side fills in: callback((day: <date>) =>Book(company, day)).

when-a-question-and-when-a-callback #

A callback on its own is the lighter thing: go = callback({ }) with await first(go-[:Called]->) holds the run until somebody presses a button — no page, no queue entry, nothing to open. Write a question when you need what a question gives you: a page rendering the right control for whoever opens the link, wherever they are, and an entry in that person's queue of things to decide — a task directed at somebody. For a decision already in front of the right people in a channel, a callback is enough.

Either way, what a person can act on lives only as long as the run waiting for it. When the run ends the buttons stop working and the link stops accepting, and a late tap is told it is closed. Neither is a standing endpoint: something that must react at any time, forever, is an automation that LISTENS.