The doctrine says what the code does again

Eighteen leaves had drifted behind today's changes, and several taught the exact
mistakes that were just removed.

Corrected, among others: the identity and the profile were conflated, and
`knowledge_context-internals` still described the impersonation fallback and the
principal-to-username join as current mechanisms. `caveat_identity-ids-in-screens`
and `knowledge_data-modes` still had `joinEvent` logging and returning where it
now throws. The shape listings still carried the event host. And
`knowledge_screen-pattern`'s canonical sample taught a toast written beside the
call rather than after the write -- the very bug fixed this afternoon, sitting in
the file a new screen is copied from.

New leaves for what had no home: write rights read from the owned-document
listing, with its three states and its deliberate residual; the owner's ruling
that no "may I write this?" call is coming, so the list is the answer for good;
and the `@data` suite losing its fixtures now that the seed writes nothing into a
connected wallet.

Four doc-debt files settled, including one the hook opened mid-pass.

Worth recording how one leaf died: a caveat was written for the unguarded edit
screen exactly as briefed, then deleted on finding the fix had landed while the
pass ran. Doctrine tracks the tree, not the instructions it was given.
This commit is contained in:
Sylvain Duchesne
2026-08-16 15:25:34 +02:00
parent 13eb2c4a15
commit cebd54c978
29 changed files with 265 additions and 166 deletions
@@ -1,12 +0,0 @@
# Doc-debt — app-architecture
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/screens/EventDetailScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/user/screens/UpdateProfileScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/screens/CreateEventScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/screens/MeetingPointsScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/screens/UpdateEventScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -19,7 +19,7 @@ How the app's code is **structured** and **assembled**. *Feature-based* architec
- [[knowledge_routing]] — path-based routing (History API), route table, hooks
- [[knowledge_screens]] — screen inventory, registry, component library
- [[knowledge_screen-pattern]] — canonical anatomy of a screen (no props, flex layout, showToast)
- [[caveat_identity-ids-in-screens]] — `currentUserId` is the profile document's NURI and is **empty until the protected read lands**; empty reads like "no data"
- [[caveat_identity-ids-in-screens]] — `currentUserId` is empty until my profile document resolves, and an ownership answer can be UNKNOWN; neither means "no"
- [[caveat_boot-unverified-outside-broker]] — the unconditional `ensureIdentity()` await is verified inside the broker iframe; standalone/top-level boot is unverified
- [[knowledge_styling-system]] — `src/index.css`, `app-*` classes, vars, pitfalls (Tailwind unused, `user-content` inert)
- [[cookbook_add-screen]] — procedure for wiring up a new screen (registry + router + shell)
@@ -1,24 +1,30 @@
---
type: caveat
summary: currentUserId is now the profile document's NURI — the same value as currentUser.id — so the old two-id-spaces pitfall is gone; the live hazard is that it is EMPTY until the protected profile read lands, and nothing raises when a screen keys on it too early
last_checked: 2026-08-10
summary: Two values a screen must not read as data — currentUserId is EMPTY until my profile document resolves, and an ownership answer can be UNKNOWN; both look like ordinary values, neither means "no"
last_checked: 2026-08-16
---
# Pitfall: the current user arrives late, and empty reads like a value
# Pitfall: "not answered yet" looks exactly like an answer
## What is true now — one id, not two
Two things a screen receives can be *unresolved*, and in both cases the unresolved form reads like an ordinary value. Nothing throws.
`currentUserId` **is** `currentUser?.id`: the **NURI of the profile document** the app reads back in its own protected scope. The two are no longer distinct spaces, because the app no longer derives a principal from anything it was told — it stopped naming its own identity altogether (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). A participation written today carries that same NURI in `fp:user`.
## `currentUserId` is empty until my profile resolves
> The earlier pitfall — a stable principal `urn:festipod:user:<key>` on one side and a profile NURI on the other, never equal in connected mode — **no longer applies to values written today**. The provider still resolves the older principal form on read (`resolveParticipantUser`, concept `data-layer` → [[knowledge_context-internals]]); a screen never sees it.
`currentUserId` **is** `currentUser?.id`: the NURI of **the profile document I own**. It is not derived from the signed-in identity and shares no id space with it — the identity the session signed in as is opaque, is never rendered, and never travels into a data call (`data-layer` → [[knowledge_context-internals]]). A screen never sees it except as an attribution string.
## The live hazard: `''` before the read lands
Until that profile document resolves — the owned-document listing has to land, and a profile may have to be created — `currentUserId` is **`''`**, a perfectly ordinary empty string.
`currentUserId` is **empty** until the protected profile read resolves — and empty is a perfectly ordinary string. Nothing throws.
- A **query** keyed on it (`getUserEvents`, `isParticipating`, `getFriends`, all defaulting to it) returns an **empty result**, which renders as "you have nothing" instead of "not ready yet".
- A **mutation** that needs it now **rejects** rather than writing a malformed entity: `joinEvent` and `leaveEvent` throw, naming the cause. A caller must therefore *await* them and handle the rejection — the confirmation belongs **after** the write, never beside the call. A screen that fires and forgets shows a success it did not get.
- A **query** keyed on it (`getUserEvents`, `isParticipating`, `getFriends` — all defaulting to `currentUserId`) returns an **empty result** rather than an error, which renders as "you have nothing" instead of "not ready yet".
- A **mutation** that needs it refuses rather than writing a malformed entity: `joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` and returns. A screen that assumed the write happened shows a success it did not get.
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*.
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*. Gate on it before rendering an emptiness verdict or firing a mutation that stores it.
## An ownership answer can be UNKNOWN
The participation→profile join itself is **not** the screen's business: it is done in the provider (`resolveParticipantUser`). Full mechanics and the write/read invariant: concept `data-layer`, [[knowledge_context-internals]].
`getEventOwnership(eventId)` returns `'mine' | 'not-mine' | 'unknown'`, and `unknown` is a **real third answer** — the listing has not landed, or it failed (`data-layer` [[knowledge_write-rights-are-ownership]]).
Rendering it as "not yours" silently denies an owner their own event. Rendering it as a **disabled twin** of the real control is no better: a greyed pencil reads as "edit, broken" and invites a dead click. The slot stays occupied by a distinct *pending* mark, so the layout does not jump and nobody is told a wrong verdict — `app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]].
Never derive permission from `unknown` either. A screen that opens an editor because the answer "was not a refusal" is editing on a guess; the edit route consults the same three-state answer the control does, and renders `unknown` as its own pending state — [[knowledge_screen-pattern]].
> The participation→profile join is **not** the screen's business — it is done in the provider (`resolveParticipantUser`). Full mechanics: `data-layer` → [[knowledge_context-internals]].
@@ -1,7 +1,7 @@
---
type: knowledge
summary: Canonical anatomy of a screen — named function with no props, reads everything through useFestipodData/useNavigate/useParams, flex column layout (Header / scrollable content / BottomNav), feedback via showToast, hard-coded French labels; zero-prop rule has no exception left
last_checked: 2026-08-10
summary: Canonical anatomy of a screen — named function with no props, everything through useFestipodData/useNavigate/useParams, flex column layout, hard-coded French labels; the confirmation FOLLOWS the write, and a write affordance is decided by the three-state ownership answer
last_checked: 2026-08-16
---
# Canonical screen pattern
@@ -18,9 +18,10 @@ export function MyScreen() { // named function, NEVER any props
const [local, setLocal] = useState(); // screen-local state (steps, selections)
const handleAction = () => {
// …mutate through useFestipodData
showToast('Message', 'success'); // feedback
navigate('/path');
// THE CONFIRMATION FOLLOWS THE WRITE — never beside the call.
void Promise.resolve(mutate())
.then(() => { showToast('Message', 'success'); navigate('/path'); })
.catch((err: unknown) => { console.error(); showToast('Échec…', 'error'); });
};
return (
@@ -36,7 +37,9 @@ export function MyScreen() { // named function, NEVER any props
## Invariants
- **Zero props, no exception left**: every registered screen receives nothing; everything comes from context/hooks (`useFestipodData`, `useNavigate`, `useParams`). `WelcomeScreen` does not use `useFestipodData` (intro) — but still takes no props. (`LoginScreen`/`ConnexionScreen`/`AccessGateScreen` no longer exist — Festipod renders no access screen of its own; see [[knowledge_screens]] and [[knowledge_app-shell]].)
- **Identity: the current user may not be there yet.** `currentUserId` is the profile document the app reads back in its own protected scope, so it is **empty until that read lands** — see [[caveat_identity-ids-in-screens]] before keying anything on it.
- **Identity: the current user may not be there yet.** `currentUserId` is the NURI of the profile document this session **owns**, so it is **empty until that document resolves** — see [[caveat_identity-ids-in-screens]] before keying anything on it.
- **The confirmation FOLLOWS the write.** Mutations reject rather than returning quietly, so a screen must `await` (or `.then`) before announcing anything: success toast and navigation on resolve, an error toast on reject, and the user kept on their edits. A toast fired beside the call announces a write that may never have happened — the pattern that had to be corrected on the event and profile edit screens.
- **A write affordance is decided by ownership, in three states.** Both the control that *offers* the write and the screen that *performs* it consult the same answer (`data-layer` → [[knowledge_write-rights-are-ownership]]). `unknown` is never folded into either side: showing the form lets a non-owner edit on a guess, and bouncing them out tells a genuine owner their thing is not theirs. Render it as its own pending state.
- **Layout**: full-height flex column; `Header` at the top, content at `flex:1; overflow:auto`, `BottomNav` at the bottom **only for hub screens** (Home, Events, Profile, Friends). Flow screens (creation, editing, detail) have no `BottomNav`.
- **Feedback**: `showToast(message, 'success'|'info'|'error')` (`ToastContainer` mechanism exported by `sketchy/`).
- **Labels**: **French, hard-coded** — no i18n, no translation keys anywhere in the project.