Skip to content

Draft and publish: staged edits to published records - #109

Merged
npflood merged 21 commits into
masterfrom
feat/draft-publish
Aug 7, 2026
Merged

Draft and publish: staged edits to published records#109
npflood merged 21 commits into
masterfrom
feat/draft-publish

Conversation

@npflood

@npflood npflood commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Implements the design in #108 (whose branch this includes): an editor can stage
changes to a published record and the current version stays live until they
publish. The originating case — a half-finished translation no longer forces
the whole record out of public view — works end-to-end.

Surface

  • record_drafts: one polymorphic working-copy row per record (unique index),
    deliberately excluded from PaperTrail.
  • perspective: PUBLISHED | DRAFT on the four single-record queries. Default
    PUBLISHED; DRAFT requires update? and degrades to published content
    otherwise. Anonymous and mobile callers structurally cannot receive draft
    content — they never pass the argument.
  • draft { updatedAt author lastEditor changedFields isStale } on Plant,
    Variety, Family, Category; null unless the caller may edit.
  • saveAsDraft: Boolean on the four update mutations — same arguments, same
    authorization, live row untouched.
  • publishDraft(recordId, accessLevel, force) / discardDraft(recordId).
    Publish runs inside with_lock: authoritative conflict re-check, in-memory
    overlay, family-names mirror, publication-state flip, one save (= exactly one
    PaperTrail version), draft destroyed. Validation failure returns payload
    errors and keeps the draft.
  • Conflict detection reads the audit trail: fields changed on live since the
    draft's base, intersected with the draft's fields. Field-level on purpose —
    a coarse timestamp check would fire on every SourceSynchronizer run.
  • hasPendingChanges filter on the four list resolvers, permission-gated.

Also in this PR

  • Bug fix: ChangeHistory::Restorer never gained family_id when
    botanical families landed, so restoring a version silently skipped the
    family assignment. Fixed via a single shared whitelist
    (DraftableAttributes) that Restorer and the draft system both consume —
    the two can no longer drift. Regression spec included.
  • Design spec + implementation plan committed under docs/superpowers/,
    including a "corrections applied during execution" record.

Verification

  • Full suite: 2304 examples, 0 failures
  • spec/contracts/ (the frozen mobile contract) green and untouched throughout
  • RuboCop: 725 files, no offenses
  • CI schema-drift gate: clean (schema.graphql regenerated on the merged tree)
  • Leak-prevention reviewed adversarially: every content-returning path
    enumerated; draft content reaches only callers holding update?

Deploy gate — read before shipping

Production must be verified to have zero NULL publication_state /
access_level rows
across the five owned models before deploy.
rake ownership:verify explicitly skips NULL rows, so a green verify is not
evidence. Publishing with an access level on a NULL-trio legacy-public record
would demote it to private via VisibilityBridge's else-branch. Use
rake ownership:report (nil bucket) as a one-off task against prod.

Known deferred items

Tracked for follow-up, none load-bearing: isStale is per-record expensive
(batching follow-up; field description warns); the draft row itself is not
locked during publish (ms-window, worst case one lost draft edit); no
unattributable-principal guard in draft writes (established degraded-DB path);
DraftableAttributes::MODEL_NAMES refactor.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu

npflood and others added 21 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
Eight tasks: the record_drafts table, a single-source draftable whitelist,
the in-memory overlay, saveAsDraft on the four update mutations, the
perspective lens, conflict detection, publish/discard, and the
hasPendingChanges filter.

Task 2 also fixes a pre-existing bug it would otherwise inherit:
ChangeHistory::Restorer's whitelist never gained family_id when botanical
families landed, so restoring a version silently leaves the family
assignment untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
Adds the storage table and model that every later draft/publish task
builds on: draftable (polymorphic), jsonb data, base_updated_at, and
author/last_editor principal ids, with a DB-enforced one-draft-per-record
unique index.

