The Evo API resolves subjects and revision heads over the content-addressable store (fjs/cas, vnd.fjs.revision) without rescanning the store on every query.
A Cas<O> stores vnd.fjs.revision blobs like any other content: immutable bytes under a hash. Resolving "what are the current heads of subject X" means walking every stored revision and reversing the parents links — too expensive to redo per request. fjs/cas/evo/module.f.mjs scans the whole store once into an in-memory Cache (keyed with fjs/effects/memory) mapping subject → head hashes, then keeps that cache current as new revisions are added through it.
Not every operation is cache-backed: revision(hash) reads one revision straight from the store and returns it decoded, validated, and canonicalized, so no client has to interpret raw revision bytes itself.
type Evo<O> = {
list: (archived?: true) => Effect<MemOp, readonly Subject[]>
head: (subject: Subject) => Effect<MemOp, readonly Hash[]>
add: (rev: RevisionData) => Effect<O | MemOp, Hash, EvoChannel>
revision: (hash: Hash) => Effect<O | MemOp, RevisionData, EvoChannel>
}
type EvoChannel = EvoError | NotImplemented
type EvoError = readonly['evoError', string]
Every operation fails through one channel. A rejected revision (evoError) and an operation the runner could not dispatch (notImplemented) are told apart by their tag — evoSummary renders either — rather than by arriving in separate layers, so step short-circuits on both and no caller re-tests a verdict an earlier step already reached.
list(archived?)andhead(subject)read only the in-memory cache — no store access, no rescanning.listfilters by subject status: the active subjects by default, the archived ones witharchived: true— see Subject status.add(rev)resolvesrev'ssubject(explicit, or inherited from a single parent — see below), assembles and checks avnd.fjs.revisionblob, writes it to the store, and folds it into the cache in one step.revision(hash)reads one revision from the store, decoded, validated, and canonicalized — see Reading one revision.
There is no way yet to fetch a subject's full history (every revision behind its head(s), not just the head(s) themselves) — see todo/subject-history.md.
type RevisionData = {
readonly parents: readonly Hash[]
readonly snapshot?: Hash
readonly subject?: Subject
readonly archived?: true
readonly generation?: number
readonly lock?: LockMap
}
One structure is the whole vocabulary of both directions: add takes it and revision returns it, so what you add is what you get back and a value read back can be added again unchanged, with no fields to strip. It is the media-level Revision (fjs/media/revision) minus dialect — a serialization tag with no information left once validation has passed; the evo layer speaks the semantic content of a revision, the same in both directions.
Every direction-specific field is optional, with the per-direction guarantees documented rather than typed (no extension or intersection types):
| field | as input to add | as output of revision |
|---|---|---|
parents | required | always present, canonical |
subject | absent → inherited from the single parent | always present |
snapshot | absent → resolved from the parents | always present, canonical |
archived | optional | optional (the only one) |
generation | ignored — the server computes it | always present |
lock | optional inline map or shared-lock hash; every hash validated and canonicalized | optional; present exactly when stored, in the form it was stored in, hashes canonicalized |
lock is the format's own field as fjs/media/revision defines it: an inline map whose values are direct hashes or nested scopes, or a hash naming a vnd.fjs.lock blob holding one to share. add and revision walk a map to the bottom — validating and re-spelling every direct hash, preserving every scope — so a nested lock round-trips like a flat one and neither direction has a depth of its own; a reference is validated and re-spelled as the single hash it is.
Evo never follows a reference. It stores and reads revisions; resolving dependencies is a resolver's job, and the format deliberately defines no semantics for one — so revision(hash) hands back the reference exactly as stored, and add writes back whatever it was given.
generation is an input field purely so the round trip needs no field stripping; add always writes the value it computes itself.
Why relax what the format requires. The stored vnd.fjs.revision blob requires subject, snapshot and generation, and revision(hash) does return all three — but RevisionData is not that blob's type. It is the type of the API that reads and constructs revisions, and its purpose is to make both easy: add asks only for what a caller can actually know, resolving or computing the rest at the write boundary, and a read hands back a value that goes straight into add again. Typing the output's guarantees instead of documenting them would state one direction more precisely at the cost of splitting the one vocabulary into two — the trade this API declines. fjs/media/revision's Revision remains the all-required type of the stored blob for anyone who wants it.
The stored vnd.fjs.revision blob requires an explicit snapshot and generation (see fjs/media/revision), so add resolves both at the write boundary — the inference the format used to carry, run once here with the parents already fetched:
snapshot, when omitted, is resolved from the parents: zero parents fall back tosubjectas the snapshot reference (which must then be a hash), one parent inherits its storedsnapshot, and more than one parent without an explicitsnapshotcannot be resolved (an explicitsnapshotis required for a merge). An explicit inputsnapshotis used as-is.generationis computed, never taken from input:0for a root, else1 + max(parents' generations). Everythingaddwrites follows the formula by construction.subject, when omitted, is inherited from the single parent's ownsubjectfield (a revision has no format-level subject-inheritance rule the way it does forsnapshot, soaddRevisionresolves it explicitly by reading the parent); it cannot be resolved with zero or more than one parent and no explicitsubject.
revision(hash) is the typed read: it fetches the blob at hash, decodes and validates it as a vnd.fjs.revision, and returns its content as RevisionData. add validates on the way in, revision validates on the way out — layering, not duplication. The generic "raw bytes by hash" read (cas.read, cas_get) stays the way to fetch arbitrary content: snapshots, and anything else that is not a revision.
Two things make it more than a convenience over a raw read:
- Canonical hashes. cBase32 accepts alias spellings of the same hash (case,
i/l/o), and everything else this API returns is canonical. A blob's storedparents/snapshotcarry whatever spelling its writer used, so a client comparing raw-blob strings againstheadoutput would silently miss a match.revisionre-spells every reference canonically, so its output compares directly againsthead's. - One validator. JSON parsing, schema validation, the
dialectcheck and the hash checks happen once, server-side, instead of in every client.
Every way it can fail is its own message, not one null: the hash is not cBase32, the store has nothing under it, the store has it but could not deliver it, or what it holds is not a revision. (The internal decodeRevisionBlob deliberately collapses all but the first — it exists to scan stores that contain arbitrary content.)
Only a genuine miss — isNotFound, the same ENOENT test fjs/cas's list uses — is reported as not found. A Cas read can also fail on a blob that is really there (a permission or mid-stream I/O error, or content too large to buffer into one Vec), and answering "not found" to those would deny that a stored revision exists. A blob deleted during the read counts as a miss: the store no longer has it by the time the answer is given, and the next read says the same thing with no race left in it.
Each call is a store round trip. A per-revision memo cache is possible later — a revision is immutable, so it can never go stale — which is why the operation is declared over O | MemOp even though today it touches only the store.
A head of a subject is any stored revision of that subject whose hash is not referenced as a parents entry by another revision of the same subject. Because revisions form a DAG (no cycles), that definition is order independent: folding "this hash is a head of its subject; drop its parents from that subject's head set" over every stored revision, in any order, converges to the same result. buildCache does this once for the whole store at startup; addRevision repeats the same fold for a single new revision, incrementally.
archived is a flag on a revision, but "is this thing still being worked on" is a question about a subject. list bridges the two through the subject's current heads, since the heads are exactly the revisions anything new would be built on:
- active — at least one current head is not archived.
list(). - archived — the subject has at least one current head, and every one of them is archived.
list(true).
Concurrent heads can disagree, and both rules resolve that the same way: a single unarchived head keeps the whole subject active. Archiving is a statement that a subject is done evolving, and while a head remains that something can still be built on, it isn't. This also makes archiving reversible in the ordinary way — a new unarchived revision on top of an archived head demotes it out of the head set, and the subject is active again, with no separate "unarchive" operation and no rewriting of stored revisions.
A subject with no current heads is in neither result: the status is a statement about heads, and there are none to make it about. A hash-consistent store cannot produce one (that would need a cycle among content hashes), but nothing here verifies that a blob hashes to the key it sits under, so this is what a corrupt or hand-crafted store gets rather than an arbitrary status.
There is deliberately no all-subjects mode. Nothing needs one yet, and only this direction is reversible: adding a third value to the filter later is a compatible extension, while removing one would break callers.
The cache stores each revision's archived flag keyed by its own revision hash, next to the hashes and parents sets, and applies it to the heads derived at read time. A per-subject flag maintained during the fold would be wrong for the same reason a running head list is (see Head resolution): which revisions are heads is not known until the whole store has been folded in.
add requires every parent to belong to the revision's own subject. The vnd.fjs.revision format itself never forbade cross-subject parents — its epoch-reset scenario is exactly that: a new subject formed from an old one, still listing its origin in parents to show how it was formed (see fjs/media/revision). The restriction is an evo-layer one, and it is deliberate:
- A revision models one step in the evolution of a single mutable object. Silently inheriting
subjectorsnapshotacross that boundary would graft the new revision onto an unrelated object's history, and head demotion is scoped torevision.subject, so nothing about the cross-subject case behaves the way the rest of this API's rules read. - The mainline walk (
todo/subject-history.md) is designed against a closed subject: allowing cross-subject parents would let a walk cross into another subject's revisions. That may well be the point of an epoch reset, but it is a design decision for that feature, not a side effect of relaxing a check here. - Only the strict direction is reversible. Accepting cross-subject parents later is a compatible relaxation — writes that used to fail start succeeding. Tightening later would break writers that had relied on it. With no consumer needing the epoch-reset scenario today, staying strict keeps the choice open.
Nothing stops such a revision from being stored — a raw cas_add of a hand-written blob is accepted, and reading it back with revision(hash) works. The restriction is on what add will construct.
Every add failure — an unresolvable subject or snapshot, a revision that fails the vnd.fjs.revision hash / generation semantics, a blob too large to encode, or a store write failure — and every revision failure — an undecodable hash, a hash the store has nothing under, a read that failed for any other reason, a blob that is not a revision — comes back as error(message) (fjs/types/result), never a throw, so a transport (e.g. the MCP adapter, fjs/mcp/evo) can surface it to the caller directly.
fjs/mcp/evo— MCP tool definitions (evo_list/evo_head/evo_revision/evo_add) for agents, served by the same process asfjs/mcp'scas_add/cas_get/cas_list— one~/.cas/store, one Evo cache, one server (npx functionalscript mcp).
The cache lives in one process's memory, so every cas/evo MCP server instance (fjs/mcp) builds and holds its own — there is no sharing across concurrently running instances. An HTTP(S) MCP server would let many clients share one cache and one process; two possible shapes for that, neither implemented yet:
- A proper HTTP(S) MCP server — requires an authentication design first (see
fjs/cas/todo/web-api-server.md, which this same per-process limitation motivates for CAS more broadly). - One API HTTP(S) server plus multiple STDIO MCP proxy servers — only worth it for clients that can't speak HTTP(S) MCP directly.
A related but distinct gap: even a single running server's cache can go stale if something other than this process writes to ~/.cas/ (the cas CLI, another MCP server instance) — see todo/cache-staleness.md.