Commit23b3cbdaRecorded1 Sep 2026Repositorykiln

lang: refuse an empty when: connective, and doubt the gate in its own advice

Message

Two follow-ups to the when: evaluator, both found by checking a claim rather than shipping it.

An empty (and) or (or) linted clean and was not a gate. variadic means "any number of predicates", not "including none": (and) is vacuously true and opens permanently, (or) is vacuously false and SKIPS permanently and silently. The second is this defect again through a different mechanism -- a pipeline that quietly stops running with no error -- so both are now refused at lint. Measured before the fix: when: (and) linted clean and scheduled pending.

The scheduler's undecidable-gate log line blamed the pipeline file. The same arm catches every exception out of the evaluator, so it fires for a clause kiln genuinely cannot decide AND for a defect in kiln's own evaluator, and an operator cannot tell which from outside. It now names both, with the outward blame labelled as the likely rather than the certain cause.

test/run-when-checks.sgl: 91 -> 95 checks (empty and, empty or, a nested empty connective, and a positive control that a non-empty and still lints clean). docs/when-predicates.md documents the twelve-head vocabulary, the comparison rules, the trust table, what a clause can and cannot read, and every way one fails loudly.

Changed
 docs/when-predicates.md      | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/kiln/lang/predicates.sgl |  19 +++++++++++++++++++
 src/kiln/scheduler.sgl       |  17 +++++++++++++++--
 test/run-when-checks.sgl     |  22 ++++++++++++++++++++++
 4 files changed, 193 insertions(+), 2 deletions(-)
