wfdir

package
v0.34.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package wfdir owns the on-disk shape of a synced folder — a workflow's or a data app's: what is committed to the customer's git (ronja.json), what is purely local sync bookkeeping (.ronja/state.json), and which local files are candidates for sync at all.

The two kinds share this whole model because they ARE the same shape: a feature-owned resource whose content is a set of source files under a per-user draft/commit lifecycle, with identical server-side path rules. What differs is carried by Kind, and by the two kind-specific manifest blocks (Parameters for a workflow, Access for a data app). The package name predates data apps and is left alone rather than churning every import.

The split is the whole design, and it is a THREE-way one: what a human decided, what a deploy recorded, and what belongs to one person.

  • ronja.json describes the RESOURCE — its title, its entrypoint, its parameters — plus the STACKS it deploys to: named environments, each saying where it points and which feature its resources live in. Every one of those is a decision somebody made, which is what a pull request is about. Committed, human-edited.
  • ronja.lock.json is what the deploys DISCOVERED: the row ids they created and the live fingerprints they last agreed with, per stack. Committed too — that is what finally gives a fresh CI checkout a baseline — but machine-owned and expected to churn.
  • .ronja/state.json is this CHECKOUT's per-USER baseline: which draft is mine and what did I last write into it. Drafts are per-user, so a colleague cloning the repo must not inherit someone else's — hence never committed, and hence clone/init drop a .ronja/.gitignore that excludes the whole directory rather than trusting anyone to add it.

A folder written before stacks existed keeps its instances[] list, ids and all, and no push rewrites it into the new shape: see Manifest.FormatVersion and SaveLock for how "v1 stays v1" is actually enforced rather than intended.

Nothing here talks to the network, and the only non-stdlib import is internal/config — for NormalizeURL alone, because the manifest's per-instance keys have to fold exactly the way the credential store's do or the same instance ends up spelled two ways. That keeps the folder model unit-testable without a server.

Index

Constants

View Source
const (
	// ChecksDirName is the directory a pipeline folder keeps its checks files
	// in, relative to the folder root.
	ChecksDirName = "checks"
	// ChecksExt is the extension one carries.
	ChecksExt = ".json"
)

A pipeline folder's HEALTH CHECKS live in `checks/<stem>.json`, one file per `.sql` file the folder builds, beside the SQL rather than inside it.

Not a `-- @check` header, deliberately: a header lives inside the table's `code`, so every check edit would become a SQL change (a new draft, a rebuild, a commit, a version-history entry) for a thing that is ungoverned by design. And unlike a docs sidecar the stem is NOT an alias — it names a `.sql` file in this folder, because a check is attached to the table the folder builds. A checks file whose stem names no `.sql` file is refused at parse.

⚠️ A pipeline folder's SyncExt is ".sql", so these files are invisible to Enumerate — never pushed as SQL, and never reported as skipped. They are read by the checks pass alone.

View Source
const (
	MaxPathLength = 256
	MaxPathDepth  = 8
)

The server's own limits, mirrored from backend/resource/rworkflow/ store_files.go (validatePath) — and identical in backend/resource/rdataapp/ store_files.go, whose validatePath is a deliberate copy of the same rules, so ONE grammar covers both kinds. They are duplicated rather than discovered because the point is to tell the caller a file will be rejected BEFORE a push spends a round-trip finding out — and to know, locally, which files in a folder are even candidates for sync.

Keeping them in step with the server is load-bearing; a rule that drifts looser here means a confusing mid-push 400, and one that drifts stricter means silently skipping a file the user expected to push.

View Source
const (
	// MetricsDirName is the directory a pipeline folder keeps its metric files
	// in, relative to the folder root.
	MetricsDirName = "metrics"
	// MetricExt is the extension one carries.
	MetricExt = ".json"
)

A METRIC FILE is a committed file in a pipeline folder that holds the whole definition of one metric: its recipe, its prose, and the calendar it is evaluated at. It carries nothing else — see internal/metricfile.

WHY IT LIVES IN THE PIPELINE FOLDER rather than in a `ronja metric` loop of its own, which is the same argument the docs sidecar makes above and is worth making again because the alternative is superficially tidier. A metric is a recipe, so no .sql file can hold one — but everything a sync loop would need to reach it already exists here: one manifest, one bind map, one lock, one tree walk. A second top-level command would need its own manifest, its own lock, its own bind map and its own tree walk, all of them second copies of these. And the folder's verbs already mean exactly the right thing: a metric runs the IDENTICAL draft flow a derived table runs, so `push` builds a draft, `publish` commits it and `discard` drops it, with nothing invented.

THE LAYOUT: `metrics/<alias>.json`, beside the folder's .sql files and its `tables/` sidecars.

orders_by_month.sql             a table this folder BUILDS
tables/fortnox_invoices.json    a table it does NOT build, documented here
metrics/average_order_value.json a METRIC this folder defines

The stem is an ALIAS — a name declared in ronja.json's `dependencies` with kind `table` and bound per stack — exactly as a docs sidecar's is, which is what makes one committed folder deployable to several organizations. A literal `table-…` id is accepted too and means the folder can only ever be pushed to the organization that minted it. There is deliberately no `metric` dependency kind: `GET /api/v2/search` reports a metric as kind `table`, so a metric alias is spelled `{"kind": "table"}` and `ronja bind` binds it like any other.

⚠️ A pipeline folder's SyncExt is ".sql", so these files are invisible to Enumerate — never pushed as SQL, and never reported as skipped. They are read by the metric pass alone, which is also where their ORDER comes from: a metric contributes no `{{ ref }}` edge (no marker resolves a metric), so it cannot join the folder's topological order. The metric pass therefore runs AFTER the .sql pass, which buys the same guarantee — a metric reading a table this folder builds is always pushed after it. That is the MAIN case and the reason the ordering exists; unlike a docs sidecar there is no disjointness rule against it, because `recipe.source` is a REFERENCE to another row rather than a second carrier of it.

What a metric file and a .sql file DO share is one namespace for their own names: `requireNameFreeTx` has no `kind` predicate, so `revenue.sql` and `metrics/Revenue.json` collide server-side. That is guarded locally so the refusal arrives before the first byte rather than half way through a push.

View Source
const (
	// TableDocsDirName is the directory a pipeline folder keeps its docs
	// sidecars in, relative to the folder root.
	TableDocsDirName = "tables"
	// TableDocsExt is the extension one carries.
	TableDocsExt = ".json"
)

A DOCS SIDECAR is a committed file in a pipeline folder that documents a table the folder does NOT build: an integration table, a foundation table, a dynamic one, one a workflow writes. It carries the same three-state `{description, columns}` shape the header carries (see internal/tabledocs) and nothing else.

WHY IT LIVES IN THE PIPELINE FOLDER rather than in a loop of its own. Column prose matters most on exactly the tables a pipeline folder does not build, and those tables have no folder anywhere — but everything a sync loop needs to reach them already exists here: ronja.json's `dependencies` name a table this folder does not own, each stack's `bind` map says which of THIS organization's rows answers to that name, and `ronja bind` reconciles the two. A second top-level command would need its own manifest, its own lock, its own bind map and its own tree walk, all of them second copies of these.

THE LAYOUT: `tables/<alias>.json`, beside the folder's .sql files.

orders_by_month.sql          a table this folder BUILDS  (documented by its
                             own `-- @table` header, in the SQL)
tables/fortnox_invoices.json a table it does NOT build   (documented here)

The stem is an ALIAS — a name declared in ronja.json's `dependencies` with kind `table` and bound per stack — which is what makes one committed folder deployable to several organizations: the file says `fortnox_invoices`, and each stack's bind map says which id that is there. A literal `table-…` id is accepted too, exactly as it is everywhere else in the alias layer, and means the folder can only ever be pushed to the organization that minted it.

The two carriers must be DISJOINT — one table, one carrier — and that is enforced on the ID a sidecar resolves to, not on its name: see the builtBy check in resolveTableDocsSidecars. CheckAliasCollisions is a different rule about a different thing (a declared alias NAME against a sibling .sql stem) and cannot do this job: a sidecar named after a literal `table-…` id, or an alias whose bind value happens to be a built table's id, collides with no stem at all and would otherwise document one table twice — the header onto the draft, the sidecar onto the live row.

⚠️ A pipeline folder's SyncExt is ".sql", so these files are invisible to Enumerate — never pushed as SQL, and never reported as skipped. They are read by the docs pass alone.

View Source
const (
	// ManifestName is the committed manifest at the folder root.
	ManifestName = "ronja.json"
	// LockName is the committed, machine-owned STATE file beside it. Committed,
	// deliberately: it is what gives a fresh CI checkout a baseline at all. See
	// Lock.
	LockName = "ronja.lock.json"
	// StateDirName holds everything local-only.
	StateDirName = ".ronja"
	// StateFileName is the sync baseline inside StateDirName.
	StateFileName = "state.json"
	// GitignoreName is written inside StateDirName with "*" in it, so the
	// baseline cannot be committed by accident.
	GitignoreName = ".gitignore"
)

The folder's fixed filenames.

View Source
const (
	KindWorkflow = "workflow"
	KindDataApp  = "dataapp"
	// KindPipeline is a folder of derived-table SQL files. Unlike the other two
	// it binds MANY resources — one table per .sql file — which is why Binding
	// carries a Tables map and why the single-id helpers refuse this kind rather
	// than answering with a workflow's field.
	KindPipeline = "pipeline"
	// KindModule is a folder of shared Python package sources — the `{{ module }}`
	// library a workflow imports instead of copying helper files between folders.
	//
	// Shaped exactly like a workflow folder (one resource, source files, a
	// per-user draft/commit lifecycle) minus the two things a workflow has and a
	// library does not: an entrypoint and anything runnable.
	KindModule = "module"

	// KindAutomation is a folder of automation .json files, one per automation.
	// Many-resource like KindPipeline, and refused by the single-id helpers for
	// the same reason.
	KindAutomation = "automation"
)

The manifest kinds this CLI understands. An unknown value is refused rather than ignored: the folder types are different resources with different semantics, and a command that pushed one's files into the other would be actively destructive.

View Source
const (
	RuntimeDefault = 1
	RuntimeDurable = 2
	RuntimeQuery   = 3
)

The workflow runtime versions a folder may declare.

RuntimeDefault is the runtime a folder may still DECLARE, and its behaviour is frozen: v1 runs stay bit-identical. It is NOT what a workflow gets when the create body says nothing — the server's create default is RuntimeQuery, so a folder that wants v1 has to keep saying so. It stays the value an ABSENT `runtime` key means to THIS CLI (see Manifest.RuntimeVersion), which is a separate question from what the server stamps. RuntimeDurable journals every @tools.step result, which is what makes a failed run resumable. RuntimeQuery is a SUPERSET of Durable: it keeps the journal and additionally withholds every table credential from the container, so the workflow's code reads a Ronja table only through tools.query.

Runtime 1 and 2 are RETIRED on the instance: nothing new is created on them and 1 → 2 is refused, so 3 is the only runtime a push may create or raise to (see RetiredRuntime). A folder still DECLARING 1 or 2 stays loadable all the same — `wf clone` of a legacy workflow records its true runtime, and that folder keeps pushing files to the workflow's draft. RuntimeDefault keeps its name and its value because it is still what an ABSENT key means to a folder this CLI opens, which is a fact about old folders, not a recommendation.

Listed rather than range-checked, because the CLI has to refuse a value it does not know: the manifest is committed, and a folder declaring a runtime the instance has never heard of would create a workflow whose runtime nobody can name — discovered at the first run, not at the push. That refusal is ValidRuntime, and LoadManifest applies it to every folder it opens.

View Source
const DefaultEntrypoint = "main.py"

DefaultEntrypoint matches the server's own default for a new workflow.

Retained as a package-level constant because it is what `wf init` writes and what a manifest missing an entrypoint falls back to; new code should prefer Kind.DefaultEntrypoint.

View Source
const LockFormatVersion = 1

LockFormatVersion is the highest ronja.lock.json format this build can read.

It carries the same machinery as the manifest's for the same reason, and the reason is worth stating rather than inferring from the symmetry: the lock file is COMMITTED and SHARED. Everything that makes an unknown key dangerous in ronja.json is true here — a colleague on an older CLI rewrites it, and whatever the newer format recorded is gone from the customer's repo — and the data at risk is worse, because the lock is what a CI checkout reads to know which rows it is updating rather than creating.

1, and an ABSENT version means 1, exactly as the manifest's does. Nothing WRITES the key at 1 — the file first appears with this change, so every lock file in existence is version 1 and says so by saying nothing — though a file that already declares 1 by hand decodes and is re-emitted as it was, since preserving what a committed file says is the whole habit of this package.

⚠️ When this number moves, MarshalJSON has to grow the stamp Manifest's has: raise to the version the CONTENT requires, never lower, and put the key first. There is nothing to stamp while the only version is 1, and writing the guard before there is anything to guard would be a rule with no case to test it.

View Source
const ManifestFormatVersion = manifestVersionDependencies

ManifestFormatVersion is the highest ronja.json format this build can read.

It moves only for a change unknown-key preservation CANNOT absorb — a key that changed meaning, or an absence that stopped meaning what it used to. ADDING a key is not one of those: the whole point of the sidecar is that a CLI which has never heard of a key still carries it through untouched, so an added key must not cost every older CLI in the field its ability to read the file.

1 is also what an ABSENT formatVersion means. Every manifest committed before the key existed has none, and there is no version below 1, so "unstated" and "1" are the same claim — which is what lets this ship without rewriting a single file in a single customer's repo.

**2 is stacks**, and it is worth writing down exactly why adding a key needed a version at all when the paragraph above says adding a key does not. What changed is not that "stacks" appeared: it is that the ABSENCE of an instances[] entry stopped meaning "this folder has never been pushed there". In a v2 manifest a stack says where the folder is bound and the ids live in ronja.lock.json, so an older CLI — which sees only the instances[] remainder — would read a fully-deployed folder as unbound and its next push would CREATE A SECOND SET OF RESOURCES in the customer's organization, reporting success. It is the failure InstanceKey's own doc names, arriving through a different door, and preservation cannot absorb it: the sidecar keeps the bytes, not the meaning. That is the bar for moving this number, and nothing smaller is.

A manifest is written at 2 only when it CONTAINS a stack (see Manifest.MarshalJSON). A folder that has never named one round-trips as version 1, byte for byte, so nobody is forced onto a new CLI by a push that changed nothing meaningful.

**3 is declared dependencies**, and it clears the same bar. Adding the `dependencies` key is, on its own, exactly the kind of change the sidecar absorbs — an older CLI preserves it perfectly, in place, and never notices. That is not what changed. What changed is WHAT THE CODE MEANS. In a v3 folder `{{ secret('acme_api', 'token') }}` is an ALIAS, and the id the server is meant to receive exists nowhere in the source: this CLI reads the stack's bind map and substitutes it in before the files are sent. An older CLI does none of that. It carries `dependencies` through untouched and sends the literal string `acme_api` as the secret id — and the secret marker family SOFT-FAILS an unresolvable id into a warning rather than refusing the save. So the push succeeds, the workflow deploys with no secret bound at all, and the failure surfaces at run time in an organization the pusher may not be watching. A wrong deploy reported as a good one is precisely the bar this number exists for, and preservation cannot absorb it: the sidecar keeps the bytes, not the meaning.

The residual, stated once and not dressed up: a CLI OLDER THAN THE GATE ITSELF has no gate to trip. It reads a v3 manifest, ignores the version key it has no field for, and misreads the folder exactly as described above. Nothing shipped now can fix that — it is the same residual stacks already carries, and the protection is real only from the release that introduced the gate forwards.

A manifest is written at 3 only when it CONTAINS a non-empty `dependencies` map, on the rule 2 already follows: a folder that declares none round-trips at whatever version its content requires, so nobody is pushed onto a newer CLI by a change that did not reach their file.

View Source
const ModuleInitName = "__init__.py"

ModuleInitName is the file that makes a MODULE folder an importable Python package, and ModuleInitPath locates it.

It lives here rather than beside the module commands because two packages need the same answer: the commands seed it and refuse to push a folder without one, and this package has to know it is a name no exclusion rule may ever drop. It is an ordinary `.py` file, so SyncExt keeps it — the constant exists so nobody has to spell "__init__.py" in two places and get one of them wrong.

View Source
const RuntimeCreateDefault = RuntimeQuery

RuntimeCreateDefault mirrors the runtime an instance stamps on a workflow created with no runtime in the body. It is NOT what an absent `runtime` key means to this CLI (that is RuntimeDefault) — it is the CLI's copy of somebody else's decision, kept only so a rehearsal can name the runtime a create is about to happen on. See Manifest.RuntimeForValidate, its one caller.

Variables

