Skip to content

Application state lives in three planes, and each category has one home

State: open/draft

  • Every persisted byte belongs to exactly one of three planes — the content plane (the user’s own data, wherever the user put it), the machine-local plane (what an application knows about this machine and about that data), and the ephemeral/runtime plane (what dies with a process) — and a category of state names its plane before it names a path.
  • Four rules resolve the ambiguity that plane names alone leave: a suite that shares user data shares one machine-local plane; nothing machine-local resolves against a repo checkout; the dev-shell resolution tail is for hand-run commands and forbidden to a supervised spawn; anything under a cache path is safe to delete whole.
  • The machine-local plane is addressed in three tiers — vendor, suite, product — and a caller names the tier it means. Tiers are distinct addresses and never merge. A general tool distributed outside this fleet is not in the suite and supplies its own root instead.
  • A content root holds no application state and no database. A suite may keep one config directory there, .<suite>/, for human-authored configuration and the root’s identity anchor. Machine-local stores about a root are keyed by the root’s minted id, never by its path.
  • Every path is resolved through the fleet SDK. No application calls dirs, directories, or Tauri’s path API directly, and none hardcodes an OS path. Clippy disallowed-methods and an oxlint rule enforce it.
  • FLEET_<HOME>_DIR and FLEET_ROOT override the OS defaults for every home, in fleet and in fleet-tauri. One FLEET_ROOT moves all machine-local and runtime state into a scratch root, which is how tests and agents stay off real data (P-0017-scratch-root-isolation).

Content plane — the user’s data, at the user’s path. A vault, a project directory, a folder of photographs. The application does not choose where it sits, does not assume it is beside a checkout, and does not assume it is on local disk. It holds user content, content the application generates for the user to read and edit, and at most one suite config directory, .<suite>/, for human-authored configuration and the identity anchor that names the root (see “In-root configuration” below). It holds no application state and no other application dot-directory. It syncs, it moves, it gets copied, and it may be open in another program at the same time. Everything in it must survive all four of those.

Machine-local plane — what the application knows, per machine. The registry of known content roots, settings, downloaded models, secrets, capability grants, derived caches, logs. It is never inside a content root: a content root syncs, and a synced root that carried its own credentials or its own capability grants would let any sync peer widen the application’s exposure. It is never anchored to a repo checkout, because a packaged application has no checkout. It divides into three homes by durability:

homeholdson loss
durable config and stateregistry, settings, secrets, capability grants, models, operational historythe user has lost something; it must be backed up or re-authorised
derived cacheindexes, thumbnails, embeddings, scan results, sync cursors, build outputit re-derives; nothing is lost but time
logsper-run command outputnothing is lost

The registry plane is a named subset of the durable home: the machine-local record of which content roots this machine knows about, each with a minted id and a label, plus which was last active, plus anything else keyed by that id — a daemon’s binding to a root, per-root preferences. It is named because more than one decision refers to it. Two properties are load-bearing. It never lives inside a content root. It identifies a root by minted id, never by a hash of the root’s path, so an entry survives the root moving or being renamed.

Ephemeral/runtime plane — what dies with a process. Pidfiles, sockets, scratch space, lock files. It is a plane of its own and not a cache, because its lifetime is a process rather than an install: a stale pidfile is a bug, a stale cache entry is not. It is never placed under a path the operating system may purge while the process it describes is still running.

Rule 1 — a suite that shares user data shares one machine-local plane. A per-bundle-identifier directory is the right home for a product’s own preferences and the wrong home for anything describing the user’s data. If two products can open the same content root, then the roster of roots, the ids minted for them, and every cache keyed by those ids belong to the suite, not to either product. Split them per product and the same directory acquires two identities, and nothing keyed by id can be shared.

Rule 2 — nothing machine-local is anchored to a repo checkout. Not to <repo_root>, not to the process working directory, not to a path baked in at build time. A packaged application has no checkout, and a checkout-relative default silently serves one user’s state under another’s name whenever the process starts somewhere unexpected. An explicit override stays available for development, tests and server deployments — fleet’s FLEET_ROOT and FLEET_<HOME>_DIR (see “Overrides and scratch roots”); the default may not be checkout-relative.

