From d4544b9af3745841ec89e3a38bfcd7702642a057 Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 17:22:10 +0000 Subject: [PATCH 1/9] docs(mdcode): cut three detours from the statements section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- toolbox/mdcode/docs/semantic-model/actions.md | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/toolbox/mdcode/docs/semantic-model/actions.md b/toolbox/mdcode/docs/semantic-model/actions.md index 51a4ca44..ee80bddf 100644 --- a/toolbox/mdcode/docs/semantic-model/actions.md +++ b/toolbox/mdcode/docs/semantic-model/actions.md @@ -221,17 +221,13 @@ executor above, the model's `Account` and `accountId` appear as `account` and `account_id`. Those are the table and the column that the entity's `source` key and its fields' `expression` keys bind it to. -kcmd does translate a metric. You write `Account.balance`, and kcmd turns it -into `account.balance` before any SQL reaches your store. An action's statements -get no such pass, so the statement that runs is the one you reviewed. The only -model names a statement may use are the `@parameter` references, which name the -parameters the action declares. - -Nothing catches a model name before the call. Validation checks that each -statement is one DML verb, contains no `;`, and binds only declared parameters — -it never asks your store whether a table exists. A model name therefore fails at -run time, from the store, and the message can be hard to read. An entity named -`Order` bound to a table named `Orders` produces +The only model names a statement may use are the `@parameter` references, which +name the parameters the action declares. + +Write a model name anywhere else and nothing tells you until the action runs. +Push never asks your store whether a table exists, so the error comes back from +the store itself, and it can read like a fault in the statement rather than a +typo in a name. An entity named `Order` bound to a table named `Orders` produces ``` Syntax error: Unexpected keyword ORDER [at 1:8] @@ -239,7 +235,7 @@ Syntax error: Unexpected keyword ORDER [at 1:8] instead of "no such table", because `ORDER` is a reserved word. -Carrying the write, instead of pointing at it, buys you three things: +A `sql` executor buys you three things: - **Your blast radius is checkable.** A reader can compare `affects` against the statements instead of taking it on trust. From 19c9d790f38fcbae71ec25c79dbda54e4d0e7b04 Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 17:30:57 +0000 Subject: [PATCH 2/9] docs(mdcode): stop restating facts the reader already has 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. --- toolbox/mdcode/docs/semantic-model/actions.md | 102 ++++++++---------- 1 file changed, 47 insertions(+), 55 deletions(-) diff --git a/toolbox/mdcode/docs/semantic-model/actions.md b/toolbox/mdcode/docs/semantic-model/actions.md index ee80bddf..ead0388f 100644 --- a/toolbox/mdcode/docs/semantic-model/actions.md +++ b/toolbox/mdcode/docs/semantic-model/actions.md @@ -286,9 +286,6 @@ ontology. Declaring one adds it to the catalog and changes nothing by itself. A constraint takes effect where something references it and nowhere else, so publishing a rule can't quietly start refusing calls that succeeded yesterday. -A rule passes through four stages, and one that stops at the first does -nothing: - ``` declared referenced checked a breach ───────────────── ───────────────── ────────────── ─────────── @@ -317,9 +314,6 @@ satisfy: An account cannot be taken below its minimum balance. ``` -A constraint on its own does nothing. An action has to name it as a guard -before anything checks it. - An expression that reads an action's **parameters** describes one call rather than the stored data, so the only moment you can check it is before that call runs: @@ -403,34 +397,32 @@ strong a consequence to inherit by silence. ### Writing a judgment A language model reads your sentence at review time with the proposed write in -front of it. Five habits make that reading consistent. - -**State what must be true of the data.** Write the condition — *the memo must -name a specific service failure*, and not the procedure, *check whether the -memo is specific*. Your sentence describes a clean write, and everything -about handling a breach lives elsewhere. - -**Name fields model-qualified.** Write `LineItem.memo` rather than "the memo". -kcmd resolves every `Entity.field` token in the text against your model and -fails the push when the entity declares no such field, so a rename can't leave -your sentence pointing at nothing. The qualified name also tells the judge which -value to read. - -**Say what doesn't count.** A rule with no negative example gets graded against -whatever the model guesses you had in mind. The sentence "A memo that states -only that the customer requested a credit does not satisfy this rule" buys you -more consistency than any further description of what a good memo is. - -**Leave the consequence out of the prose.** What happens on a breach is -`on_violation`. A judgment ending "…otherwise escalate to a supervisor" states a -routing nothing reads, and the engine routes by the field regardless. - -**Keep it to one condition.** When your sentence needs "and also", the second -half is a second constraint. One `on_violation` can't carry two consequences, so -two conditions that end differently can't share a constraint. - -Those five are about wording. Claim no more in the wording than a judge can -settle. +front of it. Five habits make that reading consistent: + +1. **State what must be true of the data.** Write the condition — *the memo + must name a specific service failure*, and not the procedure, *check whether + the memo is specific*. Your sentence describes a clean write, and everything + about handling a breach lives elsewhere. +2. **Name fields model-qualified.** Write `LineItem.memo` rather than "the + memo". kcmd resolves every `Entity.field` token in the text against your + model and fails the push when the entity declares no such field, so a rename + can't leave your sentence pointing at nothing. The qualified name also tells + the judge which value to read. +3. **Say what doesn't count.** A rule with no negative example gets graded + against whatever the model guesses you had in mind. The sentence "A memo + that states only that the customer requested a credit does not satisfy this + rule" buys you more consistency than any further description of what a good + memo is. +4. **Leave the consequence out of the prose.** What happens on a breach is + `on_violation`. A judgment ending "…otherwise escalate to a supervisor" + states a routing nothing reads, and the engine routes by the field + regardless. +5. **Keep it to one condition.** When your sentence needs "and also", the + second half is a second constraint. One `on_violation` can't carry two + consequences, so two conditions that end differently can't share a + constraint. + +All five are about wording. Claim no more in it than a judge can settle. A judge settles a guard from the call's arguments and whatever it could read, so a sentence about stored data can turn out to be a rule it has no evidence for. @@ -453,11 +445,9 @@ transaction or in your schema. ### A policy whose rules end differently -Real policies have several rules, and the rules rarely end the same way. Take -the policy governing a customer-service credit, stated the way a business states -it. A support agent is about to issue one, and five separate rules bear on -whether they may. The listing below puts each rule beside how it's written and -what a breach of it does: +Real policies have several rules, and the rules rarely end the same way. A +support agent is about to issue a customer-service credit, and five separate +rules bear on whether they may: ``` the business rule written as a breach @@ -472,11 +462,12 @@ what a breach of it does: *Table 1: the five rules of the credit policy, how each one is written, and what a breach of it does.* -Those five rules produce three different outcomes, and no query can settle two -of the five. The model has an `Order` with a `total`, a `LineItem` with an -`amount` and a `memo`, and an `IssueCredit` action taking the order, the amount -and the memo. Each rule becomes one constraint, carrying its own outcome in its -own `on_violation`: +The five rules produce three different outcomes, and the two written as +judgments — the memo and the split credit — are the ones no query can settle. +Each rule becomes one constraint carrying its own outcome in its own +`on_violation`. The model behind them has an `Order` with a `total`, a +`LineItem` with an `amount` and a `memo`, and an `IssueCredit` action taking the +order, the amount and the memo: ```yaml constraints: @@ -548,8 +539,8 @@ own `on_violation`: - CreditIsNotSplitToAvoidReview ``` -Read down the `on_violation` column and you get the branching your policy -describes in prose, in a column a search can read. +The `on_violation` column now carries the branching your policy describes in +prose, in a form a search can read. **Rule 3 declares `reject`, because it's the one rule here nobody in the business may approve.** An order whose total disagrees with its line items is @@ -557,15 +548,16 @@ broken rather than merely unusual. That word takes effect only once `IssueCredit` names the rule in `guards`. Naming it makes `IssueCredit` refuse to run against an order whose books already -disagree. Catching the credit that *breaks* the agreement is a different check, -against the state the write produces, and your model can't bind one yet. Rule 3 -is the rule in this policy whose enforcement sits furthest from what it says. - -**Rules 4 and 5 are why the second body exists.** Neither reduces to arithmetic -over `Order` and `LineItem`, and before `judgment` they had nowhere to go but a -policy document nothing links to. Not everything in the policy has to move, -though. The threshold in rule 2 is arithmetic, so it stays an expression a query -settles and no model call is spent on. +disagree. It won't catch a credit that *breaks* that agreement, because that +means checking the state the write produces, and your model can't bind such a +check yet. Rule 3 is the one rule here that a guard enforces more narrowly than +it reads. + +**Rules 4 and 5 are the reason `judgment` exists.** Neither reduces to +arithmetic over `Order` and `LineItem`, so before a judged body there was +nowhere to put them but a policy document nothing links to. Not everything in +the policy has to move, though. The threshold in rule 2 is arithmetic, so it +stays an expression a query settles and no model call is spent on. **Rule 5 is a judgment that declares `reject`.** Splitting a credit to evade review is a rule the business means as unappealable, and no expression detects From 719bf9931cee7ffab4450ca77d30aed939f4d952 Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 17:37:16 +0000 Subject: [PATCH 3/9] docs(mdcode): define two terms the guide used as though established MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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. --- toolbox/mdcode/docs/semantic-model/actions.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/toolbox/mdcode/docs/semantic-model/actions.md b/toolbox/mdcode/docs/semantic-model/actions.md index ead0388f..5813f9ed 100644 --- a/toolbox/mdcode/docs/semantic-model/actions.md +++ b/toolbox/mdcode/docs/semantic-model/actions.md @@ -221,13 +221,15 @@ executor above, the model's `Account` and `accountId` appear as `account` and `account_id`. Those are the table and the column that the entity's `source` key and its fields' `expression` keys bind it to. -The only model names a statement may use are the `@parameter` references, which -name the parameters the action declares. +`@parameter` references are the exception. They name the parameters the action +declares, so they're the only names in a statement that come from your model +rather than from your database. -Write a model name anywhere else and nothing tells you until the action runs. -Push never asks your store whether a table exists, so the error comes back from -the store itself, and it can read like a fault in the statement rather than a -typo in a name. An entity named `Order` bound to a table named `Orders` produces +A statement that says `Account` where it means `account` still passes push, +because push never asks your store whether a table exists. The error comes back +from the store when the action runs, and it can read like a fault in the +statement rather than a typo in a name. An entity named `Order` bound to a table +named `Orders` produces ``` Syntax error: Unexpected keyword ORDER [at 1:8] @@ -397,7 +399,8 @@ strong a consequence to inherit by silence. ### Writing a judgment A language model reads your sentence at review time with the proposed write in -front of it. Five habits make that reading consistent: +front of it. kcmd calls that reader the judge. Five habits make that reading +consistent: 1. **State what must be true of the data.** Write the condition — *the memo must name a specific service failure*, and not the procedure, *check whether From 729e4c729a11e20d41fb84349aa2ecc46d08abb3 Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 17:46:16 +0000 Subject: [PATCH 4/9] docs(mdcode): explain the statement bundle, and drop a gate claim the runtime doesn't implement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- toolbox/mdcode/docs/semantic-model/actions.md | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/toolbox/mdcode/docs/semantic-model/actions.md b/toolbox/mdcode/docs/semantic-model/actions.md index 5813f9ed..6e556a92 100644 --- a/toolbox/mdcode/docs/semantic-model/actions.md +++ b/toolbox/mdcode/docs/semantic-model/actions.md @@ -213,6 +213,12 @@ readable — and checkable — from the model. - { concept: Account, operation: modify, fields: [balance] } ``` +`statements` is a list because one business action is often more than one write. +The transfer above debits one account and credits another, and a transfer that +did only the first would lose money. kcmd opens one transaction, runs the +statements in the order you wrote them, and commits at the end, so two writes +that only make sense together never apply by halves. + ### Statements use your database names **An action's statements reach your store exactly as you wrote them**, so every @@ -237,15 +243,14 @@ Syntax error: Unexpected keyword ORDER [at 1:8] instead of "no such table", because `ORDER` is a reserved word. -A `sql` executor buys you three things: +A `sql` executor buys you two things: - **Your blast radius is checkable.** A reader can compare `affects` against the statements instead of taking it on trust. - **A guard becomes a real gate.** An MCP, REST or gRPC call commits inside a - system kcmd doesn't control, so a check wrapped around it is advisory. A - statement runs in the caller's own transaction and can be rolled back. -- **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. + system kcmd doesn't control, so a check wrapped around it could only advise. + A refused `sql` action never opens a transaction, and one that fails partway + through rolls back. That stays safe only while the statements stay narrow, and push holds you to it: @@ -261,10 +266,11 @@ That stays safe only while the statements stay narrow, and push holds you to it: whose body arrives with the call declares nothing, and a gate can't check what was never declared. -An action that **creates** a row needs a key for it, and that key can't come -from the caller. An agent that picks its own primary keys can overwrite an -existing row by choosing one already taken. Declare the creation in `affects` -and refer to the generated key as `@newKey`: +When an action **creates** a row, kcmd generates that row's primary key — a +UUID — and binds it as `@newKey`. The caller never supplies it, +because an agent that picks its own primary keys can overwrite an existing row +by choosing one already taken. Declaring the creation in `affects` turns the +generation on: ```yaml executor: @@ -698,14 +704,13 @@ own says the same thing the bare `Account` does, and it's written back as the bare form. **Status: a `create` record is the only thing that consumes `affects`.** It -tells the runtime to generate a key for that concept, and that generated key -is the `@newKey` your statements bind. Where a statement binds one, -kcmd checks your model before anything runs. A concept keyed by several -columns, or by a key field that isn't a `String`, can't take a generated UUID, -so kcmd refuses the call and withholds the write tool. Past that, kcmd parses -`affects`, checks every concept against your model, publishes it and reads it -back. No component computes an impact from it, routes on it, or checks it -against what your executor does. +tells the runtime to generate the `@newKey` a statement binds, and +kcmd checks your model for that key before anything runs. A concept keyed by +several columns, or by a key field that isn't a `String`, can't take a +generated UUID, so kcmd refuses the call and withholds the write tool. Past +that, kcmd parses `affects`, checks every concept against your model, publishes +it and reads it back. No component computes an impact from it, routes on it, or +checks it against what your executor does. ## 4. Check it before pushing From 5acae1f2e14d185dc4579c80f8f580b990344bd3 Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 18:01:48 +0000 Subject: [PATCH 5/9] docs(mdcode): fix eight defects a review found, six of them mine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `@newKey`, 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. --- toolbox/mdcode/docs/semantic-model/actions.md | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/toolbox/mdcode/docs/semantic-model/actions.md b/toolbox/mdcode/docs/semantic-model/actions.md index 6e556a92..7351a592 100644 --- a/toolbox/mdcode/docs/semantic-model/actions.md +++ b/toolbox/mdcode/docs/semantic-model/actions.md @@ -227,12 +227,11 @@ executor above, the model's `Account` and `accountId` appear as `account` and `account_id`. Those are the table and the column that the entity's `source` key and its fields' `expression` keys bind it to. -`@parameter` references are the exception. They name the parameters the action -declares, so they're the only names in a statement that come from your model -rather than from your database. +`@parameter` references are the exception: they name a value kcmd binds at call +time rather than anything in your database. -A statement that says `Account` where it means `account` still passes push, -because push never asks your store whether a table exists. The error comes back +A statement that says `accountId` where the column is `account_id` still passes +push, because push never asks your store whether a table exists. The error comes from the store when the action runs, and it can read like a fault in the statement rather than a typo in a name. An entity named `Order` bound to a table named `Orders` produces @@ -248,9 +247,9 @@ A `sql` executor buys you two things: - **Your blast radius is checkable.** A reader can compare `affects` against the statements instead of taking it on trust. - **A guard becomes a real gate.** An MCP, REST or gRPC call commits inside a - system kcmd doesn't control, so a check wrapped around it could only advise. - A refused `sql` action never opens a transaction, and one that fails partway - through rolls back. + system kcmd doesn't control, so a check wrapped around it could only advise + after the fact. kcmd settles every guard on a `sql` action before it opens a + transaction, so a refusal leaves the store untouched. That stays safe only while the statements stay narrow, and push holds you to it: @@ -431,7 +430,8 @@ consistent: consequences, so two conditions that end differently can't share a constraint. -All five are about wording. Claim no more in it than a judge can settle. +All five are about wording. Claim no more in the wording than a judge can +settle. A judge settles a guard from the call's arguments and whatever it could read, so a sentence about stored data can turn out to be a rule it has no evidence for. @@ -473,10 +473,10 @@ a breach of it does.* The five rules produce three different outcomes, and the two written as judgments — the memo and the split credit — are the ones no query can settle. -Each rule becomes one constraint carrying its own outcome in its own -`on_violation`. The model behind them has an `Order` with a `total`, a -`LineItem` with an `amount` and a `memo`, and an `IssueCredit` action taking the -order, the amount and the memo: +The model behind them has an `Order` with a `total`, a `LineItem` with an +`amount` and a `memo`, and an `IssueCredit` action taking the order, the amount +and the memo. Each rule becomes one constraint carrying its own outcome in its +own `on_violation`: ```yaml constraints: @@ -548,7 +548,7 @@ order, the amount and the memo: - CreditIsNotSplitToAvoidReview ``` -The `on_violation` column now carries the branching your policy describes in +Each `on_violation` key carries one branch of what your policy describes in prose, in a form a search can read. **Rule 3 declares `reject`, because it's the one rule here nobody in the @@ -559,8 +559,7 @@ broken rather than merely unusual. That word takes effect only once Naming it makes `IssueCredit` refuse to run against an order whose books already disagree. It won't catch a credit that *breaks* that agreement, because that means checking the state the write produces, and your model can't bind such a -check yet. Rule 3 is the one rule here that a guard enforces more narrowly than -it reads. +check yet. A guard therefore enforces rule 3 more narrowly than the rule reads. **Rules 4 and 5 are the reason `judgment` exists.** Neither reduces to arithmetic over `Order` and `LineItem`, so before a judged body there was @@ -704,10 +703,11 @@ own says the same thing the bare `Account` does, and it's written back as the bare form. **Status: a `create` record is the only thing that consumes `affects`.** It -tells the runtime to generate the `@newKey` a statement binds, and -kcmd checks your model for that key before anything runs. A concept keyed by -several columns, or by a key field that isn't a `String`, can't take a -generated UUID, so kcmd refuses the call and withholds the write tool. Past +tells the runtime to generate the `@newKey` a statement binds. Where a +statement actually binds one, kcmd checks your model first: a concept keyed by +several columns, or by a key field that isn't a `String`, can't take a generated +UUID, so kcmd refuses the call and withholds the write tool. A statement that +supplies its own key is never refused over a generated one it doesn't use. Past that, kcmd parses `affects`, checks every concept against your model, publishes it and reads it back. No component computes an impact from it, routes on it, or checks it against what your executor does. From 9ea90b4bf57923f1764edcd63280bd06c596142e Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 18:17:37 +0000 Subject: [PATCH 6/9] docs(mdcode): close two gaps the sibling docs leave open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- toolbox/mdcode/docs/semantic-model/profiles.md | 7 +++++++ toolbox/mdcode/docs/semantic-model/reference.md | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/toolbox/mdcode/docs/semantic-model/profiles.md b/toolbox/mdcode/docs/semantic-model/profiles.md index b4069a04..615b292b 100644 --- a/toolbox/mdcode/docs/semantic-model/profiles.md +++ b/toolbox/mdcode/docs/semantic-model/profiles.md @@ -450,3 +450,10 @@ forms mean the same thing and expand to the same wire representation. follows from the store a profile binds to; the engine lowers each `expression` to that store's query language when it runs. A profile chooses the data, and the execution engine chooses the dialect. + +**An action's statements reach the store as written.** Lowering covers a +field's `expression` and stops there. kcmd passes a `sql` executor's statements +through unchanged, so they name physical tables and columns and use the dialect +of the store the profile binds. `CancelOrder` above writes `UPDATE Orders SET +Status = 'CANCELLED'` rather than the model's `Order` and `key`, and a profile +that binds a different store restates those statements for it. diff --git a/toolbox/mdcode/docs/semantic-model/reference.md b/toolbox/mdcode/docs/semantic-model/reference.md index 32aa7b8d..d4c35da8 100644 --- a/toolbox/mdcode/docs/semantic-model/reference.md +++ b/toolbox/mdcode/docs/semantic-model/reference.md @@ -466,7 +466,9 @@ and [§4.1](model_spec.md#41-narrowings-stricter-than-ossie). the closed `create` / `modify` / `delete` vocabulary for `operation`, the rejection of a repeated guard name, and the rejection of a repeated concept-and-operation pair in `affects`. Every check here is static, so it - runs on every push, regardless of destination. Note that actions themselves + runs on every push, regardless of destination. Static means nothing here asks + the store a question, so a statement naming a table or a column that does not + exist passes push and fails when the action runs. Note that actions themselves deploy **only** through the Knowledge Catalog leg — a graph-only `--no-kc` push validates them but has nowhere to put them, and warns that they will not be deployed. *(static)* From a2f36159b9801db3365889e2629a0210e6467a7c Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 18:52:33 +0000 Subject: [PATCH 7/9] docs(mdcode): gather the DML material into one section of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- toolbox/mdcode/docs/semantic-model/actions.md | 187 +++++++++--------- 1 file changed, 94 insertions(+), 93 deletions(-) diff --git a/toolbox/mdcode/docs/semantic-model/actions.md b/toolbox/mdcode/docs/semantic-model/actions.md index 7351a592..a3cb0f2e 100644 --- a/toolbox/mdcode/docs/semantic-model/actions.md +++ b/toolbox/mdcode/docs/semantic-model/actions.md @@ -128,49 +128,30 @@ kinds, and give any one executor a single kind only: with. - **`grpc`** — `{service, method}`. A service and the method on it. - **`sql`** — `{statements}`. The write itself, carried in your model instead - of named as a pointer to whoever performs it. See [writing the statements in - the model](#writing-the-statements-in-the-model). + of named as a pointer to whoever performs it, and the only kind kcmd runs. + See [carrying the write as DML](#carrying-the-write-as-dml). Both `description` and `ai_context.instructions` travel through to the catalog. Write those instructions for the agent that's going to call the action, the way the example does. -### The executor is a binding - -Everything else your action declares is logical: what it takes, what gates it, -what it changes. The executor isn't, because *how* the change gets carried out -depends on where your rows live — DML when they sit in a relational database, a -call to whoever owns them when they sit somewhere else, and different table -names and a different dialect between one relational store and the next. - -So kcmd treats the executor as a physical binding, like an entity's `source`, -and a [binding profile](profiles.md) can supply it or replace it: +### Parameters typed by an entity -```yaml -# commerce.profiles/operational.yaml — this store owns the rows, so it writes them -semantic_model: - - name: payments - actions: - - name: TransferFunds - executor: - sql: - statements: - - UPDATE account SET balance = balance - @amount WHERE account_id = @source - - UPDATE account SET balance = balance + @amount WHERE account_id = @target -``` +A parameter's type can name an entity from your model instead of a datatype. +Writing `{name: source, type: Account}` says the argument refers to an account, +so a consumer generating a tool schema knows to accept an identifier and +resolve it against `Account`'s key rather than pass a bare number through. A +parameter typed by a datatype, like `amount` above, is an ordinary value and +refers to nothing. -If your profile says nothing about an action, that action keeps your model's -executor. The `mcp` executor in the model above is therefore the default for -every store, and this profile overrides it for the one store that performs the -write as DML. Write `executor: null` in a profile to withdraw it, which leaves -you a read-only binding that performs no writes. +### Where the executor comes from -Which file an executor belongs in depends on its kind. An `mcp`, `rest`, or -`grpc` executor names an operation in another system, and that name usually -doesn't change with the store, so put it in the model as the example does. A -`sql` executor is the write itself, written in one database's own table and -column names, so put it in that database's profile — unless your model will only -ever have one store. +Everything else your action declares is logical: what it takes, what gates +it, what it changes. The executor isn't, because *how* the change gets +carried out depends on where your rows live. kcmd therefore treats it as a +physical binding, like an entity's `source`, so a [binding +profile](profiles.md) can supply one or replace the one your model declares. +An action a profile says nothing about keeps the executor the model gave it. Sometimes nobody has wired the write up yet, or another team owns it and you need your model only to record that it exists. Leave the executor out of both @@ -181,22 +162,14 @@ with no deployment target — publishes it like any other action, and `kcmd profiles` lists it under `cannot run:` for each binding that supplies no executor for it. -### Parameters typed by an entity +## Carrying the write as DML -A parameter's type can name an entity from your model instead of a datatype. -Writing `{name: source, type: Account}` says the argument refers to an account, -so a consumer generating a tool schema knows to accept an identifier and -resolve it against `Account`'s key rather than pass a bare number through. A -parameter typed by a datatype, like `amount` above, is an ordinary value and -refers to nothing. - -### Writing the statements in the model - -The three kinds above name a system that performs the write, which leaves the -write opaque to your model. Your model can state which concepts a call changes -in an `affects` list, and nothing can check that list against reality. The -fourth kind, `sql`, carries the write itself, so what your action does becomes -readable — and checkable — from the model. +The first three kinds name a system that performs the write, which leaves the +write itself opaque to your model: an `mcp` tool name says where the operation +lives and nothing about what it touches. A `sql` executor carries the write +instead, so what your action does becomes readable — and checkable — from the +model, and kcmd can [run it](#7-run-it) rather than publishing it for another +system to dispatch. ```yaml - name: TransferFunds @@ -219,6 +192,15 @@ did only the first would lose money. kcmd opens one transaction, runs the statements in the order you wrote them, and commits at the end, so two writes that only make sense together never apply by halves. +Carrying the write buys you two things: + +- **Your blast radius is checkable.** A reader can compare `affects` against the + statements instead of taking it on trust. +- **A guard becomes a real gate.** An MCP, REST or gRPC call commits inside a + system kcmd doesn't control, so a check wrapped around it could only advise + after the fact. kcmd settles every guard on a `sql` action before it opens a + transaction, so a refusal leaves the store untouched. + ### Statements use your database names **An action's statements reach your store exactly as you wrote them**, so every @@ -242,48 +224,32 @@ Syntax error: Unexpected keyword ORDER [at 1:8] instead of "no such table", because `ORDER` is a reserved word. -A `sql` executor buys you two things: +### Which file a `sql` executor belongs in -- **Your blast radius is checkable.** A reader can compare `affects` against the - statements instead of taking it on trust. -- **A guard becomes a real gate.** An MCP, REST or gRPC call commits inside a - system kcmd doesn't control, so a check wrapped around it could only advise - after the fact. kcmd settles every guard on a `sql` action before it opens a - transaction, so a refusal leaves the store untouched. - -That stays safe only while the statements stay narrow, and push holds you to it: - -- Write each statement as a **single `INSERT`, `UPDATE` or `DELETE`**. A - statement that reads is a query and belongs in a metric; one that reshapes the - schema isn't an action. A `;` inside a statement is rejected, because each - list entry runs on its own and anything after the separator would silently not - run. -- Pass every value as a **bound `@parameter`** naming a parameter your action - declares. Nothing is interpolated into the statement text, so an argument - can't become SQL. -- Expect no control flow, and no statement composed at call time. An action - whose body arrives with the call declares nothing, and a gate can't check what - was never declared. - -When an action **creates** a row, kcmd generates that row's primary key — a -UUID — and binds it as `@newKey`. The caller never supplies it, -because an agent that picks its own primary keys can overwrite an existing row -by choosing one already taken. Declaring the creation in `affects` turns the -generation on: +Because its statements name one database's own tables and columns, a `sql` +executor usually belongs in that database's profile rather than in the +model — unless your model will only ever have one store. An `mcp`, `rest` or +`grpc` executor names an operation in another system, and that name usually +doesn't change with the store, so it stays in the model the way `TransferFunds` +declares its `mcp` tool above. ```yaml +# commerce.profiles/operational.yaml — this store owns the rows, so it writes them +semantic_model: + - name: payments + actions: + - name: TransferFunds executor: sql: statements: - - >- - INSERT INTO transfer (transfer_id, amount, debited_account_id) - VALUES (@newTransferKey, @amount, @source) - affects: - - { concept: Transfer, operation: create } + - UPDATE account SET balance = balance - @amount WHERE account_id = @source + - UPDATE account SET balance = balance + @amount WHERE account_id = @target ``` -kcmd runs a `sql` executor and no other kind — see [run it](#7-run-it). The -other three are published for whoever reads your model to dispatch. +This profile overrides the model's `mcp` executor for the one store that +performs the write as DML. Write `executor: null` instead to withdraw an +inherited executor, which leaves you a read-only binding that performs no +writes. ## 2. Gate it with a constraint @@ -702,15 +668,33 @@ Name the concept now and refine it later. Writing `- concept: Account` on its own says the same thing the bare `Account` does, and it's written back as the bare form. -**Status: a `create` record is the only thing that consumes `affects`.** It -tells the runtime to generate the `@newKey` a statement binds. Where a -statement actually binds one, kcmd checks your model first: a concept keyed by -several columns, or by a key field that isn't a `String`, can't take a generated -UUID, so kcmd refuses the call and withholds the write tool. A statement that -supplies its own key is never refused over a generated one it doesn't use. Past -that, kcmd parses `affects`, checks every concept against your model, publishes -it and reads it back. No component computes an impact from it, routes on it, or -checks it against what your executor does. +### A created row gets its key from kcmd + +When an action **creates** a row, kcmd generates that row's primary key — a +UUID — and binds it as `@newKey`. The caller never supplies it, +because an agent that picks its own primary keys can overwrite an existing row +by choosing one already taken. Declaring the creation in `affects` turns the +generation on: + +```yaml + executor: + sql: + statements: + - >- + INSERT INTO transfer (transfer_id, amount, debited_account_id) + VALUES (@newTransferKey, @amount, @source) + affects: + - { concept: Transfer, operation: create } +``` + +**Status: that key generation is the only thing `affects` drives.** Where a +statement actually binds a generated key, kcmd checks your model first: a +concept keyed by several columns, or by a key field that isn't a `String`, +can't take a generated UUID, so kcmd refuses the call and withholds the write +tool. A statement that supplies its own key is never refused over a generated +one it doesn't use. Past that, kcmd parses `affects`, checks every concept +against your model, publishes it and reads it back. No component computes an +impact from it, routes on it, or checks it against what your executor does. ## 4. Check it before pushing @@ -754,6 +738,23 @@ declare. An operation outside `create` / `modify` / `delete` never gets this far — the vocabulary is closed, so your document doesn't parse at all. All of these checks are static, so they run on every push whatever the destination. +### What push holds a statement to + +kcmd checks a `sql` executor further than the other three kinds, because it +carries the write rather than a pointer to whoever performs it: + +- Write each statement as a **single `INSERT`, `UPDATE` or `DELETE`**. A + statement that reads is a query and belongs in a metric; one that reshapes the + schema isn't an action. A `;` inside a statement is rejected, because each + list entry runs on its own and anything after the separator would silently not + run. +- Pass every value as a **bound `@parameter`** naming a parameter your action + declares. Nothing is interpolated into the statement text, so an argument + can't become SQL. +- Expect no control flow, and no statement composed at call time. An action + whose body arrives with the call declares nothing, and a gate can't check what + was never declared. + ## 5. Push it ```bash From 409d0805851a8407c4d266c4254547418047d55b Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 19:08:31 +0000 Subject: [PATCH 8/9] docs(mdcode): give each kind of constraint a section of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- toolbox/mdcode/docs/semantic-model/actions.md | 148 +++++++++--------- 1 file changed, 76 insertions(+), 72 deletions(-) diff --git a/toolbox/mdcode/docs/semantic-model/actions.md b/toolbox/mdcode/docs/semantic-model/actions.md index a3cb0f2e..fca9d01d 100644 --- a/toolbox/mdcode/docs/semantic-model/actions.md +++ b/toolbox/mdcode/docs/semantic-model/actions.md @@ -276,20 +276,16 @@ publishing a rule can't quietly start refusing calls that succeeded yesterday. *Figure 2: a constraint moves from declared, to referenced by an action, to checked before a call, to a breach routed by `on_violation`.* -Write an expression over stored data to state a condition your data has to -satisfy: +A constraint states its rule in one of two bodies: an `expression`, when a query +over your ontology decides the question, or a `judgment`, when no query can. +Which one you wrote shows up on the published constraint as a derived +`evaluation` field reading `deterministic` or `judged`, so a consumer can select +on it. -```yaml - constraints: - - name: BalanceStaysPositive - expression: Account.balance >= Account.minimumBalance - description: >- - An account cannot be taken below its minimum balance. -``` +### Naming a constraint as a guard -An expression that reads an action's **parameters** describes one call rather -than the stored data, so the only moment you can check it is before that call -runs: +An action's `guards` list is what puts a constraint to work. Declare the rule, +then name it on the action — one line on the `TransferFunds` from section 1: ```yaml constraints: @@ -300,39 +296,19 @@ runs: amount again before retrying. actions: - name: TransferFunds - executor: - mcp: - server: //agentregistry.googleapis.com/projects/acme-ops/locations/us-central1/mcpServers/payments - tool: transfer_funds - parameters: - - { name: source, type: Account } - - { name: target, type: Account } - - { name: amount, type: Float } guards: [AmountIsPositive] ``` `guards` holds the names of constraints your model declares, and listing one -there is what makes it apply to that action. Put both kinds of rule there. A -rule that reads the action's parameters has no other moment to run. A rule over -stored data, named as a guard, says the call must not proceed on data that's -already broken. - -Every guard is checked before the call, with the arguments bound. A rule over -the parameters is settled completely there, since the arguments are the whole -of what it reads. A rule over stored data is a condition on the state that a -write produces, so checking it before the call reports only that the call isn't -starting from a broken state. It says nothing about the state the call leaves -behind. Nothing in your model binds a rule to the result of a write, and that's -the gap between what a data rule says and what a guard can enforce. +there is what makes it apply to that action. Both kinds of body go in the same +list. Put the reference on the action rather than on the constraint, because the +same rule may gate `TransferFunds` and leave `CloseAccount` alone. -Whatever dispatches the call is what checks its guards. Handing a rule to your -store instead works for some rules and not others. A condition on a single row -lowers to a store-level `CHECK`. One that aggregates across a child table, such -as an order total matching the sum of its line items, lowers to neither Spanner -nor BigQuery. - -Put the reference on the action rather than on the constraint, because the same -rule may gate `TransferFunds` and leave `CloseAccount` alone. +Whatever dispatches the call is what checks its guards, and it checks every one +of them before the call, with the arguments bound and before any transaction +opens. Nothing in your model binds a rule to the state a write leaves behind, so +a guard reports on the arguments it was handed and on the data the call starts +from, and on nothing else. `guards` and `on_violation` are independent, the way the two right-hand columns of figure 2 are: one says when the constraint gets checked, the other says what @@ -340,7 +316,47 @@ a breach does. So guarding a constraint that declares `warn` is a real shape. Your organization may not be ready to block on a rule; guarding it anyway still gets the rule checked at the moment of the call and reported back. -### When no expression decides it +kcmd reports a mismatch from either side. A guard that names no constraint fails +the push. A constraint over parameters that no action names loads with a +warning, because nothing will ever evaluate it. That scan reads expressions +only. A judgment is prose, and a word in it matching a parameter name is not a +read of that parameter. + +### Rules a query settles + +Write the rule as an `expression` when a query over your ontology decides it. A +condition over stored data names the entities and fields it reads: + +```yaml + constraints: + - name: BalanceStaysPositive + expression: Account.balance >= Account.minimumBalance + description: >- + An account cannot be taken below its minimum balance. +``` + +A condition over the action's parameters describes one call instead, the way +`AmountIsPositive` above reads `amount` and nothing else. A rule like that is +settled completely before the call, since the arguments are the whole of what it +reads. A rule over stored data is a condition on the state that a write +produces, so checking it before the call reports only that the call isn't +starting from a broken state. It says nothing about the state the call leaves +behind, and that gap is the difference between what a data rule says and what a +guard can enforce. + +An expression can also go to your store rather than to whatever dispatches the +call, and that works for some rules and not others. A condition on a single row +lowers to a store-level `CHECK`. One that aggregates across a child table, such +as an order total matching the sum of its line items, lowers to neither Spanner +nor BigQuery. + +**Status: no component evaluates an expression against live data.** kcmd parses +one, validates it, publishes it with the `guards` that name it, and reads it +back. At run time [`kcmd action run`](#7-run-it) refuses an action whose +`guards` name an expression rather than apply a write your model says must be +checked first. + +### Rules no query settles Your business enforces some rules that can't be written as a boolean. A credit memo may or may not explain the failure it claims to refund. A discount may or @@ -367,6 +383,14 @@ judgment must state `on_violation`, and any of the three words will do. Omit it and the push fails, because an unmarked constraint would reject, and that's too strong a consequence to inherit by silence. +No judge settles a rule about the state a write *leaves behind*, however much it +can read. "An order's total equals the sum of its lines" has nothing to look at +when a guard runs. Put that rule inside the transaction or in your schema. + +**Status: a judgment is the one body kcmd settles.** At run time, +[`kcmd action run --judge`](#a-guard-settled-in-words) puts each judged guard to +a language model and routes the verdict by `on_violation`. + ### Writing a judgment A language model reads your sentence at review time with the proposed write in @@ -413,11 +437,6 @@ value is on record and has to be read, because the judge decides for itself whether to look. The same rule refuses every call when it goes to a judge that can't read. See [a guard that reads a row](#a-guard-that-reads-a-row). -No judge settles a rule about the state a write *leaves behind*, however much it -can read. Guards run before the transaction opens, so "an order's total equals -the sum of its lines" has nothing to look at yet. Put that rule inside the -transaction or in your schema. - ### A policy whose rules end differently Real policies have several rules, and the rules rarely end the same way. A @@ -538,10 +557,9 @@ review is a rule the business means as unappealable, and no expression detects it, so the alternative to writing it this way is leaving it out of your model. The pairing carries a real cost, because a language model can decide two identical credits differently and `reject` leaves nobody to appeal to. kcmd -publishes it instead of forbidding it, and makes it findable. Every constraint -carries a derived `evaluation` field, which reads `judged` here, so an auditor -asking which unappealable rules your model settles gets an answer from one -query. +publishes it instead of forbidding it, and makes it findable. Its `evaluation` +field reads `judged`, so an auditor asking which unappealable rules your model +settles gets an answer from one query. ### Two calls through that policy @@ -588,28 +606,14 @@ costs a model call, none can lower to a store-level check, and each may decide two identical calls differently. `IssueCredit` stays clear of that: three of its five guards are expressions. -**Status: kcmd settles a judgment, and doesn't settle an expression.** It parses -both bodies, validates them, publishes them with the `guards` that name them, -and reads them all back, along with a derived `evaluation` field reading -`deterministic` or `judged` so a consumer can select on it. At run time, -[`kcmd action run --judge`](#a-guard-settled-in-words) puts each judged guard to -a language model and routes the verdict by `on_violation`. No component here -evaluates an expression against live data, so -[`kcmd action run`](#7-run-it) refuses an action whose `guards` name one rather -than apply a write your model says must be checked first — and `IssueCredit` -names three. The two calls above are therefore what the published policy says -should happen rather than what kcmd does with this action today. A run doesn't -compute the strictest outcome either. `--judge` puts the judged guards to the -judge in the order your model declares them, and stops at the first one that -fails without being advisory. What comes back is that guard's outcome rather -than the strictest of them, and a `warn` collected on the way there doesn't -travel with the refusal. - -kcmd reports a mismatch from either side. A guard that names no constraint fails -the push. A constraint over parameters that no action names loads with a -warning, because nothing will ever evaluate it. That scan reads expressions -only: a judgment is prose, in which a word matching a parameter name isn't a -read of that parameter. +**Status: a run doesn't compute the strictest outcome.** `--judge` puts the +judged guards to the judge in the order your model declares them and stops at +the first one that fails without being advisory. What comes back is that guard's +outcome rather than the strictest of them, and a `warn` collected on the way +there doesn't travel with the refusal. And because `IssueCredit`'s other three +guards are expressions, [`kcmd action run`](#7-run-it) refuses it outright, so +the two calls above are what the published policy says should happen rather than +what kcmd does with this action today. ## 3. Say what it changes From 2f07d17b92a2e1f1e5de6b53c7436cd177841b5e Mon Sep 17 00:00:00 2001 From: Bei Li Date: Tue, 15 Sep 2026 19:30:35 +0000 Subject: [PATCH 9/9] docs(mdcode): fix ten defects a second review found 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 `@newKey`, 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 #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 #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. --- toolbox/mdcode/docs/semantic-model/actions.md | 57 ++++++++++--------- toolbox/mdcode/src/libts/semantic/ir.ts | 18 +++--- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/toolbox/mdcode/docs/semantic-model/actions.md b/toolbox/mdcode/docs/semantic-model/actions.md index fca9d01d..b909d804 100644 --- a/toolbox/mdcode/docs/semantic-model/actions.md +++ b/toolbox/mdcode/docs/semantic-model/actions.md @@ -168,8 +168,9 @@ The first three kinds name a system that performs the write, which leaves the write itself opaque to your model: an `mcp` tool name says where the operation lives and nothing about what it touches. A `sql` executor carries the write instead, so what your action does becomes readable — and checkable — from the -model, and kcmd can [run it](#7-run-it) rather than publishing it for another -system to dispatch. +model, and kcmd can [run it](#7-run-it) rather than handing the write to another +system to perform. Written into the model, it replaces the `mcp` executor +`TransferFunds` declared above: ```yaml - name: TransferFunds @@ -197,9 +198,10 @@ Carrying the write buys you two things: - **Your blast radius is checkable.** A reader can compare `affects` against the statements instead of taking it on trust. - **A guard becomes a real gate.** An MCP, REST or gRPC call commits inside a - system kcmd doesn't control, so a check wrapped around it could only advise - after the fact. kcmd settles every guard on a `sql` action before it opens a - transaction, so a refusal leaves the store untouched. + system kcmd doesn't control, so a write it performed can't be rolled back if + the rest of the action fails. A `sql` action's guards settle before kcmd opens + a transaction, and a call that doesn't clear them never reaches one, so a + refusal leaves the store untouched. ### Statements use your database names @@ -253,11 +255,12 @@ writes. ## 2. Gate it with a constraint -Section 1 gave you an action that runs. This section is how you stop it running -when it shouldn't. A **constraint** is a named rule your model states over its -ontology. Declaring one adds it to the catalog and changes nothing by itself. A -constraint takes effect where something references it and nowhere else, so -publishing a rule can't quietly start refusing calls that succeeded yesterday. +The sections so far declared an action and gave it a write to perform. This +section is how you stop it running when it shouldn't. A **constraint** is a +named rule your model states over its ontology. Declaring one adds it to the +catalog and changes nothing by itself. A constraint takes effect where +something references it and nowhere else, so publishing a rule can't quietly +start refusing calls that succeeded yesterday. ``` declared referenced checked a breach @@ -383,9 +386,10 @@ judgment must state `on_violation`, and any of the three words will do. Omit it and the push fails, because an unmarked constraint would reject, and that's too strong a consequence to inherit by silence. -No judge settles a rule about the state a write *leaves behind*, however much it -can read. "An order's total equals the sum of its lines" has nothing to look at -when a guard runs. Put that rule inside the transaction or in your schema. +No judge settles a rule about the state a write *leaves behind*. A guard runs +before the transaction opens, so "an order's total equals the sum of its lines" +has nothing to look at yet. Put that rule inside the transaction or in your +schema. **Status: a judgment is the one body kcmd settles.** At run time, [`kcmd action run --judge`](#a-guard-settled-in-words) puts each judged guard to @@ -610,10 +614,10 @@ five guards are expressions. judged guards to the judge in the order your model declares them and stops at the first one that fails without being advisory. What comes back is that guard's outcome rather than the strictest of them, and a `warn` collected on the way -there doesn't travel with the refusal. And because `IssueCredit`'s other three -guards are expressions, [`kcmd action run`](#7-run-it) refuses it outright, so -the two calls above are what the published policy says should happen rather than -what kcmd does with this action today. +there doesn't travel with the refusal. And [`kcmd action run`](#7-run-it) won't +perform `IssueCredit` as declared here, so the two calls above are what the +published policy says should happen rather than what kcmd does with this action +today. ## 3. Say what it changes @@ -709,7 +713,7 @@ trip: kcmd push --validate-only ``` -Four things about an action can be statically wrong once your document parses, +Four things about any action can be statically wrong once your document parses, and each one is a hard error: ``` @@ -739,8 +743,8 @@ learns nothing. kcmd checks the rest of an `affects` entry just as strictly. Fields beside a `delete` are a hard error, and so is a field the concept doesn't declare. An operation outside `create` / `modify` / `delete` never gets this far -— the vocabulary is closed, so your document doesn't parse at all. All of these -checks are static, so they run on every push whatever the destination. +— the vocabulary is closed, so your document doesn't parse at all. These checks +are static, so they run on every push whatever the destination. ### What push holds a statement to @@ -749,15 +753,16 @@ carries the write rather than a pointer to whoever performs it: - Write each statement as a **single `INSERT`, `UPDATE` or `DELETE`**. A statement that reads is a query and belongs in a metric; one that reshapes the - schema isn't an action. A `;` inside a statement is rejected, because each + schema isn't an action. A `;` anywhere but the end is rejected, because each list entry runs on its own and anything after the separator would silently not run. - Pass every value as a **bound `@parameter`** naming a parameter your action - declares. Nothing is interpolated into the statement text, so an argument - can't become SQL. -- Expect no control flow, and no statement composed at call time. An action - whose body arrives with the call declares nothing, and a gate can't check what - was never declared. + declares, or the `@newKey` a `create` in `affects` generates. Nothing + is interpolated into the statement text, so an argument can't become SQL. +- Nothing else is available: no control flow, and no statement composed at call + time. `statements` is a fixed list in your model, so an action whose body + arrived with the call would declare nothing, and a gate can't check what was + never declared. ## 5. Push it diff --git a/toolbox/mdcode/src/libts/semantic/ir.ts b/toolbox/mdcode/src/libts/semantic/ir.ts index 94391306..ac72aec2 100644 --- a/toolbox/mdcode/src/libts/semantic/ir.ts +++ b/toolbox/mdcode/src/libts/semantic/ir.ts @@ -419,21 +419,23 @@ export type Executor = * DML statements. * * The other three executor kinds name a system that performs the write, so what - * the write does is opaque to the model. This one contains it, which buys three + * the write does is opaque to the model. This one contains it, which buys two * things the opaque kinds cannot offer. * * - The blast radius is checkable. `affects` can be read against the * statements rather than taken on trust. * - A guard becomes a real gate. An MCP, REST or gRPC call commits inside a - * system the runtime does not control, so a check around it is advisory; a - * statement run in the runtime's own transaction can be rolled back. - * - The statements run where the constraints are probed, so the gate observes - * the uncommitted result of the write it is gating. + * system the runtime does not control, so a write it performed could not be + * rolled back if the rest of the action failed; a statement run in the + * runtime's own transaction can be. Guards settle before that transaction + * opens (see run_action.ts), so a refusal leaves the store untouched and no + * check ever observes the write it gates. * * The narrowness is the safety argument, and validate.ts enforces it. A - * statement is a single INSERT, UPDATE or DELETE. Every value it uses arrives as - * a bound query parameter naming a declared action parameter, so nothing is - * interpolated into the text and an argument cannot become SQL. There is no + * statement is a single INSERT, UPDATE or DELETE. Every value it uses arrives + * as a bound query parameter, and each one names either a declared action + * parameter or the key kcmd generates for a row the action creates, so nothing + * is interpolated into the text and an argument cannot become SQL. There is no * control flow, no statement composed at call time, and no way for a caller to * supply a statement of its own: an action whose body arrives with the call * declares nothing, and a gate cannot check what was never declared.