Design: staged drafts on published records - #108
Closed
npflood wants to merge 2 commits into
Closed
Conversation
Records a draft/publish design so an in-progress edit (typically an unfinished translation) no longer forces the whole record out of public view. Live rows are never touched by editing; a pending-changes table holds the working copy and Publish replays it through the normal update path. Records why PaperTrail is not the storage mechanism, despite looking like a fit: versions.object holds pre-change state, recordHistory ships in production and would fill with edits that never happened, and the payload is unqueryable YAML. PaperTrail is used for conflict detection instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
paper_trail-association_tracking looks like the way to bring relations into drafts. It is not: drafts live in record_drafts.data, not in PaperTrail. The gem also recommends against itself, breaks on transactional tests and STI (both of which we have), and duplicates VersionedUnderRoot, which already surfaces relation, common-name and image changes in recordHistory. Staging relations is deferred rather than rejected, with the reasoning for why that is not a one-way door. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
Contributor
Author
|
Landed via #109's squash merge (the design doc is in master). Closing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design record only. No code, no schema, no behaviour change.
Problem
Today
visibility: draftmeans "hidden", and there is exactly one copy of arecord. An editor who starts a translation and cannot finish it has one lever:
mark the whole record draft. It vanishes from public view, taking its finished
locales with it, because every locale lives in one Mobility container blob on
one row.
Families have the inverse problem: no
visibilityenum at all andFamilyPolicy#show?returning true unconditionally, so every save to a familydescription is instantly world-visible with no way to stage it.
Approach
The live row is never touched by editing. A polymorphic
record_draftsrowholds the working copy; publishing replays it through the normal update path,
producing exactly one PaperTrail version whose changeset is the real
before/after.
Reads use a perspective lens (
PUBLISHED | DRAFT, defaultPUBLISHED) —the Contentful/Sanity shape. Same schema, no new per-entity types, and
anonymous or mobile callers never pass the argument, so they cannot receive
draft content. This is the codebase's second read lens;
language:settingMobility.localeis the first.Writes reuse the existing mutations via
saveAsDraft: Boolean, pluspublishDraftanddiscardDraft.Why not PaperTrail, which is what prompted this
The idea came from noticing PaperTrail already stores what looks like a draft.
Recorded in full in the spec, but in short:
versions.objectholds the statebefore a change, so a draft would have to be written as a version that never
corresponded to a real saved state — a lie in an append-only audit log. It would
also fill the
recordHistorydrawer (live since 2026-08-06) with edits thatnever happened, and
restorePlantVersionwould happily restore something neverpublished. The payload is unqueryable YAML, and association tracking is not
installed.
PaperTrail is used here, correctly, for conflict detection: the versions since
a draft's
base_updated_at, intersected with the draft's changed keys, answer"did live move under this draft?" precisely enough not to fire on every
SourceSynchronizerrun.paper_trail-association_trackingwas evaluated and rejected on its own merits— its README recommends
active_snapshotinstead, and it documents breakage ontransactional tests and STI, both of which we have.
VersionedUnderRootalreadysurfaces relation, common-name and image changes in
recordHistory.Scope
Plant, Variety, Family, Category (selected by "has translatable fields and an
editing surface worth staging"). The record's own columns, including the
translationsjsonb — which covers the originating complaint in full. Relations,common names and images are deliberately deferred, with the reasoning for why
that is not a one-way door.
Note that "live is only touched by Publish" is an editing-UI policy, not an
API restriction.
SourceSynchronizer, the importers and the mobile app keepwriting live directly, and the frozen mobile contract is unchanged.
Sequencing
🤖 Generated with Claude Code
https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu