Conversation
Until now a guarded action was refused outright, because nothing evaluated a constraint and running the write would have applied it past a rule the model says checks it. This turns that blanket refusal into a real check. `lowerGuards` resolves each name in `guards` and turns the constraint's expression into one query against the store. When it runs follows from what the expression reads, and the model never states it: a rule over the action's parameters is settled before any statement runs, and a rule over stored data runs after the statements and inside the same transaction, where it reads the state the write produced. A violation rolls the write back and reports which rule stopped the call, in the words the constraint's `description` gives. `ActionOutcome` gains a `refused` arm carrying the effect, the violated rules and the message. `committed` gains `warnings` for advisory violations and `unchecked` for advisory rules the runtime could not evaluate. Several violations combine by the strictest effect: any reject refuses, failing that any escalate holds, failing that any warn reports. Two kinds of guard still do not become a query. A judgment is one, since nothing calls a judge; an expression outside the grammar is the other. A rule that would reject or escalate and cannot be checked stops the action, and every such rule is named. A rule that would warn does not: it reports nothing to begin with, so failing to check it cannot be grounds for stopping the write, and it is reported back as not checked instead. The grammar accepts comparisons over an entity's stored fields, the action's parameters and literals, joined by AND and OR. It refuses a function call, a parenthesised subexpression, an expression spanning two entities, an abstract or composite-keyed entity, and an entity the action takes no reference parameter for -- each with the reason in the message. The refusal tests from the action-runtime change are replaced one for one by tests of the check that now happens. The guide's status paragraphs, the run flow, the outcome set and the agent-tools listing are rewritten against real command output.
…hole Review of the constraint evaluator turned up three defects and three claims the code does not support. A rule over stored data was probed against the first parameter of that entity and no other, so `TransferFunds(source: Account, target: Account)` guarded by a rule over `Account` asked about the source alone and reported the rule as checked. The probe now scopes to every reference the action names. The scan for AND/OR and the scan for a comparison operator both ran over the raw expression, so `Account.status = 'ON HOLD OR CLOSED'` came apart inside the quotes and, because an unlowerable rule that rejects stops the call, was refused for a fault it does not have. Both scans now read a mask that blanks quoted spans and preserves offsets. A `warn` probe the store refused escaped as a StoreError and rolled the write back, undoing one layer down the carve-off `lowerGuards` makes for an advisory rule. Such a failure now joins the rules that could not be lowered. The documentation said a guard over stored data stops a call on data that is already broken, which the derived timing makes false: a rule reading no parameter is asked of the post-state. That claim is corrected, and the limitation it hid -- a precondition on the state before the write cannot be expressed -- is stated. The demo README described `CreditWithinOrderTotal` as running after the statements when it reads a parameter and runs before them. The `Constraint` doc comment still said no component evaluates a constraint. The captured runs under "What a violated guard does" were re-run. The rejecting example named an action whose table does not exist in the demo store, so it could not have been captured as shown; it is replaced with a rejecting run of the credit policy, and the committed run carries its real timestamp.
The evaluator carried its own regex for a single-quoted span. sql_expr_utils already defines one, used by six other modules, and it also covers the double-quoted form and backslash escapes. The operator scan takes blankStringLiterals unchanged. The AND/OR split cannot: blanking to spaces merges a literal into the whitespace on either side of it, so the pattern matches across where the literal used to be. It reuses the shared literal grammar with a non-space filler instead.
…eeding both moments Two defects in the guard evaluator, both found by exercising the lowering rather than by reading it. The gates that refuse `==` and parentheses read the expression raw, so `Order.status = 'a==b'` and `Order.note = 'see (attached)'` were turned away for a fault they do not have. On a reject-class guard that leaves the action permanently unrunnable. Both now read the text with its literals blanked, which is what the AND/OR split and the operator scan already did. Timing was derived once for the whole expression, from whether any comparison read a parameter. `Order.total >= 0 AND amount > 0` therefore lowered to one probe timed `before`, which checked the stored conjunct against the pre-state and never looked again: the write that drove the total negative committed, and the outcome reported the rejecting guard as checked. A probe runs at a single moment, so an expression needing both is now refused, the way one spanning two entities already is.
The evaluator did five jobs in one file -- scan the expression, decide when the rule runs, resolve logical names to physical ones, write GoogleSQL, report -- so a second way to check a rule meant editing all five. Two things vary, and independently. WHAT SETTLES THE RULE is the constraint's own body, an axis the IR has named since CONSTRAINT_EVALUATIONS landed while the evaluator special-cased it with one `if`. It is now a registry keyed by that enumeration and total over it, so a third body cannot be added to the model without failing to compile here. HOW A STORE IS ASKED is the query language -- GoogleSQL over the entity's table today, GQL over the pushed property graph next -- and dialect.ts holds that alone. The point of the split is that deciding and spelling come apart. Whether a rule may be checked, which entity it reads and which moment it runs at are settled in analysis.ts with no store and no language in hand; which table and column it reads comes from the profile in bind.ts; only the writing down belongs to the dialect. A dialect can therefore neither widen what the runtime agrees to check nor move when it checks it, which a test now holds it to by planning the same rules through a second one. The package also stops naming a backend. A check reports the parameters it wrote rather than having them scanned back out of its SQL, and the one place a Spanner statement is built is run_action.ts, alongside every other one. Nothing the runtime does changes. The GoogleSQL a rule lowers to is asserted byte for byte, and the 51 tests pass unchanged, with 3 added for the seam.
This was referenced Sep 14, 2026
libei
added a commit
that referenced
this pull request
Sep 16, 2026
…434) Section 7 of the actions guide, "Run it", read against `run_action.ts`, `judge_store.ts`, `gemini.ts`, `loader.ts`, `commands.ts`, `agent_tools.ts`, `validate.ts`, `dialect.ts` and the demo's `commerce.yaml`. Seven claims the code contradicts: - "the action's `guards` name the judged rule alone" -- `IssueCredit` names four, all judgments, as section 7 itself says later. - "this constraint declares `reject`" of `CreditMemoNamesAServiceFailure` -- it declares `warn`; the three captured runs came from toggling the field. - resolution SQL showing both predicates for `"Alice Checking"` -- `accountId` is an `Integer`, so `bindScalar` fails and the key predicate is dropped, as the same passage states three paragraphs later. - "Three of the five guards ... which is also why they load with the all-judged warning" -- section 2 concludes from that same count that `IssueCredit` stays clear of the warning. - `onViolation: warn` -- the YAML key is `on_violation`; `onViolation` is the internal TS field. - "kcmd refuses a call that an expression guards" -- `run_action.ts` filters `on_violation: warn` constraints out of the guard set before the refusal test, so an advisory expression guard warns and the write proceeds. Both statements of the rule now say non-advisory. - "ends every entry with the command line that runs it" -- `action list` prints a run line only where the profile binds an executor. Figure 3 was captioned "one run of `TransferFunds`", and `TransferFunds` is guarded by a non-advisory expression, so it completes no run. The caption now describes the path a run takes. Structure and prose: - The section opened on a `kcmd action run` invocation it then retracted. It now opens on why you would run an action at all: nothing so far has put it in front of the database the write lands on, and pasting the statements into a SQL console would tell you the DML is valid and nothing else. - Figure 3 moves up under a new "What a run does", so the three steps arrive before a page of detail rather than after it. - "How a row is identified" becomes "Which rows a call touches". It had no inbound links; the three anchors that do are untouched. - The paragraph introducing `kcmd action run` drops four sentences that argued rather than informed: a defence of figure 3's choice of example, a restatement of `--profile` the guide already introduces for push, a negation of the sentence before it, and a caveat that framed a guard doing its job as the tool printing a command that does not work. - One prose class recurred throughout: a reduced object-relative standing as the subject, with the main verb landing late ("a call an expression guards is refused", "the expression nothing checked"). All unwound. - Every announced count was audited against its list, including `MAX_READS`, `DEFAULT_ROW_LIMIT` against the `LIMIT 21` shown, and `DEFAULT_CELL_LIMIT`. Three fenced lines change on purpose; every other fenced block is byte-identical. Left for later: section 7 says "Nothing in kcmd evaluates an expression against live data today", which #421 falsifies when it lands. The `action list` block was built from the guide's own fenced YAML rather than captured from a live run, and is worth diffing against one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third of the constraint split. #398 added the construct, #413 added the action runtime and refused a guarded action outright. This turns that refusal into a real check.
What it does
lowerGuardsresolves each name inguardsand turns the constraint's expression into one query against the store. When a guard runs follows from what its expression reads, and the model never states it:A violation rolls the write back and reports which rule stopped the call, in the words the constraint's
descriptiongives.Outcomes
ActionOutcomegains arefusedarm carrying the effect, the violated rules and the message.committedgainswarningsfor advisory violations anduncheckedfor advisory rules the runtime could not evaluate. Several violations combine by the strictest effect: anyrejectrefuses, failing that anyescalateholds, failing that anywarnreports.What still cannot be checked
A judgment, since nothing calls a judge; and an expression outside the grammar. A rule that would
rejectorescalateand cannot be checked stops the action, and every such rule is named rather than only the first:An advisory rule is the exception, and this is the one design call worth flagging. A
warnrule reports a violation rather than stopping one, so failing to check it cannot be grounds for stopping the write either — treating it as one would leave a model that states advisory rules permanently unrunnable, which is exactly the carve-out #413 made deliberately. The write goes ahead and the rule is reported as not checked:The grammar
Comparisons over an entity's stored fields, the action's parameters and literals, joined by
ANDandOR.= NULLand!= NULLbecomeIS NULL/IS NOT NULL;<>normalises to!=. It refuses a function call, a parenthesised subexpression, an expression spanning two entities, an abstract entity, a composite-keyed entity, an unbound or expression-bound field, and an entity the action takes no reference parameter for — each with the reason in the message. The expression language is GoogleSQL, so equality is a single=;==is refused with that said explicitly.Probe shapes:
The
NOT COALESCE(…, FALSE)wrapper makes a predicate that returns NULL count as a breach rather than as silence.Tests
44 new tests for the evaluator; #413's refusal tests replaced one for one by tests of the check that now happens. 1012 pass, 0 fail,
tsc --noEmitclean. The CLI tests keep a judged guard so they still stop hermetically before any session opens.Docs
actions.md§2 and §7, andreference.md, are rewritten against real command output rather than by hand: the status paragraphs, the four-stage diagram (timing is derived, not "before the call"), the run flow, the outcome set (committed · refused · nothing written · unknown), and thekcmd agent toolslisting, wheretransfer_fundsis no longer[NOT RUNNABLE].Order.total == SUM(...)is corrected to=in the guide and the demo model.Wiring the demo's own
guards:so the $30 credit escalates is the next change; the demo README says so rather than claiming the old behaviour.