Skip to content

Design: staged drafts on published records - #108

Closed
npflood wants to merge 2 commits into
masterfrom
docs/draft-publish-design
Closed

Design: staged drafts on published records#108
npflood wants to merge 2 commits into
masterfrom
docs/draft-publish-design

Conversation

@npflood

@npflood npflood commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Design record only. No code, no schema, no behaviour change.

Problem

Today visibility: draft means "hidden", and there is exactly one copy of a
record. 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 visibility enum at all and
FamilyPolicy#show? returning true unconditionally, so every save to a family
description is instantly world-visible with no way to stage it.

Approach

The live row is never touched by editing. A polymorphic record_drafts row
holds 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, default PUBLISHED) —
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: setting
Mobility.locale is the first.

Writes reuse the existing mutations via saveAsDraft: Boolean, plus
publishDraft and discardDraft.

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.object holds the state
before 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 recordHistory drawer (live since 2026-08-06) with edits that
never happened, and restorePlantVersion would happily restore something never
published. 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
SourceSynchronizer run.

paper_trail-association_tracking was evaluated and rejected on its own merits
— its README recommends active_snapshot instead, and it documents breakage on
transactional tests and STI, both of which we have. VersionedUnderRoot already
surfaces 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
translations jsonb — 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 keep
writing live directly, and the frozen mobile contract is unchanged.

Sequencing

  1. Category detail-page promotion — done, shipped as plant_data_admin_interface#24
  2. Draft and publish — this design

🤖 Generated with Claude Code

https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu

npflood and others added 2 commits August 7, 2026 02:16
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
@npflood

npflood commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Landed via #109's squash merge (the design doc is in master). Closing.

@npflood npflood closed this Aug 7, 2026
@npflood
npflood deleted the docs/draft-publish-design branch August 7, 2026 23:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant