faceless-photolib

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 id

Heads 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:

  • undo moves 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" }).
  • redo takes its target explicitly and validates it's really a forward step — the current node must appear in the target's parents — 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.

Samples — format-history-dag
Run checks in Test Lab

Preparing sample list…