The action queue¶
What is waiting for a person to do something about it, and how they find out.
Two things, not one¶
A notification system for this platform is two mechanisms that look like one, and building them as one makes each worse.
The event feed is what happened. It is the events table, read from a
cursor, and an entry stays true for ever: a turn that completed at 14:32
completed at 14:32 whoever reads it later. Its per-user state is read or
unread -- a fact about the reader, not about the event.
The action queue is what is waiting on somebody. Its truth condition is the opposite: an item is true until it is settled, and it leaves the queue when a colleague answers it rather than when the reader has seen it. Its state is a lifecycle, not a reading.
Folding one into the other means giving the log a state machine or giving the queue a cursor, and neither is an improvement on two tables. It also makes the per-user storage wrong in whichever direction you fold: read/unread on a queue nags about decisions somebody else already made, and a lifecycle on a log rewrites history.
The event feed half is scoped in roadmap.md, under Notifications, and the inbox that shows them -- the second poll loop, read state as a cursor or a row per item, and which kinds reach the inbox. Nothing here changes that. This document is the queue.
The queue is a read model. The hold is the truth¶
inhibitors.md already says what a pending approval is:
HITL is an inhibitor of strength
suspended, held by a pending request and released when somebody answers it. It is not a tool result.
So a pending human-in-the-loop request is an inhibitors row. It is what gates
the work, worker::inhibited already consults it at every checkpoint, and
release is already how it stops being pending.
The queue does not restate that. Storing a pending/resolved column beside a
hold that is itself either held or released puts one fact in two places, and
invariants.md opens with what that costs here: cancelled was
added to a check constraint and two SQL lists and missed in two others, and a
session wedged permanently. A queue row saying "still waiting" for a hold
somebody released is the same bug, and its symptom is a person answering a
question that was already answered.
What the queue adds is everything the hold cannot express:
- Who should answer.
inhibitorsis scoped platform, workspace, agent or session. There is no room in it for "the finance role", and the doc leaves may this person answer approvals at all as an authority that does not exist yet. - One list across workspaces. A hold belongs to one workspace. A person belongs to several, and the workspace they are not looking at is exactly where an unseen decision sits. The platform's own workspace counts as one an operator belongs to, so what is waiting on the operator -- a model's upgrade -- is raised there and reaches them wherever they are.
- A badge. One number for the whole application, which no per-workspace read can give.
So: the hold decides whether the work runs and whether the request is still open. The queue decides who is asked and renders the asking.
Which way round, concretely¶
An item names its hold rather than carrying its state:
inhibitor_idon the queue row, not anevent_idand not a state column.- Open means the hold is still held. Derived, the same way
inhibitor::decidederives a verdict rather than storing one. - Answering is
release(id)throughInhibitorStore, which is where the race belongs: two people answering at once should contend over the thing that gates the turn, not over a row that gates nothing. - Expiry is the hold's business too. A queue that expired its own row while the hold stayed held would hide a suspended turn rather than resolve it.
The cost is a join to read the queue, and a queue row outliving its hold when something deletes one without the other. Both are cheaper than two copies of "is this still waiting".
What is built¶
The targeting and the reads, on action_items and action_targets, behind
ActionStore. Raising, adding and removing targets, the per-workspace reads,
the global read and badge, a Postgres listener with a long poll over it, and
GET /v1/action-items with its count.
Two decisions in there are worth keeping whichever way the rest goes.
A target is a role, stored as the role. Expanding it to its members when the item is raised freezes a snapshot of membership into a row meant to outlive the moment: somebody joining tomorrow never sees today's request, somebody leaving keeps it, and a request raised while the role is empty resolves to nobody and is silently lost. Membership is joined when the queue is read instead, so a role changing hands writes no queue rows at all.
The global read takes no workspace argument. A token carries one workspace and the answer spans every workspace the reader belongs to, so there is nothing to resolve an authority against -- and gating on the token's workspace would be worse than nothing, since a role grant in one workspace would license reading another's. The workspace set is derived from the reader's own role grants and there is deliberately no parameter for it. Both reads return only items addressed to the caller, so there is nothing to escalate to.
That boundary is the one the schema does not hold. workspace_id is in every
key, so a query that drops it fails loudly; the line between two people inside
one workspace is a role comparison in one exists clause, and a mistake there
is quiet. It is checked by mutation: with the role match dropped, three tests
fail naming the leak, and none of the cross-workspace tests notices.
What is built wrongly, by the reasoning above¶
action_items.state, resolved_by, resolved_at, expires_at, settle() and
sweep_expired() all model a lifecycle the hold should own. They were written
before this document, against event_id rather than inhibitor_id, and they
work -- there are tests for the settle race and the expiry sweep -- but they are
the second copy this document argues against.
They stay until suspension exists, because deleting them now would leave the
queue unable to express "waiting" at all, and be a second rewrite when the hold
arrives. What they must not acquire meanwhile is a caller: nothing should start
depending on state as the answer to "is this open".
Order of work¶
inhibitors.md gives it, and this document is late to it:
Notifications after that. They look independent and are not: until inhibitors exist there is nothing to notify about, and what the notification system has to carry is decided by what generates the events.
That was right and the queue was built first anyway, which is how it came to
point at event_id: the payload was designed without knowing that the thing
generating these carries a reason, a held_by and a scope.
What remains, in order:
- ~~Suspension.~~ Built. A suspended verdict parks the job rather than
completing it, and releasing the hold gives it back, scoped the way the hold
was. The serial key needed no change -- the claim excludes keys that are
running-- and there is a test that fails if that ever widens to parked. What still cannot happen is a suspended hold being taken: both endpoints writeStrength::Stopped. approvals:answer, the authority for may this person answer approvals at all. Ordinary, and held by whoever a workspace decides. What a yes is worth once given, and what declares that an operation needs one, is approvals.md.- The producer. Something that takes a suspended hold and raises a queue item naming it. Only now is it clear what that item carries.
- Answering, which releases the hold and lets the parked turn resume.
- Repointing the queue at
inhibitor_id, and deleting the state columns above.
The capability an approval mints, and what its extent is, stays where inhibitors.md leaves it: open, and not blocking any of the above.
Web push, later¶
A browser that is asleep cannot be reached by any of the above: a long poll needs a live connection, and a backgrounded tab is throttled to nothing.
Web Push is the mechanism, and its constraints decide its shape rather than leaving it open. Payloads are capped near 4KB, and Chrome and Firefox require that every push shows a visible notification -- so it cannot be a silent "go refresh", and it cannot carry the item. What it can be is a nudge naming the workspace, deep-linking to the queue, which the client then reads properly.
Three things follow, and none needs deciding before the work above:
- A subscription is per browser, not per workspace. One device serves every workspace its owner belongs to, which makes it the first thing here that is not workspace-partitioned.
- Coalesce on the
Topicheader. A later push with the same topic replaces an undelivered earlier one, so a phone asleep through a busy hour wakes to one notification per workspace rather than a pile. Lossless, because the payload is a link. - Prune on 404 and 410. Endpoints rot constantly -- reinstalls, profile resets, revocations -- and an unpruned table pays outbound requests on dead endpoints for ever.
Do not push to somebody who is connected and looking at the queue. The badge has already moved, and a phone buzzing for something they watched arrive is the fastest way to lose the permission.