Frameset–Pin Contract
Design-decision record for the frameset/pin matching, draft-divergence, live- tree selector, pristine-snapshot rollback, and debounced-save flush subsystem introduced in Epic #1744, with current matcher/configuration contracts and explicitly marked historical v1 decisions.
Warning
This document is the deliverable for sub-issue #1757 (Wave 5 of Epic #1744, Frameset Pin Architecture). It records the ten design decisions (D1–D10) introduced across Waves 1–5 of #1744.
Historical note: D2, D3, D4, D8, and D9 describe v1 concepts superseded by the v2 singleton-frameset model (Epic #1961). Those sections are preserved as historical record and carry individual :::caution callouts. D1, D5, D10 remain relevant and normative. The authoritative v2 description is inFrame / Frameset / Pin / Provider Instance Cache.
See also: Frame Component Contractfor the broader provider / leaf-state / toolbar contract that this document extends.
Why this contract exists
The Frameset Pin Architecture epic (#1744) replaced the previous ad-hoc frameset switching with a principled system: every saved frameset is a named, identity-comparable snapshot, and the live UI tree is continuously matched against that set. When the live tree diverges from all saved framesets, the app enters a "draft" mode rather than silently corrupting the saved copy. This document captures the ten design decisions that make that system correct and deterministic.
D1 — Matcher equivalence rule
Two frameset trees are equivalent iff they share the same structure shape, the same
providerIdat every leaf, and the samematchIdentity(props)value at every leaf. Split ratio,frameId,instanceId, and per-leafstateare NOT compared.
Implementation
framesetsEquivalent in packages/ recursively canonicalises both trees via canonicalize, then JSON-stringifies and compares the result. canonicalize descends the tree and at each leaf node calls:
registry.getProvider(leaf.providerId)?.matchIdentity(leaf.props)
?? defaultMatchIdentity(leaf.props)where defaultMatchIdentity returns { layoutId: props.layoutId ?? null } from raw JsonValue.
Provider matchIdentity table
| Provider id | matchIdentity return value | Meaning |
|---|---|---|
core.inbox | { scope, creationDefaults, readOnly } | Normalized registered-note query and creation policy; selection/layout remain separate leaf state. |
core.empty | { layoutId } (default) | Same layout = same identity |
core.kanban-board | { layoutId, boardPath } | Same .md file + layout |
core.mindmap-board | { layoutId, source } | Valid workspace/UUID binding, or ordinary-root-file path identity; malformed canonical bindings never fall back to a path. |
core.todo-board | { layoutId, source } | Valid workspace/UUID binding, or ordinary-root-file path identity; malformed canonical bindings never fall back to a path. |
core.external-file-editor | { layoutId, paths, treeRoot } | Sorted set of open local paths + layout + embedded-tree root; collapse is presentation only |
core.doc-cloud | { projectSlug, initialSurface, editorLayout } | Project/navigation/editor-layout seed; embedded-outline collapse is presentation only |
core.external-file-editor uses a sorted set of open paths so that opening the same files in a different order still produces a match. "Opening a different file" means a different set of open paths — adding or removing even one file produces a distinct identity.
What is NOT compared
The following differences do NOT change D1 lifetime equivalence:
Split ratio — resizing does not change general lifetime equivalence. It does change exact saved configuration.
frameId— generated per tree-restructure; carries no semantic identity.instanceId— survives provider swap and pop-out; not a structural key.Leaf
state("normal"/"collapsed"/"zoomed"/"popped-out") — visibility variants of the same provider instance.Embedded-pane collapse — EFE
treeCollapsedand Doc CloudoutlineCollapsedpersist as presentation state but do not change D1 identity. Exact saved configuration still compares serialized presentation props.
Exact saved-configuration comparison
D1 remains the general lifetime matcher; do not add sizing to it. Pin Save uses pinConfigurationsEqual in tauri- instead. That comparison includes split sizing, normalized provider configuration and the structural AI source relation (target position in the tree, not generated frame IDs). It excludes note bodies, selection, open-note membership, editor state and conversation state. Object key order does not distinguish configurations.
findActivePinId prefers an exact match, then falls back to D1 plus structural AI relations. Duplicate Save uses only the exact comparison, including hidden Pins. A duplicate opens existing Pin settings; a modified configuration can become a new Pin even when the lifetime highlight remains on the old one.
Canonical TODO/Mind Map sources match by workspace and UUID after a move. A new UUID reusing the old path, or the same UUID in another workspace, does not match. Ordinary root documents keep path identity; invalid/incomplete canonical bindings are not silently reinterpreted as ordinary files.
Defining matchIdentity on a new provider
Add matchIdentity(props: JsonValue): unknown to the provider object. Return a JSON-comparable value whose equality implies "same logical content". The returned value is passed through JSON.stringify and compared as a string, so nested objects and arrays are fine — just ensure order stability for arrays (sort them). If your provider does not declare matchIdentity, the default { layoutId } identity is used automatically.
D2 — Draft frameset slot
Caution
The draftFrameset slot and originFramesetId breadcrumb described here are v1 design decisions that no longer exist in the codebase. The v2 singleton-frameset model eliminates both: AppSettings.frameset always holds the live tree directly, and temporary trees are held separately in a session-only registry. Only the active tree is persisted; multiple inactive temporary entries retain their frame IDs and sessions. The authoritative description of the temp-pin mechanism is in Frameset and Pin Architecture — "Temp pin" section.
This section is preserved as a historical record.
AppSettings.draftFramesetis an ephemeral field that is present when and only when the live tree has diverged from all saved framesets.currentFramesetIdALWAYS resolves to a real saved-frameset id — there is no "draft" sentinel value for that field.
Type signatures
// packages/app-defaults/src/types.ts
interface AppSettings {
// …other fields…
/** Id of the currently-active saved frameset. */
currentFramesetId: string;
/**
* Present when the live tree has diverged from every saved frameset.
* Absence (undefined) means the live tree matches currentFramesetId exactly.
* Design decision D2 (#1744): currentFramesetId ALWAYS resolves to a saved
* frameset — "user is on a draft" is expressed only via this field's presence.
*/
draftFrameset?: {
tree: FramesetTree;
activeFrameId?: string;
/** The saved frameset the user was on when they first diverged. */
originFramesetId: string;
createdAt: number;
updatedAt: number;
};
}Lifecycle
| Event | currentFramesetId | draftFrameset |
|---|---|---|
| App loads, tree matches saved frameset | Saved frameset id | undefined |
| User resizes a pane / switches layout | Unchanged | Set (draft created) |
| Live tree re-matches a saved frameset | Updated to matched id | Cleared |
| User promotes draft to a new pin | New frameset id | Cleared |
| User discards draft | Restored to originFramesetId | Cleared |
The draft's originFramesetId records which saved frameset the user was on immediately before they first diverged. Discard restores currentFramesetId to that value.
D3 — Live-tree selector
Caution
getActiveFramesetView and frameset-selectors.ts no longer exist in the codebase. In v2 there is no framesets[] array or currentFramesetId — the singleton frameset is read directly from AppSettings.frameset viauseSavedFrameset (singular). This section is preserved as a historical record. The authoritative v2 description is inFrameset and Pin Architecture.
All consumers of the current frameset tree MUST read from
getActiveFramesetView(tauri-). Direct indexing ofapp/ renderer/ lib/ frameset- selectors. ts framesets[]bycurrentFramesetIdis forbidden.
ActiveFramesetView interface
interface ActiveFramesetView {
/** The tree to render. Draft tree when isDraft; saved tree otherwise. */
tree: FramesetTree;
/** Active frame within the tree (if known). */
activeFrameId?: string;
/** True when the live tree is a draft (diverged from all saved framesets). */
isDraft: boolean;
/** Id of the saved frameset this view is logically tied to. */
savedFramesetId: string;
/** Present only when isDraft; the saved frameset the user diverged from. */
originFramesetId?: string;
}getActiveFramesetView contract
function getActiveFramesetView(
framesets: Frameset[],
currentFramesetId: string,
draftFrameset?: DraftFrameset,
): ActiveFramesetView | nullReturns
nullonly whenframesetsis empty (app is initialising).Falls back to the inbox frameset or
framesets[0]whencurrentFramesetIdis not found (e.g. deleted frameset id still in settings).When
draftFramesetis present,treeisdraftFrameset.treeandisDraft = true; otherwisetreeis the matching saved frameset's tree.WeakMap-cached. The selector memoises on
(framesets, currentFramesetId, draftFrameset)identity so callers do not re-render from reference churn.
Why not index directly
framesets is an array. Finding the active frameset by currentFramesetId is a linear scan. More importantly: during the brief window after a draft is promoted or discarded, the live currentFramesetId may not yet match any entry in the serialised framesets[] snapshot the component sees. getActiveFramesetView handles these edge cases uniformly; ad-hoc indexing at every call site re-creates the same bugs.
D4 — Pin highlight derivation
Caution
The derivation rule described here is v1 logic. Current highlighting prefers exact saved configuration, then falls back to the D1 lifetime matcher plus structural AI-source comparison. It does not decide whether Save is a duplicate. Multiple temporary entries can remain available alongside saved Pins. The current behavior is documented in Frameset and Pin Architecture.
This section is preserved as a historical record.
Pin highlight state is a pure derivation — it is never stored. A pin is highlighted iff the live tree currently matches the pin's saved frameset AND no draft is active.
Derivation rule (pseudocode)
const isHighlighted =
!draftFrameset &&
pin.framesetId === currentFramesetId &&
useCurrentMatchingFramesetId() === pin.framesetId;The double guard is necessary:
pin.framesetId === currentFramesetId— the pin points to the currently-selected saved frameset.useCurrentMatchingFramesetId() === pin.framesetId— the live tree currently matches that saved frameset (not some other saved frameset the user navigated to mid-draft).
Without guard 2, a pin would stay highlighted even when the user navigates away but has not yet diverged. Without guard 1, the wrong pin would highlight when the user happens to match a non-current frameset.
Temp pin
When draftFrameset is present, no saved pin is highlighted. Instead, a temp pin (TempPin component in toolbar.tsx) appears with a LayoutGrid icon in the left cluster. It represents the unsaved diverged tree. This paragraph describes the historical single-temp UI. Current temporary-entry lifetime is documented in Frameset and Pin Architecture.
D8 — Pristine snapshot rollback
Caution
pristineTreeByFramesetIdRef, draftFrameset, and useSavedFramesets (plural)no longer exist in the codebase. Epic #1961 replaced the multi-frameset model with a singleton: AppSettings.frameset holds one FramesetTree directly, anduseSavedFrameset (singular) manages it. The pristine-snapshot rollback mechanism described here was removed with the v1 draft concept. This section is preserved as a historical record. The authoritative v2 description is inFrameset and Pin Architecture.
Each saved frameset has a pristine baseline snapshot, seeded from the on-disk tree at hook load. When the live tree diverges from a saved frameset, that frameset's stored tree is rolled back to its pristine baseline — preventing the saved frameset from silently tracking in-flight mutations.
Implementation
useSavedFramesets (tauri-) maintains:
const pristineTreeByFramesetIdRef = useRef<Map<string, FramesetTree>>(new Map());The map is seeded once, when the hook first loads framesets from disk. It is never updated after that seed (unless a frameset is created, at which point its initial tree is added).
When handleLiveTreeChange detects divergence from frameset F:
It reads
pristineTreeByFramesetIdRef.current.get(F.id)to get the baseline.It writes the baseline tree back into
F's stored entry in settings.It sets
draftFramesetto the current live tree.
This ensures that if the user discards the draft, the saved frameset is the same shape it had when the user last explicitly saved it — not some intermediate state the live tree happened to pass through.
Why the name "pristine"
"Pristine" = untouched by in-flight mutations. The baseline is the snapshot from disk, before any live-tree changes. Contrast with "saved" (which in casual speech could mean "last-written-to-disk", including intermediate writes the hook does for other reasons).
D9 — Debounced-save flush contract
Caution
promoteDraftToPin, discardDraft, and flushPendingSave no longer exist in the codebase. These functions were part of the v1 draft/frameset model removed by Epic #1961. In v2 there is no draft state — the singleton frameset is updated directly via useSavedFrameset (singular). This section is preserved as a historical record of the v1 flush-before-write safety contract.
promoteDraftToPinanddiscardDraftMUST callflushPendingSave()before writing any new settings state. Skipping the flush can cause a debounced save to overwrite the newly-written state.
Context
useSavedFramesets debounces settings writes: live-tree changes queue a save that fires 300 ms after the last change. promoteDraftToPin and discardDraft also write settings. If the debounced timer fires between the point where the action reads the current state and the point where it writes, the debounced write overwrites the action's result.
flushPendingSave() cancels the pending timer and executes the save immediately, so the state the action reads is the same state on disk when it writes.
Call order
async function promoteDraftToPin(opts) {
flushPendingSave(); // ← must be first
// … build new Frameset + HeaderLeftPin …
await writeSettings(next); // ← safe: no pending timer can race
await validateSettings(next);
await syncToDisk(next);
}
async function discardDraft(opts) {
flushPendingSave(); // ← must be first
// … restore originFramesetId, clear draft …
await writeSettings(next);
}Any future action that reads-then-writes settings within a component that owns a debounced save MUST follow the same pattern.
D10 — Layout-change divergence routing
Structural live-tree changes must pass through the app's live-tree change path, so persistence and temporary-tree tracking observe the same result.
Why this matters
Provider swaps, split changes and structural configuration changes must reach handleLiveTreeChange. Bypassing it can lose divergence registration or persist a stale tree. The former v1 promise that every Note Tray layout change creates a new saved configuration no longer applies: Note Tray layout, selection and presentation live in its per-frame cache, outside serialized Pin configuration.
Consequence for new layout-aware providers
A provider that persists layout in its structural props must route that mutation through the live-tree change path. A provider whose layout is session state must use its cache/persistence coordinator instead; do not inject runtime layout or selection into Pin props solely to trigger divergence.
D5 — Pin providerProps schema
HeaderLeftPin.providerPropsis a typed per-provider map. New provider-specific providerProps keys MUST be added toPinProviderPropsByProviderIdinpackages/.app- defaults/ src/ types. ts
Schema table
PinProviderPropsByProviderId in @takazudo/app-defaults maps each provider id to its known providerProps fields:
| Provider id | providerProps keys | Meaning |
|---|---|---|
core.todo-board | initialBoardPath?, sourceWorkspaceId?, noteId? | Canonical source captured by workspace/UUID; path is routing/display. Ordinary root files remain path-only. |
core.kanban-board | initialBoardPath?: string | Workspace-relative backing-file seed. |
core.mindmap-board | initialBoardPath?, sourceWorkspaceId?, noteId? | Same canonical source contract as TODO. |
core.external-file-editor | initialFiles?: string[]; initialTreeRoot?: string; treeCollapsed?: boolean | Local-absolute files and tree root; the runtime leaf converts these to openPaths / treeRoot |
core.doc-cloud | projectSlug?: string | null; initialSurface?: "projects" | "overview" | "activity" | "editor"; editorLayout?: "edit" | "preview" | "split"; outlineCollapsed?: boolean | Safe one-frame navigation/layout seeds only; never source, tabs, guards, fetched state, or credentials |
core.inbox | scope, creationDefaults, name?, readOnly, restoreLastLayout | Normalized Note Tray configuration; no selected/open notes or content. |
core.ai-assistant | sourceTrayFrameId: string | null | Structural source relation only; no conversation or runtime prompt bindings. |
| (all others) | — | No providerProps keys registered |
Doc Cloud pins always contain one core.doc-cloud leaf. The outline is an embedded pane, never a second provider or a two-leaf template.
The old standalone Directory View provider is a deliberate pre-release break: there is no migration for pins or trees that still name it. Validation may preserve an opaque old pin, which can render provider-not-found until the user removes it or resets settings; no local file content is changed. The retired id must not be registered as a compatibility provider.
Promotion semantics for initialBoardPath
A board can promote an explicit initialBoardPath seed to its runtime routing path. For canonical TODO/Mind Map sources, the captured workspace and UUID remain authoritative on reopen: a move resolves the same note, and path reuse cannot select a different note. A saved path-only canonical board is refused rather than bound to whichever note now occupies that path. The explicit file picker can capture a new source binding. Ordinary root documents retain their path contract.
For Note Tray, normalizeInboxProviderConfig whitelists scope/defaults/policies; it does not interpret retired directory props. All folders includes Archives; folder scope includes descendants and may intersect an exact sticker. Defaults must create inside the scope. Applying scope changes does not retag/move existing notes or discard retained open editors.
Template application remints all frame/instance/branch IDs in two passes, then remaps active-frame intent and AI source targets that are valid Note Tray leaves. Missing or invalid source targets become null. This structural rule makes no claim about AI conversation ownership or server-side source enforcement.
ROOT generate-step settings isolation
Pin providerProps are not configurable at generation time. The ROOT's generate dialog has no settings step at all — it is a single step (app name, display name, workspace picker) and bakes no settings into the new LEAF — and the shared settings sections in packages/ contain neither terminal settings nor pin editors. Pin providerProps are configured per-pin inside the generated LEAF app's Manage header pins dialog.
Watch-for-next-time patterns
These patterns are known footguns. They appear here so the next implementor does not rediscover them.
Provider matchIdentity with arrays — sort them
If matchIdentity includes an array (e.g. the list of open paths in core.external-file-editor), the array must be sorted before returning it. canonicalize uses JSON.stringify for comparison; ["a","b"] and ["b","a"] stringify differently even though they represent the same set. core.external-file-editor sorts openPaths alphabetically before returning { layoutId, paths }.
Do not call updateFramesetTree from layout-persistence hooks
See D10 for structural mutations. Note Tray runtime layouts instead use their per-frame cache; they must not be copied into saved configuration.
getActiveFramesetView caches on identity, not value
Historical v1 only: the WeakMap key was the framesets array reference and the draftFrameset object reference. If a caller recreates these on every render (e.g. via useMemo with too broad a dependency), the cache misses on every call. Ensure framesets comes from a stable store slice and draftFrameset is the same object reference between renders when it has not changed.
flushPendingSave before any read-modify-write on settings
The named function is historical v1. Current Save reads, validates, duplicate-checks and writes inside runSerializedSettingsWrite, sharing the existing settings queue with live-tree persistence. It captures the confirmed template before await but persists the latest live tree, checks workspace identity, and never installs a stale template snapshot after Save. See D9 for the original race, not an API to call today.
Draft exists iff draftFrameset !== undefined
Historical v1 only: do not test draftFrameset === null — the field is undefined when absent, not null. JSON.parse of a settings file that omits draftFrameset returns an object with the key absent (not null). A null check will pass when it should not.
See also
Frame Component Contract — provider shape, leaf-state schema, toolbar slot, and active-frame border.
Header Pins Architecture — data-flow, routing trade-off, and the two metadata-only registries.
ピン — current end-user Pin operations.