Skip to content

docs(mdcode): second pass on the actions guide — state it once, state it plainly - #428

Merged
libei merged 9 commits into
GoogleCloudPlatform:mainfrom
libei:actions-guide-statements-section
Sep 15, 2026
Merged

libei merged 9 commits into
GoogleCloudPlatform:mainfrom
libei:actions-guide-statements-section

Conversation

@libei

@libei libei commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #427. Reading the merged guide straight through turned up passages
that are clean by the checklist and still hard to get through. They fail in
three related ways: a passage explains a rule by pointing somewhere else
instead of stating it, it says something the page has already said, or it uses
a term of art the guide never defined. Two commits then act on the structural
version of the same complaint, one for each of the guide's two crossed-over
subjects, and the last one fixes what a second review found in all of it.

Nothing in the prose contradicts the code. Eight lines of fenced YAML were
deliberately deleted, named below; the other 470 are byte-identical to main as
a multiset, every frozen heading is unchanged, and the five inbound anchors from
reference.md, model_spec.md and the agent demo README resolve.

Each kind of constraint now has a section of its own

A constraint carries one of two bodies, and section 2 taught them in an order
that kept crossing between the two. An 88-line preamble opened on the concept,
ran two expression examples, then spent twenty lines on guards, on_violation
and the moment a guard is checked — machinery that belongs to both kinds —
before "When no expression decides it" reached the judged body. A reader
deciding which body to write had to carry all of it.

The section now states the fork once, in the preamble, and keeps the three
subjects apart afterwards. "Naming a constraint as a guard" holds everything
true of either kind: the guards list, why the reference sits on the action,
when a guard is checked, why guards and on_violation are independent, and
the push error and load warning that report a mismatch between them. "Rules a
query settles"
holds the expression body, what it can read, and how far a
guard over stored data actually reaches. "Rules no query settles" holds the
judgment body, followed by the existing "Writing a judgment". The two
worked-example sections are unchanged and still take both kinds together.

Two facts were stated once per kind and are now stated once. Both copies of the
pre-write timing rule — "Every guard is checked before the call" in the
expression material, "Guards run before the transaction opens" in the judged
material — collapse into one sentence in the shared section, and each kind keeps
only the consequence that is its own. The derived evaluation field was defined
in the status note and again in the policy example; it is now defined in the
preamble, where it makes the two-body distinction visible in the catalog, and
the policy example only uses it.

The status note split along the same line, because it was one paragraph
reporting three unrelated states. What kcmd does with an expression sits under
the expression section, what --judge does with a judgment sits under the
judged section, and the run-time precedence gap stays with the strictest-outcome
prose it qualifies.

The eight deleted fenced lines are the ones that made the guards example
reprint TransferFunds whole — executor, server URI, three parameters — to
teach the single line guards: [AmountIsPositive], for an action the reader has
already seen declared twice. The example now shows the constraint and that line.

The DML material is now one section

Three subsections of step 1 were about writing DML — "The executor is a
binding", "Writing the statements in the model" and "Statements use your
database names" — with concept material between them, so a reader who had not
yet decided how the write happens met physical detail twice before finishing
the declaration. Step 1 ran 229 lines, and three quarters of the span below its
four-kind list was detail rather than concept or procedure.

Step 1 now ends at the declaration, at 106 lines: the name and parameters, the
model, the four executor kinds, entity-typed parameters, and where an executor
comes from. Everything about DML follows under one unnumbered H2, "##
Carrying the write as DML"
, skippable by its title for anyone using mcp,
rest or grpc. Inside it the statements come first, then the names they use,
then which file to put them in — so the profile override fence sits under the
rule it demonstrates rather than 80 lines above it.

Two facts left step 1 for the step that owns them. Key generation is an
affects fact, so it is now "### A created row gets its key from kcmd" in step
3, whose Status paragraph already said affects drives it; that paragraph
stops re-explaining the binding and opens on what it actually reports. The
three statement rules are push-time rules, so they are now "### What push holds
a statement to" in step 4, beside the four static action errors already there.

"The executor is a binding" is gone. Its rationale and its inheritance rule
restate profiles.md:104-123, which says it better — "the same action, the
same blast radius, performed by whatever mechanism the bound store actually
has."
Step 1 keeps the part a reader needs there: the executor is physical, a
profile can supply or replace it, and an action a profile says nothing about
keeps the model's.

The push rules moved without being reworded. validate.ts:299 tests
text.slice(0, -1).includes(';'), which strips the last character before
looking, so a trailing ; passes and an interior one fails — exactly what "a
; inside a statement is rejected" says, and what model_spec.md:490 and
reference.md:458 say as "other than a trailing one". There was nothing to
correct.

A gate claim the runtime does not implement

"The gate sees the write" said a check observes the uncommitted result of
the write it is gating. Guards settle before the transaction exists
(run_action.ts:181-198), there is no expression evaluator at all — an action
guarded by one is refused rather than run (run_action.ts:529-535) — and
nothing runs between the last statement and the commit. The guide already
stated the true version twice, including in section 2: "Guards run before the
transaction opens, so 'an order's total equals the sum of its lines' has
nothing to look at yet." The bullet is gone. It predates #427.

The statements and the transaction

The example is a two-statement list, and nothing beside it said why an action
would have more than one statement or what running them together guarantees.
The transaction facts were in the guide but roughly seven hundred lines
downstream, in a figure caption and the run-time outcomes section, and the
figure there shows a single statement between BEGIN and COMMIT, so a
multi-statement list is never shown as one unit. A paragraph under the example
now says it: one transaction, statements in the order written, committed at the
end. Verified in run_action.ts — the transaction opens once at 212-222, the
loop at 291-293 is a serial for…of over plan.statements, commit is a single
call at 325-327, and any statement throwing lands in the rollback at 354-360.

"A statement runs in the caller's own transaction and can be rolled back" names
a transaction no external caller can supply: RunActionOptions has no way to
pass one in, and the runner opens its own. A refusal also rolls nothing back,
because it happens before there is a transaction.

An action that creates a row led with a prohibition and never named the
mechanism. It now says kcmd generates the key, that the key is a UUID
(run_action.ts:897, crypto.randomUUID), and that affects turns the
generation on, before explaining why a caller-chosen key would be unsafe.

Physical names, and what was never defined

The metric comparison is gone. "kcmd does translate a metric. You write
Account.balance… an action's statements get no such pass" taught a second
feature so the reader could infer a rule the bolded sentence above it already
states outright.

"Nothing catches a model name before the call" opened on an absence and then
listed what validation does check, which is the push-rule list further down the
same section. A reader hunting that list found it split across two places. The
paragraph now says what happens when you get it wrong: nothing tells you until
the action runs, the error comes from the store, and it can read like a fault
in the statement rather than a typo in a name. That last phrasing is
validate.ts's own, from the comment above validateRunnable.

"Carrying the write, instead of pointing at it, buys you three things" made the
reader decode that carrying means a sql executor and pointing means the other
three, and it restated a contrast the section opening already drew forty lines
earlier.

"Model name" was never defined. The section opened on the concrete pair — the
model's Account and accountId reach the store as account and account_id
— but labelled only the database half, then used "model names" as vocabulary.
The guide also uses "model name" in a second, unrelated sense further down,
where --judge-model takes the name of a Gemini model.

"Write a model name anywhere else and nothing tells you until the action runs"
compounded that: it opens in the imperative, so it reads as an instruction to
do the wrong thing, and "anywhere else" only resolves against the paragraph
above. The subject is now a statement rather than a command, and it leads with
the rule before the consequence.

Constraints

The "four stages" lead-in above Figure 2 duplicated the figure's own caption
and restated, as an abstraction, the inert-constraint fact stated plainly two
paragraphs above it. Figure 1 has no lead-in and this one does not need one.

"A constraint on its own does nothing. An action has to name it as a guard
before anything checks it." was the third statement of that fact inside
thirty-six lines, and it forward-referenced guards, which prose does not
introduce for another twenty-five lines. The first two statements stay.

The five habits for writing a judgment become a numbered list. They were five
bold-led paragraphs under a sentence announcing a count, so the count and its
members lived in different blocks and nothing tied a habit to its position.

"The judge" was the other undefined term. "Writing a judgment" opens by saying
a language model reads your sentence at review time, then eleven lines later
starts calling that reader the judge, which the guide never attaches to
anything even though --judge-model and --judge-reads-store both depend on
it. One clause now attaches it.

The policy table's intro announced the table in almost the caption's own words,
and "no query can settle two of the five" made the reader go back to the table
to learn which two, so the two judged rules are named. "The second body" points
at judgment by position over a set given far earlier, so it names judgment.

Two gaps in the sibling docs

profiles.md tells a profile author the engine lowers each expression to the
bound store's query language, and never says an action's statements are exempt.
They are — kcmd passes them to the store verbatim, which is why actions.md
tells you to write physical names in them. The file's own CancelOrder
override depends on the exemption without stating it, writing UPDATE Orders SET Status = 'CANCELLED' against a model whose entity is Order and whose
field is key. A fourth note under ## Notes states the rule.

reference.md says "Every check here is static, so it runs on every push,
regardless of destination" and stops. One sentence now draws the consequence an
action author actually hits: nothing in the push path asks the store a
question, so a statement naming a table or a column that does not exist passes
every check and fails when the action runs.

What the first review caught

Eight defects, six introduced by the edits above.

A case-only difference is not an error. "A statement that says Account where
it means account" would commit fine on both supported stores — GoogleSQL
matches identifiers case-insensitively and PostgreSQL folds unquoted ones to
lower case — so the sentence promised a failure that never happens. The example
is now accountId against a column named account_id.

"@parameter references … are the only names in a statement that come from
your model" is contradicted by @new<Concept>Key, which is derived from a
concept name and is not a declared parameter. validate.ts:282-286 adds it to
bindable as a separate case for exactly that reason.

The affects status note dropped a condition. The composite-key and
non-String refusals fire only where a statement actually binds the generated
key — run_action.ts:474 skips the check otherwise, so that an INT64-keyed
entity whose DML supplies its own key is not refused over a value it never
uses. As written it read as unconditional, which would push a reader into
deleting a correct affects record.

"A guard becomes a real gate" lost the sentence that justified it when the
false "gate sees the write" bullet went, leaving a headline about gating over a
body about atomicity. The body now states the gate property the runtime does
have: every guard on a sql action settles before the transaction opens, so a
refusal leaves the store untouched.

Three smaller ones. "Rule 3 is the one rule here that a guard enforces more
narrowly than it reads" claims a uniqueness rule 5 falsifies. Reordering put a
model description immediately before a colon and a YAML block showing
constraints instead. "The on_violation column" pointed at a column in a block
that has keys. And "Claim no more in it than a judge can settle" made the
pronoun reach past "All five" to "wording".

What the second review caught

Ten defects. Five are claims about kcmd that the code contradicts, and the
ir.ts changes below are comment-only — every changed line in that file is
inside a doc comment.

"kcmd can run it rather than publishing it for another system to dispatch" made
carrying the write an alternative to publishing. kc_actions.ts:225-226 copies
sqlStatements onto the published action verbatim, so a sql action is
published like any other and what changes is who performs the write.

"kcmd settles every guard on a sql action before it opens a transaction"
overclaims twice. A guard kcmd cannot settle is refused rather than settled
(run_action.ts:530-535 for an expression), and a warn guard left unsettled
produces a warning rather than a refusal (run_action.ts:199-204). The bullet
now claims only the gate property the runtime has: the guards settle before the
transaction opens (run_action.ts:180-204, ahead of withSession at 212-222),
and a call that does not clear them never reaches one.

"Because IssueCredit's other three guards are expressions, kcmd refuses it
outright" names a cause never reached. IssueCredit has an mcp executor, so
whyRefusedWithoutRunning stops at the executor-kind check
(run_action.ts:429) before it looks at the guards.

"Pass every value as a bound @parameter naming a parameter your action
declares" omits @new<Concept>Key, which validate.ts:282-286 seeds into
bindable for every create in affects. As written the rule forbids the
guide's own create example. The same omission was in ir.ts's SqlExecutor
comment.

ir.ts also still carried the "gate sees the write" bullet that #427 removed
from the guide — "the statements run where the constraints are probed, so the
gate observes the uncommitted result of the write it is gating". Guards settle
before the transaction exists, so nothing observes that. The bullet is gone and
the count above it drops from three to two.

Five are prose defects the restructure introduced or left behind. "Section 1
gave you an action that runs" was true before the DML material became its own
section. "Four things about an action can be statically wrong" reads as four
things about the particular action on the page. "All of these checks are
static" reaches back past an intervening list. "Expect no control flow" tells
the reader to hold an expectation when the point is a property of statements.
And moving the no-post-write paragraph left "however much it can read" pointing
at the store-reading flag twenty lines ahead of it.

Five findings were checked and rejected. warn guards are evaluated and
reported (run_action.ts:580, 608-609, 622-623) rather than skipped, so the
existing wording holds. Figure 2's lead-in was deleted in #427 on purpose and
Figure 1 has none either. The unnumbered ## Carrying the write as DML heading
is the requested shape. The Integer-keyed Transfer is the known defect
recorded below. And the semicolon rule is not factually wrong: validate.ts:299
tests text.slice(0, -1).includes(';'), so a trailing ; passes and an
interior one fails, exactly as claimed. It was ambiguous rather than wrong, and
now reads "anywhere but the end" to match reference.md:458.

Known defects not fixed here

Three live inside fenced blocks, which this branch has otherwise left
byte-identical:

  • Transfer.transferId is datatype: Integer, but the create example binds
    @newTransferKey and the runtime generates a UUID string, so
    unusableGeneratedKey (run_action.ts:903-925) refuses it. The guide's only
    key-generation example is the one example that cannot run. Fixing it ripples
    into the captured agent-tool listing, which should come from a live run.
  • Figure 3 invokes TransferFunds with source and amount, omitting the
    required target, and shows one UPDATE between BEGIN and COMMIT for an
    action that declares two statements.
  • The profile example is labelled # commerce.profiles/operational.yaml but
    the model inside it is payments; the convention everywhere else is
    <model>.profiles/<name>.yaml.

Three passages in "Statements use your database names" explained the rule by
pointing somewhere else instead of stating it. Two of them were duplicating
material the section already carries.

The metric comparison is gone. "kcmd does translate a metric. You write
`Account.balance`... an action's statements get no such pass" taught a second
feature so the reader could infer a rule the bolded sentence above it already
states outright, with the `Account` to `account` mapping shown in the example.
What survives is the part that adds something: `@parameter` references are the
only model names a statement may use.

"Nothing catches a model name before the call" opened on an absence and then
listed what validation does check — one DML verb, no `;`, declared parameters
only — which is the "push holds you to it" list further down the same section.
A reader hunting that list found it split across two places. The paragraph now
says what happens when you get it wrong: nothing tells you until the action
runs, the error comes from the store, and it can read like a fault in the
statement rather than a typo in a name. That last phrasing is validate.ts's
own, from the comment above validateRunnable.

"Carrying the write, instead of pointing at it, buys you three things" made the
reader decode that carrying means a `sql` executor and pointing means the other
three, and it restated a contrast the section opening already drew forty lines
earlier. It names the executor instead.

No behaviour claim changed. Push validation really is static and
target-independent, so it never asks the store whether a table exists; the
fenced blocks are untouched and every anchor still resolves.
Four passages in the constraints sections said something the page had already
said, which is what made them read as hard to digest rather than wrong.

The "four stages" lead-in above Figure 2 duplicated the figure's own caption
and restated, as an abstraction, the inert-constraint fact stated plainly two
paragraphs above it. Figure 1 has no lead-in, and this figure does not need
one either.

"A constraint on its own does nothing. An action has to name it as a guard
before anything checks it." was the third statement of that fact inside
thirty-six lines, and it forward-referenced `guards`, which prose does not
introduce for another twenty-five lines. The first two statements stay.

The five habits for writing a judgment become a numbered list. They were five
bold-led paragraphs under a sentence announcing a count, so the count and its
members lived in different blocks and nothing tied a habit to its position.

In "A policy whose rules end differently" the intro announced the table in
almost the caption's own words, and the paragraph after it packed a statistic,
the model's shape and the mapping rule into three unconnected sentences. "No
query can settle two of the five" also made the reader go back to the table to
learn which two, so the two judged rules are now named. "The second body"
points at `judgment` by position over a set given far earlier in the guide, so
it names `judgment` instead.

No behaviour claim changed. All 564 fenced lines stay byte-identical to main,
every heading is unchanged, and the five inbound anchors resolve.
@libei libei changed the title docs(mdcode): cut three detours from the statements section docs(mdcode): second pass on the actions guide — state it once, state it plainly Sep 15, 2026
"Model name" was never defined. The statements section opens on the concrete
pair — the model's `Account` and `accountId` reach the store as `account` and
`account_id` — but labels only the database half, then the next two paragraphs
use "model names" as vocabulary. The guide also uses "model name" in a second,
unrelated sense further down, where `--judge-model` takes the name of a Gemini
model. Both paragraphs now say which side of the binding a name comes from
instead of naming a category the reader has to construct.

"Write a model name anywhere else and nothing tells you until the action runs"
compounded that. It opens in the imperative, so it reads as an instruction to
do the wrong thing, and "anywhere else" only resolves against the paragraph
above. The subject is now a statement rather than a command, it reuses the
`Account` and `account` from two paragraphs up, and it leads with the rule —
push accepts it — before the consequence.

"The judge" had the same problem one section down. "Writing a judgment" opens
by saying a language model reads your sentence at review time, and eleven lines
later starts calling that reader the judge, a term the guide never attaches to
anything even though `--judge-model` and `--judge-reads-store` both depend on
it. One clause now attaches it.

No behaviour claim changed. All 564 fenced lines stay byte-identical to main,
every heading is unchanged, and the five inbound anchors resolve.
… runtime doesn't implement

The `sql` executor's example is a two-statement list, and nothing next to it
said why an action would have more than one statement or what running them
together guarantees. The transaction facts were in the guide, but roughly seven
hundred lines downstream, in a figure caption and the run-time outcomes
section, and the figure there shows a single statement between BEGIN and
COMMIT, so a multi-statement list is never actually shown as one unit. A
paragraph under the example now says it: one transaction, statements in the
order written, committed at the end. Verified in run_action.ts — the
transaction opens once at 212-222, the loop at 291-293 is a serial `for…of`
over `plan.statements`, commit is a single call at 325-327, and any statement
throwing lands in the rollback at 354-360.

"The gate sees the write. The statements run where the constraints are probed,
so a check observes the uncommitted result of the write it's gating." is not
true of this runtime, and the guide already said so in two other places. Guards
settle before the transaction exists (run_action.ts:181-198), there is no
expression evaluator at all — an action guarded by one is refused rather than
run (run_action.ts:529-535) — and nothing whatever runs between the last
statement and the commit. Section 2 states the true version already: "Guards
run before the transaction opens, so 'an order's total equals the sum of its
lines' has nothing to look at yet." The bullet is gone and the count above it
drops to two.

Its neighbour overstated in the same direction. "A statement runs in the
caller's own transaction and can be rolled back" names a transaction no
external caller can supply — RunActionOptions has no way to pass one in, and
the runner opens its own. A refusal also rolls nothing back, because it happens
before there is a transaction. Both are now stated as they behave.

An action that creates a row led with a prohibition and never named the
mechanism. It now says kcmd generates the key, that the key is a UUID
(run_action.ts:897, crypto.randomUUID), and that `affects` turns the generation
on, before explaining why a caller-chosen key would be unsafe. The `affects`
status note four hundred lines later no longer re-explains the same binding.

All 564 fenced lines stay byte-identical to main, every heading is unchanged,
and the five inbound anchors resolve.
A case-only difference is not an error. "A statement that says `Account` where
it means `account`" would commit fine on both supported stores — GoogleSQL
matches identifiers case-insensitively and PostgreSQL folds unquoted ones to
lower case — so the sentence promised a failure that never happens. The example
is now `accountId` against a column named `account_id`, which no folding rule
reconciles.

"`@parameter` references ... are the only names in a statement that come from
your model" is contradicted by `@new<Concept>Key`, which is derived from a
concept name and is not a declared parameter. validate.ts:282-286 adds it to
`bindable` as a separate case for exactly that reason, and the guide's own
create example binds one. The sentence now says what is true of both: a
`@parameter` names a value kcmd binds at call time.

The `affects` status note dropped a condition. The composite-key and non-String
refusals fire only where a statement actually binds the generated key —
run_action.ts:474 skips the check otherwise, with a comment saying an
INT64-keyed entity whose DML supplies its own key must not be refused over a
value it never uses. As written it read as unconditional, which would push a
reader into deleting a correct `affects` record.

"A guard becomes a real gate" lost the sentence that justified it when the false
"gate sees the write" bullet went, leaving a headline about gating over a body
about atomicity — which also restated the transaction fact a paragraph above.
The body now states the gate property the runtime does have: every guard on a
`sql` action settles before the transaction opens, so a refusal leaves the store
untouched.

Three smaller ones. "Rule 3 is the one rule here that a guard enforces more
narrowly than it reads" claims a uniqueness rule 5 falsifies, so it no longer
claims it. Reordering put the model description immediately before a colon and a
YAML block showing constraints instead, so the clauses are swapped back. "The
`on_violation` column" pointed at a column in a block that has keys.

And "Claim no more in it than a judge can settle" made the pronoun reach past
"All five" to "wording"; the noun is restored.

All 564 fenced lines stay byte-identical to main, every heading is unchanged,
and the five inbound anchors resolve.
`profiles.md` tells a profile author that the engine lowers each `expression` to
the bound store's query language, and never says an action's statements are
exempt. They are: kcmd passes a `sql` executor's statements to the store
verbatim, which is why `actions.md` tells you to write physical table and column
names in them. The file's own `CancelOrder` override depends on the exemption
without stating it — line 325 writes `UPDATE Orders SET Status = 'CANCELLED'`
against a model whose field is `key` and whose entity is `Order` — so a reader
who takes the dialect note at its word writes logical names and gets a store
error at the first call. A fourth note under `## Notes` states the rule and
points at the block that relies on it.

`reference.md` says "Every check here is static, so it runs on every push,
regardless of destination" and stops, leaving the reader to work out what static
excludes. The consequence is the one an action author actually hits: nothing in
the push path asks the store a question, so a statement naming a table or a
column that does not exist passes every check and fails when the action runs.
One sentence now draws it, next to the sentence it qualifies.

Neither file's headings change and no anchor moves. `actions.md` is untouched.
Three subsections of step 1 were about writing DML — "The executor is a
binding", "Writing the statements in the model" and "Statements use your
database names" — and concept material sat between them, so a reader who had
not yet decided how the write happens read physical detail twice before
finishing the declaration. Step 1 ran 229 lines to "## 2. Gate it with a
constraint" and three quarters of the span below its four-kind list was detail.

Step 1 now ends at the declaration and runs 106 lines: the name and parameters,
the model, the four executor kinds, entity-typed parameters, and where an
executor comes from. Everything about DML follows it under one unnumbered H2,
"## Carrying the write as DML", which a reader using `mcp`, `rest` or `grpc`
can skip by its title. Inside it the statements come first, then the names they
use, then which file to put them in — so the profile override fence now sits
under the rule it demonstrates instead of 80 lines above it.

Two facts left step 1 for the step that owns them. Key generation is an
`affects` fact, so it becomes "### A created row gets its key from kcmd" in
step 3, whose Status paragraph already said `affects` drives it; that paragraph
no longer re-explains the binding and opens on what it is actually reporting.
The three statement rules are push-time rules, so they become "### What push
holds a statement to" in step 4, beside the four static action errors that were
already there.

"The executor is a binding" is gone. Its rationale paragraph and its
inheritance rule restate `profiles.md:104-123`, which says it better —
"the same action, the same blast radius, performed by whatever mechanism the
bound store actually has". What step 1 keeps is the part a reader needs
there: the executor is physical, a profile can supply or replace it, and an
action a profile says nothing about keeps the model's.

The push rules move without being reworded. `validate.ts:299` tests
`text.slice(0, -1).includes(';')`, which strips the last character before
looking, so a trailing `;` passes and an interior one fails — exactly what "a
`;` inside a statement is rejected" says, and what `model_spec.md:490` and
`reference.md:458` say with "other than a trailing one". There was nothing to
correct.

No fenced line changed: all 478 are byte-identical to the ones on main, as a
multiset, with four blocks in new positions. No prose line exceeds 80
characters, the five frozen anchors resolve, and the one internal link into a
renamed heading now points at the new section.
A constraint carries one of two bodies, and section 2 taught them in an
order that kept crossing between the two. An 88-line preamble opened on
the concept, ran two expression examples, then spent twenty lines on
`guards`, `on_violation` and the moment a guard is checked — machinery
that belongs to both kinds — before "When no expression decides it"
reached the judged body. A reader deciding which body to write had to
carry all of it.

The section now states the fork once, in the preamble, and then keeps the
three subjects apart. "Naming a constraint as a guard" holds everything
true of either kind: the `guards` list, why the reference sits on the
action, when a guard is checked, why `guards` and `on_violation` are
independent, and the push error and load warning that report a mismatch
between them. "Rules a query settles" holds the `expression` body, what
it can read, and how far a guard over stored data actually reaches.
"Rules no query settles" holds the `judgment` body, followed by the
existing "Writing a judgment". The two worked-example sections are
unchanged and still take both kinds together.

Two facts were stated once per kind and are now stated once. Both copies
of the pre-write timing rule — "Every guard is checked before the call"
in the expression material and "Guards run before the transaction opens"
in the judged material — collapse into one sentence in the shared
section, and each kind keeps only the consequence that is its own. The
derived `evaluation` field was defined in the status note and again in
the policy example; it is now defined in the preamble, where it makes the
two-body distinction visible in the catalog, and the policy example just
uses it.

The status note split along the same line, because it was one paragraph
reporting three unrelated states. What kcmd does with an expression sits
under the expression section, what `--judge` does with a judgment sits
under the judged section, and the run-time precedence gap stays with the
strictest-outcome prose it qualifies.

Eight lines of fenced YAML go. The `guards` example reprinted
`TransferFunds` whole — executor, server URI, three parameters — to teach
the one line `guards: [AmountIsPositive]`, for an action the reader has
already seen declared twice. It now shows the constraint and that line.
No other fenced line changed: the remaining 470 are byte-identical to
main as a multiset. No prose line exceeds 80 characters, all five frozen
anchors resolve, and no internal or inbound link broke.
Five are claims about kcmd that the code contradicts.

"kcmd can run it rather than publishing it for another system to
dispatch" made carrying the write an alternative to publishing.
`kc_actions.ts:225-226` copies `sqlStatements` onto the published action
verbatim, so a `sql` action is published like any other; what changes is
who performs the write. The sentence now says that, and names the
`TransferFunds` it is about to redeclare.

"kcmd settles every guard on a `sql` action before it opens a
transaction" overclaims twice. A guard it cannot settle is refused
rather than settled (`run_action.ts:530-535` for an expression), and a
`warn` guard left unsettled produces a warning rather than a refusal
(`run_action.ts:199-204`). The bullet now claims only the gate property
the runtime has: the guards settle before the transaction opens
(`run_action.ts:180-204`, ahead of `withSession` at 212-222), and a call
that does not clear them never reaches one.

"Because `IssueCredit`'s other three guards are expressions, kcmd
refuses it outright" names a cause that is never reached. `IssueCredit`
has an `mcp` executor, so `whyRefusedWithoutRunning` stops at the
executor-kind check (`run_action.ts:429`) before it ever looks at the
guards. The sentence now states the outcome without attributing it to
the wrong check.

"Pass every value as a bound `@parameter` naming a parameter your action
declares" omits `@new<Concept>Key`, which `validate.ts:282-286` seeds
into `bindable` for every `create` in `affects`. Stated as written, the
rule forbids the guide's own create example. The same omission was in
`ir.ts`'s `SqlExecutor` comment and is fixed there too.

`ir.ts` also still carried the "gate sees the write" bullet that GoogleCloudPlatform#427
removed from the guide: "the statements run where the constraints are
probed, so the gate observes the uncommitted result of the write it is
gating". Guards settle before the transaction exists, so nothing
observes that. The bullet is gone and the count above it drops from
three to two.

