Complex actions: composite specverb verbs anchored by an until keyword (CI-watch first use case) #140
Labels
No labels
burndown-2026-06
sunday-sprint
coherence-core
consult
headless
interactive
P0
P1
P2
P3
P4
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
coilyco-flight-deck/cli-guard#140
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Complex actions: composite verbs in the specverb guardfile, anchored by an
untilkeywordDesign spec for a new complex actions layer in cli-guard's specverb engine. First
use case: a native CI-watch verb for ward. This issue is self-contained — it carries the
whole design so it can be implemented without a separate docs/ file.
Motivation
specverb today is a one-swagger-op-per-leaf expander (
http/specverb/expansion.go):a guardfile
can <verb> <resource>resolves through the curated expansion table to asingle operation, deny-by-default. That cannot express a poll-until-terminal loop or any
multi-call workflow, so composite operator logic (e.g. "watch a CI run to completion, then
report failures") has no home in a guardfile and leaks into hand-written scripts.
ward's interim
scripts/watch-ci.sh(see ward#88) is exactly such a leak: it poll-loopslist tasksand tails failing-job logs over coily's surface. As coily retires into ward,the poll-and-report shape should become a first-class, gated, audited guardfile construct.
What a complex action is
A named, authored composite inside a
wrapblock that orchestrates a bounded sequenceof already-granted leaves with control flow. It is sugar over the allowlist, never an
escape from it. Five invariants keep it true to cli-guard's deny-by-default ethos:
call/polloperations the same guardfile grantsvia
can. Referencing an ungranted op fails atlock/buildtime, not runtime.timeoutandevery. No unboundediteration exists in the grammar — that is what makes a poll loop reviewable.
verb.Wrapauditrow, plus the action writes one envelope row (
action.<name>) tying them together.--dry-runon an action prints the call sequence with boundparams and the compiled
untilexpression, firing nothing, reusing each leaf's dry-run.--queryuses.The leaf seam (important)
Complex actions compose leaves, where a leaf is either:
ListActionTasks), orsee "Logs reality" below).
The grammar does not care which kind a leaf is. This seam is what makes deferring the log
capability both safe and non-regretful: the action layer is identical either way.
Grammar (KDL), against the CI-watch use case
untilsemanticsEach tick: fire the leaf, decode the JSON response, evaluate the
untilJMESPath againstit. Truthy ends the loop and binds the last response (
as run_tasks).timeoutfirst meansa non-zero exit. The loop body is exactly one granted call, so the blast radius is a single
audited operation repeated on a clock.
Input binding. Inputs reach conditions as native JMESPath
$variablesvia thejmespath-community lib's scope-injection entrypoint (
pkg/api/pkg/binding), NOT stringsubstitution. No injection-shaped surface in a security tool — this was the deciding reason
for the lib choice.
Reserved slots (forward design, do not implement in v1)
pollreserves two future slots so live log tailing is a later addition, not a rewrite:emit <projection>— per-tick output (the streaming delta).cursor <name> from <expr>— state threaded tick -> tick (the tail position).Bindings should also carry a kind (
valuenow,streamreserved) so the planner'stypes never hardcode single-value-forever. Reserve the keywords
follow/stream/tail.The action body is a straight line in v1 but should be defined as data-dependency
ordered (a DAG that today happens to be linear), so if true server-push streaming ever
arrives, concurrency is a scheduler change, not a grammar change.
Logs reality (why logs are deferred, verified)
Forgejo has no OpenAPI endpoint for action logs (gitea#35176). The web UI tails logs by
cursor polling:
POST /repos/:owner/:repo/actions/runs/:run/jobs/:job~1/s with aper-step
logCursor, returning new lines since the cursor; logs are stored as NDJSON with aline->byte-offset index. This is client-pull tailing, NOT server push — so it collapses
into the same poll/until model (poll the cursor, emit the delta, until terminal). When ward
owns it, the log leaf is hand-written gated Go in
cmd/ward(no swagger op exists togenerate from), slotting into
pollvia the reservedemit+cursorslots. Deferred forv1; ward's
scripts/watch-ci.shkeeps tailing in the meantime.Execution model / engine placement
http/specverb(planner) +http/guardfile(parser node)."fire and capture decoded value" path that feeds the next step; stdout is reserved for the
final
as/yield/fail-whenresult.poll/call/readtargetagainst the expansion table (or the hand-written-leaf registry) at lock/build time.
ward-kdlguardfile path (cmd/ward-kdl/*.guardfile.kdl).v1 scope (locked)
action,input,poll,until,every,timeout,as,fail-when.ci-watchoverListActionTasks. Native poll + status table +exit code via
fail-when. Log tailing stays inscripts/watch-ci.sh.Decisions locked
github.com/jmespath-community/go-jmespathv1.1.1 for native$vars. Already applied to the cli-guard working tree (go.mod,go.sum,http/respfmt/respfmt.go— import path only; rootSearchis a drop-in).make test+make lintgreen, no--queryregression. NOT yet committed — a new session shouldverify and commit this alongside the feature.
until+fail-whenonly for v1;each/yieldfan-out deferred.emit+cursor+ binding-kind + the spec-or-handwritten leaf seam now.Open (for the implementing session)
action/pollparser node + AST inhttp/guardfile.pkg/bindinginput injection so$repo/$runresolve inuntil.http/specverb: fire-and-capture, the bounded poll loop,fail-whenexitmapping, the per-call + envelope audit rows, dry-run-as-plan.
ci-watchaction to ward's forgejo guardfile; retire the poll loop inscripts/watch-ci.sh(keep its log-tail wrapper until the logs leaf lands).Grounding references
http/specverb/expansion.go({list, tasks} -> ListActionTasks).--queryis JMESPath:http/respfmt/respfmt.go(jmespath.Search).ListActionTaskstask fields used byuntil:status,run_number,head_sha,name(job),id.ward ci watchverb), wardscripts/watch-ci.sh.