View Source
var (
	// WorkflowKind is a Python workflow folder.
	WorkflowKind = Kind{
		Name:              KindWorkflow,
		Label:             "workflow",
		Command:           "ronja wf",
		DefaultEntrypoint: "main.py",
	}
	// DataAppKind is a data-app folder.
	//
	// The entrypoint is the server's fixed default AND its only value: a data
	// app's entrypoint is stamped at create and rdataapp.Patch carries no field
	// to change it, so unlike a workflow's this is not a preference the manifest
	// can move.
	DataAppKind = Kind{
		Name:              KindDataApp,
		Label:             "data app",
		Command:           "ronja app",
		DefaultEntrypoint: "App.tsx",
		SkipDirs:          []string{"node_modules", "dist", "build"},
	}
	// PipelineKind is a folder of derived-table SQL files.
	//
	// No entrypoint: the other two kinds have ONE file the server runs, while a
	// pipeline folder is a SET of tables with no distinguished member — so the
	// concept has nothing to name here. Empty is carried deliberately rather
	// than defaulted to something harmless, because "" is what LoadManifest
	// falls back to and what SaveManifest round-trips.
	//
	// No SkipDirs, because SyncExt already covers what they would: a folder that
	// syncs `.sql` and nothing else has no reason to name node_modules/ or
	// venv/ — their contents are ruled out by extension, before anything is read
	// off disk.
	PipelineKind = Kind{
		Name:    KindPipeline,
		Label:   "pipeline",
		Command: "ronja pipeline",
		SyncExt: ".sql",
	}
	// ModuleKind is a folder of shared Python package sources.
	//
	// No entrypoint, for the reason PipelineKind has none and a different one: a
	// pipeline folder has no DISTINGUISHED member, while a module has no
	// runnable member at all. `import <name>` resolves through `__init__.py`,
	// which is seeded server-side at create and is a file like any other here —
	// naming it as an entrypoint would invite `ronja module` to grow a run verb
	// for something that is not a program.
	//
	// SyncExt is `.py` because the SERVER accepts nothing else (V1, plan §3.9):
	// a module is a Python package, and the runtime's own module-name grammar
	// mangles a non-.py path (`data.csv` is written as `data/csv.py`). Declaring
	// it here means a README or a fixture in the folder is skipped locally
	// rather than PUT and refused mid-push — and, because SyncExt is threaded
	// through NotSyncable, both Enumerate and CheckLocalPaths answer the same
	// way, which is what stops a `.md` file the server somehow holds from being
	// written once and then read as a local deletion.
	//
	// No SkipDirs, matching PipelineKind: a folder that syncs `.py` and nothing
	// else already rules out node_modules/ and dist/ by extension, and a
	// Python-shaped folder's venv/ is a dot-name or full of non-.py files.
	// ⚠️ `__pycache__/` is NOT excluded by name, and does not need to be — its
	// contents are `.pyc`, which SyncExt drops.
	ModuleKind = Kind{
		Name:    KindModule,
		Label:   "module",
		Command: "ronja module",
		SyncExt: ".py",
	}

	// AutomationKind is a folder of automation files — one .json per automation,
	// each the curated (trigger, action) shape the create/update bodies take.
	//
	// No entrypoint, for PipelineKind's reason exactly: a folder of automations
	// is a SET with no distinguished member, so there is nothing for the concept
	// to name. Empty is carried deliberately rather than defaulted.
	//
	// SkipDirs is REQUIRED here, and the contrast with PipelineKind is the
	// point: a pipeline gets away without one because `.sql` is an extension
	// nothing else in a repository carries, so node_modules/ and dist/ are ruled
	// out by SyncExt before anything is read. `.json` is the opposite — a
	// JavaScript dependency tree is MADE of it. Enumerate prunes by DIRECTORY
	// (StructuralExclusion) and only then by extension, so without this list an
	// automation folder living anywhere in a JS repository walks the entire
	// dependency tree and offers every package.json in it as an automation to
	// push. Same three names DataAppKind carries, for the same reason.
	//
	// ⚠️ SyncExt ".json" is safe ONLY because StructuralExclusion refuses
	// ronja.json, ronja.lock.json and .ronja/ BEFORE NotSyncable consults the
	// extension. Without that ordering this folder would push its own manifest
	// and its own recorded state into the customer's automations, and a later
	// read-back would write the server's stale copy over the real lock file. The
	// ordering is pinned by TestAutomationFolderNeverSyncsItsOwnMachinery.
	AutomationKind = Kind{
		Name:     KindAutomation,
		Label:    "automation",
		Command:  "ronja automation",
		SkipDirs: []string{"node_modules", "dist", "build"},
		SyncExt:  ".json",
	}
)
View Source
var ErrAmbiguousInstance = fmt.Errorf("this folder is bound to several organizations")

ErrAmbiguousInstance is returned when the caller's organization is unknown and the folder is bound to several on one instance. Picking one would be a coin flip between two organizations' workflows.

View Source
var ErrAmbiguousStack = fmt.Errorf("this folder names several stacks that could be meant")

ErrAmbiguousStack is returned when nothing says which stack a command means and more than one could be it. Distinct from ErrAmbiguousInstance, which is about ORGANIZATIONS on one instance: this one is reachable with a perfectly well-known organization, because two stacks may name two features in it.

View Source
var ErrMultiResourceBinding = errors.New("this folder kind binds many resources, not one — use the per-path map its kind keeps (Binding.Tables, Binding.Automations)")

ErrMultiResourceBinding is returned by the single-id binding accessors for a kind whose folder binds many resources.

A sentinel rather than a formatted string because the refusal is a PROGRAMMING error, not a user-facing one: reaching here means a command asked a pipeline folder for "the" bound row, and the answer is that it has to use Tables.

View Source
var ErrNoManifest = errors.New("no ronja.json found")

ErrNoManifest is returned when a directory (or any of its parents) holds no ronja.json. Callers translate it into "run this inside a workflow folder".

Runtimes is every runtime a folder may declare, for the messages that have to list them. Derived from the constants so a fourth cannot be added to one without the other.

Functions

func CheckAliasCollisions added in v0.28.5

func CheckAliasCollisions(deps map[string]Dependency, stems []string) error

CheckAliasCollisions refuses a declared alias that collides with the stem of a sibling file in the same folder.

A pipeline folder resolves `{{ ref('orders') }}` against a sibling `orders.sql` FIRST — the file you can see wins over a declaration you have to scroll for — so a dependency declared under the same name is dead text that reads as if it were in force. Someone binds it in every stack, reviews the diff, and the code goes on reading the local file. It is refused at the point the name is INVENTED rather than shadowed at the point it is used, because shadowing is only visible to whoever already suspects it.

stems are the local names a folder's own files claim — for a pipeline, each .sql file's stem, which is exactly what tableNameFor derives.

CASE-INSENSITIVE, and the fold is the load-bearing half rather than a courtesy. `ronja bind` answers a declaration with strings.EqualFold against a hit's title — it has to, because the search endpoint's own exact-match test is ILIKE — so a folder holding `Orders.sql` and declaring `orders` gets that declaration bound to the very row `Orders.sql` created. One word then names two things that are the same row, spelled two ways, with a `bind` entry in the committed file asserting they are different: exactly the ambiguity this refusal exists for, arrived at through a capital letter. Comparing byte for byte would wave it through.

Exported because it is enforced from the commands layer — checkAliases calls it at the same acceptance point as CheckBind, since the stems it needs are the folder's files and this package never reads those.

func CheckLocalPaths

func CheckLocalPaths(paths []string, kind Kind) error

CheckLocalPaths reports whether a REMOTE file set can be laid out in a folder without losing a file, and is meant to run before the first byte is written.

It exists because the baseline is a promise: .ronja/state.json says "these paths are on disk with these hashes", and every later command believes it. A file the server has but the folder does not is read as a LOCAL DELETION — status shows a phantom deleted entry, and a push acts on it by deleting the file server-side. Losing the user's code to a folder that was wrong from the moment it was created is not a warning-level event.

Three ways a set cannot be laid out:

  • A path WriteFile refuses (legacy rows, or a malicious one). Skipping it with a warning is what produced the phantom-deletion case above.
  • A path Enumerate excludes by NAME (NotSyncable): a dot-file or dot-directory ANYWHERE in it, the folder's own ronja.json / ronja.lock.json / .ronja/, one of this Kind's SkipDirs, or a file outside this Kind's SyncExt — a `.md` handed to a pipeline folder is written once and then invisible to every walk. The server's path grammar allows `.` in a segment, so `.env` and `.config/x.py` are legal workflow paths that clone would happily write — and `dist/app.js` is a legal data-app path — and that the walk would then never see again. Exactly the phantom-deletion shape above, just arrived at by naming rather than by refusal. This is why Kind is a parameter here and not only on Enumerate: the two must answer identically.
  • Paths differing only by case. "Main.py" and "main.py" are two distinct rows server-side and ONE file on macOS or Windows, so the second write silently destroys the first — and the baseline then claims both exist. Checked unconditionally rather than by probing the filesystem, so a clone behaves identically everywhere and a folder cloned on Linux does not become unusable the moment a colleague pulls it on a Mac.

The remedy is not something the CLI can perform, so the error names the offending paths and points at the web builder, which can rename them.

func ChecksPath added in v0.32.2

func ChecksPath(stem string) string

ChecksPath is the folder-relative path of one stem's checks file.

func ChecksStem added in v0.32.2

func ChecksStem(p string) (string, bool)

ChecksStem is the inverse: the `.sql` stem a checks path names, and whether the path is a checks file at all. FLAT, for TableDocsAlias's reason.

func DirIsEmpty

func DirIsEmpty(dir string) (bool, error)

DirIsEmpty reports whether a directory has no entries. A missing directory counts as empty — the caller is about to create it.

func FileSlug added in v0.27.0

func FileSlug(name, fallback string) string

FileSlug turns a resource's NAME into a filename stem. It is Slug plus two rules: an underscore survives, and so does CASE.

The two are deliberately separate functions rather than one with a flag, because they name different things and only one of them round-trips. Slug names a clone DIRECTORY — a local choice, made once, that nothing compares against later. FileSlug names a file inside a folder that lives in the customer's git, and that file's stem is also the table's display name, so the name goes out and has to come back the same way. Folding `_` into `-` broke exactly that loop: an author writing orders_clean.sql got orders-clean.sql back from `pipeline clone`, which is a second file for one table and a phantom deletion the moment either is pushed.

Everything outside [a-z0-9_-] becomes a separator, and a literal `-` is treated as one too rather than as an allowed character — so runs collapse ("Sales / Revenue" and "sales---revenue" both give one dash) exactly as they do in Slug. Underscores are NOT collapsed: they are content here, and orders__clean is a filename somebody typed on purpose.

Only dashes are trimmed from the ends, not underscores. A leading `_` is a real naming convention for staging tables, and stripping it would be this same round-trip bug in a smaller font.

Case survives for that same round trip, and it is the last place the loop folded: a table named `Orders` came back as orders.sql, so the file git was already tracking as Orders.sql became a SECOND file for one table — visible only on a case-SENSITIVE filesystem, which is where CI runs. Collisions are still resolved case-INSENSITIVELY by uniqueSQLPath, because `Orders` and `orders` are one file on macOS and Windows.

func Find

func Find[T any](items []T, keyOf func(T) InstanceKey, want InstanceKey) (int, error)

Find locates the entry matching want, returning its index or -1.

Two rules, and the second is the interesting one:

  • A key that NAMES an organization matches only an entry with that exact organization. It never adopts an entry belonging to another, and never adopts an untagged one — a hand-written entry with no organization could belong to anybody.
  • A key with an UNKNOWN organization (signed out) matches on URL alone when that is unambiguous, and errors when it is not. Without this a bound folder would report itself unbound to a signed-out `wf status`, which is a read-only command that used to work fine.

URLs are compared through config.NormalizeURL, because ronja.json is a committed file people edit by hand and the two spellings they reach for — a trailing slash and an uppercase hostname — are exactly what it folds away. A raw comparison misses those, and the folder then silently reports as unbound against the instance it is plainly bound to, which a first push would "fix" by creating a second workflow.

func FindAll added in v0.28.5

func FindAll[T any](items []T, keyOf func(T) InstanceKey, want InstanceKey) []int

FindAll lists every entry matching want, by exactly the rules Find documents — which is the point of it existing: Find is written in terms of this, so the two can never disagree about what "matches" means.

It reports MATCHES, not a verdict. Deciding that two of them is an ambiguity worth refusing is the caller's, because the two callers disagree: Find's contract is one answer or none, while Manifest.Select has a --stack flag to offer and can name what it found.

func FindRoot

func FindRoot(start string) (string, error)

FindRoot walks up from start looking for a ronja.json, git-style, so the commands work from a subdirectory of the folder rather than only from its root. Returns ErrNoManifest when the filesystem root is reached.

func Hash

func Hash(body []byte) string

Hash is the content fingerprint used everywhere in this package: sha256 over the raw bytes, hex-encoded.

The remote side has no hash of its own (workflow_files carries only content and timestamps), so the CLI hashes the `content` string it receives with exactly this function. Local and remote fingerprints are therefore comparable by construction — as long as both sides hash BYTES, which is why this takes a []byte and the string form converts rather than the other way round.

func HashString

func HashString(body string) string

HashString fingerprints content that arrived as a string (i.e. from the API).

func LockPath added in v0.28.5

func LockPath(root string) string

LockPath locates the lock file inside a folder root.

func ManifestPath

func ManifestPath(root string) string

ManifestPath and StatePath locate the two files inside a folder root.

Both are pure joins, for naming a path in a message. Anything that WRITES inside StateDirName goes through StateDir instead.

func Matches

func Matches(a, b InstanceKey) bool

Matches reports whether two keys name the same instance and organization, tolerating URL spelling the same way Find does. Used when writing, to replace an equivalent entry rather than accumulate a second one beside it.

func MetricAlias added in v0.29.2

func MetricAlias(p string) (string, bool)

MetricAlias is the inverse: the alias a metric file names, and whether the path is a metric file at all.

FLAT, deliberately, for TableDocsAlias's reason: `metrics/a/b.json` is not a metric file. The stem is an alias, and an alias is a single name — allowing a nested one would invent a second spelling for it (`a/b`) that ronja.json's dependency map has no way to express, so the file would be committed, never read, and never reported.

func MetricPath added in v0.29.2

func MetricPath(alias string) string

MetricPath is the folder-relative path of one alias's metric file, in the slash-separated spelling every map in this package is keyed by.

func ModuleInitPath added in v0.29.0

func ModuleInitPath(root string) string

func NotSyncable added in v0.27.0

func NotSyncable(path string, kind Kind) string

NotSyncable reports why a FILE path is not one this kind syncs, or "" when it is. It is StructuralExclusion plus the kind's own content rule (SyncExt).

The one predicate both the walk and the layout check apply — Enumerate for every file it finds, CheckLocalPaths for every path it is about to write — and they must answer identically. A file one keeps and the other drops leaves a baseline claiming a path that no later walk returns: `status` invents a deletion and the next push acts on it by deleting the file server-side.

FILES only. A DIRECTORY is tested by StructuralExclusion alone: `staging/` has no extension, and pruning it here would hide every .sql file beneath it.

func RetiredRuntime added in v0.33.1

func RetiredRuntime(n int) bool

RetiredRuntime reports whether n is runtime 1 or 2, which an instance no longer creates a workflow on or raises one to. Zero is not retired: it is a folder that declares nothing, and the instance then creates on its default, which is 3.

func SaveFolder added in v0.28.5

func SaveFolder(root string, m *Manifest, l *Lock) error

SaveFolder writes both committed files, and is what every caller should use rather than SaveManifest alone: a manifest that names a stack whose lock entry never landed is a folder whose next push reads it as unbound and creates a SECOND set of resources.

The LOCK goes first, deliberately. The two orders fail differently and only one of them fails safely, and it is worth being exact about why — the original claim here, that a lock entry with no stack naming it is "inert because no selection can reach it", was the wrong reason for the right ordering. Unreachable would have been the WORSE property, not the safer one: a created workflow id recorded in the lock and unreachable is a resource that exists and that the next push creates AGAIN. What actually makes this order safe is that selectNamed treats a lock entry under the requested name as BOUND, so `--stack <name>` picks the stranded ids up rather than duplicating them. The other order — manifest first — has no such recovery: a stack with no lock entry reads as never-pushed, which is the duplicate-creation above. Neither file has a transaction to offer, so the choice is which half to leave behind and which half can be recovered from.

func SaveLock added in v0.28.5

func SaveLock(root string, l *Lock) error

SaveLock writes the lock file, indented and newline-terminated because it is committed and diffed like the manifest.

It writes NOTHING for a folder that has no stacks and no lock file already — which is precisely a legacy instances[] folder. That is the mechanism behind "a v1 folder keeps round-tripping as v1": push, discard and publish all call this, and on a folder nobody has named a stack in, all of them leave the directory exactly as they found it.

func SaveManifest

func SaveManifest(root string, m *Manifest) error

SaveManifest writes the manifest back. It is committed to the customer's repo, so it is indented and newline-terminated — a file people read and diff, not an opaque blob.

func SaveState

func SaveState(root string, s *State) error

SaveState writes the baseline and (re)asserts the .gitignore beside it.

Writing the ignore file on every save rather than only at clone time is deliberate: the baseline records what one USER's draft looked like, and a second developer inheriting it from git would see phantom drift against a draft that is not theirs. Both writers locate the directory through StateDir rather than joining it, so neither can be redirected out of the folder by a committed symlink at .ronja — see StateDir. They are the two writes that happen on every push and every clone, which is why the check lives there and not at either call site.

func Slug

func Slug(title, fallback string) string

