diff options
Diffstat (limited to 'doc/DESIGN_HISTORY.md')
| -rw-r--r-- | doc/DESIGN_HISTORY.md | 200 |
1 files changed, 200 insertions, 0 deletions
diff --git a/doc/DESIGN_HISTORY.md b/doc/DESIGN_HISTORY.md new file mode 100644 index 0000000..5eb664b --- /dev/null +++ b/doc/DESIGN_HISTORY.md | |||
| @@ -0,0 +1,200 @@ | |||
| 1 | ## The head / draft / autosave / translation lifecycle | ||
| 2 | |||
| 3 | ### Era 1 — the original design (2009, `doc/README_FOR_APP`). | ||
| 4 | |||
| 5 | Two layers only: `head` (the published, public revision) and `draft` | ||
| 6 | (a full copy of head's content, created the moment someone starts | ||
| 7 | editing). | ||
| 8 | |||
| 9 | No separate lock concept. The draft's *author* was the lock. | ||
| 10 | Pessimistic, one editor at a time, enforced by ownership rather than a | ||
| 11 | distinct column. An author could withdraw authorship (the draft | ||
| 12 | becomes author-less, open for someone else to pick up) or discard the | ||
| 13 | draft entirely, reverting to head. | ||
| 14 | |||
| 15 | Admins could override any lock or remove any stuck draft on another | ||
| 16 | editor's behalf. No autosave. | ||
| 17 | |||
| 18 | Globalize wasn't part of the permission model (`Permission` grants | ||
| 19 | admin rights to steal a lock a node. | ||
| 20 | |||
| 21 | ### Why a third layer got added. | ||
| 22 | |||
| 23 | Under the original model, the moment someone started typing, a real, | ||
| 24 | numbered revision already existed: `acts_as_list` assigns revision | ||
| 25 | numbers at creation, scoped to `node_id`. | ||
| 26 | |||
| 27 | A speculative edit (open the editor, type a few words, close the tab) | ||
| 28 | still counted as a real Draft, and a real future revision if it were | ||
| 29 | ever published. | ||
| 30 | |||
| 31 | The rebuild introduced `autosave` as a genuinely separate layer: the | ||
| 32 | same `Page` model, but created with `node_id: nil` specifically so | ||
| 33 | it's excluded from `Node#pages`, the actual revision list. | ||
| 34 | |||
| 35 | Typing progress survives a crash or a closed tab, but nothing becomes | ||
| 36 | a real revision until the editor deliberately saves, which promotes | ||
| 37 | the autosave's full content, wholesale, into the draft layer via | ||
| 38 | `clone_attributes_from`. | ||
| 39 | |||
| 40 | A canonical six-state reference table (head / draft / autosave present | ||
| 41 | or absent, and what each combination permits) lives alongside the | ||
| 42 | model code as the authority for any future work on this lifecycle. | ||
| 43 | |||
| 44 | ### Translations (Globalize), layered on top | ||
| 45 | |||
| 46 | The governing principle for admin views from that rebuild: | ||
| 47 | |||
| 48 | *presentation locale* (what language the admin chrome renders in) and | ||
| 49 | *content-target locale* (which translation a given screen reads or | ||
| 50 | writes) are separate concerns that must never share a mechanism. | ||
| 51 | |||
| 52 | The route's `:locale` segment governs only the former. A locale is | ||
| 53 | either the default one — edited exclusively through the primary node | ||
| 54 | editor, pinned via `Globalize.with_locale` regardless of what the | ||
| 55 | route happens to say, so a stray `/en/` URL can't silently edit the | ||
| 56 | German content (as has been the case before the rewrite in 2026), or | ||
| 57 | one of the non-default locales, edited exclusively through a | ||
| 58 | Translations sub-resource under a deliberately distinct route | ||
| 59 | parameter (`translation_locale`), chosen specifically because it must | ||
| 60 | never leak into `default_url_options` the way the chrome locale does. | ||
| 61 | Never both paths for the same locale. | ||
| 62 | |||
| 63 | For now, admin chrome language stays tied to the route locale for now. | ||
| 64 | A per-editor preference column was considered and set aside as | ||
| 65 | revisit-later, not built. | ||
| 66 | |||
| 67 | Translations carry no independent publish state: a translation goes | ||
| 68 | live exactly when the draft containing it is promoted to head, same as | ||
| 69 | everything else in that `Page` row. Both a per-translation | ||
| 70 | `published_at` and full per-locale draft/head/autosave parity with | ||
| 71 | `Node` were considered and rejected as unneeded machinery absent a | ||
| 72 | real editorial need for staggered release timing. | ||
| 73 | |||
| 74 | ## Events and the calendar | ||
| 75 | |||
| 76 | ### Problem | ||
| 77 | |||
| 78 | Calendar entries required an internal Node — every event needed a page | ||
| 79 | somewhere in the tree. Editors avoided this: either the updates stream | ||
| 80 | absorbed non-news content, or a page got created with no natural place | ||
| 81 | in the navigation. The calendar widget went dormant rather than get | ||
| 82 | used under those terms. | ||
| 83 | |||
| 84 | ### Rebuild. | ||
| 85 | |||
| 86 | The Event model was rewritten around the RRULE humanizer | ||
| 87 | (`app/models/concerns/rrule_humanizer.rb`) and a picker UI bound to it | ||
| 88 | by a deliberate scope rule: the picker only generates or reads back | ||
| 89 | RRULE shapes the humanizer can also render as prose. A picker that | ||
| 90 | could express more than the humanizer can describe would let an editor | ||
| 91 | create a schedule that renders as blank text on the public page — | ||
| 92 | worse than a raw string the editor had to type by hand. | ||
| 93 | `Event#node_id` became optional, so an event can point outward (an | ||
| 94 | external URL) instead of requiring a page. This is what made reviving | ||
| 95 | the calendar possible at all. | ||
| 96 | |||
| 97 | ### The "where to put an event" problem | ||
| 98 | |||
| 99 | Once events no longer needed a node, turning the calendar on directly | ||
| 100 | would have meant every chapter's regular open evening becoming an | ||
| 101 | entry — a handful of one-off items buried under dozens of recurring, | ||
| 102 | low-signal rows. | ||
| 103 | |||
| 104 | ### Resolution: | ||
| 105 | |||
| 106 | Two widgets, not one: | ||
| 107 | |||
| 108 | - `open_erfas_today` took the recurring, date-filtered case — which | ||
| 109 | chapters are open today. Its label went through rejected drafts | ||
| 110 | before landing on phrasing that admits it's a curated sample, not a | ||
| 111 | complete listing. | ||
| 112 | - `div#frontpage_calendar` was reserved for the one-off case — | ||
| 113 | conferences, memorial gatherings. Its CSS carries an unconditional | ||
| 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. | ||
| 116 | |||
| 117 | ## The action log (`NodeAction`) | ||
| 118 | |||
| 119 | ### Origin | ||
| 120 | |||
| 121 | The action log grew directly out of an audit of what timestamps | ||
| 122 | actually mean: Two things were missing in the old model: no record of | ||
| 123 | who actually clicked Publish (`page.editor` reflects the last content | ||
| 124 | save, not necessarily that specific act), and the dashboard's | ||
| 125 | recent-changes widget was actively wrong, showing an unrelated | ||
| 126 | in-progress draft's byline next to a timestamp that didn't correspond | ||
| 127 | to why the node appeared on the list at all. | ||
| 128 | |||
| 129 | The log was designed to fill the gap and to answer "what did people | ||
| 130 | do," a question the existing timestamp fields were never designed to | ||
| 131 | answer. | ||
| 132 | |||
| 133 | ### Governing principle | ||
| 134 | |||
| 135 | A derived log is only as reliable as the discipline that maintains it. | ||
| 136 | |||
| 137 | Named explicitly before any code was written, not discovered afterward. | ||
| 138 | Three ways it can drift: | ||
| 139 | |||
| 140 | 1. 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). | ||
| 143 | 2. 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. | ||
| 149 | 3. 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 | |||
| 155 | A draft save or publish that produces no visible difference shouldn't | ||
| 156 | create a visible log entry. Solved by reusing | ||
| 157 | `Page#diff_against` / `has_changes_to?` — comparisons that already | ||
| 158 | existed and were already tested — rather than inventing new diffing | ||
| 159 | logic for the log to own. | ||
| 160 | |||
| 161 | ### The verb vocabulary | ||
| 162 | |||
| 163 | Firstly, the log deliberately excludes locks (transient, no history | ||
| 164 | value), autosave and draft-saving itself (exactly the noise the log | ||
| 165 | exists to filter out), and tag/asset/event edits made *on a draft* | ||
| 166 | (those will later surface as changed-flags at publish time, which | ||
| 167 | is when they become real). | ||
| 168 | |||
| 169 | Deliberately included, on reflection: reverts and discards (arguably | ||
| 170 | more worth tracking than promotions: "who discarded this work, and | ||
| 171 | when" is the fact someone would actually go looking for), shared | ||
| 172 | preview link generation and revocation (security-relevant, handing | ||
| 173 | unpublished 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 | ||
| 180 | vocabulary. One verb, one meaning ("a page got promoted to head"), | ||
| 181 | distinguished by how it got there. | ||
| 182 | |||
| 183 | ### Backfill | ||
| 184 | |||
| 185 | Historical entries mirror the live vocabulary exactly. Diff content | ||
| 186 | (what actually changed) is computed from the real historical revision | ||
| 187 | data, since consecutive `Page` rows already existed to compare — only | ||
| 188 | the actor and the timestamp are ever inferred, each backfilled entry | ||
| 189 | carrying 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 — | ||
| 192 | so 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 | ||
| 199 | entry explains why it's shaped the way it is, not what it says field | ||
| 200 | by field. | ||