Rule 3 — the dev-shell resolution tail survives for hand-run commands and is forbidden to a supervised spawn. A person who typed a command beside a checkout is well served by a ladder that walks up and finds the obvious answer. A supervised process is not: when its binding is lost or mangled it does not crash, it resolves some other root that exists and serves it with full confidence. So a supervised process is told which root it serves, by id, and exits non-zero naming the binding when that root cannot be resolved. No config fallback, no repo walk, no guess. A wrong answer is worse than no answer, because no answer is the only one anybody investigates.

Rule 4 — anything under a cache path is safe to delete, whole and at any moment. The operating system may purge a cache directory under disk pressure, and a user may delete it to reclaim space; both are supported operations, not faults. Two consequences. Data that no re-derivation restores — a human decision, an authored document, an audit trail, snapshot history — is not cache, whatever directory it currently sits in; it belongs in the content plane or the durable machine-local home. And eviction removes files whole: a cache consumer may be handed a missing file, never a truncated one, so a writer that distinguishes “absent” from “corrupt” keeps that distinction meaningful.

The file format follows the authoring hand

Section titled “The file format follows the authoring hand”

JSON for machine-written state. Every language in the fleet parses it, and it has one unambiguous spelling. It carries no comments, and nothing is lost by that, because no human reads the file.

YAML for human-authored configuration. It carries comments. That is the whole reason a file a person edits differs from one a machine writes. Structured per-root configuration lives in <root>/.<suite>/<app>.yaml.

Markdown frontmatter for flat per-root settings. Scalars and lists of scalars may live in <root>/_system/<app>.md frontmatter, so the user edits them in the application already open on that vault. Anything Obsidian’s Properties editor cannot edit is structured and goes in the YAML file.

TOML for nothing we own. A format an external tool defines is that tool’s contract. .codex/config.toml stays TOML because Codex reads it, and is out of scope for this standard.

The three planes already exist in this repo. Nothing had written them down, so each application answered “where does state live” locally and the answers did not agree. The failures are all the same shape — a category of state placed by convenience rather than by what it is:

  • Derived state placed under the user’s data makes the data non-portable in the other direction: it is now large, it churns, and it syncs.
  • Human decisions placed under a directory documented as safe to delete are deleted, correctly, by the next person who reads the documentation.
  • Machine-local state anchored to a checkout works in development and cannot ship.
  • Per-product state that describes the user’s data forks that data’s identity once a second product opens it.

Naming the planes does not by itself resolve any of these; the four rules do. Each rule exists because a real location in this repo breaks it, and each is falsifiable against the tree rather than decorative.

The applies_to.paths above are the trees that persist state. The rule binds to new state, to relocations, and to any review of an existing location — not as a demand that everything move at once.

State that does not outlive the process that wrote it is runtime. For everything else, ask three questions in order. The first yes decides.

  1. Can a re-derivation (a rescan) restore it? Then it is cache.
  2. Did a person author it, would they expect it on another machine, and can two machines’ edits be merged as text? Then it travels with the content root, as content or as in-root configuration.
  3. Is it machine-written and growing, such as history, journals, undo data or audit trails? Then it stays machine-local, on the durable tier, keyed by the root’s minted id. It does not travel. A second machine starts with none.

Anything else that describes a root, such as a setting the application keeps about a root on this machine, is machine-local durable state, keyed by the root’s minted id.

A database file never lives in a content root. Sync tools copy SQLite and its WAL side files mid-write and corrupt them. Two machines’ copies cannot be merged. Every scan rewrites the file.

If a user should see history, offer a generated read-only view. Do not write the store into the vault.

A suite may keep one config directory at a content root: .<suite>/. This is the one exception to the content plane’s rule against application dot-directories. It holds human-authored configuration, plus one file that is not configuration: the identity anchor, .<suite>/vault.md. It never holds state, caches, indexes, databases, logs or machine-written history.

Structured configuration lives in .<suite>/<app>.yaml. Structured means nested maps, lists of objects, or embedded schemas.

  • A JSON Schema validates the file. The schema is published with the content contracts.
  • An application writes the file through a comment-preserving YAML splice. The write is atomic.
  • A missing file or a missing key means defaults.
  • A key that is present but malformed fails closed. Every destructive operation that depends on it stops with a clear error. Non-destructive work may continue.