Slug turns a title into a directory name: lowercase, ASCII alphanumerics and dashes, no runs of dashes, no leading or trailing dash.

Used only to pick a default clone directory, so a title made entirely of characters this drops (an emoji, a non-Latin script) must still produce something — fallback — rather than an empty path.

func StateDir added in v0.28.4

func StateDir(root string, sub ...string) (string, error)

StateDir locates the folder's local-only state directory — or a path inside it, when sub is given — and refuses one a symlink puts OUTSIDE root.

Everything under .ronja/ is written, overwritten and (for `app test`'s artefacts) deleted by the CLI at paths the CLI itself chose, not ones anybody typed. A folder is an ordinary git checkout, so `.ronja` — or any component under it — can arrive as a COMMITTED SYMLINK, and following one hands whoever wrote that repository the ability to point this CLI's writes and its deletes at any path on the machine running it.

The check resolves the longest EXISTING prefix of the path and asserts the result is still inside root. Resolving the LEAF alone is not enough, which is the bug this replaced: os.Lstat follows every component before the last, so a link at `.ronja` itself answered "not a symlink" for `.ronja/test` and the guard passed. Components that do not exist yet cannot be links; they are the caller's to create. Both sides are resolved, because root itself routinely arrives through one (/var → /private/var on macOS, and every t.TempDir).

func StatePath

func StatePath(root string) string

func StructuralExclusion

func StructuralExclusion(path string, kind Kind) string

StructuralExclusion reports why a resource-relative path can never live in a synced folder of this kind, or "" when it can.

These are the rules Enumerate applies BY SHAPE, before the server's path grammar gets a look in — and the server's grammar accepts every one of them (`.` is an allowed segment character, so `.env` and `.config/x.py` are perfectly legal workflow file paths server-side; so is `dist/app.js`). That asymmetry is the whole reason this is a named, exported rule rather than an inline condition: anything that lays files out on disk and then trusts a baseline — clone, the post-publish baseline refresh — has to refuse a set containing one of these, because the file would be written, never enumerated again, and read as a LOCAL DELETION on the next status (and acted on by the next push).

Kind is a parameter for exactly that reason. A data app excludes node_modules/ and dist/ where a workflow does not, so "is this path syncable" has no kind-free answer — and answering it with one Kind here and another in Enumerate is the phantom-deletion bug above.

Keep in step with Enumerate.

func TableDocsAlias added in v0.29.0

func TableDocsAlias(p string) (string, bool)

TableDocsAlias is the inverse: the alias a sidecar path names, and whether the path is a sidecar at all.

FLAT, deliberately: `tables/a/b.json` is not a sidecar. The stem is an alias, and an alias is a single name — allowing a nested one would invent a second spelling for it (`a/b`) that ronja.json's dependency map has no way to express, so the file would be committed, never read, and never reported.

func TableDocsPath added in v0.29.0

func TableDocsPath(alias string) string

TableDocsPath is the folder-relative path of one alias's docs sidecar, in the slash-separated spelling every map in this package is keyed by.

func ValidAliasName added in v0.28.5

func ValidAliasName(name string) error

ValidAliasName reports whether a name may be declared as a dependency, and says why not when it may not.

Narrow, for the reasons ValidStackName is, plus two rules that exist only here and are each a silent wrong-deploy if they are missing:

  • ALL DIGITS is refused because `{{ ref('0') }}` is already a grammar: a POSITIONAL ref, an index into the row's input_models, and the form the AI build path persists (see the tablerefs package doc). An alias called "0" would be a name that reads as an index to the resolver and as an index to the server, and the table it landed on would depend on which of them looked first. markers.Occurrence.Positional is the other half of this guarantee; neither half is safe alone.

  • ID-SHAPED is refused for every MARKER kind, not only the one declared. An alias called `table-orders` resolves to itself — it is already id-shaped, so every reader downstream, here and on the server, takes it for a literal id and looks for a row nobody has. Checking every kind rather than the declared one costs nothing and closes the case where a `secret` dependency is named `table-x`.

    ⚠️ markers.MarkerKinds(), NOT markers.Kinds(), and the narrowing is the whole point. This function runs at LOAD, over manifests committed before this build existed. A kind that joins the legal set LATER brings its id prefixes with it, and scanning those here would retroactively refuse a name that was legal when it was written: `note` arrived carrying `note-` and `skill-`, so an existing workflow folder with an alias called `note-taking` would stop opening — for every command, `status` included, with hand-editing a committed file as the only recovery. That is verbatim the failure checkStacks forbids: THE CLI MUST NEVER WRITE A MANIFEST IT WILL THEN REFUSE TO LOAD. The marker-less half of this rule is real and is enforced at the acceptance point instead — see CheckDeclarations.

func ValidRuntime added in v0.28.5

func ValidRuntime(n int) bool

ValidRuntime reports whether n is a runtime this CLI knows how to declare.

Zero is valid: a manifest may simply not mention a runtime, which is what a folder written before runtimes existed looks like and what a folder that leaves the choice to the instance looks like. (The two are not the same folder any more: a create's write-back records what the server stamped, so an unpinned folder does not stay unpinned past its first push.) Everything else is refused at load, so a hand-edited `"runtime": 4` fails at the push that would have created it rather than at the first unattended run.

func ValidStackName added in v0.28.5

func ValidStackName(name string) error

ValidStackName reports whether a name may be written into a committed manifest, and says why not when it may not.

Deliberately narrow. The name appears in three places — a JSON object key, a --stack argument, and prose in an error message — so anything that needs quoting in a shell, or that two readers would spell differently, is refused at the point it is invented rather than discovered later by whoever types it.

func ValidatePath

func ValidatePath(path string) error

ValidatePath reports whether a relative path is one the server will accept: slash-separated, at most MaxPathDepth segments, at most MaxPathLength characters, each segment drawn from [A-Za-z0-9_.-] and non-empty, no leading or trailing slash, no traversal.

func WriteFile

func WriteFile(root, path, content string) error

WriteFile writes one workflow file into the folder, creating parent directories as needed. The path is validated first: it arrives from the server, and a row carrying a traversal path (legacy data, a future bug) must not be able to write outside the folder.

func WriteStateGitignore

func WriteStateGitignore(root string) error

WriteStateGitignore drops a "*" .gitignore inside .ronja/, so the directory excludes itself no matter what the surrounding repo's rules are.

Types

type AppliedTag added in v0.32.2

type AppliedTag struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	// contains filtered or unexported fields
}

AppliedTag is one tag a folder put on a metric: the tag's id, which survives a rename, and the name the FOLDER asserted it under.

⚠️ THE NAME IS THE FILE'S CLAIM, spelled as the server spells the tag it matched. On an ordinary match that is the tag's current name (a file saying `finance` records `Finance`); on a tag an admin renamed since, it is the name recorded BEFORE the rename, because that is the name the file still says and the entry is what tells the next push the file's name is already satisfied.

func (AppliedTag) MarshalJSON added in v0.32.2

func (a AppliedTag) MarshalJSON() ([]byte, error)

func (*AppliedTag) UnmarshalJSON added in v0.32.2

func (a *AppliedTag) UnmarshalJSON(data []byte) error

type Binding

type Binding struct {
	WorkflowID string `json:"workflowID,omitempty"`
	// DataAppID is the WorkflowID of a data-app folder. Two fields rather than
	// one polymorphic "resourceID" because the manifest is a file people read: a
	// key naming what it points at is worth more than one saved line, and the
	// kind gate means only one of the two can ever be populated.
	//
	// Like WorkflowID, this may name a row that is still a parentless DRAFT:
	// since migration 000495 POST /dataapp mints a draft, and publish promotes
	// that same row in place, so the id is stable from first push onwards but
	// only names a LIVE app once it has been published.
	DataAppID string `json:"dataAppID,omitempty"`
	// ModuleID is the WorkflowID of a MODULE folder, a third field for the
	// reason DataAppID is a second one: the manifest is a file people read, and
	// a key naming what it points at is worth more than one saved line. Like
	// both of the others it is the STABLE identity — the live module, or a
	// parentless draft's own id — never a per-user edit draft's.
	ModuleID  string `json:"moduleID,omitempty"`
	FeatureID string `json:"featureID,omitempty"`
	// Tables is a PIPELINE folder's binding: local relative path → the live
	// `table-…` id that file builds. One folder, many resources — which is the
	// real divergence from the two single-resource kinds and the reason the
	// single-id helpers below refuse KindPipeline (and KindAutomation) instead
	// of quietly answering with WorkflowID.
	//
	// Keyed by the SLASH-separated relative path, exactly as Enumerate reports
	// it, so the manifest and the baseline agree about what a file is called on
	// every platform.
	//
	// omitempty: a workflow or data-app manifest must not grow an empty "tables"
	// key, and a pipeline folder that has never pushed has nothing to record.
	Tables map[string]string `json:"tables,omitempty"`
	// Automations is an AUTOMATION folder's binding: local relative path → the
	// `scheduled_jobs` id that file describes. Keyed exactly as Tables is, and
	// separate from it because the two kinds cannot share a folder — one map per
	// kind keeps "which resource does this path name" answerable without also
	// asking what kind of folder we are in.
	//
	// ⚠️ The id is the ONLY tie between a file and its row, and what a lost
	// entry costs is stated once, on LockAutomation.AutomationID. Read it before
	// treating this map as recoverable bookkeeping.
	Automations map[string]string `json:"automations,omitempty"`
}

Binding is what one instance knows about this folder.

WorkflowID is ABSENT until the first push: `wf init` binds a feature (the container the workflow will be created in) but creates nothing server-side, so there is no id to record yet. FeatureID is what makes that first push possible, which is why it is recorded from the start.

WorkflowID is always the STABLE identity — the live row, or a parentless draft's own id — never a per-user edit draft's id. Draft ids come and go with every checkout/commit cycle; a manifest that recorded one would be a committed file pointing at a row that stops existing the moment its author publishes.

func (Binding) ResourceID

func (b Binding) ResourceID(kind Kind) (string, error)

ResourceID is the bound row for a kind, so callers that do not care which kind they are working with do not have to branch.

It REFUSES a multi-resource kind rather than falling through to WorkflowID. The fall-through was safe while there were exactly two kinds and the branch was `if dataapp { … } else { workflow }`; with a third it becomes a silent wrong answer — an empty string that reads as "never pushed here" for a folder that is bound to a dozen tables, which a push would then act on by creating all of them again.

func (Binding) WithAutomation added in v0.28.5

func (b Binding) WithAutomation(path, automationID string) Binding

WithAutomation returns a copy binding one relative path to one automation id.

It COPIES the map for WithTable's reason, which bites harder here: a binding "recorded" by writing through the caller's map and then lost to a failed save is a path whose row the next push cannot find, and for this kind that is not a miss — see LockAutomation.AutomationID for what the next push does instead.

func (Binding) WithResourceID

func (b Binding) WithResourceID(kind Kind, id string) (Binding, error)

WithResourceID returns a copy naming the bound row for a kind, refusing a multi-resource kind for the reason ResourceID does — and here the fall-through would have been worse than a wrong read: it would have WRITTEN a table id into a pipeline manifest's workflowID field, where nothing would ever look for it.

func (Binding) WithTable added in v0.27.0

func (b Binding) WithTable(path, tableID string) Binding

WithTable returns a copy binding one relative path to one live table id.

It COPIES the map rather than writing through it. Binding is a value type but a map field is a reference, so mutating the caller's copy in place would reach into whatever Manifest.Instances entry it came from — a binding "recorded" before SaveManifest ran, and silently kept after a failure that should have left it unwritten.

func (Binding) WithoutAutomation added in v0.28.5

func (b Binding) WithoutAutomation(path string) Binding

WithoutAutomation returns a copy of the binding with one path dropped — what `automation push --prune` records once the row behind a deleted file is gone.

It COPIES for WithAutomation's reason, and the asymmetry matters here too: a forget written through the caller's map and then LOST to a failed save leaves a folder still bound to a row that no longer exists, whose next push refuses on a binding nobody can repair from the message. Copying means the forget is recorded exactly when the save succeeds.

A nil map answers a nil map: dropping a path from a binding that has none is not a state worth allocating for.

type ChecksState added in v0.32.2

type ChecksState struct {
	TableID string                    `json:"tableID"`
	Managed map[string]LockCheckEntry `json:"managed,omitempty"`
}

ChecksState is one checks file's recorded state in a LEGACY folder's local baseline — the mirror of wfdir.LockChecks.

type Dependency added in v0.28.5

type Dependency struct {
	// Kind is which primitive the alias names — one of markers.Kinds(). It is
	// declared rather than inferred from the code, because the marker family is
	// what would have to infer it, and a name used by no marker yet (a
	// dependency added before the code that reads it) would then have no kind at
	// all.
	Kind string `json:"kind"`
	// contains filtered or unexported fields
}

A DEPENDENCY is a name this folder's CODE uses for a row it does not own, and it is what makes a folder deployable to more than one organization.

The problem it solves: a resource id belongs to exactly one organization. A folder whose SQL says `{{ ref('table-abc') }}` is a folder that can only ever be pushed to the organization that minted table-abc — cloned into another and pushed, it builds against an id nobody there has, and the refusal it earns names an id that means nothing to the reader. So the code says `{{ ref('orders') }}` instead, ronja.json declares `orders` as a dependency of kind `table`, and each stack binds that name to the id its own organization uses.

The alias namespace is owned by the FOLDER, and resolution happens in the CLI before anything is sent. The server's authorization surface is unchanged by all of this: it only ever receives ids, exactly as it did before, because a second grammar on that surface would be a second place to get access checks right.

Two halves, in two places, on the split this package draws everywhere: Manifest.Dependencies is CONFIG — what a person decided, and what a pull request is about — and so is Stack.Bind, because choosing which of this organization's rows answers to `orders` is a decision, not something a deploy discovered. Neither is ever written by a push.

func (Dependency) MarshalJSON added in v0.28.5

func (d Dependency) MarshalJSON() ([]byte, error)

MarshalJSON writes the struct's own keys and puts the unrecognised ones back.

A VALUE receiver, so a Dependency inside a map — which is never addressable — is marshalled through this method too. A pointer receiver would silently skip every entry in Manifest.Dependencies, which is the only place these live.

func (*Dependency) UnmarshalJSON added in v0.28.5

func (d *Dependency) UnmarshalJSON(data []byte) error

UnmarshalJSON / MarshalJSON keep the keys this build has no field for, for the reasons on Manifest's pair. plainDependency is the method-less twin that stops the recursion; see forwardcompat.go.

type Diff

type Diff struct {
	Added    []string `json:"added"`
	Modified []string `json:"modified"`
	Deleted  []string `json:"deleted"`
	// Unchanged is a count rather than a list: it is the boring majority, and
	// nothing acts on the names.
	Unchanged int `json:"unchanged"`
}

Diff is a comparison of two path → sha256 maps: what the left side has that the right side does not, and so on. Lists are sorted so output is stable.

func DiffHashes

func DiffHashes(current, baseline map[string]string) Diff

DiffHashes compares a current set of hashes against a baseline.

One function serves both comparisons the sync loop needs, because they are the same question asked twice:

  • LOCAL vs baseline — what have I changed since the last sync (what push would send).
  • REMOTE vs baseline — what has changed on the server since the last sync (drift: someone else's web-builder edit to the same per-user draft), which push must refuse to overwrite blindly.

func (Diff) Dirty

func (d Diff) Dirty() bool

Dirty reports whether anything differs at all.

func (Diff) Total

func (d Diff) Total() int

Total is how many paths differ.

type Enumeration

type Enumeration struct {
	// Files maps a syncable relative path to its sha256.
	Files map[string]string
	// Skipped lists everything the walk left out, sorted by path: path-invalid
	// files, non-regular files, AND the structural exclusions (dot-files,
	// dot-directories). A dot-DIRECTORY is reported once, by directory, rather
	// than once per file inside it — .git would otherwise drown the report.
	//
	// The folder's own machinery (ronja.json, .ronja/) is the one thing NOT
	// listed: it is the CLI's bookkeeping, not the user's mistake.
	Skipped []Skipped
}

Enumeration is the syncable content of a folder plus what was left out.

func Enumerate

func Enumerate(root string, kind Kind) (*Enumeration, error)

Enumerate walks a synced folder and hashes every file that could be pushed.

Three classes of exclusion, in order:

  1. The folder's own machinery: ronja.json, ronja.lock.json and .ronja/. Never syncable, and the only exclusion not reported as Skipped.
  2. Structural exclusions: dotfiles and dot-directories anywhere in the tree — .git above all, which is enormous, full of paths the server would reject, and never source — plus this Kind's SkipDirs (node_modules/, dist/ and build/ for a data app). Ruled out by DIRECTORY so a repo's whole object store, or a whole dependency tree, is skipped rather than walked and discarded file by file (and reported once, by directory).
  3. Anything else the server's path rules reject, which is reported as Skipped so the caller can say so.

Every exclusion but the first is REPORTED. Silently dropping a file the server would have accepted is what turns a clone into a folder whose baseline lies: see StructuralExclusion and CheckLocalPaths.

Symlinks are not followed (WalkDir does not follow them), so a link pointing outside the folder cannot smuggle content into a push.

type FileState

type FileState struct {
	SHA256    string `json:"sha256"`
	UpdatedAt string `json:"updatedAt,omitempty"`
}

FileState is one file as the server last had it. UpdatedAt is diagnostics only — drift is decided on the hash, because workflow_files.updated_at moves for reasons that do not change content.

type Instance

type Instance struct {
	URL      string `json:"url"`
	TenantID string `json:"tenantID,omitempty"`
	Binding
	// Bind is this entry's alias → resource id map, the legacy shape's half of
	// what Stack.Bind carries. See Stack.Bind for why it sits on the ENTRY and
	// not on the embedded Binding — the reason is the same one, and it is the
	// reason that matters most here: SetBinding replaces an entry wholesale from
	// a caller-supplied Binding, so a Bind that travelled inside Binding would
	// be erased by `wf discard`.
	Bind map[string]string `json:"bind,omitempty"`
	// contains filtered or unexported fields
}

Instance is one binding: where it points, and what it points at.

TenantID is an organization identifier, not a secret. It is opaque, appears in ordinary API paths, and is useless without a credential — it is here for the same reason the URL is, so a committed folder says plainly which organization each binding belongs to.

func (Instance) Key

func (i Instance) Key() InstanceKey

Key is the (instance, organization) this entry is bound to.

func (Instance) MarshalJSON added in v0.28.5

func (i Instance) MarshalJSON() ([]byte, error)

MarshalJSON writes the entry's own keys and puts the unrecognised ones back.

func (*Instance) UnmarshalJSON added in v0.28.5

func (i *Instance) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes one entry and keeps whatever it did not recognise, for the reasons on Manifest.UnmarshalJSON.

⚠️ These two methods must stay on Instance and must NEVER be added to Binding, and the vector is plainInstance rather than Instance itself. On Instance these methods sit at depth 0 and SHADOW anything promoted from the embedded Binding, so a Binding.MarshalJSON looks harmless — it is not. plainInstance has no methods of its own but still embeds Binding, so a Binding.MarshalJSON WOULD be promoted onto it and would win inside Instance.MarshalJSON: json.Marshal(plainInstance(i)) would emit the binding alone, and the entry would lose the url and tenantID that say which instance and organization it belongs to — from a file the customer has committed. The unmarshal direction fails the same way: a promoted Binding.UnmarshalJSON would swallow the whole entry object and url/tenantID would never be decoded. TestBindingHasNoJSONMethods is the guard, and it is load-bearing: nothing about adding a method to Binding fails to compile.

type InstanceBaseline

type InstanceBaseline struct {
	URL      string         `json:"url"`
	TenantID string         `json:"tenantID,omitempty"`
	State    *InstanceState `json:"state"`
}

InstanceBaseline is one binding's baseline. It mirrors Manifest.Instance so both files can share the lookup in Find.

func (InstanceBaseline) Key

func (i InstanceBaseline) Key() InstanceKey

Key is the (instance, organization) this baseline belongs to.

type InstanceKey

type InstanceKey struct {
	URL      string
	TenantID string
}

InstanceKey identifies what a folder is bound TO: one instance and one organization on it.

The organization is part of the key because workflow ids are scoped to one. Keyed by URL alone, a person who belongs to two organizations on the same instance has one entry for both — so a push made while signed in to the second sends the FIRST one's workflow id under the second's token. That does not fail safely: the id is simply not found there, and the push path treats not-found as "create it", quietly producing a duplicate workflow in the wrong organization.

It is deliberately NOT keyed by profile NAME. ronja.json is committed and shared, and a profile name is local to one machine — my "prod" is not yours.

TenantID may be empty, meaning "not known right now" — a signed-out `wf status` has no way to learn it. See Find for how that degrades.

func KeysOn added in v0.28.5

func KeysOn[T any](items []T, keyOf func(T) InstanceKey, url string) []InstanceKey

KeysOn lists the keys of every item bound to one instance, in slice order.

It folds URL spelling exactly as Find does, and lives beside it for that reason. Two callers depend on the two agreeing: the refusal that enumerates the organizations a folder names on an instance, and the check that decides whether resolving the caller's own organization is worth a round trip. A rawer comparison here would name a different set from the one the lookup saw — the worst kind of disagreement, since it is invisible until the spellings differ.

func (InstanceKey) Known

func (k InstanceKey) Known() bool

Known reports whether the key names an organization.

type InstanceState

type InstanceState struct {
	SourceID          string `json:"sourceID"`
	SourceLifecycle   string `json:"sourceLifecycle,omitempty"`
	BaselineUpdatedAt string `json:"baselineUpdatedAt,omitempty"`
	Title             string `json:"title,omitempty"`
	// Name is a MODULE's Python package name as the server last had it, and it
	// is the same guard Title is: a colleague renaming the package in the web
	// UI must not be silently reverted by the next push. omitempty because
	// every other kind's baseline has no such field and must not grow an empty
	// one — and because an absent value means NO GUARD, which is what a state
	// file written before modules existed deserves.
	Name              string                   `json:"name,omitempty"`
	Entrypoint        string                   `json:"entrypoint,omitempty"`
	Parameters        *[]api.WorkflowParameter `json:"parameters,omitempty"`
	ReportingTimezone *string                  `json:"reportingTimezone,omitempty"`
	Access            *api.DataAppAccess       `json:"access,omitempty"`
	Files             map[string]FileState     `json:"files"`
	// Tables is a PIPELINE folder's per-file remote state: relative path →
	// which row the baseline describes, and whether this checkout holds an open
	// draft of it.
	//
	// Separate from Files rather than folded into FileState because the two
	// answer different questions and are written at different moments: Files is
	// "what were the bytes at the last sync" (the drift baseline), Tables is
	// "which rows are these files, and where is my draft" (so push can resume
	// its own draft without a round trip and status can say "draft staged"
	// without one either).
	//
	// omitempty and three-state-safe: ABSENT means this folder does not track
	// table state — the ordinary reading for every workflow and data-app
	// baseline, and for a pipeline folder written before the key existed. It
	// never means "no tables", which is what a present-but-empty map says.
	Tables map[string]TableState `json:"tables,omitempty"`
	// TableDocs is a PIPELINE folder's per-DOCS-SIDECAR state, and it is the
	// LEGACY half of the pair Lock.TableDocs owns. A folder on a NAMED STACK
	// keeps its sidecar recordings in the committed lock, where a fresh CI
	// checkout can read them; a legacy unnamed instances[] folder has nowhere
	// committed to put one, so it keeps them here exactly as it kept everything
	// else before stacks existed. The fork is spelled once, in the commands
	// package's liveHashes.
	//
	// omitempty and three-state-safe, for the reason Tables is: ABSENT means this
	// folder tracks no sidecar state, never "there are no sidecars".
	TableDocs map[string]TableDocsState `json:"tableDocs,omitempty"`
	// Metrics is a PIPELINE folder's per-METRIC-FILE state, and it is the LEGACY
	// half of the pair Lock.Metrics owns — the same fork, for the same reason,
	// as TableDocs above: a folder on a NAMED STACK keeps its metric recordings
	// in the committed lock, where a fresh CI checkout can read them, and a
	// legacy unnamed instances[] folder has nowhere committed to put one. The
	// fork is spelled once, in the commands package's liveHashes.
	//
	// omitempty and three-state-safe, for the reason Tables is: ABSENT means this
	// folder tracks no metric state, never "there are no metrics".
	Metrics map[string]MetricState `json:"metrics,omitempty"`
	// Checks is the LEGACY half of Lock.Checks — a checks file's recorded table
	// and managed checks on a folder with no named stack. The fork is spelled
	// once, in the commands package's liveHashes.
	Checks map[string]ChecksState `json:"checks,omitempty"`
}

InstanceState is what one instance's baseline records.

SourceID names the ROW the files came from, which is not the same as the manifest's WorkflowID: a clone prefers the caller's open draft when there is one, so the baseline may describe a draft row while the binding names the live workflow it belongs to. SourceLifecycle keeps that distinction legible without another round-trip.

Title and Entrypoint are the row's METADATA as the server last had it, and they are here for the same reason the file hashes are: without them a push sends the manifest's title and entrypoint blind, so a colleague renaming the workflow in the web builder is silently reverted by the next sync. They are omitempty because a state file written before they existed must keep parsing — and an absent one simply means no metadata guard, which is what a folder that has never recorded them deserves. Parameters is the same guard for the declared parameter set: a colleague adding one in the web builder must not be silently reverted by the next push. Pointer for the same reason it is one on the Manifest — a state file written before parameters were recorded has to be distinguishable from one that recorded "the row declares none", or every pre-existing folder would read as having lost a parameter set it never knew about. ReportingTimezone is the same guard for the declared execution calendar: a colleague changing the workflow's zone in the web builder must not be silently reverted by the next push. Pointer for the reason the others are — a state file written before the zone was recorded (nil) has to be distinguishable from one that recorded "the row declares none" (a pointer to ""), or every pre-existing folder would read as having lost a declaration it never knew about and the guard would fire on a difference nobody made. Access is the data-app counterpart of Parameters, and it is a pointer for the same reason: a state file written before allowlists were recorded must be distinguishable from one that recorded "the row allows nothing". Without that distinction every pre-existing folder would read as having lost an allowlist it never knew about, and the first push would report drift it cannot explain.

func (*InstanceState) Hashes

func (i *InstanceState) Hashes() map[string]string

Hashes flattens a baseline into path → sha256 for diffing. A nil receiver yields an empty map, which is the right answer: no baseline means everything present locally is new.

func (*InstanceState) TableStateFor added in v0.27.0

func (i *InstanceState) TableStateFor(path string) TableState

TableStateFor returns one file's recorded table state, or the zero value when there is none — which is also what a nil receiver answers, since a folder that has never synced here has no state for any path. The zero value disarms both legs of the drift guard, which is the documented meaning of "no baseline".

type Kind

type Kind struct {
	// Name is the manifest's `kind` value, and the default directory name when a
	// title slugifies to nothing.
	Name string
	// Label is how the kind is spoken about in messages: "workflow", "data app".
	Label string
	// Command is the CLI command group that drives folders of this kind —
	// "ronja wf", "ronja app", "ronja pipeline".
	//
	// It lives ON the Kind rather than in a lookup beside it because it was
	// previously spelled twice: once in this package (for LoadManifest's
	// wrong-kind refusal, which names the command that WOULD work) and once in
	// internal/commands (for "run this inside a folder created by …"). Both were
	// binary if/else on KindDataApp, so a third kind would have been routed to
	// `ronja wf` by BOTH of them — silently, in exactly the messages a lost
	// caller reads. Declared here, a new kind cannot be added without answering
	// the question.
	Command string
	// DefaultEntrypoint matches the server's own default for a new resource of
	// this kind. EMPTY is a legitimate value (a pipeline folder has no
	// entrypoint at all): LoadManifest falls back to it, so an empty default
	// simply leaves Manifest.Entrypoint empty.
	DefaultEntrypoint string
	// SkipDirs are directory names this kind never syncs, on top of the dot-name
	// rule every folder shares. Matched by SEGMENT at any depth.
	//
	// Empty for workflows: a Python folder has no conventional build output, and
	// excluding a directory nobody has would only be a rule to trip over. A data
	// app is a TSX folder, where node_modules/ and dist/ are ordinary — and where
	// their absence from this list is expensive, because the server caps an app
	// at 100 files and would reject the push late, by count, naming nothing
	// useful.
	SkipDirs []string
	// SyncExt, when non-empty, is the ONE file extension a folder of this kind
	// synchronises; every other file in it is ignored, silently, so the folder
	// can be an ordinary repo directory.
	//
	// It lives on the Kind — beside SkipDirs and for the same reason — because
	// this is a rule Enumerate and CheckLocalPaths must answer IDENTICALLY: a
	// walk that drops a path the baseline keeps produces a baseline claiming a
	// file no later walk will see, which status reports as a deletion and a push
	// acts on. Threaded as one value, that mismatch is unrepresentable. See
	// NotSyncable, which is where both sides read it.
	SyncExt string
}

Kind is the resource a synced folder describes, plus the rules that differ between them.

A single VALUE rather than a handful of parameters, because these rules have to travel TOGETHER. If Enumerate decides a path is not syncable while CheckLocalPaths thinks it is, the folder ends up with a baseline claiming a file is on disk that the walk will never see again — `status` invents a deletion and the next push acts on it by deleting the file server-side. One Kind threaded through every entry point makes that mismatch unrepresentable, where "remember to pass the same exclusion list to both" would not.

func AllKinds added in v0.28.5

func AllKinds() []Kind

AllKinds is every folder kind this build understands, ordered by name so a listing built from it reads the same way twice.

func KindByName added in v0.28.5

func KindByName(name string) (Kind, bool)

KindByName resolves a manifest's `kind` for a caller OUTSIDE this package — one that has to read a folder's kind without already knowing it.

Exported because there are exactly two such readers and BOTH were hand-kept lists that the fourth kind was added without: `ronja bind`'s kind-agnostic probe (folderKindAt) and `ronja context`'s local-work report. Neither failure is loud. The probe reports an unrecognised kind as an unreadable folder, which makes `ronja sync status` exit 2 for the whole tree; the report falls back to "this is a workflow folder" and recommends `ronja wf` — which is precisely the misrouting Kind.Command was introduced to stop. Derived from the registry, a fifth kind cannot repeat either.

func (Kind) Syncable added in v0.27.0

func (k Kind) Syncable(path string) bool

Syncable reports whether a FILE path holds content a folder of this kind sends. Case-insensitive, because `.SQL` is an ordinary thing to find in a repo that has been through Windows.

A kind with no SyncExt syncs everything the structural rules allow, which is how workflows and data apps behave.

type Lock added in v0.28.5

type Lock struct {
	// FormatVersion is the lock FORMAT, not the resource's. See
	// LockFormatVersion: omitempty, nothing writes it at 1, and a file that
	// declares 1 by hand keeps saying so.
	FormatVersion int `json:"formatVersion,omitempty"`
	// Stacks is the recorded state, one entry per stack name in ronja.json.
	//
	// A map keyed by name and NOT a list keyed by (url, tenantID): the lock's
	// whole job is to be the state half of a config entry, so it has to be keyed
	// by the same thing the config is. Keyed by url+tenant it would go stale the
	// moment a stack was repointed, and would answer for a stack that no longer
	// exists.
	Stacks map[string]LockStack `json:"stacks"`
	// contains filtered or unexported fields
}

Lock is ronja.lock.json — the committed, MACHINE-OWNED half of a folder.

The split it completes: ronja.json says what a human decided (title, entrypoint, parameters, and which stack points where), and this file says what a deploy discovered (the ids it created, the fingerprints it agreed with). Before the split both lived in ronja.json, so every CI push dirtied the file a reviewer reads and every developer's pull rewrote it — and a fresh CI checkout had no baseline at all, which is why the loops documented --force as the way to run from CI.

COMMITTED, and that is the point rather than an oversight. A lock file that stayed local would leave CI exactly where it was. It is also why it is not in .ronja/: that directory excludes itself with a "*" .gitignore, and this file has to be in the repo.

Keyed by STACK NAME, so it can only describe a folder that has stacks. A legacy instances[]-only folder keeps its ids inline exactly as it always did and never grows a lock file — see SaveLock.

func LoadLock added in v0.28.5

func LoadLock(root string) (*Lock, error)

LoadLock reads the lock file. A missing one is NOT an error: it is what a legacy instances[] folder has, and what a stack folder has before its first push. Callers get an empty Lock and read nothing from it.

func (*Lock) AutomationSeen added in v0.28.5

func (l *Lock) AutomationSeen(stack, path string) (automationID, updatedAt string)

AutomationSeen reads one automation file's recorded row id and the `updatedAt` this folder last agreed with. An unrecorded path answers two empty strings, which is what disarms the drift and re-enable guards — "no answer", never "agreed".

func (*Lock) Binding added in v0.28.5

func (l *Lock) Binding(stack string) Binding

Binding is the recorded state for one stack, in the shape every command already reads. FeatureID is deliberately NOT filled in here — it is config and lives in the manifest's stack — so callers go through Manifest.Select rather than reading the lock directly.

A nil receiver answers the zero value, which is what "no lock file" means: nothing has been created here yet.

func (*Lock) ChecksPaths added in v0.32.2

func (l *Lock) ChecksPaths(stack string) []string

ChecksPaths lists the checks files this stack has a record for, sorted — the way a push finds a record whose file was deleted.

func (*Lock) ChecksSeen added in v0.32.2

func (l *Lock) ChecksSeen(stack, path string) (tableID string, managed map[string]LockCheckEntry)

ChecksSeen reads one checks file's recorded table id and managed checks. An unrecorded path answers an empty id and a nil map.

func (*Lock) DropChecksSeen added in v0.32.2

func (l *Lock) DropChecksSeen(stack, path string)

DropChecksSeen forgets one checks file's record: its file is gone and nothing it owned is still enabled.

func (*Lock) HeadVersion added in v0.28.5

func (l *Lock) HeadVersion(stack string) string

HeadVersion reads the committed head-version pointer for one stack, empty when there is none — which disarms that leg of the drift guard exactly as an absent live fingerprint does for a pipeline, and as an absent local baseline does for everything.

func (Lock) MarshalJSON added in v0.28.5

func (l Lock) MarshalJSON() ([]byte, error)

MarshalJSON writes the struct's own keys and puts the unrecognised ones back.

It does NOT stamp a format version, because there is only one and an absent key already means it — see the ⚠️ on LockFormatVersion for what has to happen here the day that changes.

A VALUE receiver, so the empty-map substitution below lands on this copy and not on the caller's struct, and so a LockStack inside a map (which is never addressable) is marshalled through its own method too.

func (*Lock) MetricSeen added in v0.29.2

func (l *Lock) MetricSeen(stack, path string) (metricID, live, declared string)

MetricSeen reads one metric file's recorded row id, the live metric's recipe fingerprint this folder last agreed with, and what the file itself last declared. An unrecorded path answers three empty strings, which disarms both guards.

func (*Lock) MetricTags added in v0.32.2

func (l *Lock) MetricTags(stack, path string) (metricID string, applied []AppliedTag, refused *TagsRefusal)

MetricTags reads one metric file's TAG half: the row it was recorded against, what the folder last applied there, and a recorded refusal. An unrecorded path — or a nil lock, a fresh clone's — answers nothing, which reconciles additively and retries any refusal.

The id is returned so the caller can apply LockMetric's invariant: a record taken against another row says nothing about this one.

func (*Lock) SetAutomationSeen added in v0.28.5

func (l *Lock) SetAutomationSeen(stack, path, automationID, updatedAt string)

SetAutomationSeen records the row this path is, and the `updatedAt` the server reported for it at the moment this folder agreed with it.

Called only where that is TRUE — the read a status or a push just made, and the response of a create or an update — and never with a timestamp from anywhere else. An updatedAt taken from one response and stored against a later state of the row is worse than none: it reports agreement with a version nobody read.

An empty updatedAt CLEARS the anchor, which is how a caller says "I recorded the id but cannot honestly claim to have seen the row's current state" without having to know whether one was there before.

func (*Lock) SetBinding added in v0.28.5

func (l *Lock) SetBinding(stack string, b Binding)

SetBinding records one stack's created ids, preserving the per-table live fingerprints the binding does not carry.

Preserving them is not tidiness: Binding is path → table id, so writing the map wholesale would drop every LiveSHA256 on the first push after a clone, and the drift guard would silently stop having anything to compare against — the exact failure the lock exists to fix, reintroduced by the writer.

An automation folder's Automations map is preserved the same way and for the sharper version of that reason: the value it would drop is UpdatedAt, and losing that does not degrade a guard — it disarms the two that LockAutomation.UpdatedAt names.

func (*Lock) SetChecksSeen added in v0.32.2

func (l *Lock) SetChecksSeen(stack, path, tableID string, managed map[string]LockCheckEntry)

SetChecksSeen records the table a checks file applies to and the checks the folder owns on it. A LEGACY folder has no stack name and keeps this in .ronja/state.json instead — see SetMetricSeen.

func (*Lock) SetHeadVersion added in v0.28.5

func (l *Lock) SetHeadVersion(stack, versionID string)

SetHeadVersion records the live version this folder now agrees with.

Called only at the moments that is TRUE, which is the whole discipline of the field: a clone that took the live row's files, a first push that created the row (nothing has been published, so the row is its own head), a push onto a draft forked from the recorded version, and a publish that just committed a new version.

Never called with a head read while somebody else's version sat between it and this folder's content — see workflow_clone's draft branch, and dataAppCloneAnchor's versioned-draft leg, both of which record nothing rather than over-claim. A --force push past a version this folder never saw CLEARS the pointer for the same reason: it must stop re-reporting the difference without pretending to have seen the version on the other side of it.

An empty id CLEARS the pointer, which is how a caller says "I cannot honestly anchor this" without having to know whether one was there before.

func (*Lock) SetMetricSeen added in v0.29.2

func (l *Lock) SetMetricSeen(stack, path, metricID, live, declared string)

SetMetricSeen records the row a metric file defines, that row's recipe fingerprint at the moment this folder agreed with it, and what the file declared then.

⚠️ A LEGACY instances[] folder has no stack name, and there is nowhere in a lock keyed by stack name to put its recording — writing one anyway puts `"stacks":{"":{…}}` into a committed file, which is the bug SetAutomationSeen names. Its recording lives in .ronja/state.json instead; see the liveHashes fork in the commands package.

func (*Lock) SetMetricTagsApplied added in v0.32.2

func (l *Lock) SetMetricTagsApplied(stack, path, metricID string, applied []AppliedTag)

SetMetricTagsApplied records what a tag reconcile left on the metric, and NOTHING ELSE: the three recipe fields are left exactly as they are, because a tag write says nothing about the definition — a tag-only push must never disarm the drift guard or claim a recipe was pushed.

A path recorded against a DIFFERENT row is reset first, by the invariant on LockMetric; the recipe fields that reset drops described that other row, and every caller has already stopped comparing against them.

func (*Lock) SetMetricTagsRefused added in v0.32.2

func (l *Lock) SetMetricTagsRefused(stack, path, metricID string, refused *TagsRefusal)

SetMetricTagsRefused records (or, with nil, clears) a tag refusal, on SetMetricTagsApplied's rules: nothing else in the entry moves.

func (*Lock) SetTableDocsSeen added in v0.29.0

func (l *Lock) SetTableDocsSeen(stack, path, tableID, meta, declared string)

SetTableDocsSeen records the row a docs sidecar describes and that row's documentation fingerprint at the moment this folder agreed with it.

⚠️ A LEGACY instances[] folder has no stack name, and there is nowhere in a lock keyed by stack name to put its recording — writing one anyway puts `"stacks":{"":{…}}` into a committed file, which is the bug SetAutomationSeen names. Its recording lives in .ronja/state.json instead; see the liveHashes fork in the commands package.

func (*Lock) SetTableLive added in v0.28.5

func (l *Lock) SetTableLive(stack, path, tableID, sha string)

SetTableLive records the LIVE row's fingerprint as the one this folder agrees with. Called at the three moments that is true — a clone, the fork of a fresh draft, and a publish that just committed — and never with a draft's SQL, for the reason recordLiveAgreement documents.

func (*Lock) SetTableMeta added in v0.29.0

func (l *Lock) SetTableMeta(stack, path, tableID, sha string)

SetTableMeta records the LIVE row's documentation fingerprint as the one this folder agrees with.

Called only where that is TRUE — a live row this command just READ, or one it just committed onto — and never with a fingerprint taken from a draft. A draft's prose is not the live row's, and recording it here would report agreement with a state nobody else can see, then refuse every later push as drift on a table that never moved.

An empty sha CLEARS the recording, which is how a caller says "I can no longer honestly claim to have seen this row's documentation" — a publish that could not re-read the row it just wrote — without having to know whether one was there before.

func (*Lock) TableDocsSeen added in v0.29.0

func (l *Lock) TableDocsSeen(stack, path string) (tableID, meta, declared string)

TableDocsSeen reads one docs sidecar's recorded table id and the documentation fingerprint this folder last agreed with. An unrecorded path answers two empty strings, which disarms the guard.

func (*Lock) TableLive added in v0.28.5

func (l *Lock) TableLive(stack, path string) string

TableLive reads one pipeline file's recorded live fingerprint, empty when there is none — which disarms that leg of the drift guard exactly as an absent local baseline does.

func (*Lock) TableMeta added in v0.29.0

func (l *Lock) TableMeta(stack, path string) string

TableMeta reads the DOCUMENTATION fingerprint this folder last agreed with for one pipeline file's live table. Empty for a path with no recording, which is what disarms the third drift leg — "no answer", never "agreed".

func (*Lock) UnmarshalJSON added in v0.28.5

func (l *Lock) UnmarshalJSON(data []byte) error

UnmarshalJSON / MarshalJSON keep the keys this build has no field for, for the reasons on Manifest's pair. plainLock is the method-less twin that stops the recursion; see forwardcompat.go.

type LockAutomation added in v0.28.5

type LockAutomation struct {
	// AutomationID is the scheduled_jobs row this file describes.
	//
	// REQUIRED, and it is the whole reason this map exists. scheduled_jobs.name
	// is neither unique nor required, so there is no second way to find the row:
	// an entry that goes missing is not a lookup that falls back to a search, it
	// is a push that CREATES a duplicate automation beside the live one, which
	// then fires alongside it.
	AutomationID string `json:"automationID"`
	// UpdatedAt is the row's `updatedAt` as the server last reported it,
	// rendered by api.Automation.Stamp — one spelling, in one place, because
	// this value is compared against ITSELF across runs and two writers
	// formatting it differently would report drift on a row nobody touched.
	//
	// ⚠️ IT IS NOT A CONTENT FINGERPRINT, and reading it as one is how it gets
	// dropped as redundant. It is the only ANCHOR this loop has: unlike a
	// workflow or a data app there is no HeadVersionID here, and unlike a
	// pipeline there is no draft/publish CAS — scheduled_jobs has no version
	// history at all, so two writers are otherwise last-write-wins with PUT's
	// list fields REPLACING rather than merging.
	//
	// Two refusals are built on it, and both are silent failures without it:
	//
	//   - A push whose row has moved since this timestamp is refused and points
	//     at `status`, rather than overwriting whatever somebody changed in the
	//     web app between the read and the write.
	//   - A push flipping `enabled` false→true is refused when the row was
	//     disabled AFTER this timestamp. A human pause (disabled_reason='user')
	//     is clearable, so without this gate a folder declaring enabled:true — a
	//     line added months ago to stop `status` reporting drift — silently
	//     reverses an incident response on the next CI merge.
	//
	// Empty DISARMS both, exactly as an absent LiveSHA256 disarms a pipeline's
	// drift guard: "no answer", never "agreed".
	UpdatedAt string `json:"updatedAt,omitempty"`
	// contains filtered or unexported fields
}

LockAutomation is one automation file's recorded state on one stack.

Deliberately NO content fingerprint, and the asymmetry with LockTable is the point. A pipeline commits LiveSHA256 because the live table's SQL is the only thing a fresh checkout could otherwise compare against, and workflows and data apps commit HeadVersionID instead because their files live in a per-user draft. An automation needs neither: since GET :jobID returns the action, the id in AutomationID plus one read gives the whole live config back, so a committed hash would be a second answer to a question the server already answers exactly — and a second answer is a thing that can go stale.

func (LockAutomation) MarshalJSON added in v0.28.5

func (a LockAutomation) MarshalJSON() ([]byte, error)

func (*LockAutomation) UnmarshalJSON added in v0.28.5

func (a *LockAutomation) UnmarshalJSON(data []byte) error

type LockCheckEntry added in v0.32.2

type LockCheckEntry struct {
	CheckID string `json:"checkID"`
	// LiveSHA256 is the live check's projection (checkfile.Entry.LiveSHA256 —
	// only the fields the file declares) at the moment the folder last verified
	// it matched. Empty disarms the drift guard for that check.
	LiveSHA256 string `json:"liveSHA256,omitempty"`
	// contains filtered or unexported fields
}

LockCheckEntry is one check the folder owns.

func (LockCheckEntry) MarshalJSON added in v0.32.2

func (e LockCheckEntry) MarshalJSON() ([]byte, error)

func (*LockCheckEntry) UnmarshalJSON added in v0.32.2

func (e *LockCheckEntry) UnmarshalJSON(data []byte) error

type LockChecks added in v0.32.2

type LockChecks struct {
	TableID string `json:"tableID"`
	// Managed is what the folder owns on that table, keyed by the check's FOLDED
	// name (lower case, trimmed — the server's unique index). A name in here that
	// the file no longer declares is an ORPHAN, which a push refuses until
	// `--prune` silences it.
	Managed map[string]LockCheckEntry `json:"managed,omitempty"`
	// contains filtered or unexported fields
}

LockChecks is one checks file's recorded state on one stack.

TableID names the live table the recordings were taken from; a checks file whose table is rebound keeps nothing, by LockTable's invariant.

func (LockChecks) MarshalJSON added in v0.32.2

func (c LockChecks) MarshalJSON() ([]byte, error)

func (*LockChecks) UnmarshalJSON added in v0.32.2

func (c *LockChecks) UnmarshalJSON(data []byte) error

type LockMetric added in v0.29.2

type LockMetric struct {
	// MetricID is the row this file's stem resolved to when the fingerprint was
	// taken. It duplicates what the manifest's bind map says today on purpose:
	// the recording has to name the row it describes, not merely that it
	// describes one.
	MetricID string `json:"metricID"`
	// LiveSHA256 is the LIVE metric's recipe fingerprint (metricfile.RecipeSHA256
	// over the bytes the server sent). Empty DISARMS the guard — "no answer",
	// never "agreed" — which is the state of a folder cloned from git and of one
	// written before this field existed.
	//
	// ⚠️ IT IS TAKEN OVER THE ROW'S CANONICALIZED RECIPE, NOT OVER THE FILE. The
	// file carries an alias in `source` and may omit a defaultable key; the row
	// carries a real id and every default filled in. Hashing the file here would
	// report drift on every stack, on the first push, for ever.
	LiveSHA256 string `json:"liveSHA256,omitempty"`
	// DeclaredSHA256 is the fingerprint of what the FILE declared when it was
	// last pushed (metricfile.Fingerprint over the resolved recipe and both
	// three-state fields) — this metric's answer to "has the local file changed
	// since the last sync", which for a .sql file is answered by the folder's own
	// content baseline.
	//
	// It needs its own field for LockTableDocs.DeclaredSHA256's two reasons. A
	// metric file is not in that baseline at all — a pipeline folder syncs
	// `.sql`, so the enumeration never sees these files — and the file cannot be
	// compared against the row directly, because the row holds the canonicalized
	// recipe and the file does not. Without it every push would re-write, re-sync
	// and re-BUILD every metric in the folder, which is expensive and, on a
	// metric whose commit re-fingerprints, noisy in governance too.
	DeclaredSHA256 string `json:"declaredSHA256,omitempty"`

	// TagsApplied is what this folder last applied to the metric through the
	// file's `tags` key: one {id, name} per name the file asserted, taken from the
	// server's answer.
	//
	// It is the TAG half's base, and it is independent of the three recipe
	// fields above on purpose: the recipe setters (SetMetricSeen, called at
	// eight moments by push, publish, discard and clone) carry it forward
	// untouched, and only SetMetricTagsApplied writes it. It is reset with the
	// rest of the entry on an id change, by the invariant above — tag ids
	// recorded against one metric say nothing about another.
	//
	// Two things are read from it and both are why it holds IDS:
	//
	//   - REMOVALS. A name the file no longer lists is removed only if THIS
	//     folder put it there, and by id — so a tag a colleague added in the web
	//     app is never touched, and a tag renamed in the web app is still found.
	//   - RENAMES. A recorded id still on the metric satisfies the file name it
	//     was recorded under even after an admin renamed it, so a folder never
	//     re-mints the old name beside the new one.
	//
	// ABSENT means "no record", never "nothing applied": a folder with no record
	// reconciles ADDITIVELY ONLY, since it cannot tell its own tags from anybody
	// else's.
	TagsApplied []AppliedTag `json:"tagsApplied,omitempty"`
	// TagsRefused records a tag reconcile the server refused with its 20-tag
	// cap (code tag_limit) — the one tag refusal recorded, because it answers the
	// same way for every caller — against the fingerprint of the file's tag list
	// at the time. A 403, a 404 from an instance without the route, a race or a
	// 5xx is never recorded: each is about the caller or the deploy, not the
	// file.
	//
	// It exists so a refusal is said once and not re-attempted on every push:
	// while the file's tag list still hashes to FileTagsHash, the metric is not
	// tag-pending, so `sync apply` cannot loop on it. Editing the file's tags, or
	// `push --retry-tags`, tries again.
	TagsRefused *TagsRefusal `json:"tagsRefused,omitempty"`
	// contains filtered or unexported fields
}

LockMetric is one metric file's recorded state on one stack.

⚠️ LiveSHA256 obeys the invariant on LockTable, and it is the same invariant for the same reason: A HASH IS ONLY EVER COMPARED AGAINST THE ROW IT WAS TAKEN FROM. MetricID is which row that was, so a metric file rebound to a different row — the alias repointed by `ronja bind`, a stack switched, a merge — drops the fingerprint with it rather than comparing one metric's definition against a hash taken from another's.

func (LockMetric) MarshalJSON added in v0.29.2

func (m LockMetric) MarshalJSON() ([]byte, error)

func (*LockMetric) UnmarshalJSON added in v0.29.2

func (m *LockMetric) UnmarshalJSON(data []byte) error

type LockStack added in v0.28.5

type LockStack struct {
	// WorkflowID and DataAppID are the STABLE identity of the row this folder's
	// pushes created, with the meaning Binding documents — never a per-user edit
	// draft's id, which would be a committed pointer at a row that stops existing
	// the moment its author publishes.
	WorkflowID string `json:"workflowID,omitempty"`
	DataAppID  string `json:"dataAppID,omitempty"`
	// ModuleID is a MODULE folder's created row, with the meaning the two above
	// carry. A module syncs its files into a per-user draft exactly as a
	// workflow does, so HeadVersionID below is its anchor too.
	ModuleID string `json:"moduleID,omitempty"`
	// HeadVersionID is the committed VERSION of the LIVE row that this folder's
	// content was forked from — the workflow/data-app answer to the question
	// LockTable.LiveSHA256 answers for a pipeline, and for exactly the same
	// reason: it is a fact about the ENVIRONMENT, true for everybody, so it is
	// the one thing a fresh checkout with no local baseline can be held to.
	//
	// A fingerprint could not do this job here. A workflow and a data app sync
	// their files into the CALLER'S OWN DRAFT, so every hash either kind has is
	// a hash of one person's row — committing one would hand a colleague a
	// baseline for a row they cannot see. The head version is the opposite: it
	// names a row everybody shares, and "has anybody published since we agreed"
	// is precisely the question a push must not answer by guessing.
	//
	// Empty means the guard is DISARMED and the folder behaves exactly as it did
	// before this field existed — a legacy instances[] folder (which has nowhere
	// committed to keep it), a stack last written by an older CLI, or a clone
	// that could not honestly claim an anchor. Empty is never treated as
	// agreement; it is treated as "no answer", which is the refusal.
	//
	// ⚠️ It describes WorkflowID/DataAppID above and nothing else. A stack
	// rebound to a different row drops it, by the same invariant LockTable
	// states: a pointer is only ever compared against the row it was read from.
	HeadVersionID string `json:"headVersionID,omitempty"`
	// Tables is a PIPELINE folder's per-file state: the slash-separated relative
	// path of each .sql file, the table it builds, and the fingerprint of that
	// LIVE row as of the last moment this folder agreed with it.
	//
	// The live fingerprint is here rather than in .ronja/state.json because of
	// the test that decides this whole file: it is a fact about the ENVIRONMENT,
	// not about a person. TableState's own doc already draws the line — the live
	// hash is "the LIVE row's SQL as of the last moment this folder agreed with
	// it", which is the same for everybody, while DraftID and DraftSHA256
	// describe one caller's open draft and stay local. Committed, it is the first
	// thing a fresh CI checkout has ever had to compare against, which is what
	// makes a CI push safe without --force.
	Tables map[string]LockTable `json:"tables,omitempty"`
	// Automations is an AUTOMATION folder's per-file state: the id of the
	// scheduled_jobs row each .json file describes, and when that row last
	// changed as the server reported it.
	//
	// Keyed by the slash-separated relative path exactly as Enumerate reports it
	// and exactly as Tables is, so both committed files agree about what a file
	// is called on every platform.
	//
	// Committed for Tables' reason: it is a fact about the ENVIRONMENT — which
	// row this path is, and when everybody last saw it move — not about a person.
	// Here it is the ONLY such fact there is: an automation has no draft, so
	// there is no per-user half to leave in .ronja/state.json.
	//
	// ⚠️ Losing an entry from this map is not a degraded lookup. LockAutomation
	// below states what it is instead, and it is the one thing to read before
	// touching any writer of this map.
	Automations map[string]LockAutomation `json:"automations,omitempty"`
	// TableDocs is a PIPELINE folder's per-DOCS-SIDECAR state: the table each
	// committed docs file describes, and that row's documentation fingerprint as
	// of the last moment this folder agreed with it.
	//
	// Keyed by the sidecar's slash-separated relative path (`tables/orders.json`),
	// exactly as Tables and Automations are keyed by theirs.
	//
	// SEPARATE FROM Tables, and the separation is the design rather than an
	// accident of typing. Tables is keyed by a .sql file — a table this folder
	// BUILDS — and a sidecar exists precisely for the tables it does not: an
	// integration table, a foundation table, one a workflow writes. Those have no
	// .sql file to key on and never will, so folding the two maps together would
	// mean inventing a fake path for a file that does not exist.
	//
	// Committed for Tables' reason: which row a path documents, and what that
	// row's prose looked like when everybody last agreed with it, are facts about
	// the ENVIRONMENT rather than about a person. A sidecar writes straight to
	// the live row — there is no draft in this path — so unlike a pipeline file
	// there is no per-user half at all.
	TableDocs map[string]LockTableDocs `json:"tableDocs,omitempty"`
	// Metrics is a PIPELINE folder's per-METRIC-FILE state: the metric each
	// committed `metrics/<alias>.json` defines, that row's recipe fingerprint as
	// of the last moment this folder agreed with it, and the fingerprint of what
	// the file itself last declared.
	//
	// Keyed by the metric file's slash-separated relative path
	// (`metrics/average_order_value.json`), exactly as Tables, Automations and
	// TableDocs are keyed by theirs.
	//
	// SEPARATE FROM Tables for TableDocs' reason and one more of its own. Tables
	// is keyed by a .sql file, and a metric has none — a metric is a recipe, so
	// there is no SQL a file could hold and no path in that map to key on. The
	// two also carry different fingerprints of different things: LockTable's
	// LiveSHA256 is the live table's SQL, this one is the live metric's
	// canonicalized RECIPE, and folding them would compare a recipe against a
	// hash taken from somebody's SELECT.
	//
	// Committed for Tables' reason: which row a path defines, and what that row's
	// definition looked like when everybody last agreed with it, are facts about
	// the ENVIRONMENT rather than about a person — and here that matters more
	// than anywhere else in this file, because the row in question is the
	// company's official definition of a number.
	Metrics map[string]LockMetric `json:"metrics,omitempty"`
	// Checks is a PIPELINE folder's per-CHECKS-FILE state: which live table a
	// committed `checks/<stem>.json` last applied to, and the checks on it the
	// folder owns — keyed by the checks file's slash-separated relative path.
	//
	// Recorded PER CHECK rather than per file: a maintainer who cannot create a
	// table's `fail` checks must not lose the `warn` ones that did land, and have
	// every push retry all of them. Committed for Tables' reason — which checks a
	// folder owns on a live table is a fact about the environment.
	Checks map[string]LockChecks `json:"checks,omitempty"`
	// contains filtered or unexported fields
}

LockStack is what this folder's deploys to one stack created and last agreed with.

Every field here answers "what did a deploy record", never "what did a person decide" — that is the test, and it is why featureID is NOT here: `wf init --feature <id>` is a human choosing where the workflow lives, so it sits in the stack's config in ronja.json even though the first push is what wrote it.

func (LockStack) MarshalJSON added in v0.28.5

func (s LockStack) MarshalJSON() ([]byte, error)

func (*LockStack) UnmarshalJSON added in v0.28.5

func (s *LockStack) UnmarshalJSON(data []byte) error

type LockTable added in v0.28.5

type LockTable struct {
	TableID    string `json:"tableID"`
	LiveSHA256 string `json:"liveSHA256,omitempty"`
	// MetaSHA256 is the LIVE row's DOCUMENTATION as of the last moment this
	// folder agreed with it — its description plus every column that carries
	// prose, hashed by tabledocs.MetaSHA256.
	//
	// A third leg beside LiveSHA256, and a separate value for the invariant this
	// type states rather than for tidiness: it is taken from a different part of
	// the row and answers a different question, so folding it into the SQL hash
	// would report "the SQL changed" for a description somebody edited in the web
	// app — and, worse, would make every existing folder's recorded LiveSHA256
	// wrong on the first push after this field shipped.
	//
	// It ARMS ONLY for a push that carries documentation. A folder whose .sql
	// files open with no `-- @table` header declares nothing about a table's
	// prose, so there is nothing for the guard to protect and nothing is
	// recorded — which is what keeps every folder written before this feature
	// behaving exactly as it did.
	//
	// Empty means the guard is DISARMED — "no answer", never "agreed" — exactly
	// as an absent LiveSHA256 disarms leg (a).
	MetaSHA256 string `json:"metaSHA256,omitempty"`
	// contains filtered or unexported fields
}

LockTable is one pipeline file's recorded state on one stack.

⚠️ LiveSHA256 obeys the invariant on wfdir.TableState and it is the whole reason these hashes are separate values: A HASH IS ONLY EVER COMPARED AGAINST THE ROW IT WAS TAKEN FROM. This one is taken from the LIVE table and compared against the live table. The draft's fingerprint is a different number about a different row, it is per-user, and it stays in .ronja/state.json.

func (LockTable) MarshalJSON added in v0.28.5

func (t LockTable) MarshalJSON() ([]byte, error)

func (*LockTable) UnmarshalJSON added in v0.28.5

func (t *LockTable) UnmarshalJSON(data []byte) error

type LockTableDocs added in v0.29.0

type LockTableDocs struct {
	// TableID is the row this sidecar's alias resolved to when the fingerprint
	// was taken. It duplicates what the manifest's bind map says today on
	// purpose: the recording has to name the row it describes, not merely that
	// it describes one.
	TableID string `json:"tableID"`
	// MetaSHA256 is that row's documentation fingerprint (tabledocs.MetaSHA256).
	// Empty DISARMS the guard — "no answer", never "agreed" — which is the state
	// of a folder cloned from git and of one written before this field existed.
	MetaSHA256 string `json:"metaSHA256,omitempty"`
	// DeclaredSHA256 is the fingerprint of what the FILE declared when it was
	// last pushed (tabledocs.Docs.Fingerprint) — this sidecar's answer to "has
	// the local file changed since the last sync", which for a .sql file is
	// answered by the folder's own content baseline.
	//
	// It needs its own field because a sidecar is not in that baseline at all: a
	// pipeline folder syncs `.sql`, so the enumeration never sees these files.
	//
	// ⚠️ TWO FINGERPRINTS OF TWO DIFFERENT THINGS, and the invariant this type
	// states is why they are not one value: MetaSHA256 is taken from the ROW and
	// compared against the row, this one is taken from the FILE and compared
	// against the file. Comparing the file against the row cannot work — a
	// documented name the table does not have is stored server-side and never
	// reported back, so such a sidecar would read as pending for ever and be
	// re-sent on every push.
	DeclaredSHA256 string `json:"declaredSHA256,omitempty"`
	// contains filtered or unexported fields
}

LockTableDocs is one docs sidecar's recorded state on one stack.

⚠️ MetaSHA256 obeys the invariant on LockTable, and it is the same invariant for the same reason: A HASH IS ONLY EVER COMPARED AGAINST THE ROW IT WAS TAKEN FROM. TableID is which row that was, so a sidecar rebound to a different table — the alias repointed by `ronja bind`, a stack switched, a merge — drops the fingerprint with it rather than comparing one table's prose against a hash taken from another's.

func (LockTableDocs) MarshalJSON added in v0.29.0

func (d LockTableDocs) MarshalJSON() ([]byte, error)

func (*LockTableDocs) UnmarshalJSON added in v0.29.0

func (d *LockTableDocs) UnmarshalJSON(data []byte) error

type Manifest

type Manifest struct {
	// FormatVersion is the manifest FORMAT, not the workflow's. It exists so a
	// change that preservation cannot survive — a key whose meaning moved, a
	// value whose absence stopped meaning what it used to — can be REFUSED by an
	// older CLI instead of quietly half-read.
	//
	// A plain int with omitempty, and the CLI never writes 1, for the reason
	// Runtime is not a pointer either: absent already means 1, so stamping it
	// would publish a distinction no code can act on and would make every
	// manifest ever committed differ on disk from one that means exactly the
	// same thing. Every folder written before this key existed is version 1 and
	// says so by saying nothing. The key first appears on the file that first
	// needs version 2.
	//
	// Reading a version ABOVE what this CLI understands is a refusal, not a
	// best-effort read — see LoadManifest.
	FormatVersion int    `json:"formatVersion,omitempty"`
	Kind          string `json:"kind"`
	Title         string `json:"title"`
	// Name is a MODULE folder's Python package name — the identifier a consumer
	// workflow writes in import position (`from {{ module('module-X') }} import
	// tracker` inlines to it). It is the one piece of module metadata that is
	// not cosmetic: two live modules cannot share it, and a workflow whose own
	// file tree has a top-level name colliding with it is refused at save.
	//
	// A plain string with omitempty rather than the three-state pointer
	// Parameters and Access carry, and the same rule Title follows: "" means
	// this folder does not manage the name, so a push leaves the row's alone.
	// The third state has nothing to describe, because the server refuses an
	// EMPTY name outright — a module with no package name is not importable, so
	// "declares the empty name" is not a thing a folder can mean. `module init`
	// and `module clone` both write the key, so a folder created by either
	// manages its name from the start.
	//
	// omitempty for the reason every other kind-specific key has it: a workflow,
	// data-app or pipeline manifest must not grow a `"name": ""` key that
	// belongs to a kind it is not.
	Name string `json:"name,omitempty"`
	// Entrypoint is the ONE file the server runs, and it is omitempty because a
	// pipeline folder has no such file at all: a folder of .sql tables has no
	// distinguished member, so `"entrypoint": ""` in a committed manifest would be
	// a key inviting somebody to fill it in with something meaningless. Safe for
	// the other two kinds because LoadManifest already falls back to
	// Kind.DefaultEntrypoint, so an absent key reads exactly as an empty one did.
	Entrypoint string `json:"entrypoint,omitempty"`
	// Parameters are the workflow's declared parameters — the things `wf test
	// --param` supplies and the script reads with tools.getVariable(name).
	//
	// A POINTER so the manifest can say three things rather than two, and the
	// difference is load-bearing:
	//
	//	absent (nil)  — this folder does not manage parameters. Push never
	//	                touches them. Every folder created before parameters
	//	                were part of the manifest is this, which is why the
	//	                distinction has to exist: treating absent as "declares
	//	                none" would make the first push after upgrading CLEAR
	//	                whatever the workflow had.
	//	[] (non-nil)  — managed, and declares none. Push clears the row's.
	//	[...]         — managed, and this is the set.
	//
	// `wf init` and `wf clone` both write the key, so a folder created by
	// either manages its parameters from the start.
	//
	// The api type is reused rather than mirrored on purpose: a parameter is
	// passed through to create/validate/patch verbatim, and a second struct
	// would only add a conversion that silently drops any field added to one
	// side. Its json tags are therefore part of the ronja.json FILE FORMAT, not
	// just the wire — renaming one is a breaking change to committed manifests.
	Parameters *[]api.WorkflowParameter `json:"parameters,omitempty"`
	// Runtime is the workflow's runtime version: 1 — the ORIGINAL runtime, which
	// journals nothing and cannot resume — 2, the DURABLE runtime, whose
	// @tools.step results are journaled so a failed run can be resumed instead
	// of re-run from the top, or 3, which is Durable plus a container that holds
	// no table credential and reads tables only through tools.query. 3 is what a
	// workflow is CREATED on, and the only runtime it can be: 1 and 2 are
	// retired, so a push refuses to create on them or to raise 1 to 2 (see
	// wfdir.RetiredRuntime) — a folder cloned from a legacy workflow still
	// declares its true 1 or 2. See the warning below on what an absent key means.
	//
	// A plain int with omitempty rather than the three-state pointer Parameters
	// and Access carry, because the third state has nothing to describe: the
	// runtime only ever moves UP (1 -> 3, 2 -> 3; every downgrade is refused),
	// so a pointer would publish a distinction no code could act on.
	//
	// It rides the create body of a first push, and after that a push RAISES a
	// row still on a lower runtime to 3 (its own PUT, after the files land, on
	// the draft; publish commits the flip). Declaring a LOWER runtime than the row already
	// has is a REFUSAL, not a silent skip: the runtime cannot be lowered, and
	// pushing code written for one runtime at a row running another is the
	// outcome worth an error.
	//
	// ⚠️ ABSENT is NOT "the default runtime" any more — it is "let the SERVER
	// choose", and the server's create default is 3. RuntimeVersion() still answers RuntimeDefault for an absent
	// key, because that is what an absent key means to a workflow this CLI has
	// already created (every such row predates the flip, or had the key written
	// back onto it by the push that created it — see resolvePushTarget).
	//
	// So it is written down wherever an answer is actually known: `wf init` writes
	// it whenever --runtime names one (only 3 is accepted), `wf clone` writes the runtime
	// the cloned row reports (1 included), and the first push of a folder that
	// declared nothing writes back whatever the server stamped.
	//
	// ⚠️ A value this build does not know is refused at LOAD (wfdir.ValidRuntime,
	// applied in LoadManifest), not here. Nothing used to validate it on push, so
	// a hand-edited `"runtime": 3` pushed and was then modelled as v2 by
	// IsDurable's `>= RuntimeDurable` — which happens to be the right answer for
	// 3 and would have been the wrong one for 4.
	Runtime int `json:"runtime,omitempty"`
	// ReportingTimezone is the IANA calendar the workflow's DuckDB session runs
	// at on every run — what date_trunc and date casts bucket against.
	//
	// It may live in a COMMITTED, shared file at all because a plain IANA name is
	// instance-independent: "Europe/Stockholm" means the same calendar on
	// localhost, staging and production, and on every profile. Nothing about it
	// is an id, a credential or an organization's, which is what keeps it above
	// Instances rather than inside a binding.
	//
	// A POINTER, with the same three states Parameters has and for the same
	// back-compat reason:
	//
	//	absent (nil)  — this folder does not manage the zone. Push never touches
	//	                it, which is what every folder created before this key
	//	                existed needs: reading absent as "declares UTC" would make
	//	                the first push after upgrading silently re-bucket the
	//	                workflow's calendar onto UTC.
	//	"" (non-nil)  — managed, declaring the RESET value. The API cannot express
	//	                "no declaration" (an explicit "" patches the row to the
	//	                literal "UTC"), so an empty string here means UTC, not
	//	                "leave it alone" — that is what absent is for.
	//	"Europe/…"    — managed, and this is the calendar. Push makes the row match.
	//
	// `wf clone` writes the key when the row DECLARES a zone; `wf init` leaves it
	// absent so a fresh folder inherits the organization's default.
	ReportingTimezone *string `json:"reportingTimezone,omitempty"`
	// Access is the DATA-APP analogue of Parameters: the capability allowlists
	// (tables, secrets, agents, workflows, codexes, metrics) and the capability
	// set the app's minted script-token is scoped from.
	//
	// It is in the manifest — and Parameters is not enough — because the two
	// kinds derive their bindings completely differently. A workflow's come from
	// `{{ ref }}` / `{{ write }}` / `{{ secret }}` markers IN THE CODE, so the
	// server can re-derive them from a file save. A data app's are explicit
	// columns that POST :id/validate deliberately does NOT scan source for, so
	// nothing on the server can recover them from the files: an app pushed
	// without them compiles and then can query nothing. A folder that could not
	// carry them would not be a complete description of its app.
	//
	// Pointer for exactly the reasons Parameters is one, and the three states
	// mean the same things:
	//
	//	absent (nil)  — this folder does not manage the allowlists. Push never
	//	                touches them, which is what a folder created before this
	//	                existed (or one deliberately leaving them to the web UI)
	//	                needs, since treating absent as "declares none" would
	//	                REVOKE the app's access on the first push after upgrading.
	//	zero value    — managed, and declares none.
	//	populated     — managed, and this is the set.
	//
	// Note what this makes ronja.json: a file that declares privileges, in the
	// customer's git. That is not a hole — governance.ValidateDataAppScope
	// re-checks every id against the pusher's own access at checkout,
	// ValidateAllowlistRefsLive re-checks at publish, and committing a shared
	// app's draft is admin-gated — but it is why `app push` prints the allowlist
	// change it is about to make rather than only noting that one exists.
	Access *api.DataAppAccess `json:"access,omitempty"`
	// Dependencies are the names this folder's CODE uses for rows it does not
	// own — alias → what kind of thing it is. See Dependency in dependencies.go
	// for what an alias is for; each stack's Bind says which of ITS
	// organization's rows answers to each name.
	//
	// Above Stacks because it is instance-independent: a declaration survives
	// being cloned by somebody signed in somewhere else, which is the whole
	// point of it. Presence is what makes the manifest format version 3, so an
	// older CLI refuses the file rather than sending the alias to the server as
	// a literal id — see ManifestFormatVersion.
	Dependencies map[string]Dependency `json:"dependencies,omitempty"`
	// Stacks are the named environments this folder deploys to — "dev", "prod" —
	// each saying where it points and which feature its resources live in. See
	// Stack for what belongs here and what belongs in ronja.lock.json.
	//
	// A MAP keyed by name, where Instances is a list keyed by (url, tenantID),
	// and the difference is the entire feature: a name is a thing a person can
	// say, on a command line and in a message. `--stack prod` is unambiguous
	// where "the entry for https://…, organization ten-…" needed a stored
	// profile to express.
	//
	// Presence is what makes the manifest format version 2, so an older CLI
	// refuses the file rather than reading only the legacy remainder and
	// concluding the folder is unbound — see ManifestFormatVersion.
	Stacks map[string]Stack `json:"stacks,omitempty"`
	// Instances lists what this folder is bound to, one entry per instance and
	// organization. No entry is not an error — it means "never pushed there
	// yet", and the first push records the binding.
	//
	// A LIST rather than a map keyed by URL: one instance can carry several
	// organizations, and each needs its own workflow id. A list also puts the
	// organization on the page for whoever opens the committed file, instead of
	// hiding it inside a composite key.
	//
	// LEGACY, and left alone rather than migrated. It is the pre-stack shape and
	// it still works exactly as it did, ids and all — a folder nobody has named a
	// stack in is never rewritten by a push, which is the promise that keeps a
	// customer off a forced CLI upgrade. Once a stack is named for one
	// (instance, organization), Manifest.Record moves that entry across and drops
	// it from here, so the two shapes never both answer for one place.
	//
	// The tag has no omitempty, so a v1 manifest keeps writing the key it always
	// wrote. MarshalJSON drops it once the folder has stacks, where an empty
	// legacy list would only be an invitation to fill it in.
	Instances []Instance `json:"instances"`
	// contains filtered or unexported fields
}

Manifest is ronja.json — the committed half of a workflow folder.

Everything above Instances describes the workflow independently of any instance, so it survives being cloned by someone signed in somewhere else. Instances holds the bindings, one per (instance, organization) this folder has been pushed to.

Keys this build of the CLI does not know are PRESERVED across a rewrite, and FormatVersion is the escape hatch for the day preservation is not enough — see unknownKeys in forwardcompat.go and ManifestFormatVersion above.

func LoadManifest

func LoadManifest(root string, kind Kind) (*Manifest, error)

LoadManifest reads and validates the manifest at a folder root, refusing one that describes a different KIND than the caller works with.

That refusal is load-bearing rather than tidy. A data-app folder and a workflow folder are indistinguishable by shape — both are source files plus a ronja.json — so without the check `ronja wf push` inside a data-app folder would create a workflow from TSX files, and `ronja app push` inside a workflow folder would overwrite an app's source with Python. Both destroy work while reporting success.

func (*Manifest) AliasNames added in v0.28.5

func (m *Manifest) AliasNames() []string

AliasNames lists the declared aliases in a stable order, so every listing and every message reads the same way twice.

func (*Manifest) Binding

func (m *Manifest) Binding(key InstanceKey) (Binding, bool, error)

Binding returns this folder's binding for one (instance, organization), and whether one exists. The bool matters: an absent entry is the ordinary "not pushed here yet" state, not a failure.

⚠️ This reads the LEGACY instances[] shape ONLY, and a folder that has named stacks will answer "not bound here" to it — which every push path treats as "create it". Nothing in the commands should call this; go through Manifest.Select, which joins the stack config with the lock file's state and falls back to instances[] for a folder that has not migrated. It stays exported because the tests that pin the legacy shape need to read it directly, and because SetBinding below is still how Record writes that half.

The error is ErrAmbiguousInstance, and only a caller with an UNKNOWN organization can get it — see Find.

Who that is: the three `status` commands, which deliberately neither resolve nor refuse — signed out, or holding a credential whose organization lookup failed. Commands that ACT resolve the organization before reading a binding (commands.folder.resolveBinding) and refuse when they cannot.

It used to say here that resolving a credential resolved the organization with it, so this reached only the signed-out half of `wf status`. That was wrong, and expensively: picking a profile and finding a token in it says nothing about which organization that token reaches, and an environment token carries none at all. Every command reached this with an unknown organization on that path, read the ambiguity as "not bound here", and reported a missing featureID — advice to add a key the file already had.

func (*Manifest) BindingEntry

func (m *Manifest) BindingEntry(key InstanceKey) (Instance, bool, error)

BindingEntry is Binding plus the entry's own key.

The key matters when the caller's organization was UNKNOWN: a matched entry names the organization it belongs to, which is a free answer to a question that would otherwise cost a round trip to /me. It is also the right answer — this folder is bound there, whatever the ambient credential thinks.

func (*Manifest) CheckBind added in v0.28.5

func (m *Manifest) CheckBind(sel Selection) error

CheckBind refuses a selected stack whose bind map cannot be acted on.

The ACCEPTANCE-point half of the validation checkDependencies deliberately stops short of, for the reason that function records: every rule here spans two objects, so a git merge or an ordinary edit can produce it, and refusing at load would make the folder unopenable rather than un-pushable. Called once a command knows which stack it is about to write through, where the refusal costs the reader one push and not their whole folder.

The three refusals:

  • A BIND WITH NO DECLARATION. Inert on its own — nothing resolves through it, since resolution goes alias → bind and there is no alias — which is exactly why it has to be said out loud: it is what a typo looks like (`orders` declared, `order` bound), and left silent the reader sees only the other half, "orders has no binding here", and adds a second entry beside the one they meant to fix.
  • A BIND VALUE THAT IS NOT ID-SHAPED for its kind. The value is what gets substituted into the customer's committed source and sent as a real id; a name there means the folder deploys with an unresolvable reference, which for a secret marker is a warning on the server and a run-time failure here.
  • TWO ALIASES ON ONE ID. Blueprints refuse the same shape for the same reason (rblueprint's ambiguous-bindings check), and there is a second one here: it makes the id → alias direction ambiguous, which is what any reverse mapping — reading a live row back into a folder, explaining which declaration a resource answers — has to be able to do.

A folder that declares nothing and binds nothing passes trivially, which is every folder that exists today.

func (*Manifest) CheckDeclarations added in v0.28.5

func (m *Manifest) CheckDeclarations() error

CheckDeclarations refuses a manifest whose DECLARATIONS this build cannot act on, at the ACCEPTANCE point rather than at load.

Every rule here describes a file A REFUSAL AT LOAD WOULD STRAND, which is what puts them beside CheckBind rather than in checkDependencies: a rule enforced at load makes the folder unopenable by every command, `status` included, and the author of a file this build cannot act on is exactly the person who needs `status` to explain it. See checkStacks for the invariant, and checkDependencies for the half that stays at load.

The first two describe a file THIS BUILD NEVER WRITES. The third is the one that widened the boundary: this build DOES write it, and an older build wrote it legitimately — the file is stranded by us adding a kind, not by somebody else's CLI. Same conclusion either way, which is why it belongs here.

The three refusals:

  • DEPENDENCIES DECLARED BELOW THE VERSION THAT MEANS THEM. `dependencies` is a v3 key and MarshalJSON stamps 3 on any manifest that carries one, so this build cannot produce the pair; a hand-authored or hand-edited file can. It matters because the pair is a LIE that the version gate is powerless against: the file MEANS v3 — its markers hold names, and the ids exist nowhere in the source — while DECLARING that a v1 or v2 CLI may read it. That colleague's CLI passes the gate, resolves nothing, and sends `acme_api` to the server as a literal secret id, which the secret families SOFT-FAIL into a warning rather than refusing. Green push, workflow deployed with nothing bound, failure at run time in an organization the pusher may not be watching. See ManifestFormatVersion, which exists for precisely this failure and can only stop it if the file says so.
  • AN UNKNOWN KIND. Refused rather than ignored because ignoring it is the failure this whole layer exists to stop: the resolver would leave that alias in the source and the server would read the literal name as an id, which for a secret marker is the same soft-failing warning again. It cannot live at load for the reason above AND for one of its own — the format version cannot catch a new kind, since adding one adds no key, so a folder written by a newer CLI carries a kind this build has never heard of with no version to warn about it. Refusing that at load would take away `status` from the one person best placed to see what happened.
  • AN ALIAS DECLARED AS A MARKER-LESS KIND AND SHAPED LIKE THAT KIND'S ID. The rule itself is ValidAliasName's — an id-shaped alias resolves to itself, and every reader downstream reads it as a literal id — and for the MARKER kinds it stays there, at load, where it has always been. It cannot follow them for a kind added LATER: `note` brought the `note-` and `skill-` prefixes into the legal set on the branch that added automations, and scanning them at load would refuse a `note` alias called `note-x` in a manifest that opened yesterday, leaving hand-editing a committed file as the only recovery. Refused here instead, that folder still opens, still reports, and is refused at the push — the one moment the bad name would actually deploy something unresolvable. ⚠️ Scoped to the alias's OWN declared kind, unlike the marker half — see the loop below for why widening it would break folders nobody touched while closing nothing.

func (*Manifest) DeclaredAccess

func (m *Manifest) DeclaredAccess() api.DataAppAccess

DeclaredAccess is the declared allowlist set, zero-valued when the folder does not manage it. For reading; use ManagesAccess to decide whether to WRITE.

func (*Manifest) DeclaredParameters

func (m *Manifest) DeclaredParameters() []api.WorkflowParameter

DeclaredParameters is the declared set, empty when the folder does not manage them. For reading; use ManagesParameters to decide whether to WRITE.

func (*Manifest) DeclaredReportingTimezone added in v0.27.0

func (m *Manifest) DeclaredReportingTimezone() string

DeclaredReportingTimezone is the declared zone, empty when the folder does not manage it. For reading; use ManagesReportingTimezone to decide whether to WRITE — an empty answer means "declares the reset value" for a managed folder and "no opinion" for an unmanaged one, and only the pointer tells them apart.

Trimmed, because the server stores the trimmed form: a hand-edited " Europe/Stockholm " that compared unequal to what the row holds would patch on every push, forever.

func (*Manifest) IsDurable added in v0.27.0

func (m *Manifest) IsDurable() bool

IsDurable reports the runtime that journals EVERY step without the author writing a key — the one property anything outside the create path branches on. It is not the same question as "can this be resumed": a standard-runtime workflow whose steps carry explicit keys is resumable too, and the journal is what decides how much a resume skips.

`>=`, and it is correct rather than accidental: RuntimeQuery is a SUPERSET of Durable — same journal, plus the withheld table credential — so every runtime above RuntimeDurable journals too. What made `>=` unsafe was not the operator but the absence of a load-time check, which let an UNKNOWN runtime through and be modelled as Durable on nothing but its number. ValidRuntime is that check.

func (*Manifest) KeysOn added in v0.28.5

func (m *Manifest) KeysOn(url string) []InstanceKey

KeysOn lists every (instance, organization) this folder names on one instance, stacks and legacy entries alike, in a stable order.

It covers BOTH shapes, and that is the point of it existing rather than each caller reaching for the free function. Reading only instances[] was correct while that was the only shape; against a stack folder it answers "nothing here", and the two callers act on that answer in ways that are wrong in opposite directions — one skips the organization lookup and then matches on URL alone, and the other lists no organizations in a refusal about which organization applies.

func (*Manifest) ManagesAccess

func (m *Manifest) ManagesAccess() bool

ManagesAccess reports whether the manifest is the source of truth for a data app's allowlists — i.e. whether push should sync them at all.

func (*Manifest) ManagesParameters

func (m *Manifest) ManagesParameters() bool

ManagesParameters reports whether the manifest is the source of truth for the workflow's parameters — i.e. whether push should sync them at all.

func (*Manifest) ManagesReportingTimezone added in v0.27.0

func (m *Manifest) ManagesReportingTimezone() bool

ManagesReportingTimezone reports whether the manifest is the source of truth for the workflow's declared execution calendar — i.e. whether push should sync it at all.

func (Manifest) MarshalJSON added in v0.28.5

func (m Manifest) MarshalJSON() ([]byte, error)

MarshalJSON writes the struct's own keys and puts the unrecognised ones back in the places they were read from.

A VALUE receiver, because SaveManifest hands MarshalIndent a *Manifest while Instances is a slice of values — a pointer receiver would cover the first and silently skip the second.

func (*Manifest) Record added in v0.28.5

func (m *Manifest) Record(lock *Lock, sel Selection, b Binding)

Record writes a resolved binding back, splitting it the way the format does: the half a human decided into ronja.json, the half a deploy discovered into ronja.lock.json. A legacy unnamed selection keeps both in instances[], exactly as it always has — a v1 folder that nobody has named a stack in is not rewritten by a push.

It also completes the migration: naming a legacy entry moves it into stacks and DROPS the instances[] entry, so the two shapes never both answer for one (instance, organization).

⚠️ A NAMED selection must carry a known organization. checkStacks refuses a stack with no "tenantID", so recording one here writes a manifest the very next command cannot open — the one thing this package must never do. It is not enforced here because there is no error to return; the two write doors guard it instead, and both say so: recordFirstBindingInto refuses outright, and adoptStack declines silently because the command it sits in is working fine and the migration is an offer rather than the job.

func (*Manifest) RuntimeForValidate added in v0.28.5

func (m *Manifest) RuntimeForValidate(live *api.Workflow) int

RuntimeForValidate answers which runtime a CANDIDATE should be checked against, which is not always the one the folder declares.

A folder that declares one is checked against it, on a push to an existing row included: that is both what the metadata patch may raise the row to and, either way, the runtime this code's author wrote for.

An absent key means "let the instance choose", and validate is a PRE-CREATION endpoint with no row to read it from — it reads 0 as "check nothing runtime-scoped". So the answer depends on what the push is about to do. A CREATE (live == nil) is rehearsed against RuntimeCreateDefault, because that is the runtime the workflow will exist on and a 0 would rehearse a different save from the one about to happen. A push to an existing row is checked against THAT row — and against nothing runtime-scoped when the instance did not report one, which is exactly what every caller written before runtimes existed asks for.

func (*Manifest) RuntimeVersion added in v0.27.0

func (m *Manifest) RuntimeVersion() int

RuntimeVersion is the runtime this folder's workflow runs under, with the absent key resolved to the default. Read this rather than the field: an unstated runtime and a stated 1 are the same workflow, and only the create path cares which spelling the file used.

func (*Manifest) Select added in v0.28.5

func (m *Manifest) Select(lock *Lock, key InstanceKey, want string) (Selection, error)

Select resolves which stack a command acts on, from the credential's own (instance, organization) and an optional explicit --stack name.

The two selectors do not compete: an explicit name WINS and is checked against the credential, and the implicit match is what every folder written before stacks existed keeps getting. The rules, and each one is a refusal somebody would otherwise have discovered as a duplicate resource:

  • --stack names a DECLARED stack. It must point where this credential reaches, or the push would send this folder's files to the named stack's feature under the wrong organization's token.
  • --stack names an UNDECLARED stack, and this (instance, organization) already has one under a different name. Refused: that is a typo, and the message names the stack that was meant. Without it `--stack prd` creates a second workflow beside `prod` and reports success.
  • --stack names an UNDECLARED stack somewhere this folder has never been pushed. That is the ordinary way a stack is born, and it is also the only way a legacy folder migrates: the human supplied the name, which is the one thing an instances[] entry does not have.
  • No --stack, and the LOCK names stacks the manifest does not. Refused: that is the stranded half of a lock-first write, and the implicit path cannot see it. See strandedLockStacks.
  • No --stack: match on (url, tenantID) exactly as the folder always did. One match is the answer; several need a name; none means "never pushed here", which is a first push.

func (*Manifest) SetAccess

func (m *Manifest) SetAccess(access api.DataAppAccess)

SetAccess marks the folder as managing the allowlists and records them, normalized so a manifest never round-trips `null` where the app declares an empty list.

func (*Manifest) SetBinding

func (m *Manifest) SetBinding(key InstanceKey, b Binding)

SetBinding records (or updates) the binding for one (instance, organization), writing the canonical URL spelling and replacing any equivalent entry.

Without the replace, a push against a hand-edited "https://app.ronja.tech/" entry would leave the manifest carrying that one AND a canonical one, and which of the two answered a later Binding() would depend on nothing legible.

func (*Manifest) SetParameters

func (m *Manifest) SetParameters(params []api.WorkflowParameter)

SetParameters marks the folder as managing parameters and records the set. A nil params still marks it managed, as an empty declaration — callers that mean "stop managing" clear the field directly.

func (*Manifest) SetReportingTimezone added in v0.27.0

func (m *Manifest) SetReportingTimezone(zone string)

SetReportingTimezone marks the folder as managing the declared zone and records it. An empty zone still marks it managed, as a declaration of the reset value — callers that mean "stop managing" clear the field directly.

func (*Manifest) StackNames added in v0.28.5

func (m *Manifest) StackNames() []string

StackNames lists the declared stacks in a stable order, so every listing and every message reads the same way twice.

func (Manifest) StampedFormatVersion added in v0.28.5

func (m Manifest) StampedFormatVersion() int

StampedFormatVersion is the version a save of this manifest writes into the file — the higher of what it already declares and what its content requires.

The one thing a message about "what your colleagues need" may say. The ManifestFormatVersion constant is the highest version this BUILD can read, which is a different number and a much larger claim: a folder that names a stack and declares no dependencies is stamped 2, and telling its author that everyone needs a version-3 CLI asks for an upgrade the change never required.

func (*Manifest) UnmarshalJSON added in v0.28.5

func (m *Manifest) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes the manifest and keeps whatever it did not recognise.

plainManifest is what stops this recursing: a defined type does not carry its source type's methods, so json.Unmarshal on it does the ordinary struct decode this method is wrapping. It leaks into encoding/json's error text, which is why LoadManifest renames it back — see rewriteInternalTypeNames.

func (*Manifest) UsesStacks added in v0.28.5

func (m *Manifest) UsesStacks() bool

UsesStacks reports whether this folder has moved to the stack layer. It is the one predicate that decides whether a NEW binding must be named, and therefore the thing that keeps a v2 folder from quietly growing a legacy entry beside its stacks.

type MetricState added in v0.29.2

type MetricState struct {
	MetricID string `json:"metricID"`
	// LiveSHA256 is the LIVE metric's recipe fingerprint — see
	// LockMetric.LiveSHA256 for why it is taken over the row's canonicalized
	// recipe and never over the file.
	LiveSHA256 string `json:"liveSHA256,omitempty"`
	// DeclaredSHA256 is what the FILE declared at the last push — see
	// LockMetric.DeclaredSHA256 for why it is a second fingerprint rather than
	// the same one.
	DeclaredSHA256 string `json:"declaredSHA256,omitempty"`
	// TagsApplied and TagsRefused are the TAG half — see LockMetric's fields of
	// the same names. Kept here for a legacy folder on exactly the fork the
	// recipe fields take, and carried into the lock by adoptLiveHashes when the
	// folder is named.
	TagsApplied []AppliedTag `json:"tagsApplied,omitempty"`
	TagsRefused *TagsRefusal `json:"tagsRefused,omitempty"`
}

MetricState is one metric file's recorded state in a LEGACY folder's local baseline — the mirror of wfdir.LockMetric, and it obeys the same invariant: A HASH IS ONLY EVER COMPARED AGAINST THE ROW IT WAS TAKEN FROM. MetricID says which row that was, so a metric file rebound to a different row drops the fingerprint with it.

type Selection added in v0.28.5

type Selection struct {
	Name    string
	Key     InstanceKey
	Binding Binding
	// Bound reports whether this folder HAS an entry for this place — a declared
	// stack, or an instances[] entry. It is not "something has been created
	// there": a stack declared with a featureID and nothing in the lock is bound
	// and has no workflow yet, exactly as a legacy entry carrying only a
	// featureID always has been. Unbound is the ordinary "never pushed there"
	// state, and is what the first push acts on.
	Bound bool
	// Bind is the selected stack's alias → resource id map — CONFIG, read-only,
	// and never written back by a push. It comes from Stack.Bind for a named
	// stack and from Instance.Bind for the legacy unnamed entry, joined here for
	// the reason everything else in Selection is: so a command reads one shape
	// and never asks which of the two it came from.
	//
	// Nil is the ordinary state and is not the same claim as "declares none": a
	// folder with no dependencies binds nothing, and so does a stack nobody has
	// bound yet. Manifest.CheckBind is what tells those apart, at the point a
	// command is about to act on one.
	Bind map[string]string
}

Selection is which stack a command is acting on, with the two halves of the answer joined back together: the config a human wrote and the state a deploy recorded.

Name is EMPTY for a legacy unnamed instances[] entry, and that empty string is not a stack called "" — it is the absence of the stack layer, which is what a folder written before stacks existed has.

Two places branch on it and no others should: Manifest.Record, which sends a named selection's config to stacks and its state to the lock while an unnamed one goes wholly to instances[]; and the pipeline loop's liveHashes, which decides where that kind's LIVE fingerprints live. Everything else reads the joined Binding and never asks which shape it came from.

func (Selection) Named added in v0.28.5

func (s Selection) Named() bool

Named reports whether this selection is a stack rather than the legacy unnamed entry.

type Skipped

type Skipped struct {
	Path   string `json:"path"`
	Reason string `json:"reason"`
}

Skipped is a local file that will never be synced, and why.

Reported rather than silently dropped: a file the user believes is part of the workflow and which the CLI quietly ignores is the worst kind of surprise, and it is invisible in every other output.

type Stack added in v0.28.5

type Stack struct {
	URL      string `json:"url"`
	TenantID string `json:"tenantID,omitempty"`
	// FeatureID is the container new resources are created in. Absent is legal —
	// it means nothing has chosen one yet, and the first push refuses with a
	// message naming this key.
	FeatureID string `json:"featureID,omitempty"`
	// Bind is this stack's answer to the folder's declared dependencies: alias →
	// the resource id THIS organization uses for it. See Dependency in
	// dependencies.go for what the alias layer is.
	//
	// CONFIG by the same test everything else here passes — which of this
	// organization's rows answers to `orders` is a decision a person makes, not
	// something a deploy discovered — so it is committed beside the stack in
	// ronja.json and never in ronja.lock.json.
	//
	// ⚠️ It lives on Stack and on Instance, and deliberately NOT on Binding.
	// Binding is the joined config+state value Manifest.Record writes back, and
	// callers build one from ids alone (`wf discard` hands in a fresh
	// Binding{FeatureID}). A bind map carried inside it would therefore be
	// CLEARED by the next push, out of a committed file, because a machine's
	// struct happened not to carry it. FeatureID above documents that exact
	// hazard and pays for it with an "empty does not clear" guard in Record; the
	// cheaper fix is the one taken here — keep config that no deploy ever writes
	// out of the value a deploy records. Selection.Bind is how a command reads
	// it.
	Bind map[string]string `json:"bind,omitempty"`
	// contains filtered or unexported fields
}

A STACK is one named environment this folder deploys to — "dev", "staging", "prod" — and it is the unit every command selects with --stack.

The name is committed and SHARED, which is the whole reason it is not a profile name. InstanceKey already says this about the key it replaces: "It is deliberately NOT keyed by profile NAME. ronja.json is committed and shared, and a profile name is local to one machine — my 'prod' is not yours." A stack name is the opposite: it is the one word a team agrees on, and the credential that reaches it stays local. Two selectors, two jobs, no third: --stack names an ENVIRONMENT, --profile names a CREDENTIAL.

What is in a Stack is CONFIG — decisions a human made, and what a pull request is about. Where it points (url, tenantID) and which feature its resources are created in (featureID) are all chosen once by a person. Everything a DEPLOY discovered — the workflow id it created, the tables it built, the fingerprint it last agreed with — is state and lives in ronja.lock.json instead. That split is Pulumi's discipline and it is why a CI push no longer dirties the file a reviewer reads.

FeatureID is config and not state even though the first push "records" it, because what it records is the answer to a question a person answered: `wf init --feature <id>` is the human choosing where this workflow lives. The line is "who decided", not "who wrote the bytes".

func (Stack) Key added in v0.28.5

func (s Stack) Key() InstanceKey

Key is the (instance, organization) this stack points at.

func (Stack) MarshalJSON added in v0.28.5

func (s Stack) MarshalJSON() ([]byte, error)

func (*Stack) UnmarshalJSON added in v0.28.5

func (s *Stack) UnmarshalJSON(data []byte) error

type State

type State struct {
	Instances []InstanceBaseline `json:"instances"`
}

State is .ronja/state.json — the local sync baseline, never committed.

Keyed like the manifest, by (instance, organization), because the same folder can be bound to staging and production — or to two organizations on one instance — and each has its own idea of what "last synced" means.

func LoadState

func LoadState(root string) (*State, error)

LoadState reads the baseline. A missing file is not an error — that is a folder someone cloned from git (whose .ronja was correctly never committed), or one that has never been pushed.

func (*State) Clear

func (s *State) Clear(key InstanceKey)

Clear drops ONE binding's baseline, leaving every other instance's standing.

The whole-file alternative — writing a fresh empty State — is a bug, not a shortcut: a folder bound to localhost, staging and production keeps one baseline each, so discarding against one instance would erase the other two. The next push there sees no baseline against a non-empty remote, which is exactly the shape the drift guard refuses, and the way out it names is --force: overwriting production with no comparison because a staging draft was thrown away.

Removal rather than an empty entry, because For then answers nil — the same answer a folder that has never synced here gives, which is what this instance now is. Every local file correctly reads as added.

func (*State) For

func (s *State) For(key InstanceKey) *InstanceState

For returns one binding's baseline, or nil when it has never been synced from this folder.

The ambiguity error Find can raise is deliberately swallowed here: a baseline is a sync optimisation, and "no baseline" degrades to treating every local file as changed — which is safe. The MANIFEST lookup for the same key reports the ambiguity properly, and that is the one that decides where a push lands.

func (*State) Set

func (s *State) Set(key InstanceKey, inst *InstanceState)

Set records a baseline for one binding.

type TableDocsState added in v0.29.0

type TableDocsState struct {
	TableID    string `json:"tableID"`
	MetaSHA256 string `json:"metaSHA256,omitempty"`
	// DeclaredSHA256 is what the FILE declared at the last push — see
	// LockTableDocs.DeclaredSHA256 for why it is a second fingerprint rather
	// than the same one.
	DeclaredSHA256 string `json:"declaredSHA256,omitempty"`
}

TableDocsState is one docs sidecar's recorded state in a LEGACY folder's local baseline — the mirror of wfdir.LockTableDocs, and it obeys the same invariant: A HASH IS ONLY EVER COMPARED AGAINST THE ROW IT WAS TAKEN FROM. TableID says which row that was, so a sidecar rebound to a different table drops the fingerprint with it.

type TableState added in v0.27.0

type TableState struct {
	TableID         string `json:"tableID"`
	DraftID         string `json:"draftID,omitempty"`
	DraftSHA256     string `json:"draftSHA256,omitempty"`
	DraftWireSHA256 string `json:"draftWireSHA256,omitempty"`
	LiveSHA256      string `json:"liveSHA256,omitempty"`
	// MetaSHA256 is the LIVE row's DOCUMENTATION fingerprint as of the last
	// moment this folder agreed with it — the LEGACY half of the pair
	// LockTable.MetaSHA256 owns, kept here for a folder with no named stack for
	// LiveSHA256's reason exactly.
	//
	// It is a fourth fingerprint of a fourth thing, and the invariant above
	// covers it unchanged: it is taken from the live row's prose and compared
	// against the live row's prose. Empty disarms its leg.
	MetaSHA256 string `json:"metaSHA256,omitempty"`
}

TableState is one pipeline file's remote identity as this checkout last saw it, plus the fingerprints of the two REMOTE ROWS that file is synced with.

The invariant the two hashes exist for, and the one to keep true when adding a third: A HASH IS ONLY EVER COMPARED AGAINST THE ROW IT WAS TAKEN FROM. A pipeline file is synced with three different things, and folding them into one fingerprint was a real bug rather than a tidiness question — the single value meant "the bytes I wrote into my draft" after a push and "the bytes the live table holds" after a clone or a publish, while the drift guard compared BOTH rows against it. An ordinary push → edit → push was then refused as drift on a live table that had never moved.

InstanceState.Files[path].SHA256  the last COMPLETE push (written AND built).
                                  What "changed since the last sync" means,
                                  for `status` and for what a bare push sends.
TableState.DraftSHA256            what this checkout last wrote into DraftID,
                                  in the DISK form — the file's own bytes,
                                  aliases and sibling stems unresolved. Leg
                                  (b) of the drift guard compares the draft
                                  against this, after de-aliasing the row.
TableState.DraftWireSHA256        the same write, in the WIRE form — the exact
                                  bytes that went over the PUT, ids
                                  substituted for every alias and stem. This
                                  is what the ROW stores, so it is the only
                                  one of the three that may be sent as the
                                  server's baseCodeSha256 precondition.
TableState.LiveSHA256             the LIVE row's SQL as of the last moment
                                  this folder agreed with it — a clone, the
                                  fork of a draft, a publish. Leg (a)
                                  compares the live table against this.

TableID is the LIVE row those hashes are fingerprints OF. It duplicates the manifest's Binding.Tables entry deliberately: the baseline has to say WHICH ROW it describes, not merely that it describes one. Without it a manifest edited to point a path at a different table — by hand, by a merge, by a second clone — would silently inherit the previous table's hashes, and the next push would compare a colleague's table against a baseline taken from something else entirely and conclude nothing had changed. `pipeline push` checks it against the binding before it writes anything, so the rebinding is DETECTED rather than trusted.

DraftID is the caller's OWN open draft of that table, empty when there is none. It is local-only for the reason the whole baseline is: drafts are per-user, and a colleague inheriting this id from git would be pointed at a row they cannot see. It is a HINT, never authority — a draft can be committed or discarded from the web UI between two commands, so a caller re-reads (GET /feature/model/:id/draft) rather than trusting a recorded id blindly.

DraftWireSHA256 is LOCAL-ONLY, for exactly the reason DraftID and DraftSHA256 are and LiveSHA256 is not: it is a fingerprint of a PER-USER draft row. "The live table held these bytes" is a fact about the environment and belongs in the committed lock; "my own open draft holds these bytes" is true for one person on one machine, and a colleague inheriting it from git would be handed a precondition taken from a row they cannot even read.

It sits beside DraftSHA256 rather than replacing it because they are fingerprints of two different things and the invariant above forbids folding them: the drift guard compares the DISK form (it has to explain drift in terms of the folder, and it de-aliases the remote row to do so), while the server's precondition compares the WIRE form (that is what the row stores). Sending the disk hash as a precondition would 409 every push from any folder that spells a ref as a sibling's stem or a declared alias — which is the ordinary pipeline folder.

All three hashes are omitempty and three-state-safe. EMPTY means "no baseline for that row", which disarms its leg of the drift guard exactly the way an absent file baseline does — the state of a folder cloned from git, of a draft this checkout never wrote to, and of a baseline written before these fields existed. For DraftWireSHA256 empty additionally means "send no precondition", which is the server's back-compatible "write unconditionally".

type TagsRefusal added in v0.32.2

type TagsRefusal struct {
	FileTagsHash string `json:"fileTagsHash"`
	Reason       string `json:"reason"`
	// contains filtered or unexported fields
}

TagsRefusal is a recorded tag refusal: the reason the server gave, and the fingerprint (metricfile.TagsFingerprint) of the file's tag list it was given for.

func (TagsRefusal) MarshalJSON added in v0.32.2

func (r TagsRefusal) MarshalJSON() ([]byte, error)

func (*TagsRefusal) UnmarshalJSON added in v0.32.2

func (r *TagsRefusal) UnmarshalJSON(data []byte) error

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL