This manual explains how to use Observer's shell-oriented workflow surface for local staged verification.
It is written for people who want to stop maintaining one-off shell or Python orchestration scripts and instead describe deterministic local workflows in Observer.
This document lives in lib/shell because that is where the runnable shell examples live in this repository.
If you want to see how shell workflows now fit into the new top-level product-certification layer, also read ../../examples/product-certify/README.md after this manual.
If you want the fastest path to the shell workflow model, start in lib/shell/starter-pipeline/ and run:
make clean
make run
make report
cat .observer/report.default.jsonl
make verifyThat sequence shows the whole artifact workflow in order:
- clean generated state
- run the human-readable pipeline
- emit the canonical JSONL report
- inspect the report directly
- compare live behavior against the checked-in snapshot
In this part of Observer, you are not writing a general-purpose script.
You are declaring a deterministic workflow contract.
That contract says:
- how cases are discovered
- what each case is called
- which stages run for each case
- which artifacts each stage publishes
- which later stages are allowed to consume those artifacts
- what facts are extracted and asserted
- how the result is reported canonically
The key idea is that stages do not communicate through guessed file paths or shell convention.
They communicate through explicit named artifacts.
That is the main shift from ad hoc scripting.
The shell workflow surface is still the stage-level mechanism.
What is new is the layer above it.
Observer can now certify one product from several declared suites together.
That matters when your workflow suite is only one part of release readiness, for example:
- one unit suite must pass
- one workflow corpus suite must pass
- both together define the product verdict
In that model:
- the shell workflow stays a normal full-surface suite
- a
product.jsonfile declares that suite as one certification stage observer certifyruns the ordered product stages and emits one product reportobserver cube-productturns the product report into per-stage analytics plus one compare-index
The smallest runnable example of that lives at ../../examples/product-certify/.
That example is useful when you want to understand where a shell workflow fits in the new product-level contract.
This surface is for workflows like:
- compile a corpus file
- certify the produced output
- lower it to another form
- run a produced executable
- inspect JSON or JSONL outputs
- assert on exit codes, stdout, stderr, extracted fields, or stage failures
It is not for:
- arbitrary shell automation
- host configuration management
- remote orchestration
- a general scripting language with mutation and loops
If you need those things, this is the wrong tool.
If you need deterministic local verification of staged artifacts, this is the right model.
An Observer shell workflow has four parts:
- case discovery
- ordered actions
- named artifacts
- explicit assertions
A good way to think about one suite item is:
- discover cases from explicit inputs
- for each case, run stages in source order
- publish artifacts when a stage produces something important
- check artifacts before downstream use
- extract structured facts when needed
- assert what must be true
A typical workflow item looks like this:
module Example.
(files: "corpus" glob: "**/*.src" key: stem) forEachCase: [ :case |
(proc: "./bin/compile" args: [
joinPath: ["corpus", (case path)],
joinPath: [".observer/out", (case stem)],
joinPath: [".observer/out", (case stem), "typed_unit.jsonl"]
] timeoutMs: 2000) ifOk: [ :compile |
expect: (compile exit) = 0.
publish: "typed_unit" kind: "jsonl" path: joinPath: [".observer/out", (case stem), "typed_unit.jsonl"].
] ifFail: [ :f |
expect: Fail msg: "compile stage failed".
].
].
That already tells you most of the model:
- cases come from files
- each case gets a binding called
case proc:runs a local processifOk:binds the result on successifFail:binds the failure on action failureexpect:records assertionspublish:turns a concrete file path into a named artifact
A full shell workflow file is UTF-8 text.
It may begin with:
module Name.
After that, it contains one or more suite items.
Comments start with ;;.
Example:
module Demo.
;; compile every corpus source file
(files: "corpus" glob: "**/*.src" key: stem) forEachCase: [ :case |
...
].
The current shell workflow examples use filesystem discovery.
The form is:
(files: <root> glob: <pattern> key: <field>) forEachCase: [ :case | ... ].
Example:
(files: "corpus" glob: "**/*.src" key: stem) forEachCase: [ :case |
...
].
Meaning:
files:declares the discovery rootglob:declares which files count as caseskey:declares how the canonical case key is derivedforEachCase:runs the body once per discovered case
Current supported key fields are:
pathnamestem
For filesystem discovery, the bound case object exposes:
key: canonical case keypath: normalized path under the declared rootname: basename including extensionstem: basename without extensionext: extension without leading dot, or empty string
Example uses:
(case path)
(case stem)
(case name)
Required discovery forms:
(... ) forEachCase: [ :case | ... ].
(... ) forEach: [ :testName | ... ].
Optional forms:
(... ) forEachCaseOptional: [ :case | ... ].
(... ) forEachOptional: [ :testName | ... ].
Use optional only when zero selected cases is acceptable.
Use required when empty selection should be treated as an error.
Observer currently supports two major full-surface patterns:
- inventory-driven workflows using
forEach:over inventory selectors - filesystem-driven workflows using
forEachCase:over files
The shell examples in lib/shell focus on filesystem-driven workflows.
Inventory-driven full-surface example shape:
("Smoke::Version") forEach: [ :testName |
(run: testName timeoutMs: 2000) ifOk: [ :r |
expect: (r exit) = 0.
] ifFail: [ :f |
expect: Fail msg: "inventory run failed".
].
].
Filesystem-driven shell example shape:
(files: "corpus" glob: "**/*.src" key: stem) forEachCase: [ :case |
...
].
The main statement forms are:
expect: <Predicate>.
publish: <String> kind: <ArtifactKind> path: <ValueExpr>.
(<ResultExpr>) ifOk: [ :<Binding> | <Statement>* ] ifFail: [ :<Binding> | <Statement>* ].
(<BoolExpr>) ifTrue: [ <Statement>* ] ifFalse: [ <Statement>* ].
You will use these constantly.
expect: records an assertion.
Examples:
expect: (compile exit) = 0.
expect: (run out) contains: "ok".
expect: (certificateStatus text) = "ok".
expect: Fail msg: "summary stage failed".
Important rule:
- a failed assertion does not automatically abort the rest of the case
Observer keeps evaluating later statements unless a future surface explicitly requests early termination.
publish: creates a named artifact binding for the current case.
Example:
publish: "typed_unit" kind: "jsonl" path: joinPath: [".observer/out", (case stem), "typed_unit.jsonl"].
This is what makes later artifact reuse explicit.
Artifact names are case-local.
Re-publishing the same artifact name in one case is a runtime error.
These branch on action success or failure.
Example:
(proc: "./bin/compile" args: [...] timeoutMs: 2000) ifOk: [ :compile |
expect: (compile exit) = 0.
] ifFail: [ :f |
expect: Fail msg: "compile stage failed".
].
Important rule:
ifOk:means the action itself completed successfully as an Observer action- it does not mean the child process exited with code
0
For proc:, a nonzero process exit still gives you a successful action result with an exit field.
So this is correct:
(proc: "./bin/tool" args: [...] timeoutMs: 2000) ifOk: [ :r |
expect: (r exit) = 0.
].
And this is what ifFail: is for:
- spawn failure
- timeout at the action boundary
- protocol-level failure for actions that can fail structurally
These branch on a boolean expression.
Example:
((run out) contains: "ok") ifTrue: [
expect: (run exit) = 0.
] ifFalse: [
expect: Fail msg: "program output did not contain ok".
].
These are the core result-producing forms you are likely to use.
Runs a local executable with explicit arguments.
Shape:
proc: <String> args: <ArgArray> timeoutMs: <u32>
Example:
(proc: "./bin/emit-unit" args: [
joinPath: ["corpus", (case path)],
joinPath: [".observer/out", (case stem)],
joinPath: [".observer/out", (case stem), "typed_unit.jsonl"]
] timeoutMs: 2000)
Result fields commonly used:
(r exit)(r out)(r err)
Use proc: for project-local tools and helper scripts.
Runs an inventory-bound test by name.
Shape:
run: <ValueExpr> timeoutMs: <u32>
This is mostly for inventory-driven suites, not the shell starter examples.
Looks up a previously published artifact by name and kind.
Shape:
artifactCheck: <String> kind: <ArtifactKind>
Example:
(artifactCheck: "typed_unit" kind: "jsonl") ifOk: [ :typedUnit |
...
].
This is the normal way to confirm a stage's published output exists and is available for downstream use.
Extract structured facts from published artifacts.
Shapes:
extractJson: <String> select: <String>
extractJsonl: <String> select: <String>
Example:
(extractJsonl: "typed_unit" select: "$.unit_id") ifOk: [ :unitId |
expect: (unitId count) = 1.
expect: (unitId text) = joinPath: ["unit", (case stem)].
].
Common result fields:
(x count)(x text)
Use extraction when you want the workflow to assert on structured content rather than just file existence.
These exist in the full surface but are not the main focus of the shell starter examples.
They are useful when a local workflow also needs explicit protocol checks.
Use parentheses to access fields on a bound value.
Examples:
(case stem)
(compile exit)
(run out)
(unitId text)
(f kind)
Turns a named artifact binding into the underlying concrete path passed to a downstream process.
Example:
artifactPath: "typed_unit"
This is one of the most important forms in the language.
It is what prevents heuristic path reconstruction.
Do this:
(proc: "./bin/certify" args: [artifactPath: "typed_unit"] timeoutMs: 2000)
Do not do this by guessing where the previous stage probably wrote its output.
Builds explicit paths from explicit components.
Example:
joinPath: [".observer/out", (case stem), "summary.jsonl"]
Use this for:
- output directories
- output file paths
- deterministic expected path construction
Constructs an explicit failure value for assertion.
Example:
expect: Fail msg: "compile stage failed".
This is useful when you want a case to fail with a clear workflow-specific message rather than only exposing a lower-level action detail.
Current predicate vocabulary includes:
(<ValueExpr>) = <ValueExpr>
(<ValueExpr>) != <ValueExpr>
(<ValueExpr>) < <ValueExpr>
(<ValueExpr>) <= <ValueExpr>
(<ValueExpr>) > <ValueExpr>
(<ValueExpr>) >= <ValueExpr>
(<ValueExpr>) contains: <ValueExpr>
(<ValueExpr>) contains: /<Regex>/
(<ValueExpr>) startsWith: <ValueExpr>
(<ValueExpr>) endsWith: <ValueExpr>
(<ValueExpr>) match: /<Regex>/
<ValueExpr> isStatus: <Int>
<ValueExpr> isStatusClass: <Int>
<ValueExpr> hasHeader: <String>
Examples:
expect: (compile exit) = 0.
expect: (run out) contains: "ok".
expect: (f msg) contains: "No such file".
expect: (run out) match: /ok|healthy/.
Common artifact kinds used in the examples are:
filejsonjsonl
Choose the kind that matches what the stage actually materializes.
Do not publish a JSONL file as file unless you deliberately want to avoid structured extraction.
If you intend to call extractJson: or extractJsonl:, publish the artifact with the matching structured kind.
Bindings only come from explicit places.
You do not have general local variables.
Bindings are introduced by:
- the suite item case binding, such as
:case ifOk:bindings, such as:compileifFail:bindings, such as:f
Examples:
(files: ... ) forEachCase: [ :case |
(proc: ... ) ifOk: [ :compile |
...
] ifFail: [ :f |
...
].
].
Each binding is scoped to its block.
This part is not optional.
Observer is designed around these constraints:
- case discovery order must be deterministic
- action order in one case is exactly source order
- artifact reuse must be explicit
- verdicts must be mechanically derived from explicit contract data
That means:
- no hidden shell interpolation semantics in the language
- no implicit downstream artifact guessing
- no depending on host filesystem iteration order
- no free-form mutable program state in the suite
If a workflow idea depends on heuristic inference, it is probably outside the intended model.
This point is important enough to isolate.
For proc::
- action success means Observer successfully ran the process and captured a result
- child exit code is just one field on that result
So this pattern is correct:
(proc: "./bin/tool" args: [...] timeoutMs: 2000) ifOk: [ :r |
expect: (r exit) = 0.
] ifFail: [ :f |
expect: Fail msg: "tool could not be run".
].
Use ifFail: for action-level failure.
Use (r exit) = 0 for subject/process-level success.
This distinction matters in real workflows.
Recommended order:
artifact_roundtrip.obsstage_failure.obsmulti_artifact_pipeline.obsstarter/starter-pipeline/starter-pipeline-failure/compiler_workflow.obs
Why this order:
- the early
.obsfiles teach isolated constructs starter/shows the smallest runnable project shapestarter-pipeline/gives the main artifact-chain "aha"starter-pipeline-failure/shows a deterministic staged breakcompiler_workflow.obsshows the larger intended destination
Use starter-pipeline/ for this walkthrough.
The main flow is:
- discover cases from
corpus/**/*.src - compile each case into a published
typed_unit - certify that unit into a published
certificate - summarize both artifacts into a published
summary - assert over extracted JSONL facts from those artifacts
In tests.obs, discovery begins here:
(files: "corpus" glob: "**/*.src" key: stem) forEachCase: [ :case |
...
].
That means Observer creates deterministic cases from the filesystem first.
For each case, the first stage publishes:
publish: "typed_unit" kind: "jsonl" path: joinPath: [".observer/out", (case stem), "typed_unit.jsonl"].
The second stage does not guess that path again. It consumes the artifact contractually:
(proc: "./bin/certify-unit" args: [
artifactPath: "typed_unit",
...
] timeoutMs: 2000)
Then the final stage consumes both typed_unit and certificate, publishes summary, and the workflow extracts facts like:
$.unit_id$.certificate_status$.pipeline$.case_key
So the shell model is not:
- run one big script
- hope files appear in the right places
It is:
- discover cases deterministically
- run explicit stages in order
- publish named artifacts
- consume artifacts by name
- assert over canonical extracted facts
Use starter-pipeline-failure/ immediately after the passing starter.
The key failure is deterministic:
alphahasbin/alpha/certify-unitbetadoes not havebin/beta/certify-unit
The failing stage is written as:
(proc: joinPath: ["./bin", (case stem), "certify-unit"] args: [
artifactPath: "typed_unit",
...
] timeoutMs: 2000) ifOk: [ :certify |
...
] ifFail: [ :f |
expect: (f kind) = "spawn".
expect: Fail msg: "certify stage failed".
].
This is the important point:
- the workflow shape did not change
- case discovery did not change
- artifact publication rules did not change
- only one stage for one case failed structurally
So the model remains deterministic.
Observer records that beta reached the certify stage and that the stage failed with a spawn failure. Downstream summary generation does not run for that case, because the artifact contract for the missing certificate was never satisfied.
That is the shell equivalent of the C distinction between transport success and test failure: you must separate action failure from normal stage outcome.
A practical workflow-authoring process looks like this.
Usually this is a corpus directory.
Example:
(files: "corpus" glob: "**/*.src" key: stem) forEachCase: [ :case |
...
].
Keep stage logic in project-local executables or scripts.
Examples:
./bin/compile./bin/certify./bin/summarize
Observer should orchestrate these tools, not replace them.
After a stage succeeds, publish what matters.
Example:
publish: "typed_unit" kind: "jsonl" path: joinPath: [".observer/out", (case stem), "typed_unit.jsonl"].
Do not reconstruct paths heuristically later.
Example:
(proc: "./bin/certify" args: [artifactPath: "typed_unit"] timeoutMs: 2000)
Example:
(extractJsonl: "typed_unit" select: "$.unit_id") ifOk: [ :unitId |
expect: (unitId count) = 1.
].
Example:
(proc: "./bin/certify" args: [artifactPath: "typed_unit"] timeoutMs: 2000) ifOk: [ :cert |
expect: (cert exit) = 0.
] ifFail: [ :f |
expect: Fail msg: "certify stage failed".
].
Use a checked-in report snapshot.
The starter examples show this pattern with:
make reportexpected.default.jsonlmake verify
This shape is representative of a small multi-stage pipeline:
module StarterPipeline.
(files: "corpus" glob: "**/*.src" key: stem) forEachCase: [ :case |
(proc: "./bin/emit-unit" args: [
joinPath: ["corpus", (case path)],
joinPath: [".observer/out", (case stem)],
joinPath: [".observer/out", (case stem), "typed_unit.jsonl"]
] timeoutMs: 2000) ifOk: [ :emitUnit |
expect: (emitUnit exit) = 0.
publish: "typed_unit" kind: "jsonl" path: joinPath: [".observer/out", (case stem), "typed_unit.jsonl"].
(artifactCheck: "typed_unit" kind: "jsonl") ifOk: [ :typedUnit |
(extractJsonl: "typed_unit" select: "$.unit_id") ifOk: [ :unitId |
expect: (unitId count) = 1.
expect: (unitId text) = joinPath: ["unit", (case stem)].
].
(proc: "./bin/certify-unit" args: [
artifactPath: "typed_unit",
joinPath: [".observer/out", (case stem)],
joinPath: [".observer/out", (case stem), "certificate.jsonl"]
] timeoutMs: 2000) ifOk: [ :certify |
expect: (certify exit) = 0.
publish: "certificate" kind: "jsonl" path: joinPath: [".observer/out", (case stem), "certificate.jsonl"].
].
].
].
].
Use the runnable version in starter-pipeline/ as the real reference.
Bad:
(proc: "./bin/certify" args: [joinPath: [".observer/out", (case stem), "typed_unit.jsonl"]] timeoutMs: 2000)
Better:
(proc: "./bin/certify" args: [artifactPath: "typed_unit"] timeoutMs: 2000)
Bad:
(proc: "./bin/tool" args: [...] timeoutMs: 2000) ifFail: [ :f |
;; expecting normal process exit handling here
].
Better:
(proc: "./bin/tool" args: [...] timeoutMs: 2000) ifOk: [ :r |
expect: (r exit) = 0.
].
If one shell script internally discovers cases, computes paths, runs all stages, and decides pass or fail, Observer sees too little.
Push stage logic into small tools, and let Observer own:
- case discovery
- stage ordering
- artifact publication
- structured checks
- final verdicts
If you want to extract JSONL fields, publish as jsonl, not generic file.
If a stage depends on some file, directory, or tool, make that dependency explicit in the workflow or local project layout.
From a starter directory such as lib/shell/starter-pipeline:
make run
make report
make verify
make cleanTypical meanings:
make run: human-readable console flowmake report: canonical JSONL reportmake verify: compare against checked-in snapshotmake clean: remove generated.observeroutput
This is the quick lookup section.
module: optional module headerforEach: required inventory-driven iterationforEachOptional: optional inventory-driven iterationfiles:: filesystem discovery rootglob:: filesystem discovery patternkey:: case-key derivation fieldforEachCase: required filesystem-driven iterationforEachCaseOptional: optional filesystem-driven iteration
expect:: record an assertionpublish:: publish a named artifactifOk:: success branch for a result expressionifFail:: failure branch for a result expressionifTrue:: true branch for a boolean expressionifFalse:: false branch for a boolean expression
run:: execute an inventory testproc:: execute a local processhttpGet:: perform an HTTP GETtcp:: perform a TCP probeartifactCheck:: look up a named artifactextractJson:: extract from a JSON artifactextractJsonl:: extract from a JSONL artifact
artifactPath:: resolve a named artifact to a path valuejoinPath:: construct a path from explicit partsFail msg:: explicit failure value for assertion
=!=<<=>>=contains:startsWith:endsWith:match:isStatus:isStatusClass:hasHeader:
The current full surface does not provide:
- user-defined functions
- mutation
- general local variables
- arbitrary loops
- implicit shell pipelines as language constructs
- heuristic path inference
- hidden file IO primitives inside the suite language
That is deliberate.
The point is to keep workflows explicit, deterministic, and mechanically derivable.
Use these in order:
lib/shell/README.mdfor the example indexlib/shell/starter/for the smallest runnable examplelib/shell/starter-pipeline/for the main passing artifact pipelinelib/shell/starter-pipeline-failure/for the failing companionlib/shell/compiler_workflow.obsfor the larger target shapespecs/30-suite.mdfor the suite surface definitionspecs/50-workflow-verification.mdfor the workflow model and constraints
If you keep one rule in mind, keep this one:
Publish artifacts explicitly, then consume them by name.