RecordDraft opts out of the ApplicationRecord-wide has_paper_trail by
overriding the inherited paper_trail_options with an always-true :unless
guard, since paper_trail 17's has_paper_trail on: [] raises ("must be
called only once") once a subclass already inherits
PaperTrail::Model::InstanceMethods, and paper_trail.disable is
request-scoped rather than a permanent class-level switch.
Implements Drafts::ConflictDetector to detect fields changed on the live
record after a draft was created, using PaperTrail's immutable version
audit trail. Queries versions strictly after base_updated_at to exclude
the snapshot moment itself, then intersects with draft.changed_fields for
field-level detection (not timestamp-level, which fires constantly on
SourceSynchronizer runs and trains editors to dismiss warnings).

Includes a dedicated test for RecordDraft#changed_fields to pin the
method the detector depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
Adds a hasPendingChanges: Boolean option to the plants, varieties,
categories, and families collection resolvers. Filters via a
correlated EXISTS/NOT EXISTS subquery against record_drafts (keyed on
draftable_type + draftable_id) rather than joining, so the filter
cannot change row multiplicity even if the one-draft-per-record
uniqueness constraint is ever relaxed for named drafts.

value.nil? is checked explicitly: search_object still passes an
argument bound to an explicit-null GraphQL variable through as `nil`
(it only omits params for arguments never supplied at all), so
skipping the nil guard would have silently treated "$v: null" the
same as "$v: false" and excluded every draft-bearing record from the
unfiltered case.

The N+1 guard for a future per-row draft field (task 8 step 5) is
deliberately not implemented here: the codebase has no established
batch-loading primitive (no GraphQL::Dataloader, graphql-batch, or
BatchLoader gem; the prior perf-plants-nplusone work solved its N+1
via plain ActiveRecord includes: on known static associations, which
does not generalize to a keyed lookup for a field that does not exist
yet). See task-8-report.md for detail; task 5's implementer should
pick the batching approach once the draft field itself is designed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
Applies a draft's staged values to a loaded record without saving, so a
resolver can serialize the draft view of a record straight from the dirty
instance.

The Mobility container column needs two things a plain attribute write does
not: a deep merge (a draft stages only the locales an editor touched, so
assigning the blob wholesale would blank every other locale), and a drop of
Mobility's per-locale read cache (memoised on the backend instance and only
cleared by reload/changes_applied, so a read that happened before the overlay
would otherwise pin the pre-draft value for the rest of the request).
Adds Mutations::Concerns::DraftWriting, included by UpdatePlant,
UpdateVariety, UpdateFamily and UpdateCategory. When saveAsDraft is true,
the mutation stages the supplied attributes onto the record's RecordDraft
(creating it on first use) instead of writing the live row, and leaves no
PaperTrail version. Translatable fields are staged under a translations
blob keyed by locale, merged against the record's current translations (or
the draft's own if one already exists) so other locales are never
clobbered. base_updated_at is set only on draft creation. Omitting
saveAsDraft is unchanged: mobile and importers keep writing live directly.
UpdatePlant's familyId argument uses loads:, so graphql-ruby hands resolve
a loaded Family record (or explicit nil) under :family, never a literal
family_id string. The generic permitted-keys slice in stageable_data could
therefore never see it, silently dropping familyId + saveAsDraft on the
floor. Adds DraftWriting#stage_family_id, guarded on the whitelist so it
only fires for models that draft family_id, mirroring the
attributes.key?(:family) idiom FamilyAssignment#apply_family already uses
so an explicit familyId: null stages a clear (family_id => nil) rather
than being skipped as absent.

Per docs/superpowers/specs/2026-08-07-draft-publish-design.md:
plants.family_id is an ordinary column and must be staged.
Review found the empty-container guard was testing the wrong value, and the
mechanism behind it was misdiagnosed.

Type::Serialized inherits the json subtype's mutable-value cast
(deserialize(serialize(value))), so Container::Coder's presence-stripping runs
at ASSIGNMENT time, not at save time. A draft clearing the last surviving
translated field assigns {"en" => {"description" => nil}} and leaves the record
holding exactly {}, which the writer turns into SQL NULL against a NOT NULL
column: the next save! raises NotNullViolation. The old guard inspected the
merged hash before assignment, where the emptiness is not yet visible, and its
comment wrongly called the branch unreachable.

