- Status: Accepted
- Date: 2026-07-16
- Deciders: thulasi.ram (architect)
- Refines: 0038 (makes
interface.inputsa launch contract, not only a reuse contract), 0041 (adds theinputsbinding to the interpolation rail), 0009 (typed IR + CEL), 0010 (read-at-ref discovery) - Relates: 0024/0037 (a manual dispatch of a deploy still hits admission + protection rules), 0022 (self-describing Run records the resolved SHA + params), 0028 (the Run-Pipeline flow), 0014 (params are not the secret rail)
Context
Section titled “Context”A user selecting a pipeline in the UI’s Run-Pipeline flow wants to see the mandatory and optional parameters that pipeline dictates — the thing Woodpecker lacks (its manual runs are an untyped env-var soup, and it is hard to tell which manual request drives which pipeline). Today Scarab cannot show this because a top-level pipeline has no way to declare launch parameters:
Event::Manual { actor }/Event::Api { actor }carry no payload beyond the actor;trigger_run_from_eventtreatsmanual/api/cronas repo-less and returns nothing for them.POST /v1/runstakes a full inline pipeline IR, is repo-less, and is baseline-only — it bypasses Environment, secrets, and admission entirely.- The only declared-parameter concept,
PipelineIr::interface.inputs(0038), is scoped toinvokeand documented as “irrelevant on a top-level triggered pipeline”. It is also untyped (Vec<String>names;with:isBTreeMap<String,String>) and all-required.
So the ask is not “add a form” — it is a domain gap at the IR/launch layer. Two sub-problems: (1) a pipeline must be able to declare typed parameters with mandatory/optional distinction; (2) the manual/API launch path must become repo+ref-aware so a run created from a button carries the same governance a webhook-triggered run does.
Decision
Section titled “Decision”1. One declaration: interface.inputs becomes the launch contract too
Section titled “1. One declaration: interface.inputs becomes the launch contract too”We unify rather than add a parallel params:/dispatch: block. A pipeline’s
interface.inputs is the single declaration of the parameters it requires,
serving both an invoke: caller’s with: and a manual/api launcher.
One declaration, one env rail (SCARAB_PARAM_<NAME>), one CEL binding
(${{ inputs.<name> }}). This reverses 0038’s “irrelevant at top level” note.
The user-facing term is Parameter (see CONTEXT.md §4.2).
The word “inputs” stays overloaded in the IR for continuity, but docs/UI say
parameter to keep it distinct from the per-Step workspace inputs:
(0007) — a different concept sharing the word.
2. Parameters are typed, from a closed vocabulary
Section titled “2. Parameters are typed, from a closed vocabulary”interface.inputs grows from Vec<String> (names) to a list of param specs:
{ name, type, required, default?, options?, validate?, description? }type∈ {string,boolean,number,choice} — a closed vocabulary, chosen so each maps to exactly one bounded UI widget (text / checkbox / numeric / dropdown).choicecarries anoptions:list. Nodict(arbitrary-form tar pit, untyped values, noSCARAB_PARAMencoding — rejected permanently for launch params). Nolistin v1; the first future extension islist<choice>(bounded multi-select), added only when a concrete non-matrix consumer appears.requiredis a static boolean. Mandatory-vs-optional must be knowable from the compiled IR alone with zero evaluation, or the UI cannot render it deterministically. Dynamic/CEL-computed requiredness is explicitly out.required: true⇒ nodefault(launcher must supply).required: false⇒defaultis mandatory. Consequence: every declared parameter always resolves to a value, so${{ inputs.<name> }}is total — there is no missing-parameter runtime failure mode (unlike 0041 outputs).validate?is an optional per-param CEL predicate the supplied value must satisfy, evaluated server-side at launch, pure/total (0031), fail-closed.description?/order are UI-serving metadata.
3. Typed interface, string-authored with:, one shared coerce
Section titled “3. Typed interface, string-authored with:, one shared coerce”with: stays BTreeMap<String,String> (zero IR churn; ${{ … }} is naturally a
string). Coercion is the shared primitive: every supplier produces a raw value —
an already-typed JSON scalar (UI), or a string (CLI --param k=v, a rendered
${{ }}, or with:) — and one pure function turns it into the declared type or
fails closed:
scarab_pipeline::coerce(raw: serde_json::Value, ty: ParamType) -> Result<TypedValue>"3"+number→3; true/"yes"+boolean→true; value ∉ options+choice→error;
then the validate: CEL runs. Three call sites (UI JSON, CLI strings, with:
strings), one path, all fail-closed.
4. Manual/API dispatch becomes a repo+ref-aware trigger (World B)
Section titled “4. Manual/API dispatch becomes a repo+ref-aware trigger (World B)”The Run-Pipeline flow rides the read-at-ref/compile/admission machinery the
webhook path already has (list_dir_at_ref → read_file_at_ref → transitive lib
pre-fetch → pure compile), not the inline-IR POST /v1/runs shortcut.
- Opt-in =
on: manual(theworkflow_dispatchanalogue). A pipeline appears in the human catalog iff itson:includesmanual;on: apiis the programmatic sibling supplying the same declared parameters. This makes the pipeline→trigger mapping explicit and discoverable — the exact ambiguity the Woodpecker/inline model (World A) cannot resolve. - Ref-scoped + resolve-to-SHA. The flow is repo → ref → catalog → pipeline →
params → dispatch, one ref threading through. Describe resolves
ref→ a concrete commit SHA and returns it; dispatch runs against that SHA, so the form and the run see byte-identical config (no branch-moved skew) and the Run is reproducible + self-describing (0022). - API surface (UI and CLI share it — invariant #5, one validator):
GET /v1/repos/:id/pipelines?ref=→ lightweight catalog (name,manual?, resolved SHA). Two-call design: no full compile just to list.GET /v1/repos/:id/pipelines/:name/interface?ref=→ the compiled param specs for the selected pipeline (compile-for-interface only on selection).- Dispatch
{ repo, ref/sha, pipeline, params }→ server re-reads at SHA, compiles,coerce+validatefail-closed, binds, creates the run.
- Inline
POST /v1/runsremains the ad-hoc/dogfood escape hatch (baseline-only, no params UI, no Environment).
5. Parameters are a frozen run-level constant on the 0041 rail
Section titled “5. Parameters are a frozen run-level constant on the 0041 rail”The resolved name → typed value map is written onto the Run at creation,
immutable for its life (a params JSON blob on the run record — a run-level
constant, not step_results).
- Binding:
inputsjoins the same CEL context 0041 assembles foroutputs.*, so${{ inputs.region }}inimage/command/env/when:flows through the existinginterpolate_spec; typed values make guards type-correct. Because params are known at creation (not per-step),inputsis in scope run-wide from creation — including run-level CEL likeconcurrency.group: ${{ inputs.env }}— whereasoutputsjoins per-step at launch. Same rail, wider scope. Unreferenced params still reach steps asSCARAB_PARAM_<NAME>env. - Restart determinism is free: params are resolved once and frozen, so a restart (new Attempt) re-reads the identical map → byte-identical interpolation. Strictly simpler than 0041 outputs (which re-derive).
6. Fail-closed and safety red lines
Section titled “6. Fail-closed and safety red lines”- Parameters are never secret. A param is persisted in cleartext, visible in
run detail / audit / time-travel, injected as plain env. Secrets stay on the
secrets:/SecretProvider rail (0014/0037). Notype: secret, no masked param in v1. - Manual dispatch is a trigger, never authority. A dispatched deploy
(
environment:pipeline) hits admission → the Environment’s protection rules (approvers, wait timer, allowed-refs, concurrency) identically to a webhook deploy: params supplied ≠ approval granted; the dispatch ref must satisfy allowed-refs, fail-closed. Every gate guarding an automatic deploy guards a manual one. - Client validation is static-only. The UI mirrors
required/type/optionsfor instant feedback; thevalidate:CEL runs server-side at dispatch only (never a JS CEL evaluator — that is an impurity/backchannel smell). CEL failure returns as a structured per-param error the form renders. - Re-run prefill: a re-run reads the prior Run’s frozen params and pre-fills the form, then resolves a fresh SHA and re-validates against the current interface — a since-changed param surfaces as a form diff, fail-closed.
Consequences
Section titled “Consequences”- The UI can finally show mandatory/optional params — the headline ask — because the compiled IR now carries a typed, statically-classifiable parameter declaration served by a ref-scoped describe endpoint.
- Manual/API launch is unified with the trigger path, so deploys dispatched
from a button inherit Environment governance for free;
manualgraduates from an enum variant to a first-class trigger. - One parameter model spans invoke-reuse and launch — fewer concepts, one
coerce, one env rail, one CEL binding. - New surface: typed
interface.inputs(expand-only, version-stamped IR change — 0022); the purecoerce+validatepath;inputsin the 0041 CEL context (run-wide); repo-awaremanual/apidispatch reusing the webhook machinery; catalog + interface + dispatch endpoints; aparamsblob on the Run; CLIscarab run … --param k=v. with:unchanged for authors — typing is additive; existing libraries keep their stringwith:and gain coercion.
Deferred / future (doors left open)
Section titled “Deferred / future (doors left open)”list<choice>multi-select — the first type-vocabulary extension, added when a concrete non-matrix consumer appears (matrix-from-param hits the deferred dynamic-graph-shape wall of 0023/0038).- Privileged dispatch that carries authority — a future “dispatch that may bypass/short-circuit a gate” is intentionally not precluded by this design; v1 dispatch is trigger-only, and gate-bypass authority is a separate ADR to brainstorm when demand appears.
- Dynamic requiredness / conditional params (a param mandatory only when another holds) — deferred; would break static describe-time classification.
- Secret-typed / masked params — deferred; use the secrets rail.
Alternatives considered
Section titled “Alternatives considered”- A separate top-level
params:/dispatch:block (GHA keepsworkflow_dispatch.inputsdistinct from reusable-workflow inputs) — rejected: a parallel declaration, a second validator, a second env convention, for no v1 payoff. Unification is more consistent with the wedge. - World A — inline-IR
POST /v1/runs+ client-side interface parsing — the Woodpecker model. Rejected: no catalog (server doesn’t know what exists at a ref), interface-parsing duplicated in JS (dances around invariant #5), and — fatally — manual runs stay repo-less, so a deploy dispatched manually gets no Environment/secrets/admission. Manual dispatch’s headline use case is exactly a deploy. - String-only parameters — rejected: reduces the UI to naked text boxes (the Woodpecker soup we are escaping) and reintroduces 0041’s lexicographic footgun in guards.
dict/ freelistparams — rejected (see §2): unbounded widget, untyped values, no env encoding.- Branch-ref dispatch without SHA resolution — rejected in favour of resolve-to-SHA: cheap, kills the form/run skew class, and makes the Run reproducible.
- Retyping
with:to JSON — rejected: fights YAML (${{ }}is a string) and churns the IR for no gain oncecoerceexists.