hraness

state models where invalid states cannot exist

parse every foreign value from unknown

Drafted by an AI agent at Ben Guo's direct request from the Hraness source repositories, and checked against those sources before publication.

A local-first application keeps its state on a machine it does not control, behind storage APIs that can refuse, abort, or evict at any time, and across sessions that may have crashed mid-write. The durable question is not how to recover from a bad state; it is how to keep bad states from being representable in the first place. The answer the Hraness codebases converge on is a design rule rather than a recovery routine: model the domain so that invalid states cannot exist, and parse every foreign value from unknown at the boundary where it enters.

That rule shows up verbatim in the working guides of three different repositories. Soundfish’s storage library treats “JSON parsing, schema parsing, serialization, and browser access” as four separate fallible boundaries: each can fail alone, and each failure has its own typed kind. Oh’s contract work parses external values from unknown, requires exact keys, rejects noncanonical values, and enforces byte, item, recursion, and path limits before doing expensive work. Sponge’s guide compresses the whole posture into one line: model invalid states out; parse foreign values from unknown.

the bug class: legal type, impossible state

Most state corruption in local software is not a foreign key violation. It is a value that fits the type and breaks the world: a checkpoint that claims to create a document and update it at once, a timestamp that precedes the record’s own creation, an installation ID presented where a library ID belongs, a deletion that completed remotely but left no local evidence. Each of these is expressible in a schema of string fields and optional flags. None is expressible in a schema designed against them.

The design move is to treat the type as the policy. If two fields must agree, they live in one parsed object with a checked refinement. If two operations are mutually exclusive, they are two variants of a union rather than two booleans. If an identifier names a different kind of thing, it is a different type, not a different prefix a caller is trusted to respect.

parse, do not assert

The boundary rule is strict: a value that crosses a trust boundary arrives as unknown and becomes typed only through a parser that can refuse it. TypeScript’s as keyword is the failure mode: it converts a claim into a type without checking anything. Parsing converts bytes into a typed value or a typed error, with nothing in between.

Wordcell’s validateSearchQuery is the small version of this. The input is unknown; the validator rejects non-strings, counts UTF-8 bytes against MAX_SEARCH_QUERY_BYTES (16 KiB) while iterating characters (so a multibyte string cannot smuggle an oversized query past a naive length check), trims, rejects empty, NFC-normalizes for deterministic matching, and returns a ValidatedSearchQuery that downstream code can trust. The validated type is a different type from the input. Callers cannot accidentally pass the raw string where the checked one is required.

Soundfish pushes the same discipline into persistence. Its IndexedDB layer decodes every stored value through a consumer-owned Zod codec, classifies browser failures by name (QuotaExceededError and Firefox’s NS_ERROR_DOM_QUOTA_REACHED both map to { kind: "quota" }, AbortError to "aborted", SecurityError to "security", DataCloneError to "invalid-value") and aborts the whole transaction after a codec or callback failure. A stored blob that fails to decode is not a crash and not silent data loss; it is a typed outcome the caller decides what to do with.

unions carry only what applies

The sharpest example is loopLibraryCheckpointSchema in lib/library/model.ts, the request a Soundfish client sends to checkpoint a loop to the cloud. A checkpoint is one of two disjoint things, and the schema makes them disjoint:

// lib/library/model.ts: two variants, mutually exclusive by construction
{
  loopId,                       // a create-claim: no head exists yet
  expectedHeadDigest: null,
  claimGeneration,              // the account's monotonic fence
  fragment,
  parentDigests: [],            // exactly zero parents
}
// or
{
  loopId,                       // an update: a head exists
  expectedHeadDigest,           // the head the caller observed
  claimGeneration: null,
  fragment,
  parentDigests: [digest, ...], // 1 through MAX_SYNC_PARENTS (2)
}

There is no way to express “create a loop that already has parents” or “update a loop while withholding the observed head.” A superRefine then adds the cross-field law that survives both variants: the expected head must be retained among the parent digests, and parents must be unique. The invariant is not a comment; it runs inside the parse.

Results get the same treatment. loopLibraryCheckpointResultSchema is a discriminated union of six outcomes (applied, unchanged, conflict, missing, claim_stale, quota_exceeded), and each variant carries only the payload that outcome can produce. conflict returns the current head. claim_stale returns the observed claim generation so the client can reconcile. quota_exceeded names which quota was exhausted (bytes, loops, versions, or writes); there is no generic “rate limited” state where the caller must guess which counter tripped. A handler that forgets a variant fails to compile; a variant that arrives without its payload fails to parse.

identity is a parsed type

Soundfish has two kinds of identifiers with different jobs, and they are different branded types over different grammars: loop_[0-9a-f]{32} for a library ID, inst_[0-9a-f]{32} for an installation ID. A function that takes a LoopLibraryId cannot be handed an installation ID, even by accident: the brand is on the type, and the regex is in the schema.

The generator proves the point about where parsing happens. generateLoopLibraryId produces a fresh UUID, formats it, and then parses its own output through loopLibraryIdSchema before returning it. The code does not trust crypto.randomUUID to have produced a well-formed value, and it does not trust its own formatting. If the ID source returns garbage, the generator throws TypeError rather than emitting a malformed ID that will corrupt comparisons later.

The same separation keeps authority honest: a library ID names one mutable project, an installation ID names a device, and the guide is explicit that an installation ID is never authentication or authorship. In Wordcell, the analogous split is document_id (a declared stable identity that survives renames) versus path identity for notes that never declare one. Wordcell never invents an ID for a note that lacks one; a kb:// cross-vault URI can only ever name a declared document_id, not a path inferred into stability.

invariants inside the schema

A schema earns its keep when it encodes the domain’s arithmetic, not merely its shapes. Soundfish’s loopLibraryHeadSchema bounds bpm to an integer in 40–240, trackCount to 0–16 (MAX_TRACKS), bars to MIN_BARSMAX_BARS (1–16), and updatedAt/createdAt to safeTimestampSchema, an integer no larger than 8,640,000,000,000,000, the maximum value a JavaScript Date can represent. The superRefine then rejects a head whose updatedAt precedes its createdAt. The loop protocol reserves channel-10 percussion semantics structurally: tracks are kind: "notes" or kind: "drums", gmProgram exists only on notes tracks, kit only on drums tracks, and MIDI import maps drums to DRUM_CHANNEL = 9, the zero-based channel every sequencer calls channel 10. A document cannot express a melodic track on the drum channel because the channel is not a field the document exposes at all.

Oh applies the same idea to infrastructure. Its SQLite storage spec fixes the connection contract (strict mode, journal_mode = WAL, synchronous = NORMAL, busy_timeout = 5000, trusted_schema = OFF), and every committed operation runs inside BEGIN IMMEDIATE. Applied migrations are checked by exact SHA-256 digest of their SQL bytes; a database that claims version 0001_oh_core with different bytes is refused, not repaired. The migration table makes “the schema is not what the code expects” unrepresentable as a successful open.

what the model buys

The payoff is not elegance; it is a specific operational property: every fallible step has a typed outcome, so retry and reconciliation are decidable. When a Soundfish transaction aborts after a codec failure, nothing partial committed, and the caller retries the operation rather than repairing the store. When a checkpoint returns claim_stale, the response itself carries the generation the server observed, so the client does not need a second query to learn why it lost. When PeopleBlade’s raw imports validate completely before their transaction commits, a malformed provider archive cannot leave half-imported identities behind; replaying the same artifact returns its stored receipt rather than double-importing.

This is also what makes the failure taxonomy honest. A quota refusal, a security refusal, an aborted transaction, and an undecodable record are different problems with different remedies: free space, fix permissions, retry, or report corruption. Collapsing them into one catch-all error makes the caller’s decision for it, badly.

the honest limit

A schema proves shape, not truth. Oh’s projection engine can return a proof tree naming the rule and premises that derived a tuple; the graph guide is explicit that an Oh proof does not prove an authored claim is true; it proves the bounded evaluator derived it from the supplied bytes. Parsing guarantees that whatever passed the boundary is well-formed, bounded, and self-consistent. Whether the bytes described the world correctly is a question for the authority layer above the schema, not the schema itself.

That boundary is exactly where the work should be. Design the model so the parser is the hardest gate a value can pass, and the remaining questions (authority, provenance, review) stay visible instead of hiding inside states that should never have existed.

sources

  • sound.fish: lib/library/model.ts, lib/storage/ and lib/protocol/ in the Soundfish codebase
  • wordcell: src/search.ts, docs/design.md
  • oh: spec/v1/storage.md, spec/v1/projection.md
  • peopleblade: docs/ARCHITECTURE.md