Complex actions: composite specverb verbs anchored by an until keyword (CI-watch first use case) #140

Closed
opened 2026-06-15 13:13:18 +00:00 by coilysiren · 0 comments
Owner

Complex actions: composite verbs in the specverb guardfile, anchored by an until keyword

Design 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 a
single 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-loops
list tasks and 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 wrap block that orchestrates a bounded sequence
of 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:

  1. Granted-only. An action may only call/poll operations the same guardfile grants
    via can. Referencing an ungranted op fails at lock/build time, not runtime.
  2. Bounded. Every loop carries a mandatory timeout and every. No unbounded
    iteration exists in the grammar — that is what makes a poll loop reviewable.
  3. Per-call audit. Each underlying HTTP call still writes its own verb.Wrap audit
    row, plus the action writes one envelope row (action.<name>) tying them together.
  4. Dry-run is a plan. --dry-run on an action prints the call sequence with bound
    params and the compiled until expression, firing nothing, reusing each leaf's dry-run.
  5. One expression engine. Conditions are JMESPath, the same engine --query uses.

The leaf seam (important)

Complex actions compose leaves, where a leaf is either:

  • specverb-generated from a swagger operation (e.g. ListActionTasks), or
  • hand-written gated Go for routes with no swagger op (e.g. forgejo's log cursor-poll,
    see "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

wrap ward ops forgejo {
    spec forgejo.swagger.v1.json
    base-url "forgejo.coilysiren.me/api/v1"
    auth header-token { header Authorization; prefix "token "; ssm "/forgejo/api-token" }

    can list tasks
    // can read task-logs        // FUTURE: hand-written leaf, not specverb (see below)

    action ci-watch {
        describe "Watch a CI run to completion, then surface failing-job status."

        input repo { positional required help "owner/name" }
        input run  { flag help "run number (default: latest in the listing)" }

        // THE PRIMITIVE: re-fire a granted leaf until JMESPath settles, or time out.
        poll list tasks {
            args { owner-repo $repo }
            until "length([?run_number==$run && status!='success' && status!='failure' \
                            && status!='cancelled' && status!='skipped']) == `0`"
            every   10s
            timeout 30m
            // emit   <projection>        RESERVED — per-tick output (streaming delta)
            // cursor <name> from <expr>  RESERVED — state threaded tick -> tick (tail pos)
        } as run_tasks

        // fail-predicate sets the process exit code.
        fail-when "length(run_tasks[?status=='failure']) > `0`"

        // FUTURE v2 (needs the hand-written task-logs leaf):
        // each "run_tasks[?status=='failure']" as job {
        //     read task-logs { args { id $job.id } } as log
        //     yield { job $job.name; status $job.status; log $log }
        // }
    }
}

until semantics

Each tick: fire the leaf, decode the JSON response, evaluate the until JMESPath against
it. Truthy ends the loop and binds the last response (as run_tasks). timeout first means
a 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 $variables via the
jmespath-community lib's scope-injection entrypoint (pkg/api / pkg/binding), NOT string
substitution. 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)

poll reserves 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 (value now, stream reserved) so the planner's
types 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 a
per-step logCursor, returning new lines since the cursor; logs are stored as NDJSON with a
line->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 to
generate from), slotting into poll via the reserved emit+cursor slots. Deferred for
v1; ward's scripts/watch-ci.sh keeps tailing in the meantime.

Execution model / engine placement

  • Engine work is cli-guard's: http/specverb (planner) + http/guardfile (parser node).
  • specverb today renders each call straight to stdout. Complex actions need an internal
    "fire and capture decoded value" path that feeds the next step; stdout is reserved for the
    final as/yield/fail-when result.
  • Deny-by-default is enforced at generation: resolve every poll/call/read target
    against the expansion table (or the hand-written-leaf registry) at lock/build time.
  • ward consumes it via the existing ward-kdl guardfile path (cmd/ward-kdl/*.guardfile.kdl).

v1 scope (locked)

  • Keywords: action, input, poll, until, every, timeout, as, fail-when.
  • One complex action: ci-watch over ListActionTasks. Native poll + status table +
    exit code via fail-when. Log tailing stays in scripts/watch-ci.sh.
  • No dependency on a logs leaf or any streaming work.

Decisions locked

  • JMESPath lib swapped to github.com/jmespath-community/go-jmespath v1.1.1 for native
    $vars. Already applied to the cli-guard working tree (go.mod, go.sum,
    http/respfmt/respfmt.go — import path only; root Search is a drop-in). make test +
    make lint green, no --query regression. NOT yet committed — a new session should
    verify and commit this alongside the feature.
  • until + fail-when only for v1; each/yield fan-out deferred.
  • Logs deferred (no OpenAPI op; hand-written leaf later).
  • Reserve emit + cursor + binding-kind + the spec-or-handwritten leaf seam now.

Open (for the implementing session)

  1. Pin the action/poll parser node + AST in http/guardfile.
  2. Wire pkg/binding input injection so $repo/$run resolve in until.
  3. Planner in http/specverb: fire-and-capture, the bounded poll loop, fail-when exit
    mapping, the per-call + envelope audit rows, dry-run-as-plan.
  4. Lock-time deny-by-default resolution of action targets.
  5. Add the ci-watch action to ward's forgejo guardfile; retire the poll loop in
    scripts/watch-ci.sh (keep its log-tail wrapper until the logs leaf lands).

Grounding references

  • specverb expansion table: http/specverb/expansion.go ({list, tasks} -> ListActionTasks).
  • --query is JMESPath: http/respfmt/respfmt.go (jmespath.Search).
  • ListActionTasks task fields used by until: status, run_number, head_sha,
    name (job), id.
  • Forgejo log cursor route + no API: gitea#35176; Gitea Actions system (DeepWiki).
  • Related: ward#88 (native ward ci watch verb), ward scripts/watch-ci.sh.
## Complex actions: composite verbs in the specverb guardfile, anchored by an `until` keyword Design 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 a single 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-loops `list tasks` and 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 `wrap` block that orchestrates a **bounded** sequence of 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: 1. **Granted-only.** An action may only `call`/`poll` operations the same guardfile grants via `can`. Referencing an ungranted op fails at `lock`/`build` time, not runtime. 2. **Bounded.** Every loop carries a mandatory `timeout` and `every`. No unbounded iteration exists in the grammar — that is what makes a poll loop reviewable. 3. **Per-call audit.** Each underlying HTTP call still writes its own `verb.Wrap` audit row, plus the action writes one envelope row (`action.<name>`) tying them together. 4. **Dry-run is a plan.** `--dry-run` on an action prints the call sequence with bound params and the compiled `until` expression, firing nothing, reusing each leaf's dry-run. 5. **One expression engine.** Conditions are JMESPath, the same engine `--query` uses. ### The leaf seam (important) Complex actions compose **leaves**, where a leaf is either: - **specverb-generated** from a swagger operation (e.g. `ListActionTasks`), or - **hand-written gated Go** for routes with no swagger op (e.g. forgejo's log cursor-poll, see "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 ```kdl wrap ward ops forgejo { spec forgejo.swagger.v1.json base-url "forgejo.coilysiren.me/api/v1" auth header-token { header Authorization; prefix "token "; ssm "/forgejo/api-token" } can list tasks // can read task-logs // FUTURE: hand-written leaf, not specverb (see below) action ci-watch { describe "Watch a CI run to completion, then surface failing-job status." input repo { positional required help "owner/name" } input run { flag help "run number (default: latest in the listing)" } // THE PRIMITIVE: re-fire a granted leaf until JMESPath settles, or time out. poll list tasks { args { owner-repo $repo } until "length([?run_number==$run && status!='success' && status!='failure' \ && status!='cancelled' && status!='skipped']) == `0`" every 10s timeout 30m // emit <projection> RESERVED — per-tick output (streaming delta) // cursor <name> from <expr> RESERVED — state threaded tick -> tick (tail pos) } as run_tasks // fail-predicate sets the process exit code. fail-when "length(run_tasks[?status=='failure']) > `0`" // FUTURE v2 (needs the hand-written task-logs leaf): // each "run_tasks[?status=='failure']" as job { // read task-logs { args { id $job.id } } as log // yield { job $job.name; status $job.status; log $log } // } } } ``` ### `until` semantics Each tick: fire the leaf, decode the JSON response, evaluate the `until` JMESPath against it. Truthy ends the loop and binds the last response (`as run_tasks`). `timeout` first means a 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 `$variables` via the jmespath-community lib's scope-injection entrypoint (`pkg/api` / `pkg/binding`), NOT string substitution. 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) `poll` reserves 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** (`value` now, `stream` reserved) so the planner's types 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 a per-step `logCursor`, returning new lines since the cursor; logs are stored as NDJSON with a line->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 to generate from), slotting into `poll` via the reserved `emit`+`cursor` slots. Deferred for v1; ward's `scripts/watch-ci.sh` keeps tailing in the meantime. ### Execution model / engine placement - Engine work is cli-guard's: `http/specverb` (planner) + `http/guardfile` (parser node). - specverb today renders each call straight to stdout. Complex actions need an internal "fire and capture decoded value" path that feeds the next step; stdout is reserved for the final `as`/`yield`/`fail-when` result. - Deny-by-default is enforced at generation: resolve every `poll`/`call`/`read` target against the expansion table (or the hand-written-leaf registry) at lock/build time. - ward consumes it via the existing `ward-kdl` guardfile path (`cmd/ward-kdl/*.guardfile.kdl`). ### v1 scope (locked) - Keywords: `action`, `input`, `poll`, `until`, `every`, `timeout`, `as`, `fail-when`. - One complex action: `ci-watch` over `ListActionTasks`. Native poll + status table + exit code via `fail-when`. Log tailing stays in `scripts/watch-ci.sh`. - No dependency on a logs leaf or any streaming work. ### Decisions locked - JMESPath lib swapped to **`github.com/jmespath-community/go-jmespath` v1.1.1** for native `$vars`. **Already applied to the cli-guard working tree** (`go.mod`, `go.sum`, `http/respfmt/respfmt.go` — import path only; root `Search` is a drop-in). `make test` + `make lint` green, no `--query` regression. NOT yet committed — a new session should verify and commit this alongside the feature. - `until` + `fail-when` only for v1; `each`/`yield` fan-out deferred. - Logs deferred (no OpenAPI op; hand-written leaf later). - Reserve `emit` + `cursor` + binding-kind + the spec-or-handwritten leaf seam now. ### Open (for the implementing session) 1. Pin the `action`/`poll` parser node + AST in `http/guardfile`. 2. Wire `pkg/binding` input injection so `$repo`/`$run` resolve in `until`. 3. Planner in `http/specverb`: fire-and-capture, the bounded poll loop, `fail-when` exit mapping, the per-call + envelope audit rows, dry-run-as-plan. 4. Lock-time deny-by-default resolution of action targets. 5. Add the `ci-watch` action to ward's forgejo guardfile; retire the poll loop in `scripts/watch-ci.sh` (keep its log-tail wrapper until the logs leaf lands). ### Grounding references - specverb expansion table: `http/specverb/expansion.go` (`{list, tasks} -> ListActionTasks`). - `--query` is JMESPath: `http/respfmt/respfmt.go` (`jmespath.Search`). - `ListActionTasks` task fields used by `until`: `status`, `run_number`, `head_sha`, `name` (job), `id`. - Forgejo log cursor route + no API: gitea#35176; Gitea Actions system (DeepWiki). - Related: ward#88 (native `ward ci watch` verb), ward `scripts/watch-ci.sh`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/cli-guard#140
No description provided.