History-DAG model
Nodes, named heads, branching, undo-as-a-view, and publish-time redaction — from @faceless-photolib/history-dag.
History in this format is a persistent, content-addressed DAG of edit nodes — not a linear undo stack. It's the format's one genuinely novel piece: the same mask sent to three different models produces three sibling nodes that share everything they have in common (the parent state, the mask blob) and differ only in what's actually different (the model, the prompt, the output).
History nodes
Every edit is recorded as an immutable node:
type HistoryNode = {
id: RefString; // v1:b3:<hex> — see Ref grammar
op: { type: string; params: Record<string, unknown> };
parents: RefString[]; // zero parents = a root node
stateBefore: RefString;
stateAfter: RefString;
inputs: { label: string; ref: ContentRef }[];
outputs: { label: string; ref: ContentRef }[];
actor: { kind: "human"; id: string } | { kind: "automation"; id: string };
uiLabel: string;
intent: { kind: IntentKind; parameters: Record<string, unknown> };
redaction: "public" | "private" | "omit-on-publish";
};
type IntentKind = "manual-edit" | "filter" | "external-tool" | "generative-edit";id is never a field the writer chooses. It's the ref of the canonical
bytes of every other field (see Ref grammar) — so
changing any field of a node necessarily changes its id, and the node store
is add-only and can never overwrite an existing id with different content
(re-adding the exact same node is a no-op already-present, not an error,
since content-addressing already means "same id ⇒ same content").
intent.kind vs actor answer different questions: actor is who
performed the edit (a person, or an automation); intent.kind is what kind
of edit it was. A generative-edit node carries the prompt/mask/model as
intent.parameters, its mask and source as named inputs, and the actual
generated pixels as an outputs blob — the raster is always persisted
independently of the model that produced it, so the document still renders
even if that model disappears.
op is a generic { type, params } label, deliberately decoupled from
document-model's own operation set — the history DAG doesn't know or care
which specific engine produced an edit.
Named heads and branching
type HeadsState = ReadonlyMap<HeadName, RefString>; // e.g. "main" → a node idHeads are plain data: createHead / moveHead / deleteHead are pure
functions returning a new HeadsState, never mutating one in place. Creating
a sibling branch needs no special "branch" API — two nodes that both cite the
same parents entry naturally share one stateBefore, and if they were also
built from the same mask blob, that blob is stored exactly once (the
content-store's ordinary dedup, not anything history-DAG-specific). A merge
is simply a node whose parents array has more than one entry.
Head movement is undo
undo/redo are pure views over head movement — neither ever deletes or
mutates a node:
undomoves a head to the current node's primary parent (parents[0], the git-style "mainline" convention for a merge node). A node with zero parents is the root of its history and can't be undone past ({ kind: "at-root" }).redotakes its target explicitly and validates it's really a forward step — the current node must appear in the target'sparents— before moving. The DAG itself doesn't remember "which child you last undid away from"; that's necessarily a UI-side concern (one stack per head), since the DAG only ever grows forward.
Nothing about undo/redo deletes history: every rejected sibling, every abandoned branch, stays in the store as durable provenance. That's also exactly why publish-time redaction (below) has to be a first-class operation rather than an afterthought.
Publish-time redaction
A package keeps its full local history — every head, every rejected sibling branch, every private prompt. Publishing projects one head down to what's safe to share:
publishProjection(store, heads, exportHead) computes the ancestor closure of
every current head (not just the one being exported — a ref a kept node
still needs must survive even if another, dropped node also produced it),
drops any node whose redaction is private or omit-on-publish, and
returns the surviving node set plus the state/content refs those surviving
nodes actually cite. Packaging that kept set into an actual .fpd (copying
the blobs, writing documents//history//blobs/) is
@faceless-photolib/package-format's job — this function only computes
what to keep.
Exporting a head whose own node is itself private/omit-on-publish is a
hard head-node-redacted error — never a silent override (which would
publish something the author explicitly marked private) and never a silent
drop of the node while still exporting its pixels (which would ship a
package whose own head has no matching provenance record). The caller must
re-tag or re-parent before publishing.
Example: three siblings from one mask
┌─ node B (model: "sdxl", redaction: public) ──▶ head "alt-1"
stateBefore ──parent─┼─ node C (model: "flux", redaction: public) ──▶ head "main"
└─ node D (model: "sdxl-2", redaction: private) ──▶ (no head)All three nodes share one stateBefore ref and one mask-blob ref; they differ
only in intent.parameters.model and their outputs ref. Publishing "main"
walks the ancestor closure of every head (alt-1 and main; D has no head
so it's unreachable from the roots and dropped along with any blob only it
referenced), keeps B and C (both public), and excludes D entirely —
without ever having deleted it from the local store.
Corpus samples
The .fpd history-DAG fixtures below (sibling branches sharing one mask blob,
a private/redacted branch) are served through the same /api/samples/<id>
proxy every other demo on this site uses; no direct storage URL ever appears
in this page.
Preparing sample list…
Ref grammar + canonical hashing
The v1:b3:<hex> ref string grammar, the RFC 8785 (JCS) canonicalization profile, and the unknown-fields rule — from @faceless-photolib/canonical-json and @faceless-photolib/content-store.
Worker API reference
The format-worker-runtime message protocol — request/response/progress shapes, transferables, and the four async UI states.