Five are prose defects the restructure introduced or left behind.
"Section 1 gave you an action that runs" was true before the DML
material became its own section and now skips it. "Four things about an
action can be statically wrong" reads as four things about the
particular action on the page. "All of these checks are static" reaches
back past an intervening list. "Expect no control flow" tells the reader
to hold an expectation when the point is a property of `statements`
itself. And moving the no-post-write paragraph left "however much it can
read" pointing at the store-reading flag twenty lines ahead of it.

Five findings were checked and rejected. `warn` guards are evaluated and
reported (`run_action.ts:580`, 608-609, 622-623) rather than skipped, so
the guide's existing wording holds. Figure 2's lead-in was deleted in
GoogleCloudPlatform#427 on purpose and Figure 1 has none either. The unnumbered `##
Carrying the write as DML` heading is the requested shape. The
Integer-keyed `Transfer` is a known fenced-block defect recorded on the
PR. And the semicolon rule is not factually wrong: `validate.ts:299`
tests `text.slice(0, -1).includes(';')`, so a trailing `;` passes and an
interior one fails, exactly as claimed. It was ambiguous rather than
wrong, and now reads "anywhere but the end" to match
`reference.md:458`.

No fenced line changed. Prose stays inside 80 characters, the five
frozen anchors resolve, and no internal or inbound link broke. Every
changed line in `ir.ts` is inside a doc comment.
@libei
libei marked this pull request as ready for review September 15, 2026 19:34
@libei
libei merged commit d6c1d01 into GoogleCloudPlatform:main Sep 15, 2026
7 checks passed
libei added a commit to libei/knowledge-catalog that referenced this pull request Sep 15, 2026
A read of the code behind `affects` refuted the section's two strongest
claims, and its own worked example turned out to be the case kcmd
refuses.

"That key generation is the only thing `affects` drives" and "No
component computes an impact from it, routes on it, or checks it against
what your executor does" are both false on the same verb. `affects`
routes. `resolve_profiles.ts:466-475` drops an action from a
profile-resolved model when `affects` names a concept the profile cannot
bind, so on that push the action is never published at all, and
`kc_actions.ts:134-142` warns when a named concept has no entry in the
push. The status note now leads with the claim that survives — nothing
compares `affects` to what the executor does — and names the two places
kcmd does route on it.

Key generation is a `sql` feature and the section never said so, while
opening on an `mcp` executor. `planFromExecutor` refuses any other kind
(`run_action.ts:937-938`), so an `mcp`, `rest` or `grpc` action declaring
a `create` gets nothing bound. The same omission was in `ir.ts`, which
also claimed the generated key is "recorded as touched so the constraint
probes cover the new row". Nothing records it: no such mechanism exists,
and guards settle before the transaction opens, so no probe could observe
a row that does not exist yet.

The composite and non-String key check reads entities only.
`unusableGeneratedKey` opens `const entity = ...find(...); if (!entity)
return null`, so a relationship passes it, and an entity whose key names
a field the model does not declare defaults to `String` and passes too.
It runs at the call rather than at the push, which the section implied
the other way round.

`Transfer` gets a String key. The guide teaches key generation by
creating a `Transfer`, and the example model keyed `transferId` as an
`Integer` — exactly what the paragraph below it says kcmd refuses, which
made the section's own example unrunnable. This is the first of the three
fenced-block defects recorded on GoogleCloudPlatform#428, and it is fixed here because it is
this section's example. `find_transfer`'s derived schema follows.

The three slot rules become one block. `concept`, `operation` and
`fields` were spread across two H3s and a trailing paragraph with a
`modify` aside between two of them, so a reader looking up what an entry
may contain had to cross the section. "What an entry may say" now holds
all three, and gains the three rules the section omitted: two entries on
one concept with the same operation are a hard load error, one bare entry
beside one with an operation warns, and a plain foreign-key edge has no
fields of its own so naming one is always an error. The guide's own
`TransferDebits` is such an edge.

Two passages elsewhere depended on what the section got wrong. Section
4's "These checks are static, so they run on every push whatever the
destination" is not true of the concept and field checks:
`validate.ts:219-221` builds the concept index only when fields are
unpruned, so a push that resolves through a binding profile falls back to
the loader's warning, and only a catalog-only push or `kcmd action run`
treats those two as hard errors. Fields beside a `delete` read only the
entry, so that one does fail everywhere. Section 7 stated "`affects`
declares the blast radius rather than limiting it" as if for the first
time, four sections after `affects` was taught; it now points back rather
than re-deriving.

Two fenced lines changed, both the `Transfer` key type, and the other 468
are byte-identical as a multiset. Prose stays inside 80 characters, no
sentence this change adds runs past 40 words, no internal link broke, the
five inbound anchors resolve, and every changed line in `ir.ts` is inside
a doc comment. The `, not just` antithesis in the opening paragraph goes
in the same pass.
libei added a commit that referenced this pull request Sep 15, 2026
…bout affects

A sequential read-through of the merged guide reached sections 2 and 3. The
first needed restructuring; the second turned out to be wrong about what
`affects` does, so a read of the code behind it drove most of this change.

Section 2's spec comes apart from its worked example. "A policy whose rules
end differently" and "Two calls through that policy" read as spec and as
example at once, with no signal which was which: the first opened on a
five-rule business policy and then stopped twice to state general rules about
`guards`, and the second walked a table of outcomes that turned on one of
them. Three rules lived only inside the example, so a reader who skipped it
never learned them. The strictest-outcome precedence moves to "Naming a
constraint as a guard", which owns everything true of either constraint kind,
and the warning an all-judged action loads with moves to "Rules no query
settles", beside the judged body it is about. What is left is an example, so
it becomes one: an unnumbered H2, "A credit policy, worked through",
skippable by its title the way "Carrying the write as DML" is. The rename
retires the anchor `#a-policy-whose-rules-end-differently`, propagated to all
three references.

