What kind of edge am I holding?
A reading guide for the served action row — every stamp, the declaration behind it, and what it is evidence of. There is no kind field, because the kinds compose: the kind of an edge IS the set of declarations it carries.
The failure this prevents
A production integration rendering the action list wanted one word to switch on — is this a navigation, a guarded action, a high-effect one, a busy one, a disabled one? — and was deriving it from the stamps by hand, in its own code, against a row whose shape it had to learn by reading JSON.
The ask was a kind field.
It was declined, and what it was owed instead is this page: the table, as a reading guide rather
than a field on the wire.
Why there is no kind
Two reasons, either sufficient on its own.
1. The kinds compose. Take the Pay button on a checkout page: it sits behind a guard, it charges a card, it goes to a receipt page, and right now it is mid-charge. All four are true of one control at one moment:
{ "action": "checkout.pay", "does": "Pay for the order",
"goesTo": "receipt", // it navigates
"highEffect": true, // it is high-effect
"enabled": false, // it is switched off
"busy": "Charging your card…" } // and it is workingAn enum has to pick one — and whichever it picks, the other three go invisible exactly when all four are true, which is the moment a reader most needs all four. So the row carries one stamp per declaration instead, each absent when the app declared nothing.
The kind of an edge IS the set of declarations it carries. That set is already on the row.
2. Evidence follows the declared claim, never a new classification. Every stamp below traces to
one thing the app said, and its evidence answers that claim. A kind would be this library's
word about the edge — a served fact with no declaration behind it — and the first question a reader
may always ask here, who said this?, would have no answer.
The table
Read it as: this key is on the row because the app declared that thing, and here is what would prove or disprove it.
| stamp on the row | the declaration behind it | what it claims | its evidence |
|---|---|---|---|
goesTo | goTo: '<page id>' | this action will move you to that page | arrival — 'claimed' until a sync() lands on that page, then 'observed'. No third value. |
highEffect | confirm: true | a person should decide before this runs | the confirm card and its receipts; under enforcement, a recorded askId |
enabled: false | enabledWhen, enabled: at registration, handle.setEnabled(…, false), or a live store row | on screen and not clickable | TOOL_DISABLED on a fire — carrying the failing enabledWhen conjuncts as evidence only where a condition is what proved it (Guards) |
blockedBecause | blockedBecause: { says, clearedBy } — or a reader returning one (Guards) | why the app says it is off, and who clears it — 'app' (wait), 'user' (interrupt the person), 'invalid' (report a validation problem) | none, and deliberately: it is the app's own sentence, carried as data beside the refusal's authored one. It rides only a row that also carries enabled: false |
busy | busy: at registration, handle.setBusy(…), or a live store row | the app is working on this control right now | none, and deliberately: it is the app's own label, never checked, never timed out |
materialized: false | nothing — the absence of a handler, a navigate-derivable href, or an instance wiring | firing this would execute nothing | NOT_MATERIALIZED on an agent fire, carrying the declared gesture |
guardUnevaluated | a when key your projector never seeded | this edge is here on faith | the key names themselves — graph.requiredStateKeys() is the fix |
holds | declareHolds(…) | what is in the box right now | it is a reading, not a binding — firing still sends your own input |
expects | input: (schema, or the literal 'none') | what a caller must send | PAYLOAD_INVALID, which carries the contract back with the refusal |
instances + enumeration | repeats: true on a container | this row stands for N cards, addressed by key — and enumeration says whether that list is complete ('selector') or only what is mounted ('mounted-window') | INSTANCE_REQUIRED / INSTANCE_UNKNOWN, each listing the live keys |
activation | a presence source other than the ordinary two | how the library came to believe this is on screen | the word itself; the ordinary 'registered' / 'synced' never ride |
staleReads | reads: ['<key>', …] | a key this action's outcome depends on was written by somebody else since you last looked | the key names, and only the ones that moved — session.keysChangedSince(sinceVersion, { for }) is the same fact as data, and the brief narrates it as prose |
staleWrites | writes: ['<key>', …] | a key this action would overwrite was written by somebody else since you last looked | the key names, and only the ones that moved — the same session.keysChangedSince(sinceVersion, { for }) fact, intersected with the other half of the declaration |
priorFireUnsettled | nothing — a fire of this control that this session is still holding a latch for | your own earlier fire of this control has not come to rest | the transitionId itself: hand it to did_it_work |
staleReads — what this control depends on that has moved
An action can declare what it writes; reads is the other half — the state keys its outcome
is computed from. Not the guard: guard keys decide whether the control is on offer at all, and they
are already served as evidence. These are the keys the answer comes from.
settle: {
does: 'Settle the claim for the amount on it',
when: { 'claim.stage': { eq: 'open' } }, // whether it is here
writes: ['purse.left'], // what it changes
reads: ['claim.total'], // what its answer is computed FROM
}Declare it and the row can be told that something under it moved:
// whats_here({ sinceVersion }) — after the user revised the claim
{ "action": "ledger.settle", "does": "Settle the claim for the amount on it",
"highEffect": true,
"staleReads": ["claim.total"] } // ← a key you depend on was written since your last lookIt refuses nothing. No value is compared and none crosses; the stamp does not say the number is different, that firing is wrong, or that anything must be re-read. Before this, the brief said a key changed and the row said here is a control, and nothing joined them.
Declared, never inferred. The library does not read your handler, promote a guard key, or guess
from co-occurrence. Which keys matter is meaning, and meaning is yours. An app that declares no
reads serves byte-identical rows, and a declared read nothing has written serves no key.
staleWrites — someone has written what you are about to write
The read side is silent, by construction, on a control that simply overwrites a key: such a
control correctly declares no reads of it, because its outcome does not depend on the old value.
That is exactly the control whose repeat costs the most — the second room, the second payment — so
the same intersection is served against the write half of the declaration:
// whats_here({ sinceVersion }) — a person held the room between your last two turns
{ "action": "board.hold-room", "does": "Put a hold on a room for those nights",
"staleWrites": ["itinerary.roomHeld", "itinerary.roomBookings"] }It does not name who. It says a key this control declares it writes has been committed since your last look, by somebody who is not you — not who that was, not that your write would be wrong, and not that this would be a repeat. Same laws as its sibling: names only, declared by you, presence-only, refusing nothing.
Your own write is not a disturbance to you
A key is stale to a caller when it moved since that caller last acted on it. Your own committed write is you acting, so it never comes back to you as a stamp; anybody else's write to that same key, afterwards, still does.
session.keysChangedSince(sinceVersion); // every key this SESSION committed
session.keysChangedSince(sinceVersion, { for: 'agent' }); // …that moved under that callerServed rows ask the second question, for the principal the port stamps its fires with ('agent'
unless you built it with source). The bound is an act, at a version — a
caller's write un-marks a key until the next motion filed under anybody else, and not one turn
longer. No principal is ever served on the row.
On 1.7.0 this was not so, and it mattered: the stamp is carried until answered, so a caller's own fire came back to it forever — the one act that clears the ledger (firing the control) is the act that re-armed it. Fixed in 1.7.1, disclosure-only: a stamp can only disappear, never appear.
Both stale stamps are carried until you answer them
The window is since you last looked, and your last look moves every time you look. So a stamp computed only from that window states its fact for one turn and then goes quiet while the fact is still true — measured in the field: present on the turn a key moved, gone two turns later with nothing changed, and the fire landed on the third.
So a served stale stamp is carried until something answers it, and only two things can:
session.acknowledgeStale('board.hold-room'); // everything outstanding for it
session.acknowledgeStale('board.hold-room', ['itinerary.roomHeld']);
// → { cleared: [...], acknowledgementId: 'ack#1' }
session.carriedStale('board.hold-room'); // a pure question; asking is not answering…or the agent firing that control, which is an act this session witnessed on the very row the stamp was on.
Every call also writes an append-only StaleAcknowledgement row (session.acknowledgements()) and
hands back its id. That id is what a freshness: { … : 'require-ack' } action's fire has to CITE —
see Freshness and single-flight. It records that a protocol
step was performed; it is not evidence that anything was understood, and the row does not claim
it was.
Nothing else clears it. Not another look — that is the defect, not the fix, and this library never serves a value, so it can never conclude that a value was read. Not a person using the control: a human's use is what creates staleness for a machine reader. Not a refused fire: an act the app turned away is not an act you got to make. And only what was served is carried — a stamp nobody was ever shown is not a thing anybody can be asked to answer for.
priorFireUnsettled — your own fire, still out there
A fire whose handler has not finished comes back effectStatus: 'pending' with
the settlement pointer. Until that fire comes to rest, every row for the
same control carries the id it is waiting on:
{ "action": "pay.send-money", "does": "Send the money", "highEffect": true,
"priorFireUnsettled": "pay.send-money#2" }It refuses nothing either, and that is deliberate: some repeats are right — a genuinely lost
fire is one — and only you can tell. Ask did_it_work with that id before you fire again.
The one that is never a stamp
when — the availability guard — has no key on the row, and that is not an omission. A failed
when hides the edge: it is not served at all, so there is no row to carry a stamp. The only
trace a guard leaves on a served row is guardUnevaluated, which says the opposite thing — the
condition could not be judged, and the edge is here on faith.
So guarded is never a property of a row you are holding. It is either a row you were never given, or a row with a taken-on-faith marker on it. Guards has the full asymmetry.
Presence is the whole claim
Every stamp above is presence-only, and the rule is the same one everywhere:
- A key means the app declared it.
- No key means this library does not know — never the opposite of the claim.
There is no enabled: true, no busy: false, no highEffect: false on a served row. A cheerful
false on the rows nobody wired would be a claim about a session that was never asked — and worse,
it would make the absence of the key on the remaining rows read as nobody knows, which is
precisely the fact the presence rule exists to preserve.
The two rows are the same row
A whats_here action row and a journey frame's readySteps row describe the same edge, and
they agree by construction:
whats_here action | readySteps step | |
|---|---|---|
| the id key | action | step |
does, goesTo, highEffect, guardUnevaluated, materialized, expects | ✔ | ✔ |
holds, enabled, busy, instances, activation | ✔ | — |
blockedBecause | ✔ | ✔ on a frame's laterSteps — a step the plan calls ready whose control is switched off carries the state, the app's reason, and what would free it side by side |
The second group is about the control as it sits on screen, and a frame's ready list is a plan
rather than a screen. When you need the screen state of a step, whats_here is the door — that is
what it is for, and the modes page says so on the same rail.
Honest limits
- The table describes what the app declared, not what is true. A
goesToon an edge nothing is wired to still saysgoesTo; abusylabel on a control that finished a minute ago still saysbusy. Every row here is the app said, and the library speaks in that voice on purpose. - A stamp is never another stamp's cause. A disabled-and-busy control has had two true things said about it, and off because busy is an inference neither of them made. The refusal that carries both says so out loud rather than leaving the hole a reader would fill in itself.
- New declarations add stamps; they never repartition the old ones. That is the whole reason this is a set rather than an enum — a new fact about an edge is a new optional key, and every reader that branched on the old ones keeps working.
Contextful actions
One wrapper at registration, and both doors into an action — the agent's fire and your app's own click — land in the same capture envelope. The anchor becomes bidirectional: it actuates for the agent and senses for the record.
When the world moved under the row
The tier above disclosure: cite the row you planned against, declare what a control does when something it was offered under has since moved, and allow one occurrence at a time. All of it opt-in; none of it on by default.