docs(solutions): compound refresh — fix reference drift, grow CONCEPTS.md

Refresh audit of all 13 docs/solutions learnings against the current
codebase. Core guidance verified accurate everywhere; three docs had
reference drift:

- dual-repo-submodule-workflow: point worktree setup/teardown at the
  make worktree-new/worktree-rm targets (manual procedure misses
  .worktree-env isolation)
- docker-exec-root-owned-bind-mount-files: tracked-plugin list now
  includes entry-actions; fix-perms description matches actual target
- grav-plugin-config-without-code-wont-enable: 3-category model's
  custom-in-repo list now includes entry-actions

CONCEPTS.md: add Container, Content repo, Outer repo, Pin, Env tree,
Remote-only plugin; refresh Active Trip (switching is one setting now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
This commit is contained in:
2026-07-08 23:37:56 +02:00
co-authored by Claude Fable 5
parent f3816bfc3e
commit 24867524a1
4 changed files with 36 additions and 21 deletions
+21 -1
View File
@@ -12,7 +12,7 @@ A **Trip** owns its **Entries** and **Stories**. Exactly one Trip is the **Activ
A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages. A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages.
### Active Trip ### Active Trip
The one Trip currently featured — set in site config and read by the home page and the post form. Switching the Active Trip is a deliberate, multi-file change; if the post form's target and the featured Trip fall out of sync, new posts land under the wrong Trip. The one Trip currently featured — set in a single site-config value and read by the home page and the posting pipeline, which derives the write target for new Entries from it at submit time. Switching the Active Trip is that one setting; there is no separate post-form target to keep in sync.
### Published / Draft ### Published / Draft
A Trip's visibility state. A **Published** Trip is listed publicly and reachable by anyone; a **Draft** Trip is hidden from anonymous visitors in the public trip list, while the signed-in owner still sees it (marked "Draft") and can flip it back. The owner toggles this per Trip from the trip list. A Trip's visibility state. A **Published** Trip is listed publicly and reachable by anyone; a **Draft** Trip is hidden from anonymous visitors in the public trip list, while the signed-in owner still sees it (marked "Draft") and can flip it back. The owner toggles this per Trip from the trip list.
@@ -28,6 +28,26 @@ The Trip's journal section is labelled "Journal" and lives in the Trip's `dailie
### Story ### Story
A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log. A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log.
### Container
A Trip's non-routable holder of child pages — one for Entries, one for Stories. A Container's own URL is deliberately inert (it renders no page of its own), while its children stay individually reachable and are aggregated onto the Trip page. Retiring a view must never delete its Container: the folder half is load-bearing data even when the page half is gone.
## Repos & deployment
### Content repo
The repository holding everything the site serves — pages, configuration, accounts, the theme. It has its own remote and its own release cadence: pushing it triggers production to pull via webhook, independent of the Outer repo.
### Outer repo
The dev-environment repository — tests, docs, scripts, container build — that nests the Content repo and records a Pin to an exact Content-repo commit, expressing "this dev-env state expects this content/theme state."
### Pin
The Outer repo's recorded Content-repo commit (also "pointer bump" for the act of updating it). Routine content churn never moves it; it is bumped once at the end of a cross-repo feature, to a commit already published on the Content repo's main branch. A stale Pin during normal work is expected, not an error.
### Env tree
A server's per-host configuration overlay. Once it exists, Grav's Admin writes **all** config edits there rather than into the shared configuration — so server-side Admin edits are server-only, invisible to content sync, and can hold live secrets. Diagnosing config on a server means checking both the shared configuration and the Env tree, with the Env tree winning at runtime.
### Remote-only plugin
One of the project's three plugin-management categories, alongside GPM-managed (declared in the shared install list and restored by the standard install flow) and custom-in-repo (code tracked in the Content repo). A Remote-only plugin is installed explicitly on servers and restored by **no** standard flow — if its code goes missing it stays missing until someone reinstalls it deliberately, even while its configuration persists in the Env tree.
## Flagged ambiguities ## Flagged ambiguities
- "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity. - "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity.
@@ -56,33 +56,28 @@ Production is unaffected either way: prod pulls `user/` directly via the content
The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions. The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions.
Set up a feature worktree off `main`: **Use the make targets — don't do the steps by hand.** From the main checkout:
```bash ```bash
# outer worktree on a new feature branch make worktree-new NAME=<feature> # create + start its own dev server
git worktree add .worktrees/<feature> -b feat/<feature> main make worktree-rm NAME=<feature> # tear down cleanly
cd .worktrees/<feature>
# populate user/ at the pinned SHA, then branch it for the cross-repo work
git submodule update --init user
git -C user checkout -b feat/<feature>
# its own dev server — separate project name + port from the main checkout's :8081
docker compose -p itte-<feature> up -d
``` ```
`worktree-new` does, in order: `git worktree add .worktrees/<feature> -b feat/<feature> main`, `git submodule update --init user`, branches `user/` onto `feat/<feature>`, writes a git-ignored `.worktree-env` (own compose project name, container name, auto-assigned port `8090+`) so every `make`/compose command run inside that worktree targets its own server, and starts the Grav service. The manual equivalent misses `.worktree-env` — without it, make commands in the worktree hit the main checkout's container on `:8081`.
`.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed). `.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed).
### Teardown ### Teardown
A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so removing the outer worktree needs a second cleanup step: A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so teardown needs a submodule-deinit step before the worktree can be removed — skipping it is what leaves orphaned `.worktrees/` dirs. `make worktree-rm NAME=<feature>` runs the full sequence:
```bash ```bash
docker compose -p itte-<feature> down # what worktree-rm does internally
cd "$(git rev-parse --show-toplevel)" # back to the main checkout make -C .worktrees/<feature> stop # compose down (its own server)
git -C .worktrees/<feature> submodule deinit user # detach the submodule worktree git -C .worktrees/<feature> submodule deinit -f user # detach the submodule worktree
git worktree remove .worktrees/<feature> # remove the outer worktree git worktree remove --force .worktrees/<feature>
git branch -d feat/<feature> # if merged git worktree prune
git branch -d feat/<feature> # manual, if merged
``` ```
### Landing a commit on main without disturbing the main checkout ### Landing a commit on main without disturbing the main checkout
@@ -143,8 +143,8 @@ The reusable principle, worth internalizing beyond this one repo:
- **Don't rely on `APACHE_RUN_USER` or compose-level `UID`/`GID` env vars to fix exec ownership** — they don't apply to `docker exec`. `APACHE_RUN_USER` only affects Apache workers; compose `user:`/env vars only affect services wired to consume them. - **Don't rely on `APACHE_RUN_USER` or compose-level `UID`/`GID` env vars to fix exec ownership** — they don't apply to `docker exec`. `APACHE_RUN_USER` only affects Apache workers; compose `user:`/env vars only affect services wired to consume them.
- **You can't just add `user:` to a service whose entrypoint needs root** (to bind privileged ports, set up cron, etc.). Drop privileges per-exec instead of per-container. - **You can't just add `user:` to a service whose entrypoint needs root** (to bind privileged ports, set up cron, etc.). Drop privileges per-exec instead of per-container.
- **If a tool run as non-root needs writable scratch dirs that are root-owned in the image, chown them container-internally first.** That doesn't touch the host. - **If a tool run as non-root needs writable scratch dirs that are root-owned in the image, chown them container-internally first.** That doesn't touch the host.
- **Root-owned files accumulate invisibly.** (session history) Plugin code under `user/plugins/<name>/` is gitignored by project convention (only `cache-on-save` and `story-blocks` are tracked), so root-owned files pile up in the bind mount without ever appearing in `git status` — they only bite at worktree-removal time. Don't wait for `git status` to reveal them; `find ./user -uid 0 | wc -l` is the real detector. - **Root-owned files accumulate invisibly.** (session history) Plugin code under `user/plugins/<name>/` is gitignored by project convention (only `cache-on-save`, `story-blocks`, and `entry-actions` are tracked), so root-owned files pile up in the bind mount without ever appearing in `git status` — they only bite at worktree-removal time. Don't wait for `git status` to reveal them; `find ./user -uid 0 | wc -l` is the real detector.
- **Keep a `make fix-perms` escape hatch** (`find ./user -uid 0 ... chown`) for residual root files — notably first-boot files the base-image entrypoint writes as root (`config/security.yaml`, `data/api-keys.yaml`), which no `-u` on a make target can reach. After this fix it's a rare mop-up, not a routine step. - **Keep a `make fix-perms` escape hatch** (container-internal `chown -R 1000:1000 /var/www/html`) for residual root files — notably first-boot files the base-image entrypoint writes as root (`config/security.yaml`, `data/api-keys.yaml`), which no `-u` on a make target can reach. After this fix it's a rare mop-up, not a routine step.
- **Verification recipe:** `docker exec -u 1000:1000 <container> touch /mnt/f && stat -c '%u' host/f` should print your uid, not `0`. - **Verification recipe:** `docker exec -u 1000:1000 <container> touch /mnt/f && stat -c '%u' host/f` should print your uid, not `0`.
This lives in the Makefile because make targets are the only sanctioned container interface in this project — the fix belongs there, not in ad-hoc docker commands. This lives in the Makefile because make targets are the only sanctioned container interface in this project — the fix belongs there, not in ad-hoc docker commands.
@@ -89,7 +89,7 @@ How the code went missing here is **unconfirmed**. It happened around the git-sy
- **Check the code layer before the config layer.** When a Grav plugin "won't enable" and config toggles do nothing, FIRST verify the code exists: `ls user/plugins/<name>/` on the server. Config-without-code is the failure class; the empty directory is the tell. - **Check the code layer before the config layer.** When a Grav plugin "won't enable" and config toggles do nothing, FIRST verify the code exists: `ls user/plugins/<name>/` on the server. Config-without-code is the failure class; the empty directory is the tell.
- **Enumerate both layers in all locations when diagnosing.** Plugins have a code layer (`user/plugins/<name>/`) and a config layer, and on prod the config can live in the env tree (`user/env/<host>/config/plugins/<name>.yaml`) and persist completely independently of the code. Remember: once `user/env/<host>/` exists, Grav Admin writes ALL config there, so always check both `user/config/...` and the env path (env wins). - **Enumerate both layers in all locations when diagnosing.** Plugins have a code layer (`user/plugins/<name>/`) and a config layer, and on prod the config can live in the env tree (`user/env/<host>/config/plugins/<name>.yaml`) and persist completely independently of the code. Remember: once `user/env/<host>/` exists, Grav Admin writes ALL config there, so always check both `user/config/...` and the env path (env wins).
- **Know which plugins are remote-only.** The 3-category model: GPM-via-`plugins.txt` (admin2 / api / flex-objects), custom-in-repo (cache-on-save / story-blocks), and remote-only (git-sync — never in `plugins.txt`). Remote-only plugins are NOT restored by the standard install/content flows, so reinstall them explicitly via GPM after any operation that could have wiped `user/plugins/`. - **Know which plugins are remote-only.** The 3-category model: GPM-via-`plugins.txt` (admin2 / api / flex-objects), custom-in-repo (cache-on-save / story-blocks / entry-actions), and remote-only (git-sync — never in `plugins.txt`). Remote-only plugins are NOT restored by the standard install/content flows, so reinstall them explicitly via GPM after any operation that could have wiped `user/plugins/`.
- **Diagnose actual state before proposing config fixes.** An `ls` is cheaper than a guess. Establishing that the code was missing would have pointed straight at the reinstall instead of a round of config poking. - **Diagnose actual state before proposing config fixes.** An `ls` is cheaper than a guess. Establishing that the code was missing would have pointed straight at the reinstall instead of a round of config poking.
## Related ## Related