The check now runs on the post-assignment container, so it cannot drift from
the coder's presence rules, and the staged locales are re-seeded in place -- the
same operation the backend's own reader performs -- so the clear is honoured and
the container persists as the empty default rather than as NULL.

Also drops a staged locale whose value is not a hash: Container::Coder#dump
calls #each on it and would raise mid-response.
Adds the Perspective enum (PUBLISHED default, DRAFT) as an argument on the
four single-record queries (plant, variety, category, family), wired through
a shared apply_perspective helper on QueryType that overlays a draft only
when the caller may edit the record -- otherwise it silently falls back to
published content rather than erroring, since perspective is a view
preference and 403ing would break ordinary anonymous/read-only page loads.

Adds the `draft` field (RecordDraftInfoType, via Types::Concerns::DraftFields)
to PlantType, VarietyType, FamilyType and CategoryType, gated per-object by
Pundit update? so an editor never sees another org's draft metadata. is_stale
wires directly to Drafts::ConflictDetector (Task 6), no stub.

Since `draft` is a plain type field it is reachable through the plants,
varieties, categories and families list resolvers, not just the four
single-record queries. Added :record_draft to each resolver's eager-load
includes to avoid a per-row N+1, proven by a query-count spec against a
20-row list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
Drafts::Publisher takes the row lock, re-checks the conflict
authoritatively inside it, applies the draft through Drafts::Overlay,
reproduces the FamilyAssignment family_names mirror, flips
publication_state on a first publish, saves ONCE (one honest PaperTrail
version) and only then destroys the draft. A failed publish keeps the
draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
CI gates schema drift (graphql:schema:dump + git diff --exit-code). The
dump had not been regenerated since the saveAsDraft and hasPendingChanges
waves merged, so this carries their additions alongside publishDraft and
discardDraft. Insertions only; nothing removed or reshaped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
The wave-D branches regenerated schema.graphql independently; the sdd/t7
dump predates sdd/t5's perspective argument and draft field. This is the
dump of the merged tree, which is what the CI drift gate checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
Addresses all findings from the final draft/publish review:

- Permission-gate hasPendingChanges on plants/varieties/categories/families:
  callers without write access get scope.none for true rather than an
  existence oracle over other people's drafts (I2), with spec coverage for
  anonymous and readonly callers (M6 also fixes the dead trust_level: param
  in favor of the :superadmin factory trait).
- Seed the draft translations blob with only staged locales/fields, not a
  full live snapshot, since Overlay/Publisher deep-merge onto live anyway
  (I3), with a new Family saveAsDraft spec covering the mobility_attributes
  path outside Plant (M8).
- Warn that isStale queries the audit trail per-record (I1).
- Derive PublishDraft/DiscardDraft's DRAFTABLE_TYPES from
  DraftableAttributes::BY_MODEL instead of a hand-copied list (M5).
- Correct PublishDraftPayload#record's description for the validation-failure
  case, not just conflict refusal (M2), and document that saveAsDraft ignores
  workflow/non-column arguments (M3).
- Narrow ConflictDetector's rescue to per-version so one undeserializable
  changeset doesn't blank the whole conflict result (M7).
- Correct the design doc's "replays through the normal update path" claims
  to describe the actual Overlay + explicit family-mirror mechanism (M1),
  and add a corrections note to the implementation plan (M9).

Full suite (2304 examples), RuboCop, and schema drift are all clean;
schema.graphql re-dumped for the description-string changes.
@npflood

npflood commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Pre-deploy gate from the review: CLEARED (2026-08-07).

Ran a read-only one-off Fargate task in the production cluster (task 3a4f1aac, exit 0, deployed image 81119c05e12a) counting NULL trio rows directly:

Model Total publication_state NULL access_level NULL
Plant 427 0 0
Variety 905 0 0
Specimen 668 0 0
Location 460 0 0
Category 14 0 0

The NULL-trio demotion path (publishDraft + accessLevel on a legacy-public record deriving :private via VisibilityBridge's else-branch) is unreachable in production data. This PR is clear to deploy on its merits.

🤖 Generated with Claude Code

@npflood
npflood merged commit 8a88c6b into master Aug 7, 2026
2 checks passed
@npflood
npflood deleted the feat/draft-publish 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