summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/DESIGN_HISTORY.md135
1 files changed, 110 insertions, 25 deletions
diff --git a/doc/DESIGN_HISTORY.md b/doc/DESIGN_HISTORY.md
index 1836333..5eb664b 100644
--- a/doc/DESIGN_HISTORY.md
+++ b/doc/DESIGN_HISTORY.md
@@ -7,10 +7,10 @@ Two layers only: `head` (the published, public revision) and `draft`
7editing). 7editing).
8 8
9No separate lock concept. The draft's *author* was the lock. 9No separate lock concept. The draft's *author* was the lock.
10Pessimistic, one editor at a time, enforced by ownership rather than 10Pessimistic, one editor at a time, enforced by ownership rather than a
11a distinct column. An author could withdraw authorship (the draft 11distinct column. An author could withdraw authorship (the draft
12becomes author-less, open for someone else to pick up) or discard 12becomes author-less, open for someone else to pick up) or discard the
13the draft entirely, reverting to head. 13draft entirely, reverting to head.
14 14
15Admins could override any lock or remove any stuck draft on another 15Admins could override any lock or remove any stuck draft on another
16editor's behalf. No autosave. 16editor's behalf. No autosave.
@@ -38,8 +38,8 @@ the autosave's full content, wholesale, into the draft layer via
38`clone_attributes_from`. 38`clone_attributes_from`.
39 39
40A canonical six-state reference table (head / draft / autosave present 40A canonical six-state reference table (head / draft / autosave present
41or absent, and what each combination permits) lives alongside the model 41or absent, and what each combination permits) lives alongside the
42code as the authority for any future work on this lifecycle. 42model code as the authority for any future work on this lifecycle.
43 43
44### Translations (Globalize), layered on top 44### Translations (Globalize), layered on top
45 45
@@ -51,22 +51,22 @@ writes) are separate concerns that must never share a mechanism.
51 51
52The route's `:locale` segment governs only the former. A locale is 52The route's `:locale` segment governs only the former. A locale is
53either the default one — edited exclusively through the primary node 53either the default one — edited exclusively through the primary node
54editor, pinned via `Globalize.with_locale` regardless of what the route 54editor, pinned via `Globalize.with_locale` regardless of what the
55happens to say, so a stray `/en/` URL can't silently edit the German 55route happens to say, so a stray `/en/` URL can't silently edit the
56content (as has been the case before the rewrite in 2026), or one of 56German content (as has been the case before the rewrite in 2026), or
57the non-default locales, edited exclusively through a Translations 57one of the non-default locales, edited exclusively through a
58sub-resource under a deliberately distinct route parameter 58Translations sub-resource under a deliberately distinct route
59(`translation_locale`), chosen specifically because it must never leak 59parameter (`translation_locale`), chosen specifically because it must
60into `default_url_options` the way the chrome locale does. Never both 60never leak into `default_url_options` the way the chrome locale does.
61paths for the same locale. 61Never both paths for the same locale.
62 62
63For now, admin chrome language stays tied to the route locale for now. 63For now, admin chrome language stays tied to the route locale for now.
64A per-editor preference column was considered and set aside as 64A per-editor preference column was considered and set aside as
65revisit-later, not built. 65revisit-later, not built.
66 66
67Translations carry no independent publish state: a translation goes 67Translations carry no independent publish state: a translation goes
68live exactly when the draft containing it is promoted to head, same 68live exactly when the draft containing it is promoted to head, same as
69as everything else in that `Page` row. Both a per-translation 69everything else in that `Page` row. Both a per-translation
70`published_at` and full per-locale draft/head/autosave parity with 70`published_at` and full per-locale draft/head/autosave parity with
71`Node` were considered and rejected as unneeded machinery absent a 71`Node` were considered and rejected as unneeded machinery absent a
72real editorial need for staggered release timing. 72real editorial need for staggered release timing.
@@ -75,11 +75,11 @@ real editorial need for staggered release timing.
75 75
76### Problem 76### Problem
77 77
78Calendar entries required an internal Node — every event 78Calendar entries required an internal Node — every event needed a page
79needed a page somewhere in the tree. Editors avoided this: either the 79somewhere in the tree. Editors avoided this: either the updates stream
80updates stream absorbed non-news content, or a page got created with 80absorbed non-news content, or a page got created with no natural place
81no natural place in the navigation. The calendar widget went dormant 81in the navigation. The calendar widget went dormant rather than get
82rather than get used under those terms. 82used under those terms.
83 83
84### Rebuild. 84### Rebuild.
85 85
@@ -87,14 +87,14 @@ The Event model was rewritten around the RRULE humanizer
87(`app/models/concerns/rrule_humanizer.rb`) and a picker UI bound to it 87(`app/models/concerns/rrule_humanizer.rb`) and a picker UI bound to it
88by a deliberate scope rule: the picker only generates or reads back 88by a deliberate scope rule: the picker only generates or reads back
89RRULE shapes the humanizer can also render as prose. A picker that 89RRULE shapes the humanizer can also render as prose. A picker that
90could express more than the humanizer can describe would let an 90could express more than the humanizer can describe would let an editor
91editor create a schedule that renders as blank text on the public 91create a schedule that renders as blank text on the public page —
92page — worse than a raw string the editor had to type by hand. 92worse than a raw string the editor had to type by hand.
93`Event#node_id` became optional, so an event can point outward (an 93`Event#node_id` became optional, so an event can point outward (an
94external URL) instead of requiring a page. This is what made reviving 94external URL) instead of requiring a page. This is what made reviving
95the calendar possible at all. 95the calendar possible at all.
96 96
97### The problem that surfaced next 97### The "where to put an event" problem
98 98
99Once events no longer needed a node, turning the calendar on directly 99Once events no longer needed a node, turning the calendar on directly
100would have meant every chapter's regular open evening becoming an 100would have meant every chapter's regular open evening becoming an
@@ -113,3 +113,88 @@ Two widgets, not one:
113 conferences, memorial gatherings. Its CSS carries an unconditional 113 conferences, memorial gatherings. Its CSS carries an unconditional
114 `display: none` with no override anywhere in the stylesheet — not a 114 `display: none` with no override anywhere in the stylesheet — not a
115 bug, a widget staged for a UI that hasn't been built yet. 115 bug, a widget staged for a UI that hasn't been built yet.
116
117## The action log (`NodeAction`)
118
119### Origin
120
121The action log grew directly out of an audit of what timestamps
122actually mean: Two things were missing in the old model: no record of
123who actually clicked Publish (`page.editor` reflects the last content
124save, not necessarily that specific act), and the dashboard's
125recent-changes widget was actively wrong, showing an unrelated
126in-progress draft's byline next to a timestamp that didn't correspond
127to why the node appeared on the list at all.
128
129The log was designed to fill the gap and to answer "what did people
130do," a question the existing timestamp fields were never designed to
131answer.
132
133### Governing principle
134
135A derived log is only as reliable as the discipline that maintains it.
136
137Named explicitly before any code was written, not discovered afterward.
138Three ways it can drift:
139
1401. A future code path can simply forget to write to it — this project
141 had already found that exact shape twice (`wipe_draft!`'s ungated
142 branch; `Page.aggregate` silently ignoring a `conditions=` argument).
1432. If the log write and the actual mutation aren't in the same
144 transaction, they can diverge on a crash. Resolved by writing from
145 *inside* the model methods themselves (`autosave!`, `save_draft!`,
146 `publish_draft!`, `trash!`, `destroy!`) rather than from the
147 controllers that call them — every future caller is covered for
148 free, the same reasoning already proven by the JS-autosave work.
1493. It can never retroactively reconstruct anything from before it
150 existed — addressed deliberately by the backfill (below), not
151 ignored.
152
153### Avoiding noise
154
155A draft save or publish that produces no visible difference shouldn't
156create a visible log entry. Solved by reusing
157`Page#diff_against` / `has_changes_to?` — comparisons that already
158existed and were already tested — rather than inventing new diffing
159logic for the log to own.
160
161### The verb vocabulary
162
163Firstly, the log deliberately excludes locks (transient, no history
164value), autosave and draft-saving itself (exactly the noise the log
165exists to filter out), and tag/asset/event edits made *on a draft*
166(those will later surface as changed-flags at publish time, which
167is when they become real).
168
169Deliberately included, on reflection: reverts and discards (arguably
170more worth tracking than promotions: "who discarded this work, and
171when" is the fact someone would actually go looking for), shared
172preview link generation and revocation (security-relevant, handing
173unpublished content to an outsider), and slug/path changes
174(link-breaking, earns its own record separate from ordinary edits).
175
176### Rollback isn't a separate verb.
177
178`restore_revision!` writes a `publish` entry with a discriminator
179(`via: "revision"` instead of `via: "draft"`) rather than its own
180vocabulary. One verb, one meaning ("a page got promoted to head"),
181distinguished by how it got there.
182
183### Backfill
184
185Historical entries mirror the live vocabulary exactly. Diff content
186(what actually changed) is computed from the real historical revision
187data, since consecutive `Page` rows already existed to compare — only
188the actor and the timestamp are ever inferred, each backfilled entry
189carrying an `inferred_from` field naming the specific heuristic used
190(e.g. "from a page revision," "from `published_at`"). A `null`
191`inferred_from` means the entry was witnessed live, not reconstructed —
192so the log can always answer, for any entry, how much to trust it.
193
194### The full metadata contract per verb
195
196`create`, `publish`, `move`, `trash`, `restore_from_trash`, `destroy`
197— lives as a code comment directly above `record!` in
198`app/models/node.rb`. That's the authoritative, current version; this
199entry explains why it's shaped the way it is, not what it says field
200by field.