Section 3 was wrong about what `affects` drives. "That key generation is the
only thing `affects` drives" and "No component computes an impact from it,
routes on it, or checks it against what your executor does" are both false on
the same verb. `affects` routes: `resolve_profiles.ts:466-475`, inside
`pruneUnavailable`, drops an action from a profile-resolved model when
`affects` names a concept the profile cannot bind, so on that push the action
is never published at all, and `kc_actions.ts:134-142` warns when a named
concept has no entry in the push. The status note now leads with the claim
that survives — nothing compares `affects` to what the executor does — and
counts the three things that do read it.

Four more corrections in the same section. Key generation is a `sql` feature
the section never named while opening on an `mcp` executor;
`planFromExecutor` refuses any other kind (`run_action.ts:937-938`). The
composite and non-String key check reads entities only —
`unusableGeneratedKey` opens `find(...); if (!entity) return null` — so a
relationship passes it, an entity whose key names an undeclared field
defaults to `String` and passes too, and it runs at the call rather than at
the push. `ir.ts` claimed the generated key is "recorded as touched so the
constraint probes cover the new row"; nothing records it, no such mechanism
exists, and guards settle before the transaction opens, so no probe could
observe a row that does not exist yet. And `Transfer` gets a String key: the
guide teaches key generation by creating a `Transfer`, and the example model
keyed `transferId` as an `Integer`, which is exactly what the paragraph below
it says kcmd refuses.

Section 3's lookup material gathers. `concept`, `operation` and `fields` were
spread across two H3s and a trailing paragraph with a `modify` aside between
two of them. "What an entry may say" now holds all three and gains three
rules the section omitted: two entries on one concept with the same operation
are a hard load error, one bare entry beside one with an operation warns, and
a plain foreign-key edge has no fields of its own so naming one is always an
error — the guide's own `TransferDebits` is such an edge.

Key generation reconnects to the entry that causes it. Its heading named a
created row rather than `affects`, its causal link was the paragraph's last
sentence, and the paragraph three above it told the reader `affects` is
checked "never against the executor", priming them to expect nothing to
happen. The heading now names the cause in the vocabulary the slot rules just
taught, the paragraph opens on the link and moves its rationale below the
example, and the earlier paragraph keeps only what a reader needs before the
slot rules.

The all-judged warning is marked as current behavior. `warnAllGuardsJudged`
warns rather than errors because an all-judged action may be exactly what the
author meant, so the check fires on correct models, and a reader who takes
the sentence as a rule will contort a model to avoid it.

Two passages elsewhere depended on what section 3 got wrong. Section 4's
"These checks are static, so they run on every push whatever the destination"
is not true of the concept and field checks: `validate.ts:219-221` builds the
concept index only when fields are unpruned, so a push resolving through a
binding profile falls back to the loader's warning, and only a catalog-only
push or `kcmd action run` treats those two as hard errors. Fields beside a
`delete` read only the entry, so that one does fail everywhere. Section 7
stated "`affects` declares the blast radius rather than limiting it" as if
for the first time, four sections after `affects` was taught, and now points
back rather than re-deriving.

Two fenced lines changed, both the `Transfer` key type, and the other 468 are
byte-identical to main as a multiset. Prose stays inside 80 characters, no
sentence this change adds runs past 40 words, no internal link broke, the
five inbound anchors from `reference.md`, `model_spec.md` and the agent demo
README resolve, and every changed line in `ir.ts` is inside a doc comment.

Two fenced-block defects from #428 remain for their own change: Figure 3
shows one UPDATE for a two-statement action and omits `--arg target`, and a
profile example is labelled `commerce.profiles/operational.yaml` inside a
model named `payments`.
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