diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-19 18:33:34 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-19 18:33:34 +0200 |
| commit | e425bbec2f589f5d13407e80a85b396c4e56ed5e (patch) | |
| tree | 2dfe39c2567fd0c2e0af9d01d37abbd86a24a382 /doc | |
| parent | f9e4623dfc3a1349522ada1be90703bf7160bf06 (diff) | |
Add action log design history to history book
Diffstat (limited to 'doc')
| -rw-r--r-- | doc/DESIGN_HISTORY.md | 135 |
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` | |||
| 7 | editing). | 7 | editing). |
| 8 | 8 | ||
| 9 | No separate lock concept. The draft's *author* was the lock. | 9 | No separate lock concept. The draft's *author* was the lock. |
| 10 | Pessimistic, one editor at a time, enforced by ownership rather than | 10 | Pessimistic, one editor at a time, enforced by ownership rather than a |
| 11 | a distinct column. An author could withdraw authorship (the draft | 11 | distinct column. An author could withdraw authorship (the draft |
| 12 | becomes author-less, open for someone else to pick up) or discard | 12 | becomes author-less, open for someone else to pick up) or discard the |
| 13 | the draft entirely, reverting to head. | 13 | draft entirely, reverting to head. |
| 14 | 14 | ||
| 15 | Admins could override any lock or remove any stuck draft on another | 15 | Admins could override any lock or remove any stuck draft on another |
| 16 | editor's behalf. No autosave. | 16 | editor'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 | ||
| 40 | A canonical six-state reference table (head / draft / autosave present | 40 | A canonical six-state reference table (head / draft / autosave present |
| 41 | or absent, and what each combination permits) lives alongside the model | 41 | or absent, and what each combination permits) lives alongside the |
| 42 | code as the authority for any future work on this lifecycle. | 42 | model 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 | ||
| 52 | The route's `:locale` segment governs only the former. A locale is | 52 | The route's `:locale` segment governs only the former. A locale is |
| 53 | either the default one — edited exclusively through the primary node | 53 | either the default one — edited exclusively through the primary node |
| 54 | editor, pinned via `Globalize.with_locale` regardless of what the route | 54 | editor, pinned via `Globalize.with_locale` regardless of what the |
| 55 | happens to say, so a stray `/en/` URL can't silently edit the German | 55 | route happens to say, so a stray `/en/` URL can't silently edit the |
| 56 | content (as has been the case before the rewrite in 2026), or one of | 56 | German content (as has been the case before the rewrite in 2026), or |
| 57 | the non-default locales, edited exclusively through a Translations | 57 | one of the non-default locales, edited exclusively through a |
| 58 | sub-resource under a deliberately distinct route parameter | 58 | Translations sub-resource under a deliberately distinct route |
| 59 | (`translation_locale`), chosen specifically because it must never leak | 59 | parameter (`translation_locale`), chosen specifically because it must |
| 60 | into `default_url_options` the way the chrome locale does. Never both | 60 | never leak into `default_url_options` the way the chrome locale does. |
| 61 | paths for the same locale. | 61 | Never both paths for the same locale. |
| 62 | 62 | ||
| 63 | For now, admin chrome language stays tied to the route locale for now. | 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 | 64 | A per-editor preference column was considered and set aside as |
| 65 | revisit-later, not built. | 65 | revisit-later, not built. |
| 66 | 66 | ||
| 67 | Translations carry no independent publish state: a translation goes | 67 | Translations carry no independent publish state: a translation goes |
| 68 | live exactly when the draft containing it is promoted to head, same | 68 | live exactly when the draft containing it is promoted to head, same as |
| 69 | as everything else in that `Page` row. Both a per-translation | 69 | everything 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 |
| 72 | real editorial need for staggered release timing. | 72 | real 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 | ||
| 78 | Calendar entries required an internal Node — every event | 78 | Calendar entries required an internal Node — every event needed a page |
| 79 | needed a page somewhere in the tree. Editors avoided this: either the | 79 | somewhere in the tree. Editors avoided this: either the updates stream |
| 80 | updates stream absorbed non-news content, or a page got created with | 80 | absorbed non-news content, or a page got created with no natural place |
| 81 | no natural place in the navigation. The calendar widget went dormant | 81 | in the navigation. The calendar widget went dormant rather than get |
| 82 | rather than get used under those terms. | 82 | used 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 |
| 88 | by a deliberate scope rule: the picker only generates or reads back | 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 | 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 | 90 | could express more than the humanizer can describe would let an editor |
| 91 | editor create a schedule that renders as blank text on the public | 91 | create a schedule that renders as blank text on the public page — |
| 92 | page — worse than a raw string the editor had to type by hand. | 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 | 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 | 94 | external URL) instead of requiring a page. This is what made reviving |
| 95 | the calendar possible at all. | 95 | the calendar possible at all. |
| 96 | 96 | ||
| 97 | ### The problem that surfaced next | 97 | ### The "where to put an event" problem |
| 98 | 98 | ||
| 99 | Once events no longer needed a node, turning the calendar on directly | 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 | 100 | would 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 | |||
| 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. | ||