Flat settings may stay in _system/<app>.md frontmatter. Flat means scalars and lists of scalars that the user should see and edit in their editor.

The test between the two: if Obsidian’s Properties editor cannot edit it, it is structured and goes in the YAML file.

A visible-file form for structured configuration may be added later. It is not part of this standard.

In-root configuration never carries machine paths, minted ids or secrets.

The identity anchor is .<suite>/vault.md. It carries the root’s minted id. It is the only place a minted id is written into a root.

The machine-local plane is addressed in three tiers. The caller names the tier it means; nothing infers it.

tierdirectory segmentshared by
vendorcom.sizerllcevery product line the company ships
suitecom.sizerllc.<suite-key>the products that share user data
productTauri app_data_dir()one product, keyed by bundle identifier
standalone<tool>one general tool, distributed outside this fleet

VENDOR is the single string constant. The suite directory is composed from it:

suite_dir = VENDOR + "." + suite_key // "com.sizerllc" + "." + "determined"

The two-segment vendor form is the one that gets validated, and that is the reason to hold it as the constant. com.sizerllc is a real prefix of every shipped determined bundle identifier — com.sizerllc.family.*, com.sizerllc.agentpants.desktop — so bundle_id.starts_with(VENDOR) is a check with teeth. A three-segment literal would be a prefix of nothing, and the same check would be decorative.

The suite segment is not derivable from a bundle identifier. Derivation yields com.sizerllc.family, the brand being retired. So suite_key stays a caller-supplied constant: chosen once per suite of products that share user data, never a per-product name, never read off a bundle id.

Rule 1 decides which tier a category lands in. Vendor holds what every product line shares — account and licence, update channel, telemetry preference. Suite holds what the products sharing user data share — the content-root registry, suite secrets, the scoped cache tree, logs. Product keeps its own window state and preferences in the Tauri directory.

The vendor and suite tiers govern a suite: products that share one owner, and at the suite tier one user’s data. A general tool this company distributes into other people’s repositories is not in that suite. Composing the vendor into its path would write this company’s namespace onto a stranger’s machine, and inventing a reverse-DNS root for it would name nobody.

So a general tool supplies its own root: the bare tool name, ~/.config/<tool> on Linux, what any general CLI idiomatically owns. The SDK exposes it as standalone(tool) — a distinct constructor from suite(key), so the two cases are visible at the call site — and it carries no vendor segment.

The test is who the tool is for, not who wrote it. solutions/ontological is developed in this repository and installed in third-party repositories, so it is standalone. A product that only ever runs beside its siblings in this fleet is a suite member.

Tiers are distinct addresses; there is no merge

Section titled “Tiers are distinct addresses; there is no merge”

vendor() and suite(k) name different locations, not two layers of one location. A given piece of state lives at exactly one tier, and the caller names that tier.

The SDK never reads two tiers and merges them. Per-key cascade is forbidden. It starts as two lines and becomes deep-merge with array-override rules nobody can predict, after which no reader can say which tier a value came from. “Look up both” is something a specific reader does deliberately, at its own call site, per file — never cascade semantics baked into the resolver.

No application calls dirs, directories, or Tauri’s path API directly. It asks the SDK for a tier and a home, and the SDK returns the path. The product tier comes from fleet_tauri::AppDirs, not from app_data_dir() at the call site, so the overrides below reach it too. A hardcoded ~/Library/... is a bug on the first machine that is not macOS. A hand-rolled ladder is a bug the moment a second copy of it disagrees with the first, which the anti-examples below show is not hypothetical.

The per-OS mapping is what the SDK owns internally. It is recorded here as rationale — so a reader can check the SDK’s answer — not as instruction to callers.

plane / homeresolver the SDK usesTauri equivalentmacOS resolves to
machine-local durabledata_dir()app_data_dir()~/Library/Application Support/
machine-local configconfig_dir()app_config_dir()~/Library/Application Support/
derived cachecache_dir()app_cache_dir()~/Library/Caches/
logs— (no dirs call)app_log_dir()~/Library/Logs/
runtimeruntime_dir() — None off Linux—no OS answer; see below
scratchthe platform temp directory—$TMPDIR
content— never derived by the application—wherever the user put it

Two traps in that table are why this is an SDK rather than a one-line helper.

Runtime has no portable answer. dirs::runtime_dir() returns Some only where XDG_RUNTIME_DIR is set, which on macOS is never. A pidfile therefore goes under the durable machine-local home in a run/ subdirectory — deliberately not under the cache home, which the operating system may purge while the process the pidfile describes is still alive.

Logs have no dirs equivalent. There is no dirs::log_dir(); macOS wants ~/Library/Logs/. The SDK branches explicitly. Folding logs into the cache home instead is silently wrong, because the operating system purges cache and Rule 4 then licenses the loss.

Scratch and runtime are different lifetimes, not one. The SDK also hands back the platform temp directory, as scratch(). It is untiered, and it is not the runtime home: scratch holds state that does not outlive a single call, runtime holds state that does and describes a live process. A pidfile in scratch is swept while its process still runs; a scratch file in runtime is never swept at all. The wrapper adds nothing to the platform answer, and that is its value — with it, “no direct OS path call outside the SDK” is a greppable invariant rather than a rule with documented exceptions.

Every base directory can also fail — headless CI and sandboxes do not always have a home directory — so the SDK returns a result and never unwraps.

Every home can be moved by environment variable. The SDK resolves each home in this order, the same in Rust (BaseDirs::from_env) and TypeScript (systemBaseDirs). The first answer wins.

ordersourceeffect
1FLEET_<HOME>_DIRmoves one home or the scratch root: FLEET_DURABLE_DIR, FLEET_CONFIG_DIR, FLEET_CACHE_DIR, FLEET_LOGS_DIR, FLEET_RUNTIME_DIR, FLEET_SCRATCH_DIR
2FLEET_ROOTmoves every home not set in step 1 into the rooted layout: <root>/durable, <root>/config, <root>/cache, <root>/logs, <root>/scratch, runtime at <root>/durable/run
3the OS defaultsthe per-OS table above

Runtime keeps its fallback. When runtime is not set but durable is, by FLEET_DURABLE_DIR or FLEET_ROOT, runtime resolves to <durable>/run, never to the OS runtime directory. A pidfile follows the state it describes.

The layout under FLEET_ROOT is BaseDirs::rooted / rootedBaseDirs, the same seam the parity fixtures use. Tiers and scopes compose under each home as usual, so FLEET_ROOT=/tmp/x puts the determined suite’s durable home at /tmp/x/durable/com.sizerllc.determined.

fleet_tauri::AppDirs honours the same variables for the product tier. Under an override, the product directory is <durable>/<bundle identifier> rather than Tauri’s app_data_dir(). Without one, it stays Tauri’s answer. A desktop app is then as isolatable as its headless twin.

No state path bypasses fleet. An override only reaches a path that resolves through the SDK. A direct dirs::*, std::env::home_dir, Tauri app_*_dir(), os.homedir() or process.env.HOME escapes it and writes to the real home under test. Lint rules make most of the bypass visible:

languagerulewhere
Rustclippy disallowed-methods, level set in [workspace.lints.clippy]root clippy.toml, repeated in any crate with its own clippy.toml (clippy reads the nearest file and does not merge)
TypeScriptoxlint no-restricted-imports (a named homedir import, Tauri dir helpers) and no-restricted-properties (os.homedir)root rules in .oxlintrc.json, turned off for packages/ts/fleet

Fleet itself opts out at the one place it reads the OS, with #[expect(clippy::disallowed_methods, reason = "…")]. An app-specific path knob, such as VAULT_BACKUP_STORE or platform-server --state-dir, is not a substitute: it moves one root and leaves the rest.

No lint sees process.env.HOME in TypeScript or a raw var_os("HOME") in Rust. The CI e2e lane with HOME set to an empty directory catches those.

Both lints run at warn. CI clippy runs with -D warnings, so the Rust guard already fails CI. T-2THY raises the oxlint rules to error.

Three layers, in one direction.

layerdepends onprovides
fleetno Taurithe vendor, suite and standalone tiers, every home, scope keys, the scratch root
fleet-taurifleet + Taurithe product tier from a Tauri App, honouring the fleet overrides, plus the vendor assertion
appone of the twoits own state, at the tier it names

fleet carries no Tauri dependency. That is a hard requirement, not a preference. Three consumer classes have no Tauri: platform-server, which is headless and resolves the same suite home as the desktop shell; the Bun CLI fleet; and the ontological CLI. A Tauri-dependent resolver cannot serve them, and headless and desktop disagreeing about a path is the defect this standard exists to remove.

The two path APIs are not interchangeable, and both stay in use. Tauri’s app_data_dir() derives its directory from the bundle identifier, so it gives each product in a suite a different answer. That is exactly right for a product’s own window state and preferences, and exactly wrong for anything Rule 1 covers. dirs::data_dir() is not keyed by bundle identifier, so the composed suite directory is the same for every product in the suite. The SDK uses dirs for the vendor and suite tiers, and Tauri’s resolver for the genuinely per-product tier.

fleet-tauri stays thin deliberately: it resolves paths and does nothing else. It does not pre-collect single-instance, window-state or deep-link registration. The two existing Tauri shells share almost nothing but path resolution — determined-platform loads single-instance, deep-link, notification and dialog across 23 vault products, while ontological’s shell loads only opener, over a different domain entirely. determined-platform grants the dialog plugin per product on purpose (crates/determined-platform/src/lib.rs:765-777), so fleet-wide uniformity in the plugin set is an anti-goal, not a missing feature. A second real consumer earns each addition.

Rule 1 and the registry plane both assume a content root has an identity that outlives its path. That means, concretely: a minted id rather than a path hash; an identity anchor written inside the root so a move is detected and not only repaired; a relocate operation on the registry that preserves the id; and a rule for what happens when a root is copied rather than moved, so two live paths carrying one id do not silently share one cache tree. An application that takes derived state out of a content root and keys it by id owes all four. Without them, a move looks to the application like a deletion plus a first run.

A path is not an identity. A store keyed by path loses its data the first time the root moves.

  • A store at a per-root tier path holds exactly one root’s data. Its location comes from the root’s minted id, so it has no root column.
  • A store that spans roots, such as the registry or application state about registered roots, keys rows by minted id. It also keeps a last-known path as a location hint. The hint is refreshed on register and on relocate. It is never a key and never used for lookup.
  • File references stored inside any store are root-relative, never absolute.
  • Every per-root store has a test that relocates the root and reads its data back.
  • A secret home at the checkout. .secrets/ sits at the repo root (.gitignore:178-183). A packaged .app has no checkout. Breaks Rule 2.
  • A vault path baked in at build time. crates/determined-platform/build.rs:212 still emits vault_root: "../../vault". Breaks Rule 2 at the earliest possible moment.
  • A supervised daemon that falls through to the dev ladder. The resolution tail vault.config.yaml → <repoRoot>/vault is correct for a person in a shell and a trap for a supervisor, which resolves some other root and serves it confidently. Breaks Rule 3; argued at length in determined’s D-0016 §4.
  • Human decisions in a directory documented as safe to delete. <vault>/.family/ is documented as derived and rebuildable, and holds a bare git repository of the vault’s entire snapshot history, an append-only log of the user’s decisions about destructive media operations, the user’s own original inbox files, and three tables of user-authored JSON Schemas and rule toggles. Breaks Rule 4 four times.
  • A crate-level claim that its store is derived, when most of it is not. crates/determined-polish/src/lib.rs:12-32 states that everything the crate computes is “derived, discardable, and rebuildable from that directory alone”. Ten of the fourteen tables in the SQLite file it describes hold user-authored config, such as lint_config, schemas and schema_rules, or history, such as the batch journal. No rescan restores them. A comment is the cheapest place for Rule 4 to fail, and the hardest place to notice.
  • A secret and an audit log under a project checkout. The sdf shell writes its platform token (src/actions/token.rs:18-46) and its action log (src/actions/audit.rs:38-57) to <project_root>/.sdlc/runtime/sdf/, in the same file that gets its registry correctly global. Breaks Rule 2 — and shows the rule is not about one application’s habits.
  • One dependency at two versions in two workspaces. dirs is 6 at the workspace root (Cargo.toml:338), and 5 in apps/ontological-legacy/src-tauri/Cargo.toml:62. 5→6 changed the macOS data_dir semantics, so two applications on one machine resolve the same category to different directories and neither is wrong locally. One SDK crate pins the version once, for everyone.
  • One application, two config homes, no shared constant. src/config.rs:52 writes config.toml to <config_dir>/com.sksizer.ontological/, while src/lib.rs:446 writes registry.db to <config_dir>/ontological/. Same application, same home, two directories. A live bug, and the shape every hand-rolled resolver takes once there is more than one of it.
  • Two resolvers for one vault, already disagreeing. crates/determined-platform/src/lib.rs:618 reads only the vault: key of vault.config.yaml, while crates/determined-vault/src/path.rs:88-91 accepts vault, vaultDir, vault_dir and path. A config file the CLI honours, the desktop silently ignores.
  • Eight repo-root walks with four marker sets. The walk is implemented eight or more times across the TypeScript apps, with four different marker lists. determined-backup/src/config.rs:14-27 puts vault-engine.config.yaml first and omits vault.config.yaml entirely, then anchors VAULT_DIR and VAULT_BACKUP_STORE to whatever root that finds. The backup writes to a root no other application would have chosen.
  • Three secret homes and one preflight. calendar-sync keeps its token under <repoRoot>/.secrets and gates the write: it shells git check-ignore and refuses when the token path is not ignored (src/gcal.ts:121). mail-sync writes OAuth credentials to <repoRoot>/.mail-sync/ (src/providers/gmail.ts:38) with no gate. todo-sync writes iCloud credentials to <cwd>/.todo-sync/ (src/cli.ts:41) — cwd-relative, so it follows the shell rather than the checkout, and also ungated. Three anchors, one preflight, and the one good idea is the one that did not spread. Breaks Rule 2 twice over.

