hacifootprint
Actions

Guards

A guard is a flat, serializable filter over your projected state — read this before writing a state projector.

A guard (when:) is a flat filter over your projected state — key: { op: value }, ANDed across keys. The operators are the ONLY eight: eq, ne, gt, gte, lt, lte, in, notIn (in/notIn take an array). There is no $or, no nesting, no disjunction of any kind — the filter is deliberately flat, so it stays serializable, explainable and diffable. An empty guard {} is rejected at build time.

The OR pattern — derive a boolean in your projector

To express "A or B", compute the boolean in the state you report, then guard on that:

// "checkout is allowed when the cart has items OR a saved order exists"
session.updateState({ canCheckout: cart.length > 0 || savedOrder != null });
// then author:  when: { canCheckout: { eq: true } }

Absent guard key ⇒ served, not hidden

A guard key the projected state has never contained is not treated as false. The edge is served anyway, carrying a guardUnevaluated marker — the condition is flagged as taken on faith, with the app still the enforcer. A key that is present but fails its test hides the edge, as you'd expect.

The reason: absence of data must not masquerade as false. That honesty is what lets one authored graph work before every key is wired — but it also means an under-seeded projector quietly turns real decisions into flagged guesses. (undefined is never stored: a report entry whose value is undefined is dropped, and a key somehow holding undefined is as unevaluable as an absent one — it rides guardUnevaluated, never sneaks past an operator.)

enabledWhen — the other question

when asks is this action here at all? enabledWhen asks is it clickable right now? They are different questions about the same control, and the difference is exactly the difference a human sees:

a failed whena false enabledWhen
on screenhidden — the edge is not servedserved, carrying enabled: false
what a fire getsGUARD_FAILED, with the evidenceTOOL_DISABLEDretriable
composed with ancestor whensyes, root→leafno — this is the control's own state, not its position
what it is forthis action does not belong here yetthe button is on screen and greyed

Declare it from the same expression that renders <button disabled={…}> and an agent stops discovering the answer by clicking:

'next-to-review': { does: 'Go on to review', goTo: 'review',
                    enabledWhen: { 'project.recipe': { ne: '' } } },

Four wires reach one refusal. An app that greys a button knows it in one place — so whichever place that is, it lands on the same typed, retriable TOOL_DISABLED:

  1. enabled: at registration;
  2. handle.setEnabled(actionId, false) on the group handle;
  3. a live store row's LiveAction.enabled (fromLiveStore);
  4. the declarative enabledWhen here.

The declaration is asked first and outranks the other three: it is a statement about the control itself, so it greys every row of a repeats container at once, and an instance registered enabled cannot re-open a door the app has said is shut.

Disabled needs proof — the same asymmetry verify uses. One false conjunct proves the conjunction false whatever the unknown keys hold; anything else serves the edge unmarked. A key missing from the state view can only ever weaken the answer toward enabled: the library refuses an action because the app said so, never because it could not look.

The fact travels twice, and both halves matter. available().edges carries enabled: false, and so does the action row a model reads over the Mode B wire — that is the greyed button, disclosed before anything is reached for. It is presence-only: a clickable control carries no key at all, because enabled: true on some rows would make silence on the rest read as nobody knows.

Reach for it anyway and the refusal teaches at the moment of use: TOOL_DISABLED, with retriable: true and one authored sentence.

The app has this control switched off right now — on screen and not clickable, the way a greyed button is for a person. That is a STATE, not a verdict on what you asked for: it can change, and nothing here knows what would change it. Do not invent a reason it is off. Call whats_here to see where things stand — a switched-off control is served there with enabled: false — and if it is still off, tell the human it is not available yet. This control also declares a condition for being clickable, and the app’s own state does not meet it — the parts that did not hold ride this result as evidence, named by the app’s own declaration and not guessed here. whats_here may also carry unblockedBy for this control: the actions the app claims write those same parts. That is what the app declared, not a promise: firing one is not promised to free this, and meeting the condition may still leave the control off for a reason nothing here can see. Say what the evidence says, and no more.

The first sentence says what is true, says out loud what is not known, and refuses to supply a cause. An integration whose relay met the bare refusal filled that hole in itself — it told its human "a required field is probably empty", which nothing in the app had ever said, and then tried again. A hole in an answer is where a guess goes.

The second sentence rides only when enabledWhen is what proved it, and it comes with the proof: the conjuncts that did not hold, on evidence, in the shape GUARD_FAILED already serves (the table above).

{ "ok": false, "reason": "TOOL_DISABLED", "retriable": true,
  "evidence": [{ "key": "project.recipe", "op": "ne", "threshold": "",
                 "actualSummary": "\"\"", "result": false, "redacted": false }] }

