Contracts and project skeleton
- lexicons/ci: run.rtw.ci.workflow, .step.result, .gate, .transition, .event - lexicons/tangled: vendored sh.tangled.git.refUpdate, .pipeline, .pipeline.status, .ci.pipeline, .ci.trigger, .ci.triggerPipeline, .ci.subscribePipelineLogs (tangled core e0ef7981d) - internal/lex: hand-written Go types, with a test checking every struct against its lexicon's properties and required fields - cmd/ci: subcommand skeleton; ci-result symlink alias - flake.nix: packages.ci (static, CGO off), overlay, checks - devenv: Go toolchain, golangci-lint, just, jq Bug 5434405.
25 files changed,  +1990, -5
M .gitignore
+1, -1
1@@ -2,7 +2,7 @@
2 .devenv*
3 devenv.local.nix
4 devenv.local.yaml
5-result*
6+/result*
7 
8 # direnv
9 .direnv/
M DESIGN.md
+6, -2
 1@@ -101,8 +101,11 @@ split across hosts later.
 2     envelope is upstream's `eventstream.Event`: `{rkey, nsid, event, created}`,
 3     where `created` is a monotonic local clock value and doubles as the cursor.
 4   - `GET /logs/{knot}/{rkey}/{workflow}` — replay the log, then tail (runner).
 5-    Lines are upstream's `sh.tangled.ci.subscribePipelineLogs` `#control` /
 6-    `#data` objects.
 7+    Messages are upstream's `spindle/models.LogLine` JSON (`kind: data |
 8+    control`, `content`, `time`, `step_id`, `stream`, `step_status`,
 9+    `step_kind`, `step_command`), which the upstream appview reads. (The
10+    newer `sh.tangled.ci.subscribePipelineLogs` XRPC sends the same content as
11+    CBOR `#control`/`#data` frames; not implemented.)
12 - Tangled lexicons used as-is (checked against tangled core `e0ef7981d`,
13   2026-10-11):
14   - `sh.tangled.git.refUpdate` — emitted by the bridge, as a knot does
15@@ -123,6 +126,7 @@ split across hosts later.
16   - `run.rtw.ci.gate` — `{pipeline, workflow, action, options[], state:
17     waiting|approved|rejected|expired|superseded, actor, prompt}`
18   - `run.rtw.ci.transition` — engine routing decisions, for the timeline
19+  - `run.rtw.ci.event` — one line of a run's `events.ndjson`
20   Tangled clients ignore unknown NSIDs, so nothing breaks.
21 - Lexicons are the single schema source. Go types are hand-written for the
22   handful of records used (Tangled's `api/tangled` package pulls in the whole
A README.md
+42, -0
 1@@ -0,0 +1,42 @@
 2+# ci
 3+
 4+A Nix-native CI/CD system that speaks Tangled's protocol: a knot-compatible
 5+bridge for a soft-serve forge, a spindle-compatible runner, a minimal appview,
 6+and `ci`, the engine that runs the same workflow on your machine and on the
 7+runner. The design, and the reasons behind it, are in [DESIGN.md](DESIGN.md);
 8+work is tracked with `bug ls` (tracking issue `eae4f87`).
 9+
10+## Layout
11+
12+| Path | What |
13+|---|---|
14+| `cmd/ci` | The `ci` binary (engine commands and the three hosts); `ci-result` is a symlink to it |
15+| `internal/lex` | Go types for the lexicons, checked against the JSON by `go test` |
16+| `lexicons/ci` | Our lexicons, `run.rtw.ci.*`: workflow manifest, step result, gate, transition, run event |
17+| `lexicons/tangled` | Vendored Tangled lexicons (see its README) |
18+
19+## Development
20+
21+```sh
22+devenv shell          # Go toolchain, golangci-lint, just
23+go test ./...
24+nix build .#ci        # ./result/bin/ci
25+```
26+
27+## Using the engine in another repo
28+
29+Add the flake as a devenv input and put the package on `PATH`:
30+
31+```yaml
32+# devenv.yaml
33+inputs:
34+  ci:
35+    url: git+ssh://[email protected]/ci
36+```
37+
38+```nix
39+# devenv.nix
40+{ pkgs, inputs, ... }: {
41+  packages = [ inputs.ci.packages.${pkgs.stdenv.system}.ci ];
42+}
43+```
A cmd/ci/main.go
+57, -0
 1@@ -0,0 +1,57 @@
 2+// Command ci is the CI/CD engine (run, resume, action, validate, view, ...)
 3+// and the three hosts around it (bridge, runner, appview). See DESIGN.md.
 4+//
 5+// Invoked as ci-result (a symlink), it is the result helper (`ci result`).
 6+package main
 7+
 8+import (
 9+	"fmt"
10+	"os"
11+	"path/filepath"
12+	"sort"
13+)
14+
15+// version is set by the Nix build (-ldflags -X main.version=...).
16+var version = "dev"
17+
18+type command struct {
19+	summary string
20+	run     func(args []string) int
21+}
22+
23+var commands = map[string]command{
24+	"version": {"print the engine version", func([]string) int { fmt.Println(version); return 0 }},
25+}
26+
27+func usage() {
28+	fmt.Fprintln(os.Stderr, "usage: ci <command> [args]\n\ncommands:")
29+	names := make([]string, 0, len(commands))
30+	for n := range commands {
31+		names = append(names, n)
32+	}
33+	sort.Strings(names)
34+	for _, n := range names {
35+		fmt.Fprintf(os.Stderr, "  %-10s %s\n", n, commands[n].summary)
36+	}
37+}
38+
39+func main() {
40+	args := os.Args[1:]
41+	if filepath.Base(os.Args[0]) == "ci-result" {
42+		args = append([]string{"result"}, args...)
43+	}
44+	if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
45+		usage()
46+		if len(args) == 0 {
47+			os.Exit(2)
48+		}
49+		return
50+	}
51+	c, ok := commands[args[0]]
52+	if !ok {
53+		fmt.Fprintf(os.Stderr, "ci: unknown command %q\n", args[0])
54+		usage()
55+		os.Exit(2)
56+	}
57+	os.Exit(c.run(args[1:]))
58+}
A cmd/ci/stubs.go
+31, -0
 1@@ -0,0 +1,31 @@
 2+package main
 3+
 4+import (
 5+	"fmt"
 6+	"os"
 7+)
 8+
 9+// Commands not implemented yet; each issue in the tracker replaces its stub.
10+func init() {
11+	for name, summary := range map[string]string{
12+		"run":      "run a workflow (engine)",
13+		"resume":   "continue a paused run (engine)",
14+		"action":   "run one action (engine)",
15+		"validate": "check a workflow manifest (engine)",
16+		"view":     "render a run's events (engine)",
17+		"result":   "write an action result to $CI_RESULT (alias: ci-result)",
18+		"trigger":  "ask the runner for a manual pipeline",
19+		"wait":     "wait for a pipeline and print failing logs",
20+		"bridge":   "forge bridge (knot-compatible event stream)",
21+		"runner":   "runner daemon (spindle-compatible)",
22+		"appview":  "web UI",
23+	} {
24+		if _, ok := commands[name]; ok {
25+			continue
26+		}
27+		commands[name] = command{summary, func([]string) int {
28+			fmt.Fprintf(os.Stderr, "ci %s: not implemented yet\n", name)
29+			return 2
30+		}}
31+	}
32+}
M devenv.nix
+6, -2
 1@@ -1,11 +1,15 @@
 2-{ ... }:
 3+{ pkgs, ... }:
 4 
 5 # Scaffold for a new project. devkit (imported in devenv.yaml) already provides
 6 # bug, secrets, local-code-review, jujutsu, and the customized interactive bash
 7 # environment, so don't repeat those here.
 8 {
 9+  languages.go.enable = true;
10+
11   packages = [
12-    # project-specific packages
13+    pkgs.golangci-lint
14+    pkgs.just
15+    pkgs.jq
16   ];
17 
18   # enterShell = ''
A flake.lock
+27, -0
 1@@ -0,0 +1,27 @@
 2+{
 3+  "nodes": {
 4+    "nixpkgs": {
 5+      "locked": {
 6+        "lastModified": 1791956154,
 7+        "narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=",
 8+        "owner": "NixOS",
 9+        "repo": "nixpkgs",
10+        "rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044",
11+        "type": "github"
12+      },
13+      "original": {
14+        "owner": "NixOS",
15+        "ref": "nixos-unstable",
16+        "repo": "nixpkgs",
17+        "type": "github"
18+      }
19+    },
20+    "root": {
21+      "inputs": {
22+        "nixpkgs": "nixpkgs"
23+      }
24+    }
25+  },
26+  "root": "root",
27+  "version": 7
28+}
A flake.nix
+61, -0
 1@@ -0,0 +1,61 @@
 2+# ci: a Nix-native, Tangled-compatible CI/CD engine and its hosts (bridge,
 3+# runner, appview). See DESIGN.md.
 4+#
 5+# Other repos pin the engine through their devenv (see README.md), so local
 6+# runs and the runner use the same engine for a given commit.
 7+{
 8+  description = "Nix-native Tangled-compatible CI/CD";
 9+
10+  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
11+
12+  outputs =
13+    { self, nixpkgs }:
14+    let
15+      inherit (nixpkgs) lib;
16+      systems = [
17+        "x86_64-linux"
18+        "aarch64-linux"
19+      ];
20+      forAllSystems = f: lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
21+      version = self.shortRev or self.dirtyShortRev or "dev";
22+    in
23+    {
24+      packages = forAllSystems (pkgs: rec {
25+        ci = pkgs.buildGoModule {
26+          pname = "ci";
27+          inherit version;
28+          src = lib.fileset.toSource {
29+            root = ./.;
30+            fileset = lib.fileset.unions [
31+              ./go.mod
32+              (lib.fileset.maybeMissing ./go.sum)
33+              ./cmd
34+              ./internal
35+              ./lexicons
36+            ];
37+          };
38+          vendorHash = null;
39+          subPackages = [ "cmd/ci" ];
40+          env.CGO_ENABLED = "0";
41+          ldflags = [ "-X main.version=${version}" ];
42+          postInstall = ''
43+            ln -s ci $out/bin/ci-result
44+          '';
45+          meta = {
46+            description = "Nix-native Tangled-compatible CI/CD engine";
47+            mainProgram = "ci";
48+            license = lib.licenses.mit;
49+          };
50+        };
51+        default = ci;
52+      });
53+
54+      overlays.default = final: _prev: {
55+        ci = self.packages.${final.stdenv.hostPlatform.system}.ci;
56+      };
57+
58+      checks = forAllSystems (pkgs: {
59+        ci = self.packages.${pkgs.stdenv.hostPlatform.system}.ci.overrideAttrs { doCheck = true; };
60+      });
61+    };
62+}
A go.mod
+3, -0
1@@ -0,0 +1,3 @@
2+module git.kilimanjaro.io/ci
3+
4+go 1.26
A internal/lex/ci.go
+276, -0
  1@@ -0,0 +1,276 @@
  2+package lex
  3+
  4+import (
  5+	"bytes"
  6+	"encoding/json"
  7+	"fmt"
  8+)
  9+
 10+const (
 11+	WorkflowNSID   = "run.rtw.ci.workflow"
 12+	ResultNSID     = "run.rtw.ci.step.result"
 13+	GateNSID       = "run.rtw.ci.gate"
 14+	TransitionNSID = "run.rtw.ci.transition"
 15+	EventNSID      = "run.rtw.ci.event"
 16+
 17+	// ResultVersion is the result contract version this engine speaks; it
 18+	// also versions the CI_* environment contract.
 19+	ResultVersion = 1
 20+
 21+	EngineDevenv = "devenv"
 22+)
 23+
 24+// Workflow is run.rtw.ci.workflow, the manifest. YAML manifests are decoded
 25+// by package manifest, which normalises shorthands into this shape.
 26+type Workflow struct {
 27+	When        []Trigger         `json:"when"`
 28+	Engine      string            `json:"engine"`
 29+	Environment map[string]string `json:"environment,omitempty"`
 30+	Repos       []Repo            `json:"repos,omitempty"`
 31+	Steps       []string          `json:"steps"`
 32+	Actions     map[string]Action `json:"actions"`
 33+}
 34+
 35+type Trigger struct {
 36+	Event  []string `json:"event"`
 37+	Branch []string `json:"branch,omitempty"`
 38+	Tag    []string `json:"tag,omitempty"`
 39+}
 40+
 41+type Repo struct {
 42+	Repo string `json:"repo"`
 43+	Path string `json:"path"`
 44+	Rev  string `json:"rev,omitempty"`
 45+	Env  string `json:"env,omitempty"`
 46+}
 47+
 48+type Action struct {
 49+	Command     string       `json:"command"`
 50+	Dir         string       `json:"dir,omitempty"`
 51+	Label       string       `json:"label,omitempty"`
 52+	Gate        *Gate        `json:"gate,omitempty"`
 53+	Concurrency *Concurrency `json:"concurrency,omitempty"`
 54+	Manual      bool         `json:"manual,omitempty"`
 55+	Style       string       `json:"style,omitempty"`
 56+	Audited     bool         `json:"audited,omitempty"`
 57+}
 58+
 59+// Gate is run.rtw.ci.workflow#gate.
 60+type Gate struct {
 61+	Prompt  string `json:"prompt,omitempty"`
 62+	Timeout string `json:"timeout,omitempty"`
 63+}
 64+
 65+type Concurrency struct {
 66+	Group     string `json:"group"`
 67+	Supersede bool   `json:"supersede,omitempty"`
 68+}
 69+
 70+// Result statuses.
 71+const (
 72+	Success = "success"
 73+	Warning = "warning"
 74+	Failure = "failure"
 75+)
 76+
 77+// Option.Then values.
 78+const (
 79+	ThenContinue = "continue"
 80+	ThenStop     = "stop"
 81+)
 82+
 83+// Result is run.rtw.ci.step.result, written by an action to $CI_RESULT.
 84+type Result struct {
 85+	Version     int             `json:"version"`
 86+	Status      string          `json:"status"`
 87+	Summary     string          `json:"summary,omitempty"`
 88+	Next        *Next           `json:"next,omitempty"`
 89+	Gated       bool            `json:"gated,omitempty"`
 90+	Attachments []Attachment    `json:"attachments,omitempty"`
 91+	Outputs     json.RawMessage `json:"outputs,omitempty"`
 92+}
 93+
 94+// Next is a result's next field. On the wire it is "stop", an action name, a
 95+// single option object or an array of options; it always marshals to "stop"
 96+// or an array.
 97+type Next struct {
 98+	Stop    bool
 99+	Options []Option
100+}
101+
102+func (n Next) MarshalJSON() ([]byte, error) {
103+	if n.Stop {
104+		return []byte(`"stop"`), nil
105+	}
106+	if n.Options == nil {
107+		return []byte(`[]`), nil
108+	}
109+	return json.Marshal(n.Options)
110+}
111+
112+func (n *Next) UnmarshalJSON(b []byte) error {
113+	b = bytes.TrimSpace(b)
114+	*n = Next{}
115+	switch {
116+	case len(b) == 0:
117+		return fmt.Errorf("empty next")
118+	case b[0] == '"':
119+		var s string
120+		if err := json.Unmarshal(b, &s); err != nil {
121+			return err
122+		}
123+		if s == "" {
124+			return fmt.Errorf("next: empty action name")
125+		}
126+		if s == ThenStop {
127+			n.Stop = true
128+			return nil
129+		}
130+		n.Options = []Option{{Action: s}}
131+		return nil
132+	case b[0] == '{':
133+		var o Option
134+		if err := strictUnmarshal(b, &o); err != nil {
135+			return fmt.Errorf("next: %w", err)
136+		}
137+		n.Options = []Option{o}
138+		return nil
139+	case b[0] == '[':
140+		var raw []json.RawMessage
141+		if err := json.Unmarshal(b, &raw); err != nil {
142+			return fmt.Errorf("next: %w", err)
143+		}
144+		if len(raw) == 0 {
145+			return fmt.Errorf("next: empty list (omit next for default routing, or use \"stop\")")
146+		}
147+		for i, r := range raw {
148+			var o Option
149+			if err := strictUnmarshal(r, &o); err != nil {
150+				return fmt.Errorf("next[%d]: %w", i, err)
151+			}
152+			n.Options = append(n.Options, o)
153+		}
154+		return nil
155+	}
156+	return fmt.Errorf("next must be \"stop\", an action name, an option or a list of options")
157+}
158+
159+// Option is run.rtw.ci.step.result#option.
160+type Option struct {
161+	Action string          `json:"action"`
162+	Then   string          `json:"then,omitempty"`
163+	Label  string          `json:"label,omitempty"`
164+	Prompt string          `json:"prompt,omitempty"`
165+	Inputs json.RawMessage `json:"inputs,omitempty"`
166+}
167+
168+type Attachment struct {
169+	Label string `json:"label,omitempty"`
170+	Path  string `json:"path"`
171+	Type  string `json:"type,omitempty"`
172+}
173+
174+// Gate states.
175+const (
176+	GateWaiting    = "waiting"
177+	GateApproved   = "approved"
178+	GateDismissed  = "dismissed"
179+	GateExpired    = "expired"
180+	GateSuperseded = "superseded"
181+)
182+
183+// GateRecord is run.rtw.ci.gate.
184+type GateRecord struct {
185+	Pipeline  string   `json:"pipeline,omitempty"`
186+	Run       string   `json:"run"`
187+	Workflow  string   `json:"workflow"`
188+	Seq       int      `json:"seq"`
189+	From      string   `json:"from,omitempty"`
190+	Prompt    string   `json:"prompt,omitempty"`
191+	Summary   string   `json:"summary,omitempty"`
192+	Options   []Option `json:"options"`
193+	State     string   `json:"state"`
194+	Choice    string   `json:"choice,omitempty"`
195+	Actor     string   `json:"actor,omitempty"`
196+	ExpiresAt string   `json:"expiresAt,omitempty"`
197+	CreatedAt string   `json:"createdAt"`
198+	DecidedAt string   `json:"decidedAt,omitempty"`
199+}
200+
201+// Transition reasons.
202+const (
203+	ReasonStart   = "start"
204+	ReasonDefault = "default"
205+	ReasonNext    = "next"
206+	ReasonChoice  = "choice"
207+	ReasonManual  = "manual"
208+	ReasonStop    = "stop"
209+	ReasonDismiss = "dismiss"
210+	ReasonFailure = "failure"
211+	ReasonError   = "error"
212+	ReasonLimit   = "limit"
213+)
214+
215+// Transition is run.rtw.ci.transition.
216+type Transition struct {
217+	Pipeline  string `json:"pipeline,omitempty"`
218+	Run       string `json:"run"`
219+	Workflow  string `json:"workflow"`
220+	Seq       int    `json:"seq"`
221+	From      string `json:"from,omitempty"`
222+	To        string `json:"to,omitempty"`
223+	Then      string `json:"then,omitempty"`
224+	Reason    string `json:"reason"`
225+	Detail    string `json:"detail,omitempty"`
226+	CreatedAt string `json:"createdAt"`
227+}
228+
229+// Event kinds.
230+const (
231+	KindRun        = "run"
232+	KindCheckout   = "checkout"
233+	KindStart      = "start"
234+	KindLog        = "log"
235+	KindEnd        = "end"
236+	KindResult     = "result"
237+	KindTransition = "transition"
238+	KindGate       = "gate"
239+)
240+
241+// RunEvent is run.rtw.ci.event, one line of events.ndjson.
242+type RunEvent struct {
243+	Seq        int         `json:"seq"`
244+	Time       string      `json:"time"`
245+	Run        string      `json:"run"`
246+	Kind       string      `json:"kind"`
247+	Action     string      `json:"action,omitempty"`
248+	Attempt    int         `json:"attempt,omitempty"`
249+	Stream     string      `json:"stream,omitempty"`
250+	Content    string      `json:"content,omitempty"`
251+	Command    string      `json:"command,omitempty"`
252+	ExitCode   *int        `json:"exitCode,omitempty"`
253+	Status     string      `json:"status,omitempty"`
254+	Repo       string      `json:"repo,omitempty"`
255+	Rev        string      `json:"rev,omitempty"`
256+	Path       string      `json:"path,omitempty"`
257+	Result     *Result     `json:"result,omitempty"`
258+	Transition *Transition `json:"transition,omitempty"`
259+	Gate       *GateRecord `json:"gate,omitempty"`
260+}
261+
262+// strictUnmarshal decodes JSON rejecting unknown fields, so a typo in a
263+// result ("summray") is a contract error rather than silently ignored.
264+func strictUnmarshal(b []byte, v any) error {
265+	d := json.NewDecoder(bytes.NewReader(b))
266+	d.DisallowUnknownFields()
267+	if err := d.Decode(v); err != nil {
268+		return err
269+	}
270+	if d.More() {
271+		return fmt.Errorf("trailing data after JSON value")
272+	}
273+	return nil
274+}
275+
276+// StrictUnmarshal is strictUnmarshal for other packages.
277+func StrictUnmarshal(b []byte, v any) error { return strictUnmarshal(b, v) }
A internal/lex/lex_test.go
+219, -0
  1@@ -0,0 +1,219 @@
  2+package lex
  3+
  4+import (
  5+	"encoding/json"
  6+	"os"
  7+	"path/filepath"
  8+	"reflect"
  9+	"slices"
 10+	"sort"
 11+	"strings"
 12+	"testing"
 13+)
 14+
 15+// lexObject is the part of a lexicon object definition the test compares.
 16+type lexObject struct {
 17+	Required   []string                   `json:"required"`
 18+	Properties map[string]json.RawMessage `json:"properties"`
 19+}
 20+
 21+// loadDef returns the object definition at ref ("<nsid>#<def>", where def may
 22+// be "main", "main.input" or "main.output" for procedures).
 23+func loadDef(t *testing.T, ref string) lexObject {
 24+	t.Helper()
 25+	nsid, def, _ := strings.Cut(ref, "#")
 26+	var path string
 27+	for _, dir := range []string{"tangled", "ci"} {
 28+		p := filepath.Join("..", "..", "lexicons", dir, nsid+".json")
 29+		if _, err := os.Stat(p); err == nil {
 30+			path = p
 31+		}
 32+	}
 33+	if path == "" {
 34+		t.Fatalf("%s: no lexicon file", ref)
 35+	}
 36+	b, err := os.ReadFile(path)
 37+	if err != nil {
 38+		t.Fatal(err)
 39+	}
 40+	var doc struct {
 41+		Defs map[string]struct {
 42+			lexObject
 43+			Record *lexObject `json:"record"`
 44+			Input  *struct {
 45+				Schema lexObject `json:"schema"`
 46+			} `json:"input"`
 47+			Output *struct {
 48+				Schema lexObject `json:"schema"`
 49+			} `json:"output"`
 50+		} `json:"defs"`
 51+	}
 52+	if err := json.Unmarshal(b, &doc); err != nil {
 53+		t.Fatalf("%s: %v", path, err)
 54+	}
 55+	name, part, _ := strings.Cut(def, ".")
 56+	d, ok := doc.Defs[name]
 57+	if !ok {
 58+		t.Fatalf("%s: no def %q", path, name)
 59+	}
 60+	switch {
 61+	case part == "input":
 62+		return d.Input.Schema
 63+	case part == "output":
 64+		return d.Output.Schema
 65+	case d.Record != nil:
 66+		return *d.Record
 67+	}
 68+	return d.lexObject
 69+}
 70+
 71+// jsonFields returns a struct's JSON field names (without $type) and which of
 72+// them are omitempty.
 73+func jsonFields(typ reflect.Type) (names []string, omit map[string]bool) {
 74+	omit = map[string]bool{}
 75+	for f := range typ.Fields() {
 76+		tag := f.Tag.Get("json")
 77+		name, opts, _ := strings.Cut(tag, ",")
 78+		if name == "" || name == "-" || name == "$type" {
 79+			continue
 80+		}
 81+		names = append(names, name)
 82+		omit[name] = strings.Contains(opts, "omitempty")
 83+	}
 84+	sort.Strings(names)
 85+	return names, omit
 86+}
 87+
 88+func TestTypesMatchLexicons(t *testing.T) {
 89+	cases := []struct {
 90+		ref string
 91+		v   any
 92+	}{
 93+		{"sh.tangled.git.refUpdate#main", GitRefUpdate{}},
 94+		{"sh.tangled.git.refUpdate#meta", GitRefUpdateMeta{}},
 95+		{"sh.tangled.git.refUpdate#langBreakdown", GitRefUpdateLangBreakdown{}},
 96+		{"sh.tangled.git.refUpdate#individualLanguageSize", GitRefUpdateLanguageSize{}},
 97+		{"sh.tangled.git.refUpdate#commitCountBreakdown", GitRefUpdateCommitCount{}},
 98+		{"sh.tangled.git.refUpdate#individualEmailCommitCount", GitRefUpdateEmailCommitCount{}},
 99+		{"sh.tangled.pipeline#main", Pipeline{}},
100+		{"sh.tangled.pipeline#triggerMetadata", PipelineTriggerMetadata{}},
101+		{"sh.tangled.pipeline#triggerRepo", PipelineTriggerRepo{}},
102+		{"sh.tangled.pipeline#pushTriggerData", PipelinePushTriggerData{}},
103+		{"sh.tangled.pipeline#pullRequestTriggerData", PipelinePullRequestTrigger{}},
104+		{"sh.tangled.pipeline#manualTriggerData", PipelineManualTriggerData{}},
105+		{"sh.tangled.pipeline#scheduleTriggerData", PipelineScheduleTrigger{}},
106+		{"sh.tangled.pipeline#workflow", PipelineWorkflow{}},
107+		{"sh.tangled.pipeline#cloneOpts", PipelineCloneOpts{}},
108+		{"sh.tangled.pipeline#pair", PipelinePair{}},
109+		{"sh.tangled.pipeline.status#main", PipelineStatus{}},
110+		{"sh.tangled.ci.triggerPipeline#main.input", TriggerPipelineInput{}},
111+		{"sh.tangled.ci.triggerPipeline#main.output", TriggerPipelineOutput{}},
112+		{"sh.tangled.ci.trigger#manual", ManualTrigger{}},
113+		{"run.rtw.ci.workflow#main", Workflow{}},
114+		{"run.rtw.ci.workflow#trigger", Trigger{}},
115+		{"run.rtw.ci.workflow#repo", Repo{}},
116+		{"run.rtw.ci.workflow#action", Action{}},
117+		{"run.rtw.ci.workflow#gate", Gate{}},
118+		{"run.rtw.ci.workflow#concurrency", Concurrency{}},
119+		{"run.rtw.ci.step.result#main", Result{}},
120+		{"run.rtw.ci.step.result#option", Option{}},
121+		{"run.rtw.ci.step.result#attachment", Attachment{}},
122+		{"run.rtw.ci.gate#main", GateRecord{}},
123+		{"run.rtw.ci.transition#main", Transition{}},
124+		{"run.rtw.ci.event#main", RunEvent{}},
125+	}
126+	for _, c := range cases {
127+		t.Run(c.ref, func(t *testing.T) {
128+			def := loadDef(t, c.ref)
129+			want := make([]string, 0, len(def.Properties))
130+			for k := range def.Properties {
131+				want = append(want, k)
132+			}
133+			sort.Strings(want)
134+			got, omit := jsonFields(reflect.TypeOf(c.v))
135+			if !slices.Equal(got, want) {
136+				t.Errorf("fields differ\n go:      %v\n lexicon: %v", got, want)
137+			}
138+			for _, r := range def.Required {
139+				if omit[r] {
140+					t.Errorf("required property %q is omitempty in Go", r)
141+				}
142+			}
143+		})
144+	}
145+}
146+
147+func TestNextForms(t *testing.T) {
148+	cases := []struct {
149+		in, out string
150+		stop    bool
151+		n       int
152+	}{
153+		{`"stop"`, `"stop"`, true, 0},
154+		{`"rollback"`, `[{"action":"rollback"}]`, false, 1},
155+		{`{"action":"rollback","then":"stop"}`, `[{"action":"rollback","then":"stop"}]`, false, 1},
156+		{`[{"action":"a"},{"action":"b","label":"B"}]`, `[{"action":"a"},{"action":"b","label":"B"}]`, false, 2},
157+	}
158+	for _, c := range cases {
159+		var n Next
160+		if err := json.Unmarshal([]byte(c.in), &n); err != nil {
161+			t.Fatalf("%s: %v", c.in, err)
162+		}
163+		if n.Stop != c.stop || len(n.Options) != c.n {
164+			t.Errorf("%s: got %+v", c.in, n)
165+		}
166+		b, _ := json.Marshal(n)
167+		if string(b) != c.out {
168+			t.Errorf("%s: marshals to %s, want %s", c.in, b, c.out)
169+		}
170+	}
171+	for _, bad := range []string{`""`, `[]`, `5`, `{"action":"a","oops":1}`, `[{"actoin":"a"}]`} {
172+		var n Next
173+		if err := json.Unmarshal([]byte(bad), &n); err == nil {
174+			t.Errorf("%s: accepted", bad)
175+		}
176+	}
177+}
178+
179+// The example result from DESIGN.md §7 round-trips.
180+func TestDesignResultExample(t *testing.T) {
181+	in := `{
182+  "version": 1,
183+  "status": "failure",
184+  "summary": "mx99.eu.sovrn.at /healthz 503 for 4m (stalwart: connection refused)",
185+  "next": [
186+    { "action": "rollback", "prompt": "mx99 degraded after deploy. Roll back sovrn?", "then": "stop" },
187+    { "action": "health-check", "label": "Check again" }
188+  ],
189+  "gated": true,
190+  "attachments": [ { "label": "health probes", "path": "health.ndjson", "type": "ndjson" } ],
191+  "outputs": { "hosts": ["mx99.eu.sovrn.at"] }
192+}`
193+	var r Result
194+	if err := StrictUnmarshal([]byte(in), &r); err != nil {
195+		t.Fatal(err)
196+	}
197+	if r.Status != Failure || !r.Gated || len(r.Next.Options) != 2 || r.Next.Options[0].Then != ThenStop {
198+		t.Fatalf("decoded %+v", r)
199+	}
200+	b, err := json.Marshal(r)
201+	if err != nil {
202+		t.Fatal(err)
203+	}
204+	var again Result
205+	if err := StrictUnmarshal(b, &again); err != nil {
206+		t.Fatalf("re-decode %s: %v", b, err)
207+	}
208+	if !reflect.DeepEqual(normalise(t, r), normalise(t, again)) {
209+		t.Errorf("round trip changed the result:\n%s", b)
210+	}
211+}
212+
213+func normalise(t *testing.T, r Result) map[string]any {
214+	b, _ := json.Marshal(r)
215+	var m map[string]any
216+	if err := json.Unmarshal(b, &m); err != nil {
217+		t.Fatal(err)
218+	}
219+	return m
220+}
A internal/lex/tangled.go
+192, -0
  1@@ -0,0 +1,192 @@
  2+// Package lex holds Go types for the lexicons this project reads and emits:
  3+// the vendored Tangled ones (lexicons/tangled) and our own run.rtw.ci.*
  4+// (lexicons/ci). Types are hand-written; lex_test.go checks each against its
  5+// lexicon so the two cannot drift.
  6+package lex
  7+
  8+import "encoding/json"
  9+
 10+const (
 11+	GitRefUpdateNSID   = "sh.tangled.git.refUpdate"
 12+	PipelineNSID       = "sh.tangled.pipeline"
 13+	PipelineStatusNSID = "sh.tangled.pipeline.status"
 14+)
 15+
 16+// Event is the envelope of a Tangled /events stream (upstream
 17+// eventstream.Event). Created is a monotonic local clock value and is the
 18+// cursor: a subscriber resumes with ?cursor=<last Created>.
 19+type Event struct {
 20+	Rkey    string          `json:"rkey"`
 21+	Nsid    string          `json:"nsid"`
 22+	Event   json.RawMessage `json:"event"`
 23+	Created int64           `json:"created"`
 24+}
 25+
 26+// GitRefUpdate is sh.tangled.git.refUpdate, emitted by a knot (here: the
 27+// bridge) for every pushed ref.
 28+type GitRefUpdate struct {
 29+	LexiconTypeID string            `json:"$type"`
 30+	Ref           string            `json:"ref"`
 31+	CommitterDid  string            `json:"committerDid"`
 32+	OwnerDid      *string           `json:"ownerDid,omitempty"`
 33+	Repo          string            `json:"repo"`
 34+	OldSha        string            `json:"oldSha"`
 35+	NewSha        string            `json:"newSha"`
 36+	ChangedFiles  []string          `json:"changedFiles,omitempty"`
 37+	PushOptions   []string          `json:"pushOptions,omitempty"`
 38+	Meta          *GitRefUpdateMeta `json:"meta"`
 39+}
 40+
 41+type GitRefUpdateMeta struct {
 42+	IsDefaultRef  bool                       `json:"isDefaultRef"`
 43+	LangBreakdown *GitRefUpdateLangBreakdown `json:"langBreakdown,omitempty"`
 44+	CommitCount   *GitRefUpdateCommitCount   `json:"commitCount"`
 45+}
 46+
 47+type GitRefUpdateLangBreakdown struct {
 48+	Inputs []GitRefUpdateLanguageSize `json:"inputs,omitempty"`
 49+}
 50+
 51+type GitRefUpdateLanguageSize struct {
 52+	Lang string `json:"lang"`
 53+	Size int64  `json:"size"`
 54+}
 55+
 56+type GitRefUpdateCommitCount struct {
 57+	ByEmail []GitRefUpdateEmailCommitCount `json:"byEmail,omitempty"`
 58+}
 59+
 60+type GitRefUpdateEmailCommitCount struct {
 61+	Email string `json:"email"`
 62+	Count int64  `json:"count"`
 63+}
 64+
 65+// Pipeline is sh.tangled.pipeline. Upstream marks the record deprecated in
 66+// favour of sh.tangled.ci.pipeline, but the spindle still emits it on
 67+// /events and the appview still consumes it.
 68+type Pipeline struct {
 69+	LexiconTypeID   string                   `json:"$type"`
 70+	TriggerMetadata *PipelineTriggerMetadata `json:"triggerMetadata"`
 71+	Workflows       []*PipelineWorkflow      `json:"workflows"`
 72+}
 73+
 74+type PipelineTriggerMetadata struct {
 75+	Kind        string                      `json:"kind"`
 76+	Repo        *PipelineTriggerRepo        `json:"repo"`
 77+	Push        *PipelinePushTriggerData    `json:"push,omitempty"`
 78+	PullRequest *PipelinePullRequestTrigger `json:"pullRequest,omitempty"`
 79+	Manual      *PipelineManualTriggerData  `json:"manual,omitempty"`
 80+	Schedule    *PipelineScheduleTrigger    `json:"schedule,omitempty"`
 81+	SourceRepo  *string                     `json:"sourceRepo,omitempty"`
 82+}
 83+
 84+type PipelineTriggerRepo struct {
 85+	Knot          string  `json:"knot"`
 86+	Did           string  `json:"did"`
 87+	RepoDid       *string `json:"repoDid,omitempty"`
 88+	Repo          *string `json:"repo,omitempty"`
 89+	DefaultBranch string  `json:"defaultBranch"`
 90+}
 91+
 92+type PipelinePushTriggerData struct {
 93+	Ref    string `json:"ref"`
 94+	NewSha string `json:"newSha"`
 95+	OldSha string `json:"oldSha"`
 96+}
 97+
 98+type PipelinePullRequestTrigger struct {
 99+	Action       *string `json:"action,omitempty"`
100+	SourceBranch string  `json:"sourceBranch"`
101+	TargetBranch string  `json:"targetBranch"`
102+	SourceSha    string  `json:"sourceSha"`
103+	Pull         *string `json:"pull,omitempty"`
104+}
105+
106+type PipelineManualTriggerData struct {
107+	Sha    string          `json:"sha"`
108+	Ref    *string         `json:"ref,omitempty"`
109+	Inputs []*PipelinePair `json:"inputs,omitempty"`
110+}
111+
112+type PipelineScheduleTrigger struct {
113+	Sha         string `json:"sha"`
114+	Ref         string `json:"ref"`
115+	ScheduledAt string `json:"scheduledAt"`
116+}
117+
118+type PipelineWorkflow struct {
119+	Name   string             `json:"name"`
120+	Engine string             `json:"engine"`
121+	RunsOn []string           `json:"runsOn,omitempty"`
122+	Clone  *PipelineCloneOpts `json:"clone"`
123+	Raw    string             `json:"raw"`
124+}
125+
126+type PipelineCloneOpts struct {
127+	Skip       bool  `json:"skip"`
128+	Depth      int64 `json:"depth"`
129+	Submodules bool  `json:"submodules"`
130+	Tags       bool  `json:"tags"`
131+}
132+
133+type PipelinePair struct {
134+	Key   string `json:"key"`
135+	Value string `json:"value"`
136+}
137+
138+// Workflow statuses shared by sh.tangled.pipeline.status and
139+// sh.tangled.ci.pipeline#workflow.
140+const (
141+	StatusPending   = "pending"
142+	StatusRunning   = "running"
143+	StatusFailed    = "failed"
144+	StatusTimeout   = "timeout"
145+	StatusCancelled = "cancelled"
146+	StatusSuccess   = "success"
147+)
148+
149+// PipelineStatus is sh.tangled.pipeline.status: one workflow's status change.
150+type PipelineStatus struct {
151+	LexiconTypeID string  `json:"$type"`
152+	Pipeline      string  `json:"pipeline"`
153+	Workflow      string  `json:"workflow"`
154+	Status        string  `json:"status"`
155+	CreatedAt     string  `json:"createdAt"`
156+	Error         *string `json:"error,omitempty"`
157+	ExitCode      *int64  `json:"exitCode,omitempty"`
158+}
159+
160+// LogLine is one message of a spindle's legacy /logs/{knot}/{rkey}/{name}
161+// websocket, as upstream's spindle/models.LogLine (not a lexicon). Data lines
162+// carry output; control lines mark a step's start and end.
163+type LogLine struct {
164+	Kind        string `json:"kind"` // data | control
165+	Content     string `json:"content"`
166+	Time        string `json:"time"` // RFC 3339
167+	StepId      int    `json:"step_id"`
168+	Stream      string `json:"stream,omitempty"`       // data: stdout | stderr
169+	StepStatus  string `json:"step_status,omitempty"`  // control: start | end
170+	StepKind    string `json:"step_kind,omitempty"`    // control: system | user
171+	StepCommand string `json:"step_command,omitempty"` // control
172+}
173+
174+// TriggerPipelineInput is the input of sh.tangled.ci.triggerPipeline, with
175+// the manual trigger (the only kind accepted here) inlined.
176+type TriggerPipelineInput struct {
177+	Repo      string         `json:"repo"`
178+	Trigger   *ManualTrigger `json:"trigger"`
179+	Workflows []string       `json:"workflows,omitempty"`
180+}
181+
182+// ManualTrigger is sh.tangled.ci.trigger#manual.
183+type ManualTrigger struct {
184+	LexiconTypeID string          `json:"$type"`
185+	Sha           string          `json:"sha"`
186+	Ref           *string         `json:"ref,omitempty"`
187+	SourceRepo    *string         `json:"sourceRepo,omitempty"`
188+	Inputs        []*PipelinePair `json:"inputs,omitempty"`
189+}
190+
191+type TriggerPipelineOutput struct {
192+	Pipeline string `json:"pipeline"`
193+}
A lexicons/ci/run.rtw.ci.event.json
+30, -0
 1@@ -0,0 +1,30 @@
 2+{
 3+  "lexicon": 1,
 4+  "id": "run.rtw.ci.event",
 5+  "description": "One line of a run's events.ndjson: the single observable format read by the terminal renderer, ci view, the runner's streams and the appview.",
 6+  "defs": {
 7+    "main": {
 8+      "type": "object",
 9+      "required": ["seq", "time", "kind", "run"],
10+      "properties": {
11+        "seq": { "type": "integer", "description": "Line number in the file, from 1." },
12+        "time": { "type": "string", "format": "datetime" },
13+        "run": { "type": "string" },
14+        "kind": { "type": "string", "knownValues": ["run", "checkout", "start", "log", "end", "result", "transition", "gate"] },
15+        "action": { "type": "string" },
16+        "attempt": { "type": "integer", "description": "Execution number of the action within the run (1, 2, ... for repeats); also the log step id." },
17+        "stream": { "type": "string", "knownValues": ["stdout", "stderr"] },
18+        "content": { "type": "string", "description": "A log line (kind log), a message (run, checkout)." },
19+        "command": { "type": "string" },
20+        "exitCode": { "type": "integer" },
21+        "status": { "type": "string", "description": "kind run: pending|running|paused|success|failure|cancelled; kind end: the action's status." },
22+        "repo": { "type": "string", "description": "kind checkout: repo name." },
23+        "rev": { "type": "string", "description": "kind checkout: checked-out commit." },
24+        "path": { "type": "string", "description": "kind checkout: checkout path." },
25+        "result": { "type": "ref", "ref": "run.rtw.ci.step.result" },
26+        "transition": { "type": "ref", "ref": "run.rtw.ci.transition" },
27+        "gate": { "type": "ref", "ref": "run.rtw.ci.gate" }
28+      }
29+    }
30+  }
31+}
A lexicons/ci/run.rtw.ci.gate.json
+27, -0
 1@@ -0,0 +1,27 @@
 2+{
 3+  "lexicon": 1,
 4+  "id": "run.rtw.ci.gate",
 5+  "description": "A pending or decided human decision in a run. Emitted on the runner's /events; tid record keys.",
 6+  "defs": {
 7+    "main": {
 8+      "type": "object",
 9+      "required": ["run", "workflow", "seq", "options", "state", "createdAt"],
10+      "properties": {
11+        "pipeline": { "type": "string", "format": "at-uri", "description": "Absent for local runs." },
12+        "run": { "type": "string", "description": "Run id." },
13+        "workflow": { "type": "string" },
14+        "seq": { "type": "integer", "description": "Transition number the gate guards." },
15+        "from": { "type": "string", "description": "The action whose result raised the gate; absent for a declared gate on the next step." },
16+        "prompt": { "type": "string" },
17+        "summary": { "type": "string" },
18+        "options": { "type": "array", "items": { "type": "ref", "ref": "run.rtw.ci.step.result#option" } },
19+        "state": { "type": "string", "knownValues": ["waiting", "approved", "dismissed", "expired", "superseded"] },
20+        "choice": { "type": "string", "description": "The chosen action, when approved." },
21+        "actor": { "type": "string", "description": "Who decided: a DID, or local:<user> for terminal decisions." },
22+        "expiresAt": { "type": "string", "format": "datetime" },
23+        "createdAt": { "type": "string", "format": "datetime" },
24+        "decidedAt": { "type": "string", "format": "datetime" }
25+      }
26+    }
27+  }
28+}
A lexicons/ci/run.rtw.ci.step.result.json
+43, -0
 1@@ -0,0 +1,43 @@
 2+{
 3+  "lexicon": 1,
 4+  "id": "run.rtw.ci.step.result",
 5+  "description": "The result an action writes to $CI_RESULT. Without a result file, exit 0 means {status: success} and anything else means {status: failure, next: stop}.",
 6+  "defs": {
 7+    "main": {
 8+      "type": "object",
 9+      "required": ["version", "status"],
10+      "properties": {
11+        "version": { "type": "integer", "minimum": 1, "maximum": 1 },
12+        "status": { "type": "string", "knownValues": ["success", "warning", "failure"] },
13+        "summary": { "type": "string", "description": "Markdown for the run page, gates and notifications.", "maxLength": 65536 },
14+        "next": {
15+          "type": "unknown",
16+          "description": "Omitted: default routing. \"stop\": end the run. An action name, one #option, or an array of #option."
17+        },
18+        "gated": { "type": "boolean", "description": "Ask a human before taking next. Can add a gate, never remove a declared one." },
19+        "attachments": { "type": "array", "items": { "type": "ref", "ref": "#attachment" } },
20+        "outputs": { "type": "unknown", "description": "Object merged into $CI_STATE/outputs.json." }
21+      }
22+    },
23+    "option": {
24+      "type": "object",
25+      "required": ["action"],
26+      "properties": {
27+        "action": { "type": "string", "description": "A declared action." },
28+        "then": { "type": "string", "knownValues": ["continue", "stop"], "description": "After the action succeeds. Default: continue for happy-path steps, stop otherwise." },
29+        "label": { "type": "string" },
30+        "prompt": { "type": "string" },
31+        "inputs": { "type": "unknown", "description": "Object passed to the action as $CI_INPUTS." }
32+      }
33+    },
34+    "attachment": {
35+      "type": "object",
36+      "required": ["path"],
37+      "properties": {
38+        "label": { "type": "string" },
39+        "path": { "type": "string", "description": "Relative to $CI_ARTIFACTS." },
40+        "type": { "type": "string", "knownValues": ["text", "markdown", "ndjson", "json"] }
41+      }
42+    }
43+  }
44+}
A lexicons/ci/run.rtw.ci.transition.json
+23, -0
 1@@ -0,0 +1,23 @@
 2+{
 3+  "lexicon": 1,
 4+  "id": "run.rtw.ci.transition",
 5+  "description": "One routing decision of the engine, for the run timeline.",
 6+  "defs": {
 7+    "main": {
 8+      "type": "object",
 9+      "required": ["run", "workflow", "seq", "reason", "createdAt"],
10+      "properties": {
11+        "pipeline": { "type": "string", "format": "at-uri" },
12+        "run": { "type": "string" },
13+        "workflow": { "type": "string" },
14+        "seq": { "type": "integer" },
15+        "from": { "type": "string", "description": "Action that just finished; absent at the start of a run." },
16+        "to": { "type": "string", "description": "Action that runs next; absent when the run ends." },
17+        "then": { "type": "string", "knownValues": ["continue", "stop"] },
18+        "reason": { "type": "string", "knownValues": ["start", "default", "next", "choice", "manual", "stop", "dismiss", "failure", "error", "limit"] },
19+        "detail": { "type": "string" },
20+        "createdAt": { "type": "string", "format": "datetime" }
21+      }
22+    }
23+  }
24+}
A lexicons/ci/run.rtw.ci.workflow.json
+102, -0
  1@@ -0,0 +1,102 @@
  2+{
  3+  "lexicon": 1,
  4+  "id": "run.rtw.ci.workflow",
  5+  "description": "A workflow manifest (.tangled/workflows/<name>.yml, engine devenv). Pure data: the happy path (steps) and the closed set of actions results may route to.",
  6+  "defs": {
  7+    "main": {
  8+      "type": "object",
  9+      "required": ["when", "engine", "steps", "actions"],
 10+      "properties": {
 11+        "when": {
 12+          "type": "array",
 13+          "description": "Trigger conditions, as upstream's workflow files. Matched by the runner on a ref update or manual trigger.",
 14+          "items": { "type": "ref", "ref": "#trigger" }
 15+        },
 16+        "engine": {
 17+          "type": "string",
 18+          "description": "Must be devenv. Upstream spindles ignore workflows for engines they don't run.",
 19+          "knownValues": ["devenv"]
 20+        },
 21+        "environment": {
 22+          "type": "unknown",
 23+          "description": "Map of variable name to string value, set for every action. Not for secrets."
 24+        },
 25+        "repos": {
 26+          "type": "array",
 27+          "description": "Extra repositories checked out next to the main one for each run.",
 28+          "items": { "type": "ref", "ref": "#repo" }
 29+        },
 30+        "steps": {
 31+          "type": "array",
 32+          "description": "The happy path: action names in order.",
 33+          "minLength": 1,
 34+          "items": { "type": "string" }
 35+        },
 36+        "actions": {
 37+          "type": "unknown",
 38+          "description": "Map of action name to #action. Every step must name an action; results may route only to actions declared here."
 39+        }
 40+      }
 41+    },
 42+    "trigger": {
 43+      "type": "object",
 44+      "required": ["event"],
 45+      "properties": {
 46+        "event": {
 47+          "type": "array",
 48+          "description": "In YAML a single string is accepted too.",
 49+          "items": { "type": "string", "knownValues": ["push", "manual", "pull_request", "schedule"] }
 50+        },
 51+        "branch": {
 52+          "type": "array",
 53+          "description": "Branch globs (* and **). In YAML a single string is accepted too.",
 54+          "items": { "type": "string" }
 55+        },
 56+        "tag": {
 57+          "type": "array",
 58+          "description": "Tag globs (* and **), push events only. In YAML a single string is accepted too.",
 59+          "items": { "type": "string" }
 60+        }
 61+      }
 62+    },
 63+    "repo": {
 64+      "type": "object",
 65+      "required": ["repo", "path"],
 66+      "properties": {
 67+        "repo": { "type": "string", "description": "Repository name on the forge (resolved to a clone URL by configuration)." },
 68+        "path": { "type": "string", "description": "Checkout path relative to the main checkout, e.g. ../servers." },
 69+        "rev": { "type": "string", "description": "Revision to check out. Default: the forge's default branch at the time the run starts." },
 70+        "env": { "type": "string", "description": "Environment variable that receives the checkout's absolute path, e.g. SECRETS_DIR." }
 71+      }
 72+    },
 73+    "action": {
 74+      "type": "object",
 75+      "required": ["command"],
 76+      "properties": {
 77+        "command": { "type": "string", "description": "Run as `devenv shell -- bash -euo pipefail -c <command>`." },
 78+        "dir": { "type": "string", "description": "Working directory relative to the main checkout. Default: the main checkout." },
 79+        "label": { "type": "string", "description": "Button and timeline label. Default: the action name." },
 80+        "gate": { "type": "ref", "ref": "#gate", "description": "Always ask a human before running. In YAML `gate: true` is accepted too." },
 81+        "concurrency": { "type": "ref", "ref": "#concurrency", "description": "In YAML a plain group name is accepted too." },
 82+        "manual": { "type": "boolean", "description": "Offer as a button on the run page at any time." },
 83+        "style": { "type": "string", "knownValues": ["normal", "danger"] },
 84+        "audited": { "type": "boolean", "description": "Local runs that execute this action report to the appview." }
 85+      }
 86+    },
 87+    "gate": {
 88+      "type": "object",
 89+      "properties": {
 90+        "prompt": { "type": "string" },
 91+        "timeout": { "type": "string", "description": "Go duration, e.g. 24h. Default 24h." }
 92+      }
 93+    },
 94+    "concurrency": {
 95+      "type": "object",
 96+      "required": ["group"],
 97+      "properties": {
 98+        "group": { "type": "string" },
 99+        "supersede": { "type": "boolean", "description": "A newer run reaching this group's gate supersedes older runs waiting at it." }
100+      }
101+    }
102+  }
103+}
A lexicons/tangled/README.md
+10, -0
 1@@ -0,0 +1,10 @@
 2+# Vendored Tangled lexicons
 3+
 4+Copied unchanged from tangled core (`lexicons/`) at commit `e0ef7981d`
 5+(2026-10-11), MIT License, Copyright (c) 2025 Anirudh Oppiliappan, Akshay
 6+Oppiliappan and contributors. Files are renamed to their lexicon id.
 7+
 8+Only the records this project reads or emits are vendored. `internal/lex`
 9+holds hand-written Go types for them; `internal/lex/lex_test.go` checks the
10+types against these files. To update: copy the newer files over these, run
11+`go test ./internal/lex`, and fix the types the test reports.
A lexicons/tangled/sh.tangled.ci.pipeline.json
+94, -0
 1@@ -0,0 +1,94 @@
 2+{
 3+  "lexicon": 1,
 4+  "id": "sh.tangled.ci.pipeline",
 5+  "defs": {
 6+    "main": {
 7+      "type": "object",
 8+      "description": "A CI pipeline. Record-like, but owned by the spindle rather than a PDS.",
 9+      "required": ["id", "trigger", "commit", "workflows"],
10+      "properties": {
11+        "id": {
12+          "type": "string",
13+          "description": "Spindle-local pipeline id"
14+        },
15+        "repo": {
16+          "type": "string",
17+          "format": "did",
18+          "description": "Repository DID"
19+        },
20+        "trigger": {
21+          "type": "union",
22+          "refs": [
23+            "sh.tangled.ci.trigger#push",
24+            "sh.tangled.ci.trigger#pullRequest",
25+            "sh.tangled.ci.trigger#manual",
26+            "sh.tangled.ci.trigger#schedule"
27+          ],
28+          "description": "Trigger event metadata"
29+        },
30+        "commit": {
31+          "type": "string",
32+          "description": "Commit Id this pipeline is running on"
33+        },
34+        "sourceRepo": {
35+          "type": "string",
36+          "format": "did",
37+          "description": "Repository DID that the commit was checked out from, if different from repo (e.g. a fork for a fork-based pull request)"
38+        },
39+        "createdAt": {
40+          "type": "string",
41+          "format": "datetime"
42+        },
43+        "workflows": {
44+          "type": "array",
45+          "description": "Triggered workflows",
46+          "minLength": 1,
47+          "maxLength": 50,
48+          "items": {
49+            "type": "ref",
50+            "ref": "#workflow"
51+          }
52+        }
53+      }
54+    },
55+    "workflow": {
56+      "type": "object",
57+      "required": ["id", "name", "status"],
58+      "properties": {
59+        "id": {
60+          "type": "string",
61+          "description": "Spindle-local workflow id. Unique per pipeline, usually same as name."
62+        },
63+        "name": {
64+          "type": "string",
65+          "description": "Name of the workflow",
66+          "minGraphemes": 1,
67+          "maxGraphemes": 40
68+        },
69+        "status": {
70+          "type": "string",
71+          "description": "Workflow status",
72+          "enum": [
73+            "pending",
74+            "running",
75+            "failed",
76+            "timeout",
77+            "cancelled",
78+            "success"
79+          ]
80+        },
81+        "startedAt": {
82+          "type": "string",
83+          "format": "datetime"
84+        },
85+        "finishedAt": {
86+          "type": "string",
87+          "format": "datetime"
88+        },
89+        "error": {
90+          "type": "string"
91+        }
92+      }
93+    }
94+  }
95+}
A lexicons/tangled/sh.tangled.ci.subscribePipelineLogs.json
+77, -0
 1@@ -0,0 +1,77 @@
 2+{
 3+  "lexicon": 1,
 4+  "id": "sh.tangled.ci.subscribePipelineLogs",
 5+  "defs": {
 6+    "main": {
 7+      "type": "subscription",
 8+      "description": "Pipeline logs stream",
 9+      "parameters": {
10+        "type": "params",
11+        "required": ["pipeline"],
12+        "properties": {
13+          "pipeline": {
14+            "type": "string",
15+            "format": "tid",
16+            "description": "Pipeline ID"
17+          },
18+          "workflows": {
19+            "type": "array",
20+            "items": {
21+              "type": "string",
22+              "description": "Workflow name"
23+            },
24+            "description": "filter logs by specific workflows"
25+          }
26+        }
27+      },
28+      "message": {
29+        "schema": {
30+          "type": "union",
31+          "refs": ["#control", "#data"]
32+        }
33+      },
34+      "errors": [
35+        {
36+          "name": "WorkflowNotFound",
37+          "description": "Workflow not found"
38+        },
39+        {
40+          "name": "InvalidRequest",
41+          "description": "Invalid request parameters"
42+        }
43+      ]
44+    },
45+    "control": {
46+      "type": "object",
47+      "required": ["time", "workflow", "step", "content"],
48+      "properties": {
49+        "time": { "type": "string", "format": "datetime" },
50+        "workflow": { "type": "string", "description": "workflow name" },
51+        "step": { "type": "integer", "description": "Step ID" },
52+        "content": { "type": "string" },
53+        "command": { "type": "string", "description": "Step command" },
54+        "status": {
55+          "type": "string",
56+          "enum": ["start", "end"],
57+          "description": "Step status"
58+        },
59+        "kind": {
60+          "type": "string",
61+          "enum": ["system", "user"],
62+          "description": "Step kind"
63+        }
64+      }
65+    },
66+    "data": {
67+      "type": "object",
68+      "required": ["time", "workflow", "step", "content", "stream"],
69+      "properties": {
70+        "time": { "type": "string", "format": "datetime" },
71+        "workflow": { "type": "string", "description": "workflow name" },
72+        "step": { "type": "integer", "description": "Step ID" },
73+        "content": { "type": "string" },
74+        "stream": { "type": "string", "enum": [ "stdout", "stderr" ] }
75+      }
76+    }
77+  }
78+}
A lexicons/tangled/sh.tangled.ci.trigger.json
+119, -0
  1@@ -0,0 +1,119 @@
  2+{
  3+  "lexicon": 1,
  4+  "id": "sh.tangled.ci.trigger",
  5+  "defs": {
  6+    "push": {
  7+      "type": "object",
  8+      "required": ["ref", "newSha", "oldSha"],
  9+      "properties": {
 10+        "ref": {
 11+          "type": "string"
 12+        },
 13+        "newSha": {
 14+          "type": "string",
 15+          "minLength": 40,
 16+          "maxLength": 40
 17+        },
 18+        "oldSha": {
 19+          "type": "string",
 20+          "minLength": 40,
 21+          "maxLength": 40
 22+        }
 23+      }
 24+    },
 25+    "pullRequest": {
 26+      "type": "object",
 27+      "required": ["targetBranch", "sourceSha"],
 28+      "description": "TODO: reference PR record with strongRef instead of embedding raw values",
 29+      "properties": {
 30+        "action": {
 31+          "type": "string",
 32+          "description": "the pull request lifecycle action that produced this trigger",
 33+          "enum": ["opened", "reopened", "closed", "merged", "synchronize"]
 34+        },
 35+        "sourceRepo": {
 36+          "type": "string",
 37+          "format": "did",
 38+          "description": "Repository DID to check out code and workflow definitions from, if different from the target repo."
 39+        },
 40+        "sourceBranch": {
 41+          "type": "string"
 42+        },
 43+        "targetBranch": {
 44+          "type": "string"
 45+        },
 46+        "sourceSha": {
 47+          "type": "string",
 48+          "minLength": 40,
 49+          "maxLength": 40
 50+        },
 51+        "pull": {
 52+          "type": "string",
 53+          "format": "at-uri",
 54+          "description": "AT-URI of the sh.tangled.repo.pull record this run belongs to"
 55+        }
 56+      }
 57+    },
 58+    "manual": {
 59+      "type": "object",
 60+      "required": ["sha"],
 61+      "properties": {
 62+        "sha": {
 63+          "type": "string",
 64+          "description": "commit SHA the manual run targets",
 65+          "minLength": 40,
 66+          "maxLength": 40
 67+        },
 68+        "ref": {
 69+          "type": "string",
 70+          "description": "optional ref the SHA was resolved from, for display and TANGLED_REF"
 71+        },
 72+        "sourceRepo": {
 73+          "type": "string",
 74+          "format": "did",
 75+          "description": "Repository DID to check out code and workflow definitions from, if different from the target repo."
 76+        },
 77+        "inputs": {
 78+          "type": "array",
 79+          "items": {
 80+            "type": "ref",
 81+            "ref": "#pair"
 82+          }
 83+        }
 84+      }
 85+    },
 86+    "schedule": {
 87+      "type": "object",
 88+      "required": ["sha", "ref", "scheduledAt"],
 89+      "properties": {
 90+        "sha": {
 91+          "type": "string",
 92+          "description": "commit SHA the scheduled run targets",
 93+          "minLength": 40,
 94+          "maxLength": 40
 95+        },
 96+        "ref": {
 97+          "type": "string",
 98+          "description": "default branch ref resolved for the scheduled run"
 99+        },
100+        "scheduledAt": {
101+          "type": "string",
102+          "format": "datetime",
103+          "description": "UTC minute selected by the cron schedule"
104+        }
105+      }
106+    },
107+    "pair": {
108+      "type": "object",
109+      "required": ["key", "value"],
110+      "properties": {
111+        "key": {
112+          "type": "string"
113+        },
114+        "value": {
115+          "type": "string"
116+        }
117+      }
118+    }
119+  }
120+}
A lexicons/tangled/sh.tangled.ci.triggerPipeline.json
+60, -0
 1@@ -0,0 +1,60 @@
 2+{
 3+  "lexicon": 1,
 4+  "id": "sh.tangled.ci.triggerPipeline",
 5+  "defs": {
 6+    "main": {
 7+      "type": "procedure",
 8+      "description": "Trigger a pipeline at an explicit commit. Runs the named workflows, or every workflow defined in the repo when none are named.",
 9+      "input": {
10+        "encoding": "application/json",
11+        "schema": {
12+          "type": "object",
13+          "required": ["repo", "trigger"],
14+          "properties": {
15+            "repo": {
16+              "type": "string",
17+              "format": "did",
18+              "description": "Target repository DID. Auth is checked against this repo."
19+            },
20+            "trigger": {
21+              "type": "union",
22+              "refs": [
23+                "sh.tangled.ci.trigger#manual",
24+                "sh.tangled.ci.trigger#pullRequest"
25+              ],
26+              "description": "Trigger metadata for this dispatch."
27+            },
28+            "workflows": {
29+              "type": "array",
30+              "items": {
31+                "type": "string",
32+                "description": "workflow name"
33+              },
34+              "description": "Workflow names to run. When not provided, every dispatchable workflow is run."
35+            }
36+          }
37+        }
38+      },
39+      "output": {
40+        "encoding": "application/json",
41+        "schema": {
42+          "type": "object",
43+          "required": ["pipeline"],
44+          "properties": {
45+            "pipeline": {
46+              "type": "string",
47+              "format": "at-uri",
48+              "description": "AT-URI of the created pipeline"
49+            }
50+          }
51+        }
52+      },
53+      "errors": [
54+        {
55+          "name": "InvalidRequest",
56+          "description": "Invalid request parameters"
57+        }
58+      ]
59+    }
60+  }
61+}
A lexicons/tangled/sh.tangled.git.refUpdate.json
+148, -0
  1@@ -0,0 +1,148 @@
  2+{
  3+  "lexicon": 1,
  4+  "id": "sh.tangled.git.refUpdate",
  5+  "defs": {
  6+    "main": {
  7+      "type": "record",
  8+      "description": "An event record representing git-push operation to git repository, emitted by knots.",
  9+      "key": "tid",
 10+      "record": {
 11+        "type": "object",
 12+        "required": [
 13+          "ref",
 14+          "committerDid",
 15+          "repo",
 16+          "oldSha",
 17+          "newSha",
 18+          "meta"
 19+        ],
 20+        "properties": {
 21+          "ref": {
 22+            "type": "string",
 23+            "description": "Ref being updated",
 24+            "maxGraphemes": 256,
 25+            "maxLength": 2560
 26+          },
 27+          "committerDid": {
 28+            "type": "string",
 29+            "description": "did of the user that pushed this ref",
 30+            "format": "did"
 31+          },
 32+          "ownerDid": {
 33+            "type": "string",
 34+            "description": "did of the owner of the repo",
 35+            "format": "did"
 36+          },
 37+          "repo": {
 38+            "type": "string",
 39+            "description": "DID of the repo itself",
 40+            "format": "did"
 41+          },
 42+          "oldSha": {
 43+            "type": "string",
 44+            "description": "old SHA of this ref",
 45+            "minLength": 40,
 46+            "maxLength": 40
 47+          },
 48+          "newSha": {
 49+            "type": "string",
 50+            "description": "new SHA of this ref",
 51+            "minLength": 40,
 52+            "maxLength": 40
 53+          },
 54+          "changedFiles": {
 55+            "type": "array",
 56+            "description": "files changed between commits",
 57+            "items": { "type": "string" }
 58+          },
 59+          "pushOptions": {
 60+            "type": "array",
 61+            "description": "push options passed on git-push",
 62+            "maxLength": 50,
 63+            "items": {
 64+              "type": "string",
 65+              "maxLength": 1024,
 66+              "knownValues": [
 67+                "ci-skip",
 68+                "ci-verbose",
 69+                "skip-ci",
 70+                "verbose-ci"
 71+              ]
 72+            }
 73+          },
 74+          "meta": {
 75+            "type": "ref",
 76+            "ref": "#meta"
 77+          }
 78+        }
 79+      }
 80+    },
 81+    "meta": {
 82+      "type": "object",
 83+      "required": ["isDefaultRef", "commitCount"],
 84+      "properties": {
 85+        "isDefaultRef": {
 86+          "type": "boolean",
 87+          "default": false
 88+        },
 89+        "langBreakdown": {
 90+          "type": "ref",
 91+          "ref": "#langBreakdown"
 92+        },
 93+        "commitCount": {
 94+          "type": "ref",
 95+          "ref": "#commitCountBreakdown"
 96+        }
 97+      }
 98+    },
 99+    "langBreakdown": {
100+      "type": "object",
101+      "properties": {
102+        "inputs": {
103+          "type": "array",
104+          "items": {
105+            "type": "ref",
106+            "ref": "#individualLanguageSize"
107+          }
108+        }
109+      }
110+    },
111+    "individualLanguageSize": {
112+      "type": "object",
113+      "required": ["lang", "size"],
114+      "properties": {
115+        "lang": {
116+          "type": "string"
117+        },
118+        "size": {
119+          "type": "integer"
120+        }
121+      }
122+    },
123+    "commitCountBreakdown": {
124+      "type": "object",
125+      "required": [],
126+      "properties": {
127+        "byEmail": {
128+          "type": "array",
129+          "items": {
130+            "type": "ref",
131+            "ref": "#individualEmailCommitCount"
132+          }
133+        }
134+      }
135+    },
136+    "individualEmailCommitCount": {
137+      "type": "object",
138+      "required": ["email", "count"],
139+      "properties": {
140+        "email": {
141+          "type": "string"
142+        },
143+        "count": {
144+          "type": "integer"
145+        }
146+      }
147+    }
148+  }
149+}
A lexicons/tangled/sh.tangled.pipeline.json
+282, -0
  1@@ -0,0 +1,282 @@
  2+{
  3+  "lexicon": 1,
  4+  "id": "sh.tangled.pipeline",
  5+  "needsCbor": true,
  6+  "needsType": true,
  7+  "defs": {
  8+    "main": {
  9+      "type": "record",
 10+      "key": "tid",
 11+      "record": {
 12+        "type": "object",
 13+        "description": "DEPRECATED: use sh.tangled.ci.pipeline instead",
 14+        "required": [
 15+          "triggerMetadata",
 16+          "workflows"
 17+        ],
 18+        "properties": {
 19+          "triggerMetadata": {
 20+            "type": "ref",
 21+            "ref": "#triggerMetadata"
 22+          },
 23+          "workflows": {
 24+            "type": "array",
 25+            "items": {
 26+              "type": "ref",
 27+              "ref": "#workflow"
 28+            }
 29+          }
 30+        }
 31+      }
 32+    },
 33+    "triggerMetadata": {
 34+      "type": "object",
 35+      "required": [
 36+        "kind",
 37+        "repo"
 38+      ],
 39+      "properties": {
 40+        "kind": {
 41+          "type": "string",
 42+          "enum": [
 43+            "push",
 44+            "pull_request",
 45+            "manual",
 46+            "schedule"
 47+          ]
 48+        },
 49+        "repo": {
 50+          "type": "ref",
 51+          "ref": "#triggerRepo"
 52+        },
 53+        "push": {
 54+          "type": "ref",
 55+          "ref": "#pushTriggerData"
 56+        },
 57+        "pullRequest": {
 58+          "type": "ref",
 59+          "ref": "#pullRequestTriggerData"
 60+        },
 61+        "manual": {
 62+          "type": "ref",
 63+          "ref": "#manualTriggerData"
 64+        },
 65+        "schedule": {
 66+          "type": "ref",
 67+          "ref": "#scheduleTriggerData"
 68+        },
 69+        "sourceRepo": {
 70+          "type": "string",
 71+          "format": "did",
 72+          "description": "Repository DID that code and workflow definitions are checked out from, when different from repo (e.g. a fork's commit for a fork-based manual trigger). If absent, source uses repo itself."
 73+        }
 74+      }
 75+    },
 76+    "triggerRepo": {
 77+      "type": "object",
 78+      "required": [
 79+        "knot",
 80+        "did",
 81+        "defaultBranch"
 82+      ],
 83+      "properties": {
 84+        "knot": {
 85+          "type": "string"
 86+        },
 87+        "did": {
 88+          "type": "string",
 89+          "format": "did"
 90+        },
 91+        "repoDid": {
 92+          "type": "string",
 93+          "description": "DID of the repo itself",
 94+          "format": "did"
 95+        },
 96+        "repo": {
 97+          "type": "string"
 98+        },
 99+        "defaultBranch": {
100+          "type": "string"
101+        }
102+      }
103+    },
104+    "pushTriggerData": {
105+      "type": "object",
106+      "required": [
107+        "ref",
108+        "newSha",
109+        "oldSha"
110+      ],
111+      "properties": {
112+        "ref": {
113+          "type": "string"
114+        },
115+        "newSha": {
116+          "type": "string",
117+          "minLength": 40,
118+          "maxLength": 40
119+        },
120+        "oldSha": {
121+          "type": "string",
122+          "minLength": 40,
123+          "maxLength": 40
124+        }
125+      }
126+    },
127+    "pullRequestTriggerData": {
128+      "type": "object",
129+      "required": [
130+        "sourceBranch",
131+        "targetBranch",
132+        "sourceSha"
133+      ],
134+      "properties": {
135+        "action": {
136+          "type": "string",
137+          "description": "the pull request lifecycle action that produced this trigger",
138+          "enum": [
139+            "opened",
140+            "reopened",
141+            "closed",
142+            "merged",
143+            "synchronize"
144+          ]
145+        },
146+        "sourceBranch": {
147+          "type": "string"
148+        },
149+        "targetBranch": {
150+          "type": "string"
151+        },
152+        "sourceSha": {
153+          "type": "string",
154+          "minLength": 40,
155+          "maxLength": 40
156+        },
157+        "pull": {
158+          "type": "string",
159+          "format": "at-uri",
160+          "description": "AT-URI of the sh.tangled.repo.pull record this run belongs to"
161+        }
162+      }
163+    },
164+    "manualTriggerData": {
165+      "type": "object",
166+      "required": [
167+        "sha"
168+      ],
169+      "properties": {
170+        "sha": {
171+          "type": "string",
172+          "description": "commit SHA the manual run targets",
173+          "minLength": 40,
174+          "maxLength": 40
175+        },
176+        "ref": {
177+          "type": "string",
178+          "description": "optional ref the SHA was resolved from, for display and TANGLED_REF"
179+        },
180+        "inputs": {
181+          "type": "array",
182+          "items": {
183+            "type": "ref",
184+            "ref": "#pair"
185+          }
186+        }
187+      }
188+    },
189+    "scheduleTriggerData": {
190+      "type": "object",
191+      "required": [
192+        "sha",
193+        "ref",
194+        "scheduledAt"
195+      ],
196+      "properties": {
197+        "sha": {
198+          "type": "string",
199+          "description": "commit SHA the scheduled run targets",
200+          "minLength": 40,
201+          "maxLength": 40
202+        },
203+        "ref": {
204+          "type": "string",
205+          "description": "default branch ref resolved for the scheduled run"
206+        },
207+        "scheduledAt": {
208+          "type": "string",
209+          "format": "datetime",
210+          "description": "UTC minute selected by the cron schedule"
211+        }
212+      }
213+    },
214+    "workflow": {
215+      "type": "object",
216+      "required": [
217+        "name",
218+        "engine",
219+        "clone",
220+        "raw"
221+      ],
222+      "properties": {
223+        "name": {
224+          "type": "string"
225+        },
226+        "engine": {
227+          "type": "string"
228+        },
229+        "runsOn": {
230+          "type": "array",
231+          "items": {
232+            "type": "string"
233+          }
234+        },
235+        "clone": {
236+          "type": "ref",
237+          "ref": "#cloneOpts"
238+        },
239+        "raw": {
240+          "type": "string"
241+        }
242+      }
243+    },
244+    "cloneOpts": {
245+      "type": "object",
246+      "required": [
247+        "skip",
248+        "depth",
249+        "submodules",
250+        "tags"
251+      ],
252+      "properties": {
253+        "skip": {
254+          "type": "boolean"
255+        },
256+        "depth": {
257+          "type": "integer"
258+        },
259+        "submodules": {
260+          "type": "boolean"
261+        },
262+        "tags": {
263+          "type": "boolean"
264+        }
265+      }
266+    },
267+    "pair": {
268+      "type": "object",
269+      "required": [
270+        "key",
271+        "value"
272+      ],
273+      "properties": {
274+        "key": {
275+          "type": "string"
276+        },
277+        "value": {
278+          "type": "string"
279+        }
280+      }
281+    }
282+  }
283+}
A lexicons/tangled/sh.tangled.pipeline.status.json
+54, -0
 1@@ -0,0 +1,54 @@
 2+{
 3+  "lexicon": 1,
 4+  "id": "sh.tangled.pipeline.status",
 5+  "needsCbor": true,
 6+  "needsType": true,
 7+  "defs": {
 8+    "main": {
 9+      "type": "record",
10+      "key": "tid",
11+      "record": {
12+        "type": "object",
13+        "description": "DEPRECATED: use sh.tangled.ci.pipeline instead",
14+        "required": ["pipeline", "workflow", "status", "createdAt"],
15+        "properties": {
16+          "pipeline": {
17+            "type": "string",
18+            "format": "at-uri",
19+            "description": "ATURI of the pipeline"
20+          },
21+          "workflow": {
22+            "type": "string",
23+            "format": "at-uri",
24+            "description": "name of the workflow within this pipeline"
25+          },
26+          "status": {
27+            "type": "string",
28+            "description": "status of the workflow",
29+            "enum": [
30+              "pending",
31+              "running",
32+              "failed",
33+              "timeout",
34+              "cancelled",
35+              "success"
36+            ]
37+          },
38+          "createdAt": {
39+            "type": "string",
40+            "format": "datetime",
41+            "description": "time of creation of this status update"
42+          },
43+          "error": {
44+            "type": "string",
45+            "description": "error message if failed"
46+          },
47+          "exitCode": {
48+            "type": "integer",
49+            "description": "exit code if failed"
50+          }
51+        }
52+      }
53+    }
54+  }
55+}