com.sizerllc and com.sksizer are one vendor with two spellings. The determined products ship com.sizerllc.*; sdf, pumice, ontological, suspenders and augmented-books ship com.sksizer.*. This is recorded as drift, not settled by a rule here. The constant unifies the spelling later, in one sweep. Bundle-identifier churn is the expensive half — it renames the on-disk directory of every shipped product — and is out of scope for this standard; see D-0018 §7.

The debug assertion bundle_id.starts_with(VENDOR) is what keeps the drift visible. It is debug-only, so it cannot break a release build, and there is no allowlist for the legacy prefix — an allowlist is how drift becomes permanent. Adopting the SDK is what surfaces an application as non-conformant, one application at a time, at the moment someone is already working in it.

Platform reality: this is a consistency problem, not a portability one

Section titled “Platform reality: this is a consistency problem, not a portability one”

For determined, macOS is the only real target today. Bundling is "active": false across every product config, there is no release workflow that produces a bundle, and the Linux CI job exists to prove the determined-macos quarantine holds rather than to ship a Linux build. So the reason to route every path through the SDK is not that a Linux user is waiting: it is that the SDK is one shared name per tier and per category, which is what stops the next application inventing a fourth answer. Cross-platform correctness is a free side effect, and it keeps the Linux compile honest.

determined’s D-0016 §1 introduced the term and used it once without defining it. Its definition is the one above, under the machine-local plane.

Applied on paper to three surfaces it was not written for

Section titled “Applied on paper to three surfaces it was not written for”

The standard was tested against three surfaces that persist state: apps/pumice, solutions/ontological/desktop (the Tauri shell whose crate and bundle identifier are both sdf), and the solutions/ontological CLI behind it. The CLI has since adopted it; the other two are still where each surface’s state would go, plus what the exercise found — which includes two defects the standard was not looking for.

apps/pumice is the clean case. Its only durable store is pumice.db under Tauri’s app_data_dir() (src-tauri/src/lib.rs:58-60), holding configuration, timer sessions and config-version history. It uses no dirs call, no XDG ladder, and nothing anchored to a checkout. Its markdown export target is VaultConfig.vault_path, a directory the user chooses (crates/pumice/src/vault.rs:61-90) — a textbook content plane, and the one unambiguous match found anywhere in the exercise. Under the standard it changes nothing: it is a single product with no suite, so the per-bundle-identifier home Rule 1 explicitly preserves is the right one, and its pre-migration .bak snapshot is the only thing that would move, to app_cache_dir(). The useful result is the negative one: the standard did not force suite machinery on an application that has no suite.

solutions/ontological/desktop (sdf) is the closest existing implementation. Its resolver (src-tauri/src/lib.rs:28-72) prefers the value Tauri’s app_data_dir() resolved at startup and falls back to an explicit macOS/XDG/home ladder under one centralised APP_DIR_NAME. Its own comments give this standard’s Rule 1 reasoning in full: the home is deliberately “GLOBAL — NOT tied to the launch project, so the project registry + focused-project setting persist across projects and restarts”. The standard expressed that without amendment. It also found three things:

  • A secret and a log anchored to a checkout. The platform token (src/actions/token.rs:18-46) and the action audit log (src/actions/audit.rs:38-57) both live under <project_root>/.sdlc/runtime/sdf/. Those are the two categories Rule 2 names most explicitly, sitting beside a registry the same file gets right. Under the standard the token goes to the machine-local durable home and the log to the log home, both keyed by project id.
  • Two products, one fallback name. sdf (com.sizerllc.ontological.sdf) and Suspenders (com.sizerllc.ontological.suspenders) share one backend crate. Tauri gives them separate app_data_dir()s, and the handle-less fallback collapses both onto the literal sdf (lib.rs:31,60-72). Rule 1 makes this a question with an answer rather than a latent collision: if the two products share the project registry, the single name is right and the split Tauri path is the bug; if they do not, the shared fallback is. It cannot be both, and today it is both.
  • The hand-rolled ladder is dirs::data_dir().join(APP_DIR_NAME) — the same answer, less code, and a correct Windows arm.

The solutions/ontological CLI is the surface that adopted the standard. It had three answers to one question — the dashboard registry in ~/.sdlc/, the host id under $XDG_CONFIG_HOME, the launcher redirect at a third XDG-only path with no macOS branch — and none agreed with the others. All three now resolve through one address scheme (lib/util/fleet.ts): the registry is dashboards.json in the durable home (lib/services/dashboard/server.ts:131), the host id is host.json beside it (lib/services/lease/runtime.ts:165), and the launcher redirect is home in the config home (lib/services/dev/ops/_common.ts:37). Each call site names its home, so a reader can see which one it picked. This is the case the standard exists for: three answers to one question, none wrong on its own, produced by three people who had nothing to consult.

What the paper test found that the standard does not cover

Section titled “What the paper test found that the standard does not cover”

Two things did not fit, and they are recorded rather than smoothed over.

A project’s own .sdlc/ directory. Worktrees, pr-cursors, the orchestrator log, quality baselines — machine-generated, gitignored application state living as a dot-directory inside the project it describes (.gitignore:101-107). The content plane allows no application state in a root; the machine-local plane is global and this is deliberately per-checkout, so that two worktrees of one repository do not collide. Neither plane as written describes it.

The reconciliation is that a project is not a vault. A project is a workspace a tool is invited into, versioned by its owner, where a conventional dot-directory is an established interface and is committed or ignored deliberately. A vault is a user’s document store with no such convention and a sync client watching it. The content-plane rule as stated binds to the second. A project-anchored dot-directory is a legitimate fourth placement, and the things inside it that are not project-scoped — a secret, a log, a pidfile — still belong in the machine-local and runtime planes, which is exactly the sdf defect above. If a third case appears that is neither a vault nor a project, this note is where the standard needs its first amendment.

State with no filesystem location at all. sdlc’s task leases are git refs (refs/sdlc/tasks/<id>), held in a local ref mirror and a remote authority (lib/services/lease/reconcile.ts:79-84). That is durable, replicated coordination state with no path to place. The three planes are about filesystem placement and have nothing to say about it. This is a gap in scope, not a violation: the standard should say it governs filesystem placement and leave replicated coordination state to whatever decision chose the ref database.


← Back to Standards