Skip to content

feat(mdcode): evaluate an action's guards against the store - #421

Open
libei wants to merge 5 commits into
GoogleCloudPlatform:mainfrom
libei:constraint-eval
Open

libei wants to merge 5 commits into
GoogleCloudPlatform:mainfrom
libei:constraint-eval

Conversation

@libei

@libei libei commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

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

lowerGuards resolves each name in guards and 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 rule over the action's parameters is settled before any statement runs
  • 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.

$ kcmd action run TransferFunds --arg source="Alice Checking" --arg target=ACC-2 --arg amount=0
Running 'TransferFunds' on projects/my-project/instances/my-instance/databases/semantic_agent_demo...
Refused (reject): Action 'TransferFunds' was refused and nothing was written. A transfer must move at least one unit. Ask the caller for the amount again before retrying. Stopped by 'AmountIsPositive' (amount > 0).
Refused (escalate): Action 'IssueCredit' needs an approval, and nothing was written. A credit over 25 dollars is above the self-service limit. A supervisor decides it. Stopped by 'CreditUnderSelfServiceLimit' (amount <= 25). Nothing is held while somebody decides: the transaction was rolled back, so run the action again once it is approved.

Outcomes

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.

What still cannot be checked

A judgment, since nothing calls a judge; and an expression outside the grammar. A rule that would reject or escalate and cannot be checked stops the action, and every such rule is named rather than only the first:

Error: Action 'IssueCredit' cannot be run: constraint 'OrderTotalMatchesLineItems' cannot be checked: it uses parentheses or a function call (Order.total = SUM(LineItem.amount)), which the grammar does not parse; constraint 'CreditIsNotSplitToAvoidReview' cannot be checked: it is settled by judgment rather than by an expression, and this runtime runs no judge. Running it would apply a write the model says is checked first, so it is refused rather than run unchecked.

An advisory rule is the exception, and this is the one design call worth flagging. A warn rule 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:

  not checked: advisory rule 'CreditMemoNamesAServiceFailure' (constraint 'CreditMemoNamesAServiceFailure' cannot be checked: it is settled by judgment rather than by an expression, and this runtime runs no judge)
Committed at 2026-09-13T18:00:00Z.

The grammar

Comparisons over an entity's stored fields, the action's parameters and literals, joined by AND and OR. = NULL and != NULL become IS 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:

-- over stored data
SELECT OrderId FROM Orders WHERE OrderId = @order AND NOT COALESCE((Total >= 0), FALSE) LIMIT 5
-- over the parameters
SELECT 1 AS violated FROM UNNEST([1]) WHERE NOT COALESCE((@amount <= 25), FALSE)

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 --noEmit clean. The CLI tests keep a judged guard so they still stop hermetically before any session opens.

Docs

actions.md §2 and §7, and reference.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 the kcmd agent tools listing, where transfer_funds is 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.

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant