Living documentation - Built from experimentation, updated when confusion is discovered.
| Command | Purpose | Example |
|---|---|---|
pond init |
Initialize a new pond | pond init --birthplace water-prod |
pond mkdir |
Create directories | pond mkdir /data |
pond copy |
Copy files into pond | pond copy host:///tmp/data.csv /data/ |
pond list |
List files (glob patterns) | pond list '**/*' |
pond cat |
Read file contents | pond cat host+csv:///tmp/data.csv --format=table |
pond describe |
Show file schema | pond describe /data/*.csv |
pond mknod |
Create factory nodes | pond mknod --config f.yaml /path |
pond run |
Execute factory nodes | pond run 20-foo collect |
pond run |
Execute from host config | pond run host+sitegen:///config.yaml build . |
pond log |
View transaction history | pond log --limit 20 |
pond remote add |
Attach a pull-mode remote (mirror or import) | pond remote add upstream s3://bucket /imports/upstream |
pond backup add |
Attach a backup (push or push+pull) | pond backup add origin s3://bucket |
pond remote list |
List attached remotes (all) | pond remote list |
pond backup list |
List push-side attachments | pond backup list |
pond remote remove |
Detach a remote (also works for backups) | pond remote remove origin |
pond remote remove --purge |
Detach AND drop the materialized mount entry | pond remote remove --purge upstream |
pond push |
Push to push/both-mode remotes | pond push |
pond capsule |
Install recovery recipes or verify portable capsules | pond capsule recipe inspect azure |
pond freeze |
Persistently freeze, inspect, or re-enable pond data writes | pond freeze status |
pond pull |
Pull from pull/both-mode remotes | pond pull |
pond restore |
Bootstrap a whole-pond replica from a backup (disaster recovery) | pond restore origin file:///mnt/backups/origin |
pond maintain |
Delta maintenance; --compact records a pushable Compact bundle |
pond maintain --compact |
pond verify |
Compare local data against remote checksums (D6.1) | pond verify origin |
pond fsck |
Local integrity check: content checksums + content-tree root fingerprint | pond fsck --verbose |
pond status |
Operator status aggregate: identity, watermarks, recovery (D6.2) | pond status |
pond tlog verify |
Verify the transparency log (inclusion, append-only, faithfulness) (D5) | pond tlog verify |
pond rebuild-control |
Reconstruct a lost control table from data (D6.3) | pond rebuild-control |
pond config |
Show/set pond configuration | pond config |
Watertown commands work in two modes:
- Pond mode: Operations on a transactional filesystem at
$POND. Files are persistent, versioned, and replicable. - Host mode (
host+prefix): Read-only operations on host filesystem files. No$PONDrequired. The same query engine and factory system work directly on local files.
| Mode | pond cat |
pond run |
pond copy |
|---|---|---|---|
| Pond | pond cat /data/file |
pond run 20-hydrovu collect |
pond copy host:///f /data/f |
| Host | pond cat host+csv:///f |
pond run host+sitegen:///c.yaml build . |
N/A (read-only) |
| Variable | Purpose | Default |
|---|---|---|
POND |
Path to pond storage | Required for pond mode; not needed for host+ operations |
RUST_LOG |
Logging level | info |
Initialize a new pond at the path specified by $POND.
export POND=/data/mypond
pond init --birthplace water-prodCreates the pond directory structure with Delta Lake metadata.
The required --birthplace is an immutable label for where the pond is
created (for example a hostname, site, or deployment name). It is
recorded permanently in the pond's identity metadata and shown by
pond status, pond log, and pond config.
List files and directories matching a glob pattern.
Unlike
ls,pond listis recursive by default. Runningpond listwith no arguments lists every file in the pond. Usepond list /for just the top-level entries.
# List everything (default behavior)
pond list # defaults to '**/*' - all files recursively
# List root directory entries
pond list / # top-level files and directories only
# List specific directory contents
pond list /data/ # trailing slash lists contents of /data
pond list '/data/*' # equivalent to above
pond list '/data/**/*' # recursive under /data
# Match specific entry (file or directory)
pond list /data # shows the /data entry itself (not contents)
# Pattern matching
pond list '**/*.csv' # all CSV files
pond list '/sensors/*/readings.series'Options:
| Flag | Purpose |
|---|---|
-l, --long |
Show full entry type names (e.g., table:series, table:dynamic) |
-a, --all |
Show hidden files |
Pattern Behavior Summary:
| Pattern | Meaning |
|---|---|
| (none) | All files recursively (**/*) |
/ |
Root directory entries only |
/dir/ |
Contents of /dir (trailing slash = /dir/*) |
/dir |
Entry named 'dir' exactly |
**/*.ext |
All files with extension recursively |
Tip: Use trailing slash to list directory contents:
pond list /data/
Create directories in the pond.
pond mkdir /data
pond mkdir /sensors/temperatureParent directories are created automatically (like mkdir -p).
Copy files between host filesystem and the pond.
Source URLs must have the host:// or host+ prefix. The entry type is encoded in the URL:
# Copy raw data (default): store as opaque bytes
pond copy host:///tmp/data.csv /data/readings.csv
# Copy as queryable Parquet table (validates PAR1 magic bytes)
pond copy host+table:///tmp/data.parquet /data/readings.parquet
# Copy as time-series Parquet (validates PAR1 + extracts temporal bounds)
pond copy host+series:///tmp/readings.parquet /data/readings.parquet
# Copy to directory (keeps filename)
pond copy host:///tmp/data.csv /data/| URL Prefix | Entry Type | Validates | Use Case |
|---|---|---|---|
host:///path |
data (raw bytes) | None | CSV, JSON, any file |
host+file:///path |
data (raw bytes) | None | Same as host:/// |
host+table:///path |
queryable Parquet | PAR1 magic | Parquet tables |
host+series:///path |
time-series Parquet | PAR1 magic + temporal | Parquet with timestamp |
Note:
host+table:///validates that the input is valid Parquet (PAR1 magic bytes). To copy a CSV file, usehost:///(raw data), then query withcsv://URL scheme.
# Correct: Copy CSV as raw data, then query with csv:// prefix
pond copy host:///tmp/data.csv /data/readings.csv
pond cat csv:///data/readings.csv --sql "SELECT * FROM source WHERE temp > 20"
# Wrong: host+table:// will error on CSV (not valid Parquet)
pond copy host+table:///tmp/data.csv /data/readings.csv # ERROR: missing PAR1 magicExport files from the pond to the host filesystem. The destination must have the host:// prefix.
Glob patterns are supported for matching multiple files.
# Export all series files preserving directory structure
pond copy '/hydrovu/devices/**/*.series' host:///tmp/export
# Export a single file
pond copy /data/readings.parquet host:///tmp/outputThe export format is determined by the source entry type:
- table/series entries → exported as Parquet (via DataFusion)
- data entries → exported as raw bytes (bit-for-bit copy)
When copying out, pond paths are preserved relative to the destination. This can produce
unwanted nesting (e.g. exporting /hydrovu/... into a directory called hydrovu/ creates
hydrovu/hydrovu/...). Use --strip-prefix to remove a leading path prefix:
# Without --strip-prefix: creates output/hydrovu/devices/123/foo.series
pond copy '/hydrovu/**/*.series' host:///tmp/output
# With --strip-prefix: creates output/devices/123/foo.series
pond copy '/hydrovu/**/*.series' host:///tmp/output --strip-prefix=/hydrovuRead and optionally transform file contents. Works on both pond files and
host filesystem files (via host+ URL prefix). No $POND is required when
reading host files.
# Read raw file contents from pond
pond cat /data/readings.csv
# Read a host file directly (no pond needed)
pond cat host+file:///tmp/readme.txt
# Query a host CSV with SQL
pond cat host+csv:///tmp/data.csv --format=table --sql "SELECT * FROM source WHERE temp > 20"
# Query a host zstd-compressed CSV
pond cat host+csv+zstd:///tmp/data.csv.zst --format=table
# Query a host Parquet file directly as a table (no pond needed)
pond cat host+table:///tmp/snapshot.parquet --format=table
pond cat host+table:///tmp/snapshot.parquet --sql "SELECT count(*) FROM source"
# Query pond CSV files with SQL (use csv:// prefix)
pond cat csv:///data/readings.csv --sql "SELECT * FROM source WHERE temp > 20"
# Query OtelJSON Lines files (use oteljson:// prefix)
pond cat oteljson:///logs/metrics.json --sql "SELECT * FROM source ORDER BY timestamp"
# Query Parquet files (stored with host+table:// via pond copy)
pond cat /data/readings.parquet --sql "SELECT AVG(temperature) as avg_temp FROM source"The table is always named source in SQL queries.
| Flag | Output | When to use |
|---|---|---|
--format=raw (default) |
Parquet bytes (binary) | Piping to files, downstream tools |
--format=table |
Human-readable text table | Terminal display, debugging |
Note:
--formatonpond catcontrols output display (raw bytes vs ASCII table).pond copydoes not have a--formatflag — entry type is encoded in the source URL (e.g.,host+table:///pathfor Parquet tables).
--sql controls what data is queried; --format controls how it's displayed.
# Parquet bytes to file (default)
pond cat oteljson:///ingest/data.json --sql "SELECT * FROM source" > output.parquet
# Human-readable table to terminal
pond cat oteljson:///ingest/data.json --format=table --sql "SELECT * FROM source LIMIT 5"
# Human-readable table, no SQL (full SELECT * ORDER BY timestamp)
pond cat oteljson:///ingest/data.json --format=tableURL schemes tell pond cat how to parse the file. Pond files and host files
use the same format/compression/entry-type syntax — host files just add the
host+ prefix.
General syntax: [host+]format[+compression][+entrytype]:///path
| Scheme | Purpose | Example |
|---|---|---|
csv:// |
Parse as CSV (pond) | pond cat csv:///data/file.csv --sql "..." |
host+csv:// |
Parse as CSV (host) | pond cat host+csv:///tmp/file.csv --format=table |
host+csv+zstd:// |
Zstd-compressed CSV (host) | pond cat host+csv+zstd:///tmp/file.csv.zst --format=table |
host+csv+series:// |
CSV as time-series (host) | pond cat host+csv+series:///tmp/ts.csv --format=table |
oteljson:// |
Parse as OtelJSON Lines | pond cat oteljson:///logs/metrics.json --sql "..." |
excelhtml:// |
Parse as Excel HTML | pond cat excelhtml:///data/export.html --sql "..." |
host+file:// |
Raw bytes (host) | pond cat host+file:///tmp/readme.txt |
file:// |
Raw bytes or Parquet (pond) | pond cat file:///data/file.parquet |
| (none) | Auto-detect (pond) | pond cat /data/file.csv |
Supported compressions: zstd, gzip, bzip2
Supported entry types: table, series
--sql flag (or --query alias) only works when the file can be parsed as a table:
- Parquet files (stored as table or series entry types)
- CSV files when using
csv://orhost+csv://prefix - OtelJSON Lines files when using
oteljson://prefix (two-pass: discovers all metric names as columns) - Raw data files without a scheme will output raw bytes, ignoring
--sql
DataFusion's information_schema is enabled, so you can discover column names dynamically:
# List all columns in the source table
pond cat oteljson:///ingest/data.json --format=table --sql "
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'source'
ORDER BY column_name
"This is useful when exploring unfamiliar data — you don't need to know the column
names in advance. pond describe also shows the schema, but information_schema
lets you query metadata with SQL alongside your data queries.
Show schema information for files.
pond describe /data/readings.csv
pond describe '/sensors/**/*.parquet'Create a factory node from a YAML configuration.
# From config file
pond mknod --config /path/to/config.yaml /destination/path
# Specific factory types
pond mknod sql-derived-table /derived/view --config-path filter.yaml
pond mknod hydrovu /system/etc/20-hydrovu --config-path hydrovu.yaml
pond mknod sitegen /system/etc/90-sitegen --config-path site.yamlSee Factory Types for configuration options.
Execute a factory node's commands. Works on both pond nodes and
host filesystem config files (via host+ URL prefix).
# Run data collection (full path)
pond run /system/etc/20-hydrovu collect
# Short name resolution (tries /system/run/ then /system/etc/)
pond run 20-hydrovu collect
# Build site
pond run /system/etc/90-sitegen build ./distShort names (without a leading /) are resolved by checking /system/run/{name}
then /system/etc/{name}. The first path that exists wins. If neither exists,
falls back to /system/run/{name} (which will produce a clear error downstream).
Note (D4+): remote-backup
push/pull/verify/showare no longer invoked throughpond runon a factory node. Use the top-levelpond push,pond pull, andpond remote ...verbs instead (see below). Configurations live under/sys/remotes/<name>, not under/system/run/<N>-remote.
When the path uses a host+ URL, pond run reads the config from the
host filesystem and executes the factory named by the URL scheme. No
$POND is required. This is useful for running any factory against
a local config file without first setting up a pond.
# Build a site from a local config
pond run host+sitegen:///path/to/site.yaml build ./distThe URL scheme (sitegen, etc.) must be a registered factory name.
Format providers like csv are not valid here -- use pond cat for those.
Config file format: the YAML file at the host path is passed directly to the factory, exactly as if it were stored inside a pond node.
View transaction history and audit trail.
# Recent transactions (default: last 10)
pond log
pond log --limit 20
# Transaction details
pond log --txn-seq 42
# Show incomplete operations (for recovery)
pond log --incompleteWatertown splits remote attachment into two verbs that match operator intent (D5.7b):
pond remote add NAME URL PATH-- attach a pull-mode remote and mount it at PATH.PATH = /is a mirror restart of this pond's own backup (foreign store_id must match this pond's pond_id). Non-root PATH is a cross-pond import (foreign store_id must differ; imported data appears under PATH).pond backup add NAME URL [--bidirectional]-- attach a backup remote. Push-only by default;--bidirectionalenables both push and pull (the rare bidirectional case). Backups always mirror the entire pond -- there is no PATH because the local pond IS the source.
Each attachment is a small YAML file under /sys/remotes/<name>
(portable; no per-pond watermarks). The local-only mode and mount
path live in the control table's raw_config map.
Credentials must be
${env:VAR}references, not literal secrets. The attachment YAML is replicated to every backup, so a literal--secret-access-keywould expose the secret on all replicas.pond remote add/pond backup addtherefore rejects a literalsecret_access_key: pass an env reference (single-quoted so your shell does not expand it) and set the value in the environment. The reference text replicates; each replica resolves it locally at use time.
# Attach an S3 backup with credentials (push-only).
# Export the secret in the environment; pass it as an env reference.
export AWS_SECRET_ACCESS_KEY=... # the actual secret, never persisted
pond backup add origin s3://my-bucket \
--region us-east-1 \
--endpoint http://localhost:9000 \
--access-key-id minioadmin \
--secret-access-key '${env:AWS_SECRET_ACCESS_KEY}' \
--allow-http
# Attach a bidirectional remote (push + pull, e.g. a federated hub)
pond backup add hub s3://hub-bucket --bidirectional \
--region us-east-1 \
--access-key-id ... --secret-access-key '${env:AWS_SECRET_ACCESS_KEY}'
# Attach a pull-mode remote as a cross-pond import
pond remote add upstream s3://prod-bucket /imports/upstream \
--region us-east-1 \
--access-key-id ... --secret-access-key '${env:AWS_SECRET_ACCESS_KEY}'
# Attach a pull-mode remote as a mirror restart of an EXISTING replica
# (a pond that already carries the source pond_id). To bootstrap a
# replica from a blank machine, use `pond restore` instead (it stamps the
# source pond_id first); a bare `pond remote add ... /` on a freshly
# `pond init`-ed pond is refused on a pond_id mismatch.
pond remote add origin file:///mnt/backups/origin /
# List all remotes (both pull and backup)
pond remote list
# List backups only
pond backup list
# Detach a remote (clears config + watermarks; preserves any materialized
# mount entry so previously-imported data is still readable by path).
pond remote remove origin
# Detach AND remove the mount entry created by a cross-pond import
# (only meaningful for pull-mode remotes with a non-root mount_path).
pond remote remove --purge upstreampond remote add writes /sys/remotes/<name> as YAML and records
remote_mode:<name> and remote_mount_path:<name> in the control
table. Re-adding the same name errors unless --overwrite is given.
Attach-time conflict checks (pull-mode only):
- The same
mount_pathcannot be used by two different pull-mode remotes. Trailing slashes are normalized, so/imports/xand/imports/x/are treated as identical.--overwriteis only honored when the conflicting attachment has the same name as the one being attached. - The same foreign
store_idcannot be mounted under two different pull-mode names. Mirror-restart attachments (PATH = /, foreignstore_id== localpond_id) are exempt because they do not materialize a mount entry.
Cross-pond mounts are read-only. Any write inside an
/imports/<name>/... subtree -- whether by pond copy, by an
explicit pond run, or by an auto-executing factory configured in
the foreign pond -- is refused with ReadOnlyImport. The steward
also filters its /system/run/* scan by local pond_id, so a
factory living in the foreign pond's filesystem is never
auto-executed by the local steward.
--purge semantics. By default, pond remote remove is a
detach: it clears /sys/remotes/<name>, the
remote_mode:<name> key, the remote_mount_path:<name> key, and the
per-URL watermarks, but leaves any materialized mount entry in place
(so imported data stays readable through the original path). With
--purge, the mount entry is also unlinked from its parent
directory. --purge is a no-op on backup-mode attachments (they
have no mount_path). Note: physically vacuuming the foreign
pond_id's rows from the underlying Delta log is deferred -- the
rows become unreachable by path after purge and are eligible for a
future compaction.
Push pending local transactions to one or more remotes.
# Push every remote with mode=push or mode=both
pond push
# Push only the named remote
pond push originAfter every write transaction, the steward also auto-pushes all
push/both-mode remotes -- so this command is mostly used when an
earlier auto-push failed (transient network), or right after attaching
a brand-new remote. Native pushes do not generate capsules; they idempotently
install or verify the small static recovery recipe before writing backup data.
Install static native-format recovery recipes and verify portable logical
recovery snapshots. The only recipe is the current
watertown.commit.v1 to pondcapsule.4 path.
# Install or verify the current recipe.
pond capsule recipe publish azure
pond capsule recipe inspect azure
# Verify and summarize a capsule downloaded into ./recovered/recovery/.
pond capsule inspect ./recovered
# Verification-only spelling for automation.
pond capsule verify ./recovered
# Import into the nonexistent path named by POND using a fresh pond identity.
# Import is experimental until bounded resume and active-remote preflight ship.
POND=/srv/watertown/recovered pond capsule import ./recovered \
--birthplace watershop-capsule-rehearsal \
--experimentalEvery backup push installs the current hash-addressed recipe. If the
discoverable recipe is absent it is created with current bytes. If it already
exists, it is left unchanged only when its exact bytes match its own
hash-addressed immutable copy. Missing or mismatched immutable copies fail
without backfill. Explicit publication creates
recovery/recipes/watertown.commit.v1/<recipe-hash>/README.sh before
recovery/README.sh.
Writes are create-only: retries accept only byte-identical objects, and differing bytes are never overwritten.
Inspection does not open or modify a pond. It validates the latest reference, canonical manifest, every physical object hash and size, every Parquet schema, every ordered logical file/table leaf hash, and each logical series root.
Inspection and import accept only pondcapsule.4; obsolete capsule formats
are rejected. Import accepts only a nonexistent target. It constructs a private sibling
staging pond with a fresh identity, persistently suppresses post-commit
factories and automatic pushes, and atomically promotes the target only after
an exact logical comparison. The importer currently leaves active-remote
preflight to the operator and requires --experimental.
See capsule-recovery-runbook.md for the complete writer-quiescence, exact-tip, staged-import, cutover, rollback, and retention procedure. See recovery-capsule-design.md for the format and trust model.
Manage the persistent local data-write freeze used for authoritative migrations:
# Fails if another process currently holds the pond write lock.
pond freeze enable --reason "storage-format migration"
# Show the protected exact content tip and freeze metadata.
pond freeze status
# Explicitly re-enable writes, for example when aborting before cutover.
pond freeze disableFreeze creation holds the same advisory lock used by all supported data-write
paths and atomically creates control/write.freeze. Every later write checks
that marker after acquiring the lock, so an already-open process cannot rely
on stale configuration. Ordinary writes, replay, pull/import, compaction,
reclamation, and control-history pruning are rejected with
PondWriteFrozen; forced control rebuild refuses to remove an active marker.
Reads, push, verify, recipe inspection, and capsule verification remain
available.
The freeze is local to this pond instance; it is not replicated to the remote.
pond freeze status and pond freeze disable read the stable marker directly
and remain available if the control Delta table is damaged and must be rebuilt.
For an authoritative capsule migration, stop all source writers and their
supervisors first, freeze the source, and verify that the recorded freeze tip,
local tip, and final remote tip agree. Follow
capsule-recovery-runbook.md.
Pull new bundles from one or more remotes.
# Pull every remote with mode=pull or mode=both
pond pull
# Pull only the named remote
pond pull upstream
# Discard and atomically rebuild only this named cross-pond graft
pond pull upstream --rebuild-graftCross-pond pull bootstrap (D5.7b.2): when a pull-mode remote is attached with a non-root
mount_path(e.g./imports/upstream) and itsstore_iddiffers from the localpond_id, the firstpond pullautomatically materializes the mount entry under the configured path. No manual seeding is required. Mirror restarts (PATH = /, samestore_id) require a pond that already carries the sourcepond_id; usepond restore(below) to bootstrap one from a blank machine.
--rebuild-graft is an explicit recovery operation for a non-root graft. It
replaces only that foreign pond partition, validates the replacement before
commit, and commits the mount and graft pin in the same transaction. It refuses
to unlink local content or a different pond's graft at the configured path.
Ordinary pulls are fast-forward only: a differing remote tip must descend from
the recorded last_pulled_tip. A stale or out-of-order remote ref is rejected
rather than rolling a mirror or graft backward. Use --rebuild-graft for an
intentional cross-pond replacement.
Bootstrap a whole-pond replica from a backup published to a remote -- the operator entry point for disaster recovery.
pond init always mints a FRESH pond_id, so a freshly-inited pond can
never attach its own backup as a mirror (pond remote add ... / refuses
on a pond_id mismatch). pond restore closes that gap: it discovers
the SOURCE pond's id by opening the remote read-only, stamps a replica
shell carrying that id, attaches the remote as a pull-mode mirror at /,
and pulls the full content graph to rebuild the pond by node_id.
# Restore a whole pond from a file:// backup into the current pond dir
pond restore origin file:///mnt/backups/origin
# Restore from S3 (same credential flags as `pond backup add`)
pond restore origin s3://my-bucket \
--region us-east-1 \
--access-key-id AKIA... \
--secret-access-key '${env:AWS_SECRET_ACCESS_KEY}'- Refuses to run over an existing pond -- restore bootstraps a fresh replica; remove the target directory (or choose an empty one) first. On any failure it removes the partially-created shell so a retry starts clean.
- After a successful restore the local pond IS the source: same
pond_idand the same content tip commit hash (the canonical cross-replica fingerprint). Byte-for-byte content -- including external>64KBblobs -- is reproduced. - The
NAMEattachment is left configured as a mirror, so a laterpond pull NAMEtracks the upstream incrementally.
The
pond fsckcontent root matches across identity-preserving mirrors (a byte copy, or apond pullmirror that keeps the producer'spond_id), because it hashes content, not local transaction history. A cross-pond consumer keeps its ownpond_id, so its top-level root differs by design; the tip commit hash is the canonical cross-pond fingerprint. Seepond verify.
Compare this pond's CURRENT content tip against one or more remotes'
published tip. The content-addressed remote holds a single object
closure and one tip ref per pond -- there is no (pond_id, seq)
frontier -- so verify reports the commit-graph relationship between the
local tip and the remote's published tip.
# Verify against every attached remote
pond verify
# Verify against only the named remote
pond verify origin
# Require exact equality; lag and an empty remote are failures
pond verify --exact originOutput (per remote):
[OK] verify <name>: live data matches remote tip at seq=<N>-- the remote's published tip equals the local tip (up to date).[OK] verify <name>: remote tip at seq=<N> is behind local by <K> commit(s); push to catch up-- the remote's tip is an ancestor of the local tip; the producer has unpushed local commits (lag, not drift).[OK] verify <name>: remote has no published tip yet (nothing pushed)-- the remote holds no ref undermain.[OK] verify <name>: no local commits and remote is empty-- both sides are empty.[MISMATCH] verify <name>: remote tip at seq=<N> is not in this pond's history (diverged)-- the remote published a commit this pond does not have.
Each line is followed by the local and remote tip commit hashes. By default,
exit code is non-zero if any remote diverges or fails to load; an empty or
behind remote is reported but remains a valid history prefix. With --exact,
exit code is non-zero unless every selected remote is UpToDate. Use exact
mode for migration and cutover gates.
Verify is symmetric: a replica bootstrapped from a remote (via
pond pull) verifies cleanly against that remote, because it rebuilds the same content closure and tip the producer published.
Local, offline filesystem-check. Unlike pond verify (which compares
against a remote), fsck validates the pond against itself and prints a
single deterministic content root that fingerprints the live content of
every pond_id in the data table -- including cross-pond imports.
# Print the content root (one line, 64 hex chars)
pond fsck
# Skip the content-rehash pass; compute only the structural root (fast)
pond fsck --quick
# Per-partition breakdown + content statistics
pond fsck --verboseThe root is a content tree of content trees. A directory is a partition,
and its recursive tree_hash (fold-excluding the reserved INDEX/LOG nodes)
is its checksum; the top-level root is a tree_hash over the per-pond
root_tree_hashes, keyed by pond_id:
root <- tree_hash over per-pond roots
|- pond_id -> root_tree_hash (that pond's content tree)
| |- directory (part_id) -> tree_hash
| | |- child_hash of each entry (recursive)
| | +- ...
| +- ...
+- ...
It is layout-, compaction-, and lineage-independent (it hashes content,
not parquet file layout or local transaction history), so two replicas of
the same pond -- a byte copy, or a mirror rebuilt independently by
pond pull -- produce the same root hex. Comparing two replicas is
therefore a string compare:
pond fsck # on replica A -> a1b2c3...
pond fsck # on replica B -> a1b2c3... (identical iff in sync)What "replica" means here. The top-level root folds the per-pond content trees keyed by
pond_id, so equal-root comparison applies to identity-preserving replicas: a byte copy of the pond directory, or a mirror that keeps the producer'spond_id. It is not a content-only fingerprint: two ponds created independently with the same files get different randompond_ids and therefore different roots. A cross-pond consumer (pond remote add ... /imports/<name>) keeps its ownpond_idand mounts the producer's partitions, so its root differs by design -- compare such a consumer at the partition level instead: every producer partition line inpond fsck --verbose(<pond_id>/<part_id> rows=N <tree_hash>) reappears verbatim in the consumer's--verboseoutput once it has pulled.
Content-checksum pass (default; skipped with --quick): the structural
root commits to each file's recorded blake3, but not to the bytes it
points at. By default fsck also re-hashes inline file content and re-reads
every external _large_files/blake3=<hash>.parquet blob, confirming the
bytes match their recorded blake3. This catches bit-rot / blob corruption
that the structural root alone cannot see.
Output:
- Default: the content root on one line.
--verbose:Root checksum, a per-partition digest list, and counts of live entries / inline content / blobs verified, ending inResult: OKorResult: FAILED.
Exit code is non-zero if any content check fails; each failure is logged
with the offending pond_id/part_id, node, version, and the mismatching
blake3. Because content errors are collected (not fatal on first hit),
one corrupt blob does not hide the rest of the report.
Show an operator-facing status aggregate for the current pond. This is
a fast, offline command -- it reads only the local control table and
/sys/remotes/* attachments and never opens a remote or touches the
network, so it is safe on a pond whose remotes are unreachable.
pond statusOutput sections:
- Identity -- pond ID, creation time / creator, on-disk location.
- Local state -- last committed write sequence and recovery health
(lists incomplete transactions with a
pond recoverhint if any are found). - Remotes -- one block per attached remote/backup: url, mount path
(
/ (mirror)for mirror attachments), and sync watermarks. For push/both remotes thelast pushedline shows lag relative to the locallast write seq(up to date,behind local by N txn, ornever pushed). For pull/both remotes thelast pulledwatermark is shown.
Note: push "lag" is computed purely from local watermarks (
last_write_seqvslast_pushed_seq:<url>). To cross-check the consumer's data against what a remote actually recorded, usepond verify.
Inspect and verify the pond's transparency log: an append-only RFC 6962
SHA-256 Merkle tree over the linear commit spine, published as C2SP
tlog-tiles under {POND}/tlog. Every write transaction appends one leaf
(its commit object). These are read-only, offline commands.
Signing (the log's trust root) is deferred, but the log's key-free
properties are fully verifiable today: pond tlog verify proves
tamper-evidence and append-only growth without any key.
pond tlog show # checkpoint, checkpoint history, tree size
pond tlog verify # verify the log; exit non-zero if any check failspond tlog show prints the current checkpoint (origin, tree size, root),
the log directory, and the append-only checkpoint history ({POND}/tlog/checkpoints),
one line per checkpoint ever published.
pond tlog verify runs four checks and prints a [PASS]/[FAIL] line for
each, exiting non-zero if any fails:
- Checkpoint reproduced -- the level-0 tiles re-fold to the checkpoint's size and root.
- Inclusion -- every published leaf proves inclusion against the checkpoint root (RFC 6962 inclusion proof).
- Append-only consistency -- every checkpoint in the history is an append-only prefix of the current tree (RFC 6962 consistency proof).
- Faithfulness -- the published leaves equal
hash_leaf(commit_object)for every spine-bearingDataCommittedrecord in the control table, leaf for leaf, tying the published log back to the authoritative source of truth.
The published tiles are a derived, re-materializable export of the
control-table commit spine. If the export is ever dropped or lags (a crash, an
I/O error, an unwritable {POND}/tlog), it self-heals on the next commit: the
writer replays every missing leaf from the control table, so verification
returns clean.
Reconstruct the control table from the data Delta table. This is a
disaster-recovery command for the case where the data Delta table
survives but the control table is lost or corrupt (so pond can no
longer open the pond).
# Rebuild when the control table is missing
pond rebuild-control
# Move an existing (corrupt) control table aside and rebuild
pond rebuild-control --forceRecovers:
- Pond identity -- the canonical
pond_idfrom the data table's bootstrap row, plus a birth timestamp from the bootstrap commit. The birth hostname/username are not stored in the data table and are recorded asunknown. - Transaction-log skeleton -- one
Begin+DataCommitted(plus a trailingCompletedfor the bootstrap) per write transaction found in the data Delta commit history, sopond logandpond statuswork again. The originaltxn_seq,txn_id, CLI args, Delta version, and commit timestamp are all recovered from thepond_txncommit metadata. - Commit spine -- the
commit_hash/root_tree_hash/commit_objectof every content-changing commit are replayed from the authoritative, pond-resident LOG node, so tip lookups and content-addressedpond pushwork against a rebuilt control table.
Does NOT recover (operator follow-up required):
- Remote attachments' settings -- remote modes and
last_pushed_seq/last_pulled_seqwatermarks live only in the control table. Re-attach remotes withpond remote add/pond backup addafter a rebuild.
Per-transaction partition checksums are no longer recorded (retired in
favour of the pond-resident content tree, Decision D9 step 5b), so a
rebuilt control table is byte-equivalent to a normally committed one for
integrity purposes -- pond fsck and pond verify both work against a
rebuilt control table without re-baselining.
Safety: if a real control table already exists, the rebuild is refused unless
--forceis given. With--force, the existing control directory is moved aside to acontrol.bak.<unix_ts>sibling before the new one is written. A stale emptycontrol/directory left behind by a failed open is removed automatically (no--forceneeded).
Note:
pond rebuild-controlis designed for primary ponds. A freshly restored replica that has not yet done local writes should be re-bootstrapped viapond remote add+pond pull, not rebuilt.
Run Delta Lake maintenance on the pond's data and control tables: checkpoint creation, commit-log cleanup, and vacuum of stale parquet files. Safe to run routinely (e.g. from cron) to keep table-open cost bounded.
# Checkpoint + vacuum (no file merging)
pond maintain
# Also compact: merge many small parquet files into fewer large ones
pond maintain --compact--compact compacts the pond's own-pond_id partitions as a recorded,
pushable transaction: the merge is written to the control table as a
Compact commit, so a subsequent pond push publishes the compacted
content closure.
Compaction never changes logical content -- watertown snapshots each partition's checksum before and after the merge and aborts if they differ. A run with nothing to merge is a clean no-op.
Show or set pond configuration (ID, factory modes, metadata, settings).
# Show configuration
pond config
# Set a configuration value
pond config set <key> <value>Note:
pond controlis still available as a hidden alias for backward compatibility.
Several well-known paths under the pond have special meaning to the steward and CLI.
Each file under /sys/remotes/<name> is a small YAML document recording
one remote's URL + mode + (S3) credentials. Managed via pond remote add, pond remote remove, pond remote list. After every write
transaction the steward iterates these and auto-pushes any whose mode is
push or both.
Factory nodes live under /system/ in directories that determine their
auto-execution behavior:
| Directory | Purpose | Post-commit | Examples |
|---|---|---|---|
/system/run/ |
Auto-executing factories | Yes (default: push) |
(currently rare; see note) |
/system/etc/ |
Manually triggered or passive | No | hydrovu, sitegen, column-rename, logfile-ingest, journal-ingest |
/system/site/ |
Static content (templates) | No | Markdown page templates |
Note (D4+): the legacy
remotefactory used to live in/system/run/<N>-remote; that pathway is gone. Remote sync is now handled by/sys/remotes/<name>+pond push|pull(see above)./system/run/is still scanned post-commit for any future factories that opt into auto-execution, but no in-tree factory currently uses it.
Post-commit auto-execution: After every write transaction, the
steward scans /system/run/* and executes each factory with its
configured mode (default: push). Only factories that support the
mode will succeed -- this is why hydrovu and sitegen must NOT be
placed in /system/run/. Separately, the steward iterates
/sys/remotes/* and auto-pushes any push/both-mode remotes.
Short name resolution: pond run resolves bare names (no leading
/) by checking /system/run/{name} then /system/etc/{name}.
Naming convention: Use numeric prefixes for ordering:
10-hrename, 20-hydrovu, 90-sitegen.
Setup pattern:
pond mkdir -p /system/etc
pond mkdir -p /system/site
# Manually triggered factories
pond mknod column-rename /system/etc/10-hrename --config-path hrename.yaml
pond mknod hydrovu /system/etc/20-hydrovu --config-path hydrovu.yaml
pond mknod sitegen /system/etc/90-sitegen --config-path site.yaml
# Static content
pond copy host:///path/to/templates /system/site --overwrite
# Backup attachment (managed by `pond backup add`, not `pond mknod`)
pond backup add origin s3://my-bucket \
--region us-east-1 --access-key-id ... --secret-access-key '${env:AWS_SECRET_ACCESS_KEY}'Apply SQL transformation to a single file.
factory: "sql-derived-table"
config:
patterns:
source: "table:///raw/data.csv"
query: "SELECT timestamp, value * 2 as doubled FROM source"Apply SQL to multiple files/versions (time series).
factory: "sql-derived-series"
config:
patterns:
source: "series:///sensors/*"
query: "SELECT * FROM source WHERE temperature > 20"Generate synthetic timeseries with configurable waveforms. Produces Arrow RecordBatches in memory (no files on disk) — useful for testing, demos, and development with deterministic, visually distinct data.
Each named point becomes a Float64 column. Its value at every timestamp is the sum of one or more waveform components, so you can layer signals to create complex but predictable shapes.
start: "2024-01-01T00:00:00Z"
end: "2024-01-02T00:00:00Z"
interval: "15m"
time_column: "timestamp" # optional, default: "timestamp"
points:
- name: "temperature"
components:
- type: sine
amplitude: 10.0
period: "24h"
offset: 20.0 # baseline value
- type: line
slope: 0.0002 # slow upward drift
- name: "pressure"
components:
- type: sine
amplitude: 5.0
period: "12h"
offset: 1013.0
- type: square
amplitude: 2.0
period: "6h"
- name: "humidity"
components:
- type: triangle
amplitude: 15.0
period: "8h"
offset: 60.0
- type: sine
amplitude: 3.0
period: "3h"
phase: 1.57 # phase offset in radiansComponent types:
| Type | Formula | Parameters |
|---|---|---|
sine |
offset + amplitude × sin(2π·t/period + phase) |
amplitude, period, offset, phase |
triangle |
offset + amplitude × tri(t/period + phase) |
amplitude, period, offset, phase |
square |
offset + amplitude × sign(sin(2π·t/period + phase)) |
amplitude, period, offset, phase |
line |
offset + slope × t |
slope, offset |
tis seconds elapsed sincestart.periodandintervalaccept human-readable durations:30s,15m,1h,2d,1h30m, etc.- All parameters default to
0.0if omitted.
Usage:
# Create the factory node
pond mkdir /sensors
pond mknod synthetic-timeseries /sensors/synth --config-path synth.yaml
# Query with SQL (table is always named "source")
pond cat /sensors/synth --sql "SELECT MIN(timestamp), MAX(timestamp), COUNT(*) FROM source"
pond cat /sensors/synth --sql "SELECT * FROM source ORDER BY timestamp LIMIT 10"
# Check value ranges
pond cat /sensors/synth --sql "
SELECT
MIN(temperature) AS temp_min, MAX(temperature) AS temp_max,
MIN(pressure) AS pres_min, MAX(pressure) AS pres_max
FROM source
"Notes:
- Data is generated on every query from the config — there is no stored state.
- The node appears as
TableDynamicinpond listoutput. - The timestamp column is
Timestamp(Millisecond, UTC). - Point names must be unique; at least one point with at least one component is required.
- Works with
pond describe,pond cat --sql, and any downstream factory that readsseries:///orfile:///patterns.
A virtual directory whose child entries are produced by other factories.
Each entry specifies a name, a factory type, and a config block.
dynamic-dir is the glue that lets you compose multiple factory outputs
under a single path — for example, several timeseries-join or
synthetic-timeseries nodes side-by-side.
factory: "dynamic-dir"
config:
entries:
- name: "station_a"
factory: "synthetic-timeseries"
config:
start: "2024-01-01T00:00:00Z"
end: "2024-01-15T00:00:00Z"
interval: "1h"
points:
- name: "temperature"
components:
- type: sine
amplitude: 5.0
period: "24h"
offset: 20.0
- name: "combined"
factory: "timeseries-join"
config:
inputs:
- pattern: "series:///sensors/station_a"
scope: "A"
- pattern: "series:///sensors/station_b"
scope: "B"- Nested factory configs are validated recursively at
mknodtime. - The directory is read-only — no
pond copyinto it. - Child entries appear with the
EntryTypereported by each factory's metadata (e.g.TableDynamicfor timeseries-join). - Each child gets a deterministic
FileIDderived from the parent'sNodeID, the entry name, factory, and config.
Usage:
pond mkdir /sensors
pond mknod dynamic-dir /sensors/all --config-path all.yaml
# Browse the virtual directory
pond list /sensors/all
# Query a child entry directly
pond cat /sensors/all/station_a --sql "SELECT COUNT(*) FROM source"Combines two or more time-series inputs into a single wide table by
FULL OUTER JOIN on the time column. Each input can have an optional
scope prefix (column names become Scope.OriginalColumn), an
optional time range filter, and optional transforms.
Inputs that share the same scope are merged with UNION BY NAME
first, then the distinct scope groups are joined. This lets you stitch
together device replacements (same scope, non-overlapping ranges) while
also combining data from different sensor types (different scopes).
factory: "timeseries-join"
config:
time_column: "timestamp" # optional, default: "timestamp"
inputs:
- pattern: "series:///data/station_a"
scope: "A"
- pattern: "series:///data/station_b"
scope: "B"Input fields:
| Field | Required | Description |
|---|---|---|
pattern |
yes | URL pattern to match input files. Supported schemes: series, csv, excelhtml, file. Glob wildcards (*, **) are supported. |
scope |
no | Prefix added to every non-timestamp column: Scope.Column. If omitted, columns keep their original names. |
range.begin |
no | ISO 8601 start time — rows before this are excluded. |
range.end |
no | ISO 8601 end time — rows after this are excluded. |
transforms |
no | List of paths to table-transform factories applied to this input before joining (e.g. ["/system/etc/10-hrename"]). |
Behaviour:
- At least two inputs are required (use
sql-derived-seriesfor one). - The result is ordered by the time column.
- Where one input has data and another does not, the missing columns are
NULL(FULL OUTER JOIN semantics). - The output time column is
COALESCE-d across all scope groups so there are no NULLs in the time column itself. - The node reports
EntryType::TableDynamic.
Production example (combine.yaml inside a dynamic-dir):
entries:
- name: "Silver"
factory: "timeseries-join"
config:
inputs:
- pattern: "/hydrovu/devices/**/SilverVulink1.series"
scope: Vulink
range:
end: 2024-05-30T00:00:00Z
- pattern: "/hydrovu/devices/**/SilverVulink2.series"
scope: Vulink
range:
begin: 2024-05-30T00:00:00Z
- pattern: "/hydrovu/devices/**/SilverAT500.series"
scope: AT500_SurfaceUsage:
pond mkdir /combined
pond mknod dynamic-dir /combined --config-path combine.yaml
# See all joined series
pond list /combined
# Query the combined data
pond cat /combined/Silver --sql "SELECT * FROM source LIMIT 10"
# Check the time span
pond cat /combined/Silver --sql "
SELECT MIN(timestamp), MAX(timestamp), COUNT(*) FROM source
"Selects specific columns from multiple inputs matched by a glob pattern, producing a wide table with one row per unique timestamp. Use it to pull a single measurement (e.g. dissolved oxygen) across all sites into one queryable view.
Each matched input's columns are scoped with the captured wildcard
segment as prefix (e.g. Silver.DO.mg/L, BDock.DO.mg/L). Missing
columns are NULL-padded automatically.
factory: "timeseries-pivot"
config:
pattern: "series:///combined/*" # wildcard captures the site name
columns:
- "AT500_Surface.DO.mg/L"
- "AT500_Bottom.DO.mg/L"
time_column: "timestamp" # optional, default: "timestamp"
transforms: # optional
- "/system/etc/10-hrename"Config fields:
| Field | Required | Description |
|---|---|---|
pattern |
yes | URL pattern with a * wildcard. The captured segment becomes the scope prefix for that input's columns. Supported schemes: series, csv, excelhtml, file, data, table, oteljson. |
columns |
yes | List of column names to select from each matched input. At least one column required. |
time_column |
no | Name of the time column. Default: "timestamp". |
transforms |
no | List of paths to table-transform factories applied to each input. |
Behaviour:
- The pattern
series:///combined/*matching/combined/Silverand/combined/BDockproduces columns like:timestamp,Silver.AT500_Surface.DO.mg/L,BDock.AT500_Surface.DO.mg/L, … - Uses LEFT JOIN on the time column (not FULL OUTER JOIN), from a CTE of all unique timestamps across all inputs.
- Columns that don't exist in a particular input are
NULL-padded (Float64) via thenull_paddingtransform. - The node reports
EntryType::TableDynamic.
Production example (single.yaml inside a dynamic-dir):
entries:
- name: "DO"
factory: "timeseries-pivot"
config:
pattern: "/combined/*"
columns:
- "AT500_Surface.DO.mg/L"
- "AT500_Bottom.DO.mg/L"
- name: "Temperature"
factory: "timeseries-pivot"
config:
pattern: "/combined/*"
columns:
- "AT500_Surface.Temperature.C"
- "AT500_Bottom.Temperature.C"Usage:
pond mknod dynamic-dir /pivot --config-path single.yaml
# See the pivoted views
pond list /pivot
# Query dissolved oxygen across all sites
pond cat /pivot/DO --sql "SELECT * FROM source ORDER BY timestamp LIMIT 20"Creates time-bucketed aggregations of source time-series at one or more resolutions. The factory produces a directory (not a file) with this structure:
/reduce/<site>/res=1h.series
/reduce/<site>/res=6h.series
/reduce/<site>/res=1d.series
The source pattern can match multiple files; each match becomes a
separate site subdirectory named by out_pattern substitution.
factory: "temporal-reduce"
config:
in_pattern: "series:///sources/*" # glob — captured group is $0
out_pattern: "$0" # output site name from captured group
time_column: "timestamp"
resolutions: ["1h", "6h", "1d"]
aggregations:
- type: "avg"
columns: ["temperature", "pressure"]
- type: "min"
columns: ["temperature"]
- type: "max"
columns: ["temperature"]
- type: "count"
columns: ["*"] # count of rows per bucketConfig fields:
| Field | Required | Description |
|---|---|---|
in_pattern |
yes | URL pattern to match source series. Glob wildcards (*, **) supported. Captured groups become $0, $1, … for out_pattern. |
out_pattern |
yes | Output site name using captured groups (e.g. "$0"). |
time_column |
yes | Name of the timestamp column in the source. |
resolutions |
yes | List of time bucket sizes. Parsed with humantime: "1h", "6h", "1d", "30m", etc. |
aggregations |
yes | List of aggregation operations (see below). |
transforms |
no | List of paths to table-transform factories applied to each input before aggregation. |
Aggregation operations:
| Field | Required | Description |
|---|---|---|
type |
yes | One of avg, min, max, sum, count. |
columns |
no | List of column names to aggregate. Supports single-* glob patterns (e.g. "Vulink*.Temperature.C"). If omitted, applies to all numeric columns discovered from the source schema. Use ["*"] with count for row count. |
Output column names are original_column.agg_type — e.g.
temperature.avg, temperature.min, pressure.avg.
Behaviour:
- Uses
DATE_TRUNCfor time bucketing, so buckets align to calendar boundaries (hour 0, midnight, etc.). - Source schema is discovered dynamically on first query — column names
in
columnsare matched against actual schema at runtime. - Each resolution file (
res=1h.series, etc.) is an independentTableDynamicnode backed bySqlDerivedFile. - The directory structure is read-only.
- When
in_patternmatches multiple files mapping to the sameout_pattern, all files are joined via UNION ALL insideSqlDerivedFile. This supports rotating-log-file ingestion where many data fragments share the same schema.
Caveat — schema inference: Schema discovery reads the lexicographically
last matching file (the newest for timestamped filenames like rotating logs).
This works correctly when columns are only added over time — the newest file
has the most complete schema. If files have incompatible schemas (columns
renamed or removed), the inferred schema may be incorrect. Use
timeseries-join for heterogeneous-schema sources that require per-file
schema discovery and column alignment.
Usage:
# Create the source data
pond mkdir /sources
pond mknod synthetic-timeseries /sources/weather --config-path weather.yaml
# Create the temporal-reduce factory
pond mknod temporal-reduce /reduce --config-path reduce.yaml
# Browse the directory structure
pond list /reduce # → /reduce/weather
pond list /reduce/weather # → res=1h.series, res=6h.series, ...
# Query the hourly aggregation
pond cat /reduce/weather/res=1h.series --sql "
SELECT timestamp,
ROUND(\"temperature.avg\", 2) AS temp_avg,
ROUND(\"temperature.min\", 2) AS temp_min,
ROUND(\"temperature.max\", 2) AS temp_max
FROM source
ORDER BY timestamp
"
# Check schema
pond describe /reduce/weather/res=1h.seriesStatic site generator using Maudit + Maud. Reads data from the pond, applies Markdown templates with shortcodes, and produces a complete static HTML site with interactive charts (DuckDB-WASM + Vega-Lite).
factory: sitegen
site:
title: "My Dashboard"
base_url: "/" # Use "/subdir/" for non-root deploy
content:
- name: "pages"
pattern: "/content/*.md" # Glob for content pages
exports:
- name: "params"
pattern: "/reduced/single_param/*/*.series"
target_points: 1500 # Points per plot (auto-partitions)
- name: "sites"
pattern: "/reduced/single_site/*/*.series"
target_points: 1500
routes:
- name: "home"
type: static
slug: "" # Root: /index.html
page: "/site/index.md"
routes:
- name: "pages"
type: content
slug: "" # One page per content file
content: "pages"
- name: "params"
type: static
slug: "params"
routes:
- name: "param-detail"
type: template
slug: "$0" # /params/Temperature.html, etc.
page: "/site/data.md"
export: "params"
partials:
sidebar: "/site/sidebar.md"
static: # Extra files to copy (text only)
- pattern: "/img/logo.svg"
sidebar: # Ordered sidebar sections (flat pills)
- "Main" # Only pages with section: Main appearConfig fields:
| Field | Required | Description |
|---|---|---|
site.title |
yes | Site title, used in HTML <title> and layout |
site.base_url |
yes | Base URL path: "/" for root, "/myapp/" for subdirectory |
content |
no | List of content stages -- glob markdown files for metadata-driven pages |
exports |
no | List of data export stages (see below) |
reports |
no | Named offline reports built from export stages (see below) |
routes |
yes | Hierarchical route tree |
partials |
no | Named Markdown partials (e.g., sidebar) |
static |
no | List of patterns for static files to copy (text files only) |
sidebar |
no | Ordered list of section names for flat sidebar rendering |
Content fields:
| Field | Required | Description |
|---|---|---|
name |
yes | Name referenced by content: in routes and content_nav shortcode |
pattern |
yes | Glob pattern matching markdown files (e.g., "/content/*.md") |
Export fields:
| Field | Required | Description |
|---|---|---|
name |
yes | Name referenced by export: in routes |
pattern |
yes | Glob pattern matching pond files. * captures become $0, $1, ... |
target_points |
no | Target data points per plot (default: 1500). Used to auto-compute temporal partitions per resolution. |
Named reports:
reports:
weekly:
title: "Weekly pond summary"
message: "Please check the instruments and glance at the latest data."
window: 7d
include_pond_size: true
sections:
- export: params
captures: [well-depth, data, res=1h]
value: well_depth_value.avg
unit: m
summary: range
chart: true
href: data/well-depth.htmlEach section selects exactly one file by matching the wildcard captures from
its named export. A missing title reuses the first capture's entry in
labels, falling back to a title-cased capture. range renders latest,
minimum, and maximum values; sum renders a total. Generate a local preview
without credentials or network delivery:
pond run /system/etc/90-sitegen report weekly ./report-previewThe output directory contains report.html, report.txt, and one PNG per
charted section.
Route types:
| Type | Produces | Has export? |
slug semantics |
|---|---|---|---|
static |
One page at that path | No | Literal slug (empty = root) |
template |
One page per unique $0 value |
Yes | $0 expands from matched captures |
content |
One page per content file | No | Uses slug from frontmatter |
Behaviour:
- Routes are hierarchical — nested routes inherit the parent slug as prefix.
- Template routes generate one HTML page per unique
$0value from the linked export. - Export stages run first: match data files, extract temporal bounds, export Parquet with partitioning.
- Each template page receives its matched
ExportedFilestructs as context for shortcodes.
Layouts (set in Markdown frontmatter):
| Layout | Purpose |
|---|---|
default |
Full-width content (home pages, hero sections) |
page |
Sidebar + article wrapper for content pages |
data |
Sidebar + chart area with JS chart infrastructure |
Shortcodes (used in Markdown templates):
| Shortcode | Purpose | Example |
|---|---|---|
{{ $0 }} |
First capture group value | Page title from pattern match |
{{ $1 }} |
Second capture group | Resolution name, etc. |
{{ chart /}} |
Chart container + DuckDB-WASM chart renderer | Data pages |
{{ breadcrumb /}} |
Breadcrumb navigation from route hierarchy | Data pages |
{{ nav_list collection="name" base="/path" /}} |
Navigation list from export collection | Sidebar |
{{ content_nav content="pages" /}} |
Navigation from content pages (see below) | Sidebar |
{{ base_url /}} |
Site base URL from config | Links in sidebar |
{{ site_title /}} |
Site title from config | Sidebar header |
{{ figure src="..." caption="..." float="right" /}} |
Figure with optional float | Content pages |
{{ name /}} form. Attributes use
key="value" syntax: {{ nav_list collection="params" base="/params" /}}.
In the title frontmatter, {{ $0 }} does NOT need the closing /}}.
Markdown templates — example data page (data.md):
---
title: "{{ $0 }}"
layout: data
---
# {{ $0 }}
{{ breadcrumb /}}
{{ chart /}}Content page frontmatter:
Content pages use YAML frontmatter to control navigation and rendering:
---
title: Water # Page title (shown in nav + <title>)
weight: 10 # Sort order (lower = higher in nav)
layout: page # Layout: default, page, or data
section: Main # Sidebar section (must match site.yaml sidebar list)
hidden: true # Optional: render page but hide from nav
slug: custom-url # Optional: override URL slug (default: filename)
---| Field | Default | Description |
|---|---|---|
title |
filename | Page title |
weight |
100 | Sort priority within section (lower = first) |
layout |
default |
HTML layout template |
section |
none | Sidebar section name -- must match a sidebar: entry to appear |
hidden |
false |
If true, page is generated but excluded from navigation |
slug |
filename | URL slug override |
Sidebar rendering with content_nav:
The {{ content_nav content="pages" /}} shortcode renders sidebar navigation
from content page frontmatter. It has two modes:
Flat mode (when sidebar: is defined in site.yaml):
Renders a flat <ul> of pill-style links. Only pages whose section: matches
one of the listed sidebar sections appear. Order follows the sidebar: list
(section order), then weight: within each section.
# site.yaml
sidebar:
- "Main" # 9 core pages# content/water.md frontmatter
section: Main # Appears in sidebar
weight: 10 # First in the list# content/envdraw.md frontmatter
section: Blog # "Blog" not in sidebar list, so NOT shown in sidebar
weight: 60 # Reachable via Blog page content insteadGrouped mode (when sidebar: is absent):
Falls back to collapsible section groups. Pages are grouped by section:,
with expand/collapse behaviour. The section containing the current page
is auto-expanded.
Markdown templates — example sidebar (sidebar.md):
{{ content_nav content="pages" /}}That single shortcode generates the entire sidebar from frontmatter metadata.
Pages without a matching section: or with hidden: true are excluded.
Build command:
pond run /system/etc/90-sitegen build ./dist
# or using short name:
pond run 90-sitegen build ./distThis single command: reads the site config → runs export stages (auto-partitions
exported Parquet per resolution based on target_points) → renders all routes
(Markdown → HTML via Maud layouts) → writes everything to ./dist.
Serving locally:
# Vite (live reload on re-build)
npx vite ./dist --port 4174 --open
# or plain HTTP
python3 -m http.server 8000 -d ./distUsage:
# Store templates in the pond
pond copy host:///path/to/templates /system/site --overwrite
# Create the sitegen factory node
pond mknod sitegen /system/etc/90-sitegen --config-path /path/to/site.yaml
# Build the site
pond run 90-sitegen build ./dist
# Update templates without recreating the factory
pond copy host:///path/to/templates /system/site --overwrite
# Update factory config
pond mknod sitegen /system/etc/90-sitegen --overwrite --config-path /path/to/site.yamlNotes:
- The generated site uses DuckDB-WASM to load Parquet files client-side -- no server needed.
- Charts use Vega-Lite for rendering.
- Vendor JS is bundled into the binary and written to the output
vendor/directory -- no CDN and no Node.js build toolchain required at page load. - Built-in CSS (
style.css) and JS (chart.js) are compiled into the binary viainclude_str!()and written to the output directory automatically. - Google Fonts (Inter) are loaded via preconnect links in all layouts.
- The
static:config copies text files from the pond to the output root (binary files like images must use filename-only paths; SVG works since it is text). - The site is fully self-contained after build -- deploy to any static host.
RSS Feed:
Sitegen can generate an RSS 2.0 feed (feed.xml) from blog content pages.
To enable, add site_url to the site config:
site:
title: "My Site"
base_url: "/"
site_url: "https://example.com" # Required for RSS (absolute URLs)When site_url is set, sitegen automatically:
- Generates
feed.xmlin the output directory - Adds an RSS icon to the top bar (next to the GitHub icon)
- Adds
<link rel="alternate" type="application/rss+xml">autodiscovery to all pages
By default, the feed includes all non-hidden content pages with section: "Blog"
and a date: field, sorted newest-first.
Optional feed: section for customization:
feed:
section: "Blog" # Section to include (default: "Blog")
content: "pages" # Content stage name (default: first stage)
description: "Latest updates" # Channel description (default: site title)If site_url is not set, RSS generation is silently skipped.
Transform factory for renaming, casting, or dropping columns. Applied via
the transforms field of other factories (timeseries-join, timeseries-pivot,
temporal-reduce).
rules:
- type: direct
from: "Date Time"
to: "timestamp"
cast: timestamp # Optional: cast to type
- type: pattern
pattern: "^(.+) \\((.+)\\)$"
replacement: "$1.$2" # Regex capture groupsRule types:
| Type | Fields | Description |
|---|---|---|
direct |
from, to, optional cast |
Rename one column exactly; optionally cast its type |
pattern |
pattern, replacement |
Regex match on column names; $1, $2 for capture groups |
Usage:
# Create the transform node
pond mknod column-rename /system/etc/10-hrename --config-path hrename.yaml
# Reference it from other factories via transforms field# In a timeseries-join config:
inputs:
- pattern: "series:///data/readings"
scope: "Station"
transforms: ["/system/etc/10-hrename"]Notes:
- The transform is applied at query time, not stored — it wraps the source TableProvider.
- Multiple transforms are applied in order.
- The
castfield supports:timestamp,float64,int64,utf8.
The legacy remote factory (pond mknod remote /system/run/<N>-backup --config-path ...) was removed in D4. Its capabilities are now
delivered by the top-level CLI:
- Configure ->
pond remote add <name> <url> <path>(pull) orpond backup add <name> <url> [--bidirectional](push); see pond remote / pond backup above. - Push ->
pond push [<name>](also auto-runs post-commit). - Pull ->
pond pull [<name>](after first-pull bootstrap via Rust API). - Inspect -> not yet exposed at the CLI (was
pond run .../show); track viapond logplusmc ls/aws s3 lsagainst the bucket directly. - Verify -> not yet exposed at the CLI (was
pond run .../verify); bundle integrity is checked end-to-end insidepond pulltoday.
Cross-pond import is available via pond remote add NAME URL PATH
where PATH != / (D5.7b). The legacy import: config block remains
unused; cross-pond data is reached through the mount path rather than
through factory-level imports.
HydroVu API data collection.
client_id: "xxx"
client_secret: "yyy"
devices:
- name: "Station A"
id: 12345
scope: "StationA"Mirror rotating log files from the host filesystem into the pond. Tracks files with bao-tree blake3 digests for efficient append detection.
# Pattern for archived (immutable) log files
archived_pattern: /var/log/app/app.log.*
# Pattern for the active (append-only) log file
active_pattern: /var/log/app/app.log
# Destination path within the pond
pond_path: /logs/app
# Optional: JSON field holding each record's event time. Setting it declares
# that this node carries logs or metrics, which makes ingest record per-version
# event-time bounds. Omit it for opaque byte streams.
timestamp_field: timeUnixNano
# Unit of that field: seconds | milliseconds | microseconds | nanoseconds
# (default: microseconds)
timestamp_unit: nanosecondsUsage:
# Create the factory node
pond mknod logfile-ingest /system/etc/10-ingest --config-path ingest.yaml
# Run ingestion
pond run /system/etc/10-ingest
pond run 10-ingest # short name works too
# Verify checksums (b3sum format)
pond run 10-ingest b3sumBehavior:
- Archived files (matching
archived_pattern): Immutable - ingested once, verified unchanged - Active file (matching
active_pattern): Append-only - detects new bytes via cumulative bao-tree hash - Rotation detection: When active file shrinks or content prefix changes, searches for matching archived file
Event-time bounds (timestamp_field):
temporal-reduce keys its partial-aggregate cache on each source version's event-time range. A version stored without one is taken to span all of time, so every sealed segment is dropped and the rollup recomputes its whole history on every build — visible only as a slow, memory-hungry build.
Setting timestamp_field declares the node temporal and changes three things:
- Each written version records the min/max of that field across its records. Corrupt lines are skipped and counted, matching what the reader does with them (NUL padding is stripped, unparseable lines are dropped), so one torn record in a large rotation does not discard the event times of every other record in it.
- Slices taken from the active file are cut at the last newline, so a version never contains a half-written record. The withheld bytes are stored on the next run, once the writer has finished the line; appends are detected by size, so nothing is lost. Archived and rotated files are final and are always stored whole.
- A version in which no record yields a usable timestamp fails the write rather than being stored unbounded. Reaching that point means the configured field or unit does not match the records.
Leaving timestamp_field unset keeps the file an opaque byte stream: no bounds, no alignment, stored byte-for-byte. Use that for anything without one record per line.
Important: Files >64KB are stored externally in parquet (not inline in oplog). See docs/large-file-storage-implementation.md.
For Linux system logs (/var/log/syslog):
Configure logrotate with nocompress to keep archived logs readable:
# /etc/logrotate.d/syslog-nocompress
/var/log/syslog
/var/log/messages
{
rotate 4
weekly
missingok
notifempty
nocompress # Required for logfile-ingest
create 644 root adm
postrotate
/usr/lib/rsyslog/rsyslog-rotate 2>/dev/null || true
endscript
}
Example configuration for syslog:
archived_pattern: /var/log/syslog.*
active_pattern: /var/log/syslog
pond_path: /logs/systemContainer testing notes:
When testing in Docker containers, rsyslog needs kernel logging disabled:
# Disable imklog module (not available in containers)
sed -i 's/module(load="imklog")/#module(load="imklog")/' /etc/rsyslog.conf
rsyslogd
# Generate log entries with logger command
logger -t myapp "Test message"Ingest the systemd journal (and optionally the kernel ring buffer) into the
pond as per-unit JSON Lines series. Runs journalctl -o json incrementally,
tracking progress with a stored cursor so each run only collects new entries.
# Destination path within the pond (required)
pond_path: logs/watershop
# journalctl binary (default: "journalctl")
journalctl_command: journalctl
# Collect the kernel ring buffer as a separate stream (default: true)
collect_kernel: true
# JSON field holding the timestamp, in microseconds since epoch
# (default: "__REALTIME_TIMESTAMP")
timestamp_field: __REALTIME_TIMESTAMP
# Extra args appended to every journalctl invocation (default: []).
# Use ["--merge"] to also pull the invoking user's journal so user-scope
# units (e.g. pond@water-prod.service) are captured.
extra_args: ["--merge"]Usage:
# Create the factory node (manually triggered -> /system/etc/)
pond mknod journal-ingest /system/etc/15-journal --config-path journal.yaml
# Collect new journal entries (default subcommand)
pond run /system/etc/15-journal
pond run 15-journal # short name works too
# Show the cursor position and per-unit entry counts
pond run 15-journal statusBehavior:
- Entries are grouped into one
.jsonlseries file per systemd unit underpond_path:<unit>.jsonl,kernel.jsonl(kernel transport),user-<unit>.jsonl(user-scope units), andother.jsonl(no unit). - Output files are
data:series(FilePhysicalSeries) -- versioned raw JSON Lines; each run appends a new version with the collected entries. - A
.journal-cursorfile inpond_pathrecords the last journal cursor, so collection is incremental and idempotent across runs (ideal for cron).
Watertown uses glob patterns for file matching:
| Pattern | Matches |
|---|---|
* |
Any single path component |
** |
Any number of path components |
? |
Any single character |
[abc] |
Character class |
Examples:
**/*- All files everywhere/*- Files in root only/data/**/*.csv- All CSVs under /data/sensors/*/latest.series- latest.series in any sensor subdirectory
Watertown factories compose into data pipelines. Here are two real-world patterns.
The Noyo Harbor example monitors water quality at multiple sites, each with multiple sensor parameters. The pipeline fans out then fans in:
hydrovu (collector)
→ /hydrovu/devices/**/*.series (raw per-device series)
timeseries-join (/combined)
→ /combined/NorthDock (all params at one site, wide table)
→ /combined/SouthDock
timeseries-pivot (/singled)
→ /singled/Temperature (one param across all sites, wide table)
→ /singled/DO
temporal-reduce (/reduced)
→ /reduced/single_site/NorthDock/res={1h,6h,1d}.series
→ /reduced/single_param/Temperature/res={1h,6h,1d}.series
sitegen (/system/etc/90-sitegen)
→ dist/ (HTML + Parquet, served as static site)
Key configs: noyo/combine.yaml (join), noyo/single.yaml (pivot),
noyo/reduce.yaml (reduce), noyo/site.yaml (sitegen).
Setup pattern:
pond init
pond mkdir -p /system/etc
pond mkdir -p /system/site
pond copy host:///path/to/templates /system/site # Markdown templates
pond mknod column-rename /system/etc/10-hrename --config-path hrename.yaml
pond mknod dynamic-dir /combined --config-path combine.yaml
pond mknod dynamic-dir /singled --config-path single.yaml
pond mknod dynamic-dir /reduced --config-path reduce.yaml
pond mknod hydrovu /system/etc/20-hydrovu --config-path hydrovu.yaml
pond mknod sitegen /system/etc/90-sitegen --config-path site.yaml
pond backup add origin s3://my-bucket \
--region us-east-1 --access-key-id ... --secret-access-key '${env:AWS_SECRET_ACCESS_KEY}'
# Operational cycle:
pond run 20-hydrovu collect # Collect data
pond run 90-sitegen build ./dist # Build site
# `pond push origin` runs automatically post-commit (mode=push)The septic station example has a single OTelJSON data source with ~40 metrics from two
scopes (BME280 environment sensor + Orenco Modbus registers) at different sample rates.
There's no need for join/pivot — the oteljson:// URL scheme provides a single wide table.
logfile-ingest (/system/etc/10-ingest)
→ /ingest/septicstation.json (raw OTelJSON Lines file)
temporal-reduce (/reduced) using oteljson:// URL scheme
→ /reduced/septic/res={1h,6h,1d}.series
sitegen (/system/etc/90-sitegen)
→ dist/ (HTML + Parquet)
Key difference from Noyo: The in_pattern in temporal-reduce uses the
oteljson:// URL scheme directly, skipping the join/pivot stages:
factory: "temporal-reduce"
config:
in_pattern: "oteljson:///ingest/septicstation.json"
out_pattern: "septic"
time_column: "timestamp"
resolutions: [1h, 6h, 1d]
aggregations:
- type: "avg"
columns:
- "septicstation_temperature"
- "orenco_RT_Pump1_Amps"
# ... all gauge metricsSetup pattern:
pond init
pond mkdir -p /system/etc
pond mkdir -p /system/site
pond mkdir -p /ingest
pond copy host:///path/to/templates /system/site
pond mknod logfile-ingest /system/etc/10-ingest --config-path ingest.yaml
pond backup add origin s3://my-bucket \
--region us-east-1 --access-key-id ... --secret-access-key '${env:AWS_SECRET_ACCESS_KEY}'
pond mknod temporal-reduce /reduced --config-path reduce.yaml
pond mknod sitegen /system/etc/90-sitegen --config-path site.yaml
# Operational cycle:
pond run 10-ingest # ingest new log data
pond run 90-sitegen build ./dist # build site (reduce is dynamic)
# `pond push origin` runs automatically post-commit (mode=push)Notes on single-source pipelines:
oteljson://discovers all metric names as columns automatically (two-pass parser).- Different sample rates (e.g., 5min Modbus, 10min BME280) produce NULL-padded rows
in the merged table.
temporal-reduceaggregations handle NULLs correctly —avg,min,maxskip NULLs per SQL semantics. - No glob in
in_patternmeansout_patternis a literal name (not$0). temporal-reduceis a dynamic factory — its output is computed on read. No explicitpond run /reduced updatestep is needed; sitegen triggers computation when it reads the export data duringbuild.
| Scenario | Pipeline | Why |
|---|---|---|
| Multiple sites, multiple params | join → pivot → reduce → sitegen | Need cross-site and cross-param views |
| Single source, many metrics | reduce → sitegen | oteljson:// or csv:// provides the wide table |
| Single source, few metrics | reduce → sitegen | Same, but simpler reduce config |
| Raw file archive only | logfile-ingest + remote | No analysis, just backup |
| Factory | Kind | Created By | Output Type |
|---|---|---|---|
synthetic-timeseries |
dynamic | pond mknod |
TableDynamic |
sql-derived-table |
dynamic | pond mknod |
TableDynamic |
sql-derived-series |
dynamic | pond mknod |
TableDynamic |
dynamic-dir |
dynamic | pond mknod |
DynamicDirectory |
timeseries-join |
dynamic | pond mknod |
TableDynamic |
timeseries-pivot |
dynamic | pond mknod |
TableDynamic |
temporal-reduce |
dynamic | pond mknod |
DynamicDirectory of TableDynamic |
column-rename |
transform | pond mknod |
Wraps TableProvider |
sitegen |
executable | pond mknod + pond run ... build |
Static files on host |
logfile-ingest |
executable | pond mknod + pond run |
data entries in pond |
journal-ingest |
executable | pond mknod + pond run [status] |
data:series JSON Lines in pond |
hydrovu |
executable | pond mknod + pond run ... collect |
table:series in pond |
remote |
executable | pond mknod + pond run ... push/pull |
Backup bundles on S3 |
Dynamic factories compute on every read — no stored state.
Executable factories have side effects — run explicitly with pond run.
Transform factories are referenced via the transforms field of other factories.
Cause: Pattern doesn't match actual paths.
Fix: Use pond list '**/*' first to see what exists, then refine pattern.
Cause: You're catting a table/series entry without --format=table or --sql.
The default output format is Parquet bytes (binary).
Fix: Add --format=table for human-readable output, or --sql "SELECT * FROM source LIMIT 10".
Cause: --format on pond cat controls output display (raw vs table).
pond copy does not use --format — entry type is in the source URL.
Fix: For pond cat, use --format=raw (default, binary) or --format=table (ASCII).
For pond copy, use host:/// (data), host+table:/// (Parquet), or host+series:/// (time-series).
Cause: host+table:/// validates PAR1 magic bytes — only Parquet input accepted.
Fix: Use host:/// for CSV (stores as raw bytes). Query CSV with pond cat csv:///path --sql "...".
Cause: The export pattern in site.yaml doesn't match what temporal-reduce creates.
Fix: Run pond list '/reduced/**' to see the actual directory structure. Common
mismatch: the reduce output might be /reduced/site/res=1h.series but the export pattern
expects /reduced/site/*.series or /reduced/site/*/*.series.
Cause: The two-pass parser reads the entire file twice — once to discover columns,
once to parse data. Large files (>100MB) can be slow.
Fix: For repeated queries, consider ingesting into a table:series via SQL pipeline.
For one-off exploration, use --sql with LIMIT to reduce output.
Cause: Dynamic factories are re-computed on every read, but they read from their
source which may itself be cached or stale.
Fix: For logfile-ingest sources, run pond run 10-ingest first to ingest new data.
The dynamic factory chain (temporal-reduce → sitegen) will then see the updated source.
Cause: Column names in aggregations.columns don't match the source schema.
Column names are matched against the actual schema at runtime.
Fix: Discover column names first:
pond cat oteljson:///ingest/data.json --format=table --sql "
SELECT column_name FROM information_schema.columns
WHERE table_name = 'source' ORDER BY column_name
"Cause: Typo in Markdown template or using wrong shortcode syntax.
Fix: Check shortcode names: {{ $0 }}, {{ chart /}}, {{ breadcrumb /}},
{{ nav_list collection="..." base="..." /}}. Note the self-closing /}} syntax.
- testsuite/ - Runnable test scripts
- watertown-overview.md - Architecture overview