The library evaluated that condition to decide the refusal and used to throw the failing half away — so the reader who cannot see the screen was handed a conclusion and could not name the field. Now it can. Three rules keep it honest, and each one is a test:

  • Only the conjuncts that failed. The ones that held are not why the control is off.
  • Absent for the imperative wires. enabled:, setEnabled(false) and a live store row declare no conditions, so there is nothing to name and nothing is invented — the bare refusal and its first sentence stand exactly as they did.
  • Not a promise. Meeting the condition may still leave the control off through one of those other three wires, none of which declares a reason. The sentence says so rather than sending an agent into a retry loop against a door that never opens.

The refusal's gap-ledger row carries the same evidence, so triage sees what the agent saw.

There is no busyWhen

A control that is greyed because it is working is a third state, and it has its own wire: busy, the app's own label for it. It deliberately has no declarative form. enabledWhen proves a state and needs no words; a busy label is words, and no expression an app could write produces its own prose — a busyWhen could only make this library author the sentence, which is the one thing the string-only shape exists to prevent.

The two are independent, and both are true at once on a Save button mid-save: enabledWhen shuts the door, busy says what the app is doing. Neither is served as the other's cause.

blockedBecause — your own reason, and who clears it

enabledWhen proves a control is greyed and hands the reader the conjuncts that failed. That is evidence, and it is derived. It is not the sentence your component already knows — "waiting for the upload to finish", "this order is already cancelled", "the amount is above your limit" — and it is not the one fact no evidence carries: who can clear it.

next: {
  does: 'Continue to review',
  blockedBecause: { says: 'Waiting for the receipt to finish uploading', clearedBy: 'app' },
},

The declared type is BlockedBecause, and it is two fields on purpose. says is your own sentence, carried as data. clearedBy is the half that decides the reader's next turn, and it is three words because there are three moves:

clearedByWhat the agent does
'app'waits — the app will clear it; there is nothing to relay
'user'interrupts the person — the one move worth a turn here, and the one it will not make from a sentence alone
'invalid'reports a validation problem — stop waiting for a state that is not coming

A fourth word is refused at both authoring doors, because there is no fourth move.

Presence-only, and only while the control is off. A live control carries no blocked sentence, however you declared one — the question does not arise, and answering it would hand a reader a reason to wait for a door that is already open (the same law unblockedBy keeps on the same row). An app that declares nothing serves byte-identical rows.

It rides beside the refusal, never inside it. Reach for the control and TOOL_DISABLED carries blockedBecause as a data field next to the authored sentence above, which is unchanged to the byte — including "nothing here knows what would change it", which is about what this library knows and stays true beside anything you say. Your words never enter a sentence we wrote.

{ "ok": false, "reason": "TOOL_DISABLED", "retriable": true,
  "blockedBecause": { "says": "Waiting for the receipt to finish uploading", "clearedBy": "app" } }

A reason that changes while the page is open

Declare a reader instead of a sentence. It is called at the moment a row is assembled — never cached — so two reads a turn apart honestly say two different things. It is the same shape, and the same discipline, as holds:

blockedBecause: () => (upload.pending
  ? { says: `Uploading ${upload.name}…`, clearedBy: 'app' }
  : undefined),

Returning undefined says nothing — absence, spelled the way it is everywhere else here. Keep it a read: it runs on a hot path (every refused fire assembles rows for its own context), and a reader that throws, or answers something this library cannot read as a reason, costs the row its sentence and nothing else — no key, one dev warning per action, and whats_here still answers. A plausible wrong reason is the worst thing this surface could ship, so absence wins.

Switching a control off with no cause declared warns once

Grey a control imperatively — handle.setEnabled(id, false), or a live store row — while it declares neither enabledWhen nor blockedBecause, and one dev warning names both doors:

'checkout.next' was switched off with nothing declared about why — no enabledWhen and no blockedBecause — so a caller that reaches for it is refused with the state and no evidence at all: told no, and taught nothing. Declare enabledWhen for derived evidence, or blockedBecause for your own sentence.

Once per action, for the session's life, and never for a control that declares either one. It is a developer warning and nothing else: the refusal is unchanged, and nothing is served differently. The reason it exists is that both cures already existed and neither was discoverable from the call that shuts the door — an integration hand-rolled its own version of this field for months.

Seed every key — requiredStateKeys()

graph.requiredStateKeys(); // → sorted, deduped string[] of every guard key the graph reads

It covers all tool whens, container-node whens (they AND-compose into descendant tools), and journey preconditions — whether or not the node is currently mounted. Seed each of these keys in your state projector so guards decide instead of deferring. This is the one call that closes the guard-honesty loop.

Container when AND-composes root→leaf into descendant tools; children can only narrow, and a contradictory ancestor/descendant pair dies at build time — see The navigation graph.

On this page