Skip to content

JSON Output

The sow.cli/v1 envelope, its fields, and result shapes for the primary command families.

Every command that produces data accepts --json. The output is a single line on stdout carrying a versioned envelope, so you can pipe it straight into jq without worrying about which command produced it.

sow status --json
{"schema":"sow.cli/v1","command":"status","ok":true,"repository":"pigsty","operation":null,
 "result":{"repository":"pigsty","status":"clean","ready_to_copy":true,"desired_revision":4,
 "built_generation":"00000000000000000004","dirty_dists":[],"dirty_reasons":[],"pending":{"count":0,"bytes":0},
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
 "created_at":"2026-08-04T04:07:17.665377Z","updated_at":"2026-08-04T04:07:18.293848Z"},
 "repository_locked":false},"errors":[]}

(Line-wrapped here for readability; the real output is one line.)

The envelope

Field Type Meaning
schema string Always sow.cli/v1. Check it before parsing anything else.
command string The command as invoked, including the subcommand: add, repo ls, config show.
ok bool true when errors is empty. Equivalent to exit code 0.
repository string or null The selected repository, or null for workspace-wide and plain-mode commands.
operation string or null The operation ID for write commands, null for read-only ones.
result object or null Command-specific payload, described below.
errors array Zero or more {code, class, message} objects.

All seven fields are always present. result is null when the command failed before meaningful work — an unknown flag, discovery failure, or invalid configuration before a Repository was selected, for example. Committed partial results and diagnostic results from check or rm --check are preserved even when the command exits nonzero.

errors

"errors":[{"code":3,"class":"partial","message":"managed: batch partially succeeded"}]
Field Meaning
code The process exit code1 through 6.
class runtime, usage, discovery, config, partial, lock, integrity, or rejected. discovery and config are stable specializations of exit code 2.
message The same text written to stderr.

Branch on class, not on message text. Messages carry paths and package names and will change; the class will not.

A nonzero exit still returns the result

When a batch partially succeeds, ok is false and result lists everything that was committed. Never discard the payload because the exit code was nonzero — for add, that is exactly where you learn which packages landed.

Operation IDs are strings

"operation":"8632724976452398569"

Operation IDs are 64-bit values serialized as decimal strings, because they routinely exceed what an IEEE 754 double can represent exactly. In JavaScript, JSON.parse on a bare number would silently corrupt them. Keep them as strings; jq handles them correctly as-is.

Generation IDs are fixed-width strings

"built_generation":"00000000000000000004"

Generation IDs cover the full unsigned 64-bit domain and are serialized as exactly 20 zero-padded decimal digits. Treat generation, built_generation, base_generation, and Generation-valued base fields as strings. The fixed width preserves numeric order under ordinary bytewise comparison.

stdout and stderr

Results and the JSON envelope go to stdout. Warnings and error diagnostics go to stderr, in addition to appearing in the errors array. So this works:

sow check --json 2>/dev/null | jq -e '.ok'

Result shapes

create

sow create /srv/offline --json
{"schema":"sow.cli/v1","command":"create","ok":true,"repository":null,"operation":null,
 "result":{"dir":"/srv/offline","rpm":4,"deb":3,
 "kept":["blackbox_exporter-0.28.0-1.aarch64.rpm","blackbox_exporter-0.28.0-1.x86_64.rpm",
 "libpq5_18.2-1.pgdg12+1_amd64.deb","pev2-1.23.0-1.noarch.rpm"],
 "removed":[],"marker":false,"noop":true,"recovered":false},"errors":[]}
Field Meaning
dir The absolute directory that was indexed
rpm, deb Package counts per format
kept Filenames included in the indexes, sorted
removed Packages deleted by --pigsty cleanup; empty otherwise
marker Whether repo_complete was written
marker_sha256 Digest of the marker file; present only with --pigsty
noop true when the indexes were already correct and nothing changed
recovered Reserved for schema compatibility; Plain create has no journal recovery and always reports false
signed Filenames re-signed; present only with --sign-with

init

"result":{"workspace":"/srv/repo","config_created":true,
 "repositories_initialized":0,"dists_initialized":0,"existing":[]}

On a rerun over a workspace that already exists, the counters are 0 and existing names what was found:

"result":{"workspace":"/srv/repo","config_created":false,
 "repositories_initialized":0,"dists_initialized":0,"existing":["sow.yml"]}

config check and config show

"result":{"workspace":"/srv/repo","repositories":1,"dists":2}

config show returns the effective configuration itself, in the same shape as sow.yml after normalization:

"result":{"schema":"sow/v3","architectures":["x86_64","aarch64"],
 "repos":{"pigsty":{"protected":false,
 "signing":{"rpm":{"packages":{"mode":"never"}}},
 "dists":{"el9":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":0,"exclude":null},
          "trixie":{"format":"deb","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}}}}

With --all, signing entries additionally carry key_fingerprint. Private key material and passphrases never appear.

repo ls, repo new, repo show

repo ls returns an array; repo new and repo show return one object of the same shape.

"result":{"repositories":[{"name":"pigsty","path":"/srv/repo/pigsty","protected":false,
 "dists":2,"generation":"00000000000000000004","desired_revision":4,"status":"clean","packages":7,"memberships":7,
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
  "created_at":"2026-08-04T04:07:17.665377Z","updated_at":"2026-08-04T04:07:18.293848Z"},
 "config":{"protected":false,"signing":{...},"dists":{...}}}]}

packages counts distinct package objects in the pool; memberships counts distribution memberships, so a package in two distributions counts once and twice respectively.

repo rm returns only the outcome:

"result":{"name":"demo","noop":false,"removed":true}

dist ls, dist new, dist show

"result":{"dists":[{"name":"el9","format":"rpm",
 "architectures":[{"family":"x86_64","ecosystem_arch":"x86_64"},
                  {"family":"aarch64","ecosystem_arch":"aarch64"}],
 "desired_members":4,"built_members":4,"generation":"00000000000000000003","dirty":false,"status":"clean",
 "effective_config_sha256":"39913af601d10d4d4033b0c29e8d66df385f8a6eb22f45219773a7fc170d4243",
 "config":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}]}

Each architecture entry carries both names: family is the canonical form used in configuration, ecosystem_arch is what appears in the published tree — identical for RPM, amd64/arm64 for DEB.

desired_members ahead of built_members, or dirty: true, means a sow build is pending. effective_config_sha256 is the digest of everything that feeds the renderer; when it changes, the distribution becomes dirty.

dist rm mirrors repo rm: {"name":"el9","noop":false,"removed":true}.

add

"result":{"operation":"320653458389425222","repository":"demo",
 "desired_revision":2,"built_generation":"00000000000000000002","dirty":false,
 "accepted":1,"failed":0,"memberships_added":1,"memberships_removed":0,
 "items":[{"input":"/incoming/pev2-1.23.0-1.noarch.rpm","status":"accepted","format":"rpm",
 "coordinate":"pev2-0:1.23.0-1.noarch",
 "sha256":"d06d7f23b9cfc6aedaab7b60c8e890cda020efe84f1f246243414862b98b1229",
 "dists":{"el9":"accepted"}}]}

One items entry per input path, in stable order. status is the item’s overall outcome, and dists gives the per-distribution decision:

status Meaning
accepted New package object, membership created
reused The identical object already existed; may still add membership elsewhere
excluded Kept out by policy — see dists for whether it was excluded or limited
failed Not admitted; error carries the reason

The per-distribution values are accepted, excluded, and limited. A package can be accepted by one distribution and limited by another in the same command:

"items":[{"input":".../libpq5_18.2-1.pgdg12+1_amd64.deb","status":"excluded","format":"deb",
 "coordinate":"libpq5=18.2-1.pgdg12+1:amd64","sha256":"310611d0...","dists":{"trixie":"limited"}}]

A failed item carries error instead of the package fields:

{"input":"/incoming/broken-1.0-1.x86_64.rpm","status":"failed",
 "error":"invalid RPM package: parse RPM reader: unexpected EOF"}

memberships_added and memberships_removed count both sides, because limit can evict older versions in the same operation that admits a new one.

rm

"result":{"operation":"3422380511083828695","repository":"pigsty",
 "desired_revision":5,"built_generation":"00000000000000000005","dirty":false,"check":false,
 "removed":[{"dist":"el9","sha256":"45171966...",
   "coordinate":"rpm:pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64","name":"pgbouncer_fdw_18"}],
 "dists":["el9"],
 "changes":[{"op":"add","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz",
   "phase":"metadata","size":382,"sha256":"1a57aa2f..."},
  {"op":"update","path":"dists/el9/x86_64/repodata/repomd.xml","phase":"pointer",
   "size":1510,"sha256":"f28ffe14..."},
  {"op":"delete","path":"dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz","phase":"delete"}]}

check is true when the command ran with -c/--check, in which case nothing was written and changes is a forecast. Note that removed lists membership removals only — pool bytes are never deleted by rm.

build

"result":{"operation":"3701044631565986409","repository":"pigsty",
 "dists":["el9","trixie"],"desired_revision":5,"built_generation":"00000000000000000005",
 "noop":true,"dirty":false}

noop: true means the desired state already matched the built tree, so no generation was created. dists lists the distributions considered, not necessarily the ones rebuilt.

status

"result":{"repository":"pigsty","status":"clean","ready_to_copy":true,
 "desired_revision":4,"built_generation":"00000000000000000004","dirty_dists":[],"dirty_reasons":[],
 "pending":{"count":0,"bytes":0},
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
  "created_at":"...","updated_at":"..."},
 "repository_locked":false}

status is one of clean, dirty, recovering, error. ready_to_copy is the field to read in a deploy script — but remember status exits 0 in every state, so test the field, not the exit code:

sow status --json | jq -e '.result.ready_to_copy' >/dev/null || exit 1

pending counts package bytes held privately after add --skip, not yet published. repository_locked reports whether another process currently holds the write lock.

check

"result":{"repository":"pigsty","status":"clean","ready_to_copy":true,
 "built_generation":"00000000000000000004","desired_revision":4,
 "layers":[{"name":"config","ok":true,"checked":5,"issues":[]},
  {"name":"state","ok":true,"checked":1,"issues":[]},
  {"name":"public-modes","ok":true,"checked":72,"issues":[]},
  {"name":"retained","ok":true,"checked":0,"issues":[]},
  {"name":"package-bytes","ok":true,"checked":7,"issues":[]},
  {"name":"desired-membership","ok":true,"checked":7,"issues":[]},
  {"name":"index","ok":true,"checked":2,"issues":[]},
  {"name":"signature","ok":true,"checked":11,"issues":[]},
  {"name":"generation-manifest","ok":true,"checked":1,"issues":[]}]}

Steady-state checks return nine layers in this order, each with a count of what it examined and any issues found. A non-terminal layout transition instead returns config, state, public-modes, and layout-transition, then stops with a not-ready result. A dirty repository can report every steady-state layer ok: true and still fail with exit 5, because the layers verify consistency while ready_to_copy reports currency:

{...,"ok":false,"result":{"status":"dirty","ready_to_copy":false,...},
 "errors":[{"code":5,"class":"integrity",
  "message":"integrity or recovery error: managed: repository is not ready to copy: repository status is dirty"}]}

changes

"result":{"repository":"pigsty","base":"00000000000000000004","generation":"00000000000000000005","dirty":false,
 "changes":[{"op":"add","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz",
   "phase":"metadata","size":382,"sha256":"1a57aa2f..."},
  {"op":"update","path":"dists/el9/x86_64/repodata/repomd.xml","phase":"pointer",
   "size":1510,"sha256":"f28ffe14..."},
  {"op":"delete","path":"dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz","phase":"delete"}]}
Field Values
op add, update, delete
phase payload, metadata, pointer, delete
path Always relative to the repository root, always /-separated
size, sha256 Present for add and update; omitted for delete

Apply the phases in that order and no client ever sees a dangling reference: package bytes first, then checksum-named metadata, then the protocol pointers (repomd.xml, Release), and only then the deletion of superseded files.

sow changes 0 yields the complete current tree as one add set — a full delivery manifest.

ls, show, where

ls returns an array of package objects; show returns exactly one under package.

"result":{"repository":"pigsty","dists":["el9"],"dirty":false,
 "packages":[{"sha256":"d06d7f23...","format":"rpm","coordinate":"pev2-0:1.23.0-1.noarch",
 "architecture":"noarch","canonical_arch":"neutral",
 "pool_path":"pool/p/pev2/pev2-1.23.0-1.noarch.rpm","filename":"pev2-1.23.0-1.noarch.rpm",
 "size":316372,"name":"pev2","source":"pev2","version":"1.23.0","epoch":"0","release":"1",
 "kind":"main","payload_sha256":"0413d629...","signature_key":"E7935D8DB9BD8B20",
 "storage":"pool","created_revision":3,"dists":["el9"],"built_dists":["el9"]}]}

The fields worth knowing:

Field Meaning
architecture As it appears in the package header: x86_64, noarch, amd64, all
canonical_arch The family SOW groups by: x86_64, aarch64, or neutral
payload_sha256 RPM only — the signature-neutral digest used to recognize re-signed copies
signature_key Key ID of the embedded signature, when the package carries one
storage pool when published, pending when added with --skip
dists / built_dists Desired membership versus what the last build published

dists longer than built_dists is another way to see that a build is pending.

where searches the whole workspace and returns locations instead of full objects:

"result":{"reference":"pev2","locations":[{"repository":"pigsty","dists":["el9"],
 "built_dists":["el9"],"sha256":"d06d7f23...","coordinate":"rpm:pev2-0:1.23.0-1.noarch"}]}

publish, retain, gc, export

Managed lifecycle commands use the same envelope and keep numeric Generation values as JSON strings:

Command Important result fields
publish repository, target, provider, generation, attempt, checkpoint, phase, objects, noop
publish --abort repository, target, provider, attempt, phase, objects
publish --rebind Same result as publish; the binding revision is durable private audit state, not an extra wire field
retain add / retain rm repository, record, record_identity, path
retain ls repository, generations[] with the same retained record shape
local gc operation, repository, base_generation, generation, objects, bytes, noop
target gc repository, target, provider, phase, reports, candidates, deleted_objects, deleted_bytes, retained_objects, pending_grace, completed_attempts, noop
export rpm-leaf repository, repository_id, generation, dist, arch, directory, method, signed, signer_identity, packages, files, manifest_sha256

An optional identity such as attempt, checkpoint, or local-GC operation is omitted when there is no value. R2 target GC reports candidates as retained and never reports remote deletion performed by SOW.

log

sow log returns the operation ledger, newest first:

"result":{"repository":"pigsty","operations":[{"id":"3701044631565986409","kind":"build",
 "state":"done",
 "payload_json":"{\"version\":2,\"repository\":\"pigsty\",\"kind\":\"build\",\"config_sha256\":\"37eb6dcf...\",\"skip\":false,\"noop\":true,\"dists\":[\"el9\",\"trixie\"],\"build_dists\":[],\"manifest_sha256\":\"125d7266...\"}",
 "result_json":"{\"dists\":2,\"dropped_pending\":[]}",
 "created_at":"2026-08-04T04:08:08.691678Z","updated_at":"2026-08-04T04:08:08.763019Z"}]}

payload_json and result_json are strings containing nested JSON, not objects. They are stored verbatim so the audit record is byte-stable; parse them with a second pass:

sow log --json | jq -r '.result.operations[] | .payload_json | fromjson | .config_sha256'

Passing an operation ID returns the full detail — state transitions, structured build_progress events, packages, memberships, and every file action:

"result":{"repository":"pigsty","detail":{"operation":{...},"duration_ms":598,
 "events":[{"sequence":0,"state":"planned","detail_json":"{}","occurred_at":"..."},
  {"sequence":1,"state":"staged",...},{"sequence":2,"state":"applied",...},
  {"sequence":3,"state":"applied","detail_json":"{\"version\":1,\"kind\":\"build_progress\",\"phase\":\"rendering\",\"completed\":1,\"total\":2,\"jobs\":8}",...},
  {"sequence":4,"state":"built",...},{"sequence":5,"state":"done",...}],
 "packages":[{"sequence":0,"input_path":"pgbouncer_fdw_18","package_sha256":"45171966...",
  "coordinate":"rpm:pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64","disposition":"removed"}],
 "memberships":[{"sequence":0,"dist":"el9","package_sha256":"45171966...","action":"remove"}],
 "files":[{"sequence":0,"action":"add","phase":"metadata","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz","size":382,"sha256":"1a57aa2f..."}]}}

sow log prune returns what it removed:

"result":{"operation":"7140280533435786353","repository":"demo",
 "before":"2026-01-01T00:00:00+08:00","pruned":0}

Note that before echoes the absolute timestamp a bare date resolved to in your local timezone.

log export is not an envelope

sow log export writes JSON Lines — one complete operation record per line, no envelope, no --json flag. It is meant for archiving, not for scripting a single command:

sow log export - | head -1
sow log export operations.jsonl

It refuses to overwrite an existing file, and it refuses a target whose parent directory is a symlink.

A worked example

Fail a deploy unless the repository is both consistent and current, then list exactly what to copy:

#!/usr/bin/env bash
set -euo pipefail

if ! sow check -r pigsty --json 2>/dev/null | jq -e '.ok' >/dev/null; then
  echo "repository is not deliverable" >&2
  exit 1
fi

# Full manifest of the current published tree, in delivery order.
sow changes 0 -r pigsty --json \
  | jq -r '.result.changes[] | [.phase, .op, .path] | @tsv'

See also