hacifootprint
Actions

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 working

An 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 rowthe declaration behind itwhat it claimsits evidence
goesTogoTo: '<page id>'this action will move you to that pagearrival'claimed' until a sync() lands on that page, then 'observed'. No third value.
highEffectconfirm: truea person should decide before this runsthe confirm card and its receipts; under enforcement, a recorded askId
enabled: falseenabledWhen, enabled: at registration, handle.setEnabled(…, false), or a live store rowon screen and not clickableTOOL_DISABLED on a fire — carrying the failing enabledWhen conjuncts as evidence only where a condition is what proved it (Guards)
blockedBecauseblockedBecause: { 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
busybusy: at registration, handle.setBusy(…), or a live store rowthe app is working on this control right nownone, and deliberately: it is the app's own label, never checked, never timed out
materialized: falsenothing — the absence of a handler, a navigate-derivable href, or an instance wiringfiring this would execute nothingNOT_MATERIALIZED on an agent fire, carrying the declared gesture
guardUnevaluateda when key your projector never seededthis edge is here on faiththe key names themselves — graph.requiredStateKeys() is the fix
holdsdeclareHolds(…)what is in the box right nowit is a reading, not a binding — firing still sends your own input
expectsinput: (schema, or the literal 'none')what a caller must sendPAYLOAD_INVALID, which carries the contract back with the refusal
instances + enumerationrepeats: true on a containerthis 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
activationa presence source other than the ordinary twohow the library came to believe this is on screenthe word itself; the ordinary 'registered' / 'synced' never ride
staleReadsreads: ['<key>', …]a key this action's outcome depends on was written by somebody else since you last lookedthe 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
staleWriteswrites: ['<key>', …]a key this action would overwrite was written by somebody else since you last lookedthe key names, and only the ones that moved — the same session.keysChangedSince(sinceVersion, { for }) fact, intersected with the other half of the declaration
priorFireUnsettlednothing — a fire of this control that this session is still holding a latch foryour own earlier fire of this control has not come to restthe 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 look

It 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 caller

Served 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 actionreadySteps step
the id keyactionstep
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 goesTo on an edge nothing is wired to still says goesTo; a busy label on a control that finished a minute ago still says busy. 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.

On this page