Diff
docs/when-predicates.mdadded
@@ -0,0 +1,137 @@
+1
# `when:` predicates — what a pipeline author can and cannot express
+2
+3
A `when:` clause gates a job or a step. It is a **closed pattern language**,
+4
not user code: twelve predicate heads, no arithmetic, no calls, no user
+5
definitions. Kiln validates a clause when it reads your file (`kiln lint`)
+6
and decides it when it schedules or runs the work.
+7
+8
```scheme
+9
(job release
+10
when: (and (tag? $ref) (trusted?))
+11
...)
+12
+13
(job build
+14
matrix: #{ target: #[ "linux-amd64" "windows-amd64" ] }
+15
steps: #[
+16
(artifact "build/${target}/bin/sigil.exe" as: "sigil-${target}.exe"
+17
when: (equal? $target "windows-amd64"))
+18
(artifact "build/${target}/bin/sigil" as: "sigil-${target}"
+19
when: (not (equal? $target "windows-amd64"))) ])
+20
```
+21
+22
## What a false gate does
+23
+24
- **A job** whose `when:` is false is scheduled `skipped`. It never runs, and
+25
anything that `needs:` it is skipped too.
+26
- **A step** whose `when:` is false is not compiled into the node's script at
+27
all. It leaves no section marker, so a gated-off step and a step that ran
+28
and printed nothing are not confusable in the log.
+29
- **A group whose jobs are all skipped** reads `skipped`, not `passed`. Green
+30
means work ran and succeeded.
+31
+32
## The vocabulary
+33
+34
| form | arity | true when |
+35
|---|---|---|
+36
| `(and P ...)` | 1+ | every `P` is true |
+37
| `(or P ...)` | 1+ | some `P` is true |
+38
| `(not P)` | 1 | `P` is false |
+39
| `(equal? A B)` | 2 | `A` and `B` are the same value |
+40
| `(member? X (v ...))` | 2 | `X` equals one of the values |
+41
| `(tag? R)` | 1 | `R` is a ref string beginning `refs/tags/` |
+42
| `(branch? R)` | 1 | `R` is a ref string beginning `refs/heads/` |
+43
| `(pull-request?)` | 0 | this run was triggered by a pull request |
+44
| `(manual?)` | 0 | this run was triggered manually |
+45
| `(trusted?)` | 0 | this run is trusted (see below) |
+46
| `(fork?)` | 0 | this run came from a forked repository |
+47
| `(changed? R)` | 1 | **not implemented — refused by `kiln lint`** |
+48
+49
`and` and `or` **do not short-circuit**. Every predicate is total and
+50
effect-free, so there is nothing to gain by stopping early, and stopping
+51
early would let an undecidable predicate hide behind a false conjunct and
+52
come back as a quiet "no".
+53
+54
They also need **at least one** operand. `kiln lint` refuses an empty `(and)`
+55
or `(or)`: an empty connective is a gate that is not a gate — `(and)` would be
+56
vacuously true and open permanently, `(or)` vacuously false and skip
+57
permanently, and neither is anything an author means to write.
+58
+59
## What a clause can read
+60
+61
Only these:
+62
+63
- `$ref` — the full ref, e.g. `"refs/tags/v1.0.0"`
+64
- `$sha`
+65
- `$trigger` — `push`, `tag`, `pull-request`, `manual` or `chained`
+66
- **your matrix dimensions** — `$target`, `$os`, whatever you named them
+67
+68
Everything else in kiln's reference namespace (`$actor`, `$repo`, `$run-id`,
+69
`$workspace`, `$needs.build.out`, `$steps.compile.x`) passes `kiln lint`,
+70
because those are legal references elsewhere in a job file, and **has no
+71
value where a gate is decided**. A gate is decided before the work it gates,
+72
so `$needs.*` and `$steps.*` are meaningless there by construction, and kiln
+73
records no actor or workspace for a run at all.
+74
+75
Using one is an error, not a false. See "If kiln cannot decide" below.
+76
+77
### Comparison rules
+78
+79
- A symbol and a string spelling the same name are **equal**:
+80
`(equal? $target linux-amd64)` and `(equal? $target "linux-amd64")` both
+81
work, because matrix values are strings while a bare name in a `.kiln`
+82
file reads as a symbol.
+83
- A number and a string are **not** equal: `(equal? 1 "1")` is false.
+84
- `member?`'s second argument is a list of values, written `#[ ... ]`:
+85
`(member? $target #[ "linux-amd64" "linux-arm64" ])`.
+86
+87
### `trusted?` and `fork?`
+88
+89
`trusted?` is kiln's secret-bearing classification, computed by
+90
`classify-trust` from the trigger:
+91
+92
| trigger | trusted? |
+93
|---|---|
+94
| `push` | yes |
+95
| `tag` | yes |
+96
| `manual` | yes |
+97
| `pull-request` | yes, unless the PR is from a fork |
+98
| `chained` | inherits the parent run's trust |
+99
| anything else | no |
+100
+101
`fork?` is **derived** from that, because kiln stores no fork flag: a
+102
pull-request run is a fork run exactly when it is untrusted, and a
+103
push/tag/manual run cannot be one (all three require write access to this
+104
repository). For a `chained` run kiln has no record of where the chain
+105
began, so **`(fork?)` is an error on a chained run** rather than a
+106
plausible-looking `no`.
+107
+108
## If kiln cannot decide
+109
+110
**A predicate that cannot be decided fails loudly. It never silently
+111
evaluates false.** This is the point of the whole design: a skip and an
+112
undecidable gate produce the same absence of work, and if they were the same
+113
outcome, a release pipeline could quietly stop publishing and look green
+114
doing it.
+115
+116
Concretely:
+117
+118
- **At lint time** — `changed?` is refused when you write the file, because
+119
kiln does not collect the changed-file list for a run and never can decide
+120
it. An unknown head (`(supported? $ref)`) is refused as unknown.
+121
- **At schedule time** — a job whose clause cannot be decided is scheduled
+122
`errored`, not `skipped`, with the reason in its run log. Its dependents
+123
are skipped and the group fails.
+124
- **At run time** — a step whose clause cannot be decided aborts the node's
+125
script compilation, and the node errors with the offending clause named.
+126
+127
The cases you can actually hit: an unresolvable `$reference`, a dotted
+128
`$needs.*` / `$steps.*` reference, `changed?`, and `(fork?)` on a chained
+129
run.
+130
+131
## Where this lives
+132
+133
`src/kiln/lang/predicates.sgl` validates a clause;
+134
`src/kiln/lang/when-eval.sgl` decides one. Gates are applied in
+135
`schedule-plan-group!` (`src/kiln/scheduler.sgl`) for jobs and in
+136
`emit-step` (`src/kiln/nodeexec.sgl`) for steps.
+137
`test/run-when-checks.sgl` is the check suite.
src/kiln/lang/predicates.sglmodified
@@ -111,6 +111,25 @@
111
(define (check-arity sink head arity args line form)
112
(let ((n (length args)))
113
(cond
+114
;; `variadic` means "any number of predicates", NOT "including
+115
;; none". An empty connective is a gate that is not a gate:
+116
;; `(and)` is vacuously true and opens permanently, `(or)` is
+117
;; vacuously false and SKIPS permanently and silently. Both are
+118
;; the shape of an author deleting the last operand and not
+119
;; noticing, and the second one lands the pipeline right back
+120
;; in the defect (kiln lang when-eval) exists to close.
+121
;;
+122
;; Measured before this arm existed: `when: (and)` linted clean
+123
;; and scheduled `pending`.
+124
((and (eq? arity 'variadic) (= n 0))
+125
(diag-error! sink
+126
(string-append "when: '" (symbol->string head)
+127
"' needs at least one predicate; an empty '"
+128
(symbol->string head)
+129
"' is not a gate -- (and) is always true and "
+130
"(or) is always false, so the clause silently "
+131
"stops deciding anything")
+132
line form))
133
((eq? arity 'variadic) #t)
134
((not (= n arity))
135
(diag-error! sink
src/kiln/scheduler.sglmodified
@@ -203,10 +203,23 @@
203
;; An undecidable gate must not look like an intended
204
;; skip, so it gets a status of its own AND the reason
205
;; written where an operator reads it.
+206
;;
+207
;; The message names BOTH possible causes rather than
+208
;; blaming the pipeline file. This arm catches every
+209
;; exception from the evaluator, so it fires for a clause
+210
;; kiln genuinely cannot decide AND for a defect in the
+211
;; evaluator -- and an operator reading it cannot tell
+212
;; which from the outside. A gate's remediation advice
+213
;; doubts the gate first.
214
(when (pair? gate)
215
(db-append-log! db id 1 "stderr"
−208
(string-append "[kiln] node " node-id
−209
" could not be scheduled: " (cdr gate))))
+216
(string-append
+217
"[kiln] node " node-id " was not scheduled: its when: "
+218
"clause could not be decided, so kiln refused to guess. "
+219
(cdr gate)
+220
" -- this is usually the clause, but the same path "
+221
"reports a fault in kiln's own evaluator; if the clause "
+222
"looks correct, treat it as a kiln bug.")))
223
(loop (cdr ns) (cons id ids)
224
(or gated? (not (eq? gate 'open)))))))))
225
test/run-when-checks.sglmodified
@@ -518,6 +518,28 @@
518
(sink-of "(pipeline p (job a when: (supported? $ref) steps: #[ (shell \"x\") ]))")
519
"unknown when: predicate"))
520
+521
;; An empty connective is a gate that is not a gate. `(and)` opens
+522
;; permanently; `(or)` SKIPS permanently and silently, which is this
+523
;; defect again with a different mechanism. Both refused at lint.
+524
(check "an empty (and) is refused at lint time"
+525
(has-diag?
+526
(sink-of "(pipeline p (job a when: (and) steps: #[ (shell \"x\") ]))")
+527
"at least one predicate"))
+528
(check "an empty (or) is refused at lint time"
+529
(has-diag?
+530
(sink-of "(pipeline p (job a when: (or) steps: #[ (shell \"x\") ]))")
+531
"at least one predicate"))
+532
(check "a nested empty connective is refused too"
+533
(has-diag?
+534
(sink-of "(pipeline p (job a when: (not (or)) steps: #[ (shell \"x\") ]))")
+535
"at least one predicate"))
+536
;; Positive control: a NON-empty variadic connective still lints clean,
+537
;; so the new arm is not refusing every and/or.
+538
(check "positive control: a non-empty (and) still lints clean"
+539
(not (has-diag?
+540
(sink-of "(pipeline p (job a when: (and (tag? $ref)) steps: #[ (shell \"x\") ]))")
+541
"at least one predicate")))
+542
543
;; ---- summary ----
544
(display (string-append "\n" (number->string passes) " passed, "
545
(number->string fails) " failed\n"))