Problem
#61's rule is "every mutation snapshots the record's prior full state." For associations it holds in one direction only.
Stack.saveVersion() (packages/core/src/stack.ts:1597-1608) writes associations onto the snapshot only when the record has them:
...(record.associations && { associations: record.associations }),
So a snapshot of a record with zero associations has no associations key — indistinguishable from a legacy snapshot that predates #61. On restore, adapters only touch associations when the key is present (record-logic.ts:290 if (target.associations !== undefined), testing.ts:265). Net effect: restore can add associations back but can't remove them.
Repro: create note (v1, no tags) → associate a tag (v2; the snapshot of v1 has no associations key) → restoreVersion(1) → content reverts but the tag survives.
Worse, it's adapter-divergent. An empty association set is materialized differently per adapter:
- SQL adapters:
rowToRecord sets associations only if (associations.length) (mappers.ts:29) → an emptied record has associations: undefined.
MemoryAdapter.dissociate leaves associations: [] (testing.ts:222-223) → truthy → snapshotted → restore does clear.
So the identical create→tag→dissociate→restore sequence behaves differently on the test double than on real storage — the double is actually more correct than production here, which also means the test suite can't catch the production bug.
Fix
- Always snapshot
associations (empty array included) — treat "the record has no associations" as [], not absent. Old snapshots lacking the key stay "leave as-is" (legacy), so no data migration needed.
- Always restore associations when the (now always-present) key is there, including clearing to empty.
- Normalize the empty representation so SQL and memory adapters agree (both
undefined or both [] on read) — pick one and pin it with a shared assertion, since this is exactly the two-engine drift sqlite-shared exists to prevent.
Not a bug — confirm + document
The snapshot does not capture parentId or appId, so restore can't revert a re-parent or app reattribution. This is intended: #61's decision-of-record enumerated what RecordVersion gained (associations/permissions/typeId), and restore's contract is "content + typeId + associations, never permissions." Action here is a one-line spec clarification in §Versions that restore deliberately does not cover parentId/appId — not a code change. (Permissions are likewise deliberately never restored — no action.)
Tests
Refs
#61 (one-versioning-rule / full-state snapshots), #62 (restoreVersion semantics). From docs/design-assessment-2026-07.md §B1 (PR #105).
Problem
#61's rule is "every mutation snapshots the record's prior full state." For associations it holds in one direction only.
Stack.saveVersion()(packages/core/src/stack.ts:1597-1608) writesassociationsonto the snapshot only when the record has them:So a snapshot of a record with zero associations has no
associationskey — indistinguishable from a legacy snapshot that predates #61. On restore, adapters only touch associations when the key is present (record-logic.ts:290if (target.associations !== undefined),testing.ts:265). Net effect: restore can add associations back but can't remove them.Repro: create note (v1, no tags) →
associatea tag (v2; the snapshot of v1 has noassociationskey) →restoreVersion(1)→ content reverts but the tag survives.Worse, it's adapter-divergent. An empty association set is materialized differently per adapter:
rowToRecordsetsassociationsonlyif (associations.length)(mappers.ts:29) → an emptied record hasassociations: undefined.MemoryAdapter.dissociateleavesassociations: [](testing.ts:222-223) → truthy → snapshotted → restore does clear.So the identical create→tag→dissociate→restore sequence behaves differently on the test double than on real storage — the double is actually more correct than production here, which also means the test suite can't catch the production bug.
Fix
associations(empty array included) — treat "the record has no associations" as[], not absent. Old snapshots lacking the key stay "leave as-is" (legacy), so no data migration needed.undefinedor both[]on read) — pick one and pin it with a shared assertion, since this is exactly the two-engine driftsqlite-sharedexists to prevent.Not a bug — confirm + document
The snapshot does not capture
parentIdorappId, so restore can't revert a re-parent or app reattribution. This is intended: #61's decision-of-record enumerated whatRecordVersiongained (associations/permissions/typeId), and restore's contract is "content + typeId + associations, never permissions." Action here is a one-line spec clarification in §Versions that restore deliberately does not coverparentId/appId— not a code change. (Permissions are likewise deliberately never restored — no action.)Tests
restoreVersionto the pre-associate version removes the associationMemoryAdapterand both SQLite adapters (regression against the divergence)Refs
#61 (one-versioning-rule / full-state snapshots), #62 (restoreVersion semantics). From
docs/design-assessment-2026-07.md§B1 (PR #105).