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:
@@ -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.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Doc-debt — app-security
|
||||
|
||||
> 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/modules/auth/steps/e2e/connexion.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -11,6 +11,7 @@ triggers:
|
||||
Festipod's **security, privacy and authorization** model.
|
||||
|
||||
- **Enforced model** — **isolation between scopes** (public / protected / private) is **enforced by the data SDK** (`@ng-eventually/polyfill`), which exposes to each user only what they are entitled to. The app **trusts** the SDK: no screen carries authorization logic. See [[knowledge_trust-model]].
|
||||
- **Write rights** — only a document's owner writes it, and the owned-document listing is the whole answer; the app reads it to decide what to *offer*, never to enforce. Settled: [[decision_2026-08-16_write-rights-are-the-owned-list]].
|
||||
- **Target authorization matrix** — the detail of *who may do what* per actor × verb (personal data = network, anonymity through the notification inbox): [[brief_2026-05-18_authorization-matrix]]. **Incubating.** It will graduate into `rule_`/`behavior_` as the product settles.
|
||||
|
||||
## Pitfalls
|
||||
@@ -25,5 +26,6 @@ Festipod's **security, privacy and authorization** model.
|
||||
- [[knowledge_authentication]] — wallet-based auth, everyone authenticated, no anonymous access, no screen of Festipod's own
|
||||
- [[decision_2026-08-10_the-barrier-names-no-identity]] — the app names no identity: the barrier takes nothing, signing in is one `ensureIdentity()`
|
||||
- [[decision_2026-08-10_sdk-renders-the-barrier]] — Festipod renders no access screen of its own; the SDK draws whatever a first-time device needs to see
|
||||
- [[decision_2026-08-16_write-rights-are-the-owned-list]] — may-I-write is the owned-document listing and nothing else; no capability probe is planned
|
||||
- [[brief_2026-05-18_authorization-matrix]] — target authorization matrix (incubating)
|
||||
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — which scope for which entity (product fact)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Target authorization matrix per data type (meeting point, registration, event, profile, connection) expressed as public/protected/private + dialog scopes; settled framing decisions (everyone authenticated, public meeting points, personal data = network, notification through an identified-or-anonymous inbox); open questions on the event write model and on host identity
|
||||
summary: Target authorization matrix per data type (meeting point, registration, event, profile, connection) mapped onto the public/protected/private/dialog scopes; framing decisions settled and event update now settled as owner-only; host identity and event deletion still open
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
@@ -68,10 +68,10 @@ Notes: no `C` differentiation (connections are a UI display filter, not a right,
|
||||
|---|---|---|---|
|
||||
| create | ✓ (becomes declarer) | — | ✓ (becomes declarer) |
|
||||
| read / subscribe | ✓ | ✓ | ✓ |
|
||||
| update | ? **to be decided** | ? **to be decided** | ? **to be decided** |
|
||||
| update | ✓ (owner, sole writer) | ✗ | ✗ |
|
||||
| delete | ? **to be decided** | ✗ | ✗ |
|
||||
|
||||
**Open questions:** who may **update** a declared event — the declarer alone (owner)? every user (wiki)? nobody (immutable)? Central to deduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Who may **delete** it, and what becomes of the grafted meeting points (orphaned/cascade/marked deleted)?
|
||||
**Update is settled — owner only**, and forced rather than chosen: only a document's owner writes it and no call adds a writer, so "wiki" is not expressible ([[decision_2026-08-16_write-rights-are-the-owned-list]]). It constrains deduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). **Open:** who may **delete** an event, and what becomes of the grafted meeting points (orphaned/cascade/marked deleted)?
|
||||
|
||||
### User profile
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: decision
|
||||
summary: May-I-write is answered by the list of documents this session owns, and by nothing else — no "may I write this?" call is planned, now or later; the residual window this leaves open is accepted rather than closed
|
||||
---
|
||||
|
||||
# Decision (2026-08-16): write rights are the owned list, permanently
|
||||
|
||||
## Context
|
||||
|
||||
Screens need to know whether this session may **write** an event's document, in order to offer an edit affordance at all. [[contract_polyfill-surface]] leaves exactly one reading of write rights: *"Only a document's owner writes to it. Holding its read key never grants a write"*, and, under non-guarantees, *"No delegated writing. A received key never grants a write, and no call adds a writer to a document."* Owning a document and being able to write it are therefore the same fact, and `storeRegistry.listMyEntityDocs(scope)` is the only call that reports it. No call answers "may I write this?" — the surface publishes none.
|
||||
|
||||
## Decision
|
||||
|
||||
**Ownership, read from the owned-document listing, IS the write right — and that is the permanent answer.** The project owner has ruled that **no capability probe is planned**: Festipod will not ask the provider for a "may I write this?" call, and no future one is being waited on. `listMyEntityDocs('public')` says which events are this session's, and a screen asks nothing else.
|
||||
|
||||
The answer a screen receives is **three-state** — `mine` / `not-mine` / `unknown` — never a boolean. A rejected or not-yet-landed listing means **UNKNOWN**, and the contract is explicit that *"a rejection means 'unknown', never 'absent'"*. Collapsing it into "not mine" is how an owner gets silently told their own event is not theirs.
|
||||
|
||||
## Consequences accepted with it
|
||||
|
||||
- **UNKNOWN renders neither the control nor a greyed twin of it.** A disabled look-alike reads as "edit, broken" and invites a dead click; the slot stays occupied by a distinct pending mark, so an owner is never silently told the event is not theirs. Screen-side rule: `app-architecture` → [[caveat_identity-ids-in-screens]].
|
||||
- **"Not mine" is inferred from ABSENCE**, and absence is not authoritative. The reactive read and the listing are two separate mechanisms, so an event can be on screen a moment before a listing can see it; ruled out in that window, it is only re-examined when some other unclassified event triggers a fresh listing. This residual is **deliberate and stated**, not an oversight.
|
||||
- **The window is not closed**, because closing it needs either a timer — polling, forbidden by `bdd-testing` → [[rule_no-broker-polling]] — or the probe call this decision rules out. Accepting a bounded wrong answer is the arbitration; do not "fix" it with a poll.
|
||||
|
||||
## Rejected alternative
|
||||
|
||||
**Raise the missing probe as a contract gap and wait for it.** Rejected by the project owner: the contract's ownership rule is not an omission, it is the model — a document has one writer, and a list of one's own documents is a complete answer to who that is. Treating it as a gap would keep an affordance permanently provisional against a call that is not coming.
|
||||
|
||||
## Scope
|
||||
|
||||
Applies to every write-affordance question the app asks, not only the event edit pencil. How the answer is derived and where it lives: concept `data-layer` → [[knowledge_write-rights-are-ownership]].
|
||||
@@ -20,9 +20,13 @@ last_checked: 2026-08-10
|
||||
|
||||
**Signing out.** The only logout left is the **wallet session** one (`logoutNg`, offered as « Quitter l'environnement de test » in the settings screen): it stops the shared-wallet session so the next access goes back through the broker. There is no app-level sign-out, because there is no app-level identity to sign out of.
|
||||
|
||||
## Who the current user IS, seen from the app
|
||||
## Who the current user IS — the identity and the profile are two things
|
||||
|
||||
The app does not derive an identity from anything it was told; **what it is, is the profile document it reads back in its own protected scope**. That value is therefore empty until the protected read lands — the mechanics and the hazard that follows live in concept `data-layer`, [[knowledge_context-internals]] and `app-architecture` → [[caveat_identity-ids-in-screens]].
|
||||
**The identity** is what `ensureIdentity()` returns, and nothing else derives it. The contract calls it **opaque**: do not parse it, split it, or render it as a readable name. Festipod holds it for display attribution and logging only, and **never passes it to a data-layer call** — no call takes one.
|
||||
|
||||
**The profile** — pseudo, name, initials — is **Festipod's own object**, not something the SDK knows about. "My profile" is the profile **document I own**, resolved from the owned-document listing; a failed listing leaves the answer UNKNOWN, never "none", and the app never presents somebody else's profile as mine. When a person has no profile, one is created at sign-in with placeholders that read as unset — never a plausible name, never anything derived from the identity.
|
||||
|
||||
The two share no id space and there is **no join between them**. The profile value is therefore empty until that document resolves — the mechanics and the hazard that follows live in concept `data-layer`, [[knowledge_context-internals]] and `app-architecture` → [[caveat_identity-ids-in-screens]].
|
||||
|
||||
**Vocabulary.** `username` designates the profile handle `UserProfile.username` and nothing else. `normalizeIdentifier` (`src/shared/utils/identifier.ts`) is a **pure string normalization** of that handle, applied only to `UserProfile.username` — the join between a profile and the person it belongs to, and the name given when sharing a document with a neighbour. It is never applied to the identity: normalising an identity belongs to the data layer, which the contract states outright, and no configuration hook takes it from us. It names no space, account or session.
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ Principles:
|
||||
|
||||
1. **Isolation is delegated to the SDK.** Every entity lives in the store of its **scope** (public / protected / private, see concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]); the SDK **exposes to the current user only what they are entitled to**. The app assumes that whatever it receives is already authorized — privacy rests on the SDK, not on Festipod code.
|
||||
2. **Screens carry no access rules.** No "is this user allowed to see this data" check in the components, nor in the data context. The public / network / private separation is a property of **placement by scope**, not of an application-level filter.
|
||||
|
||||
The one thing the app *does* read is **write rights**, and it reads them to decide what to **offer**, never to enforce: only a document's owner writes it, so the owned-document listing is the whole answer, in three states ([[decision_2026-08-16_write-rights-are-the-owned-list]]). Enforcement stays below — a screen that got the affordance wrong offers a doomed action, it does not open a hole.
|
||||
3. **The relationship between users ("connections") is an application-level notion.** The contract publishes no connection or friendship primitive: it models reading as **key possession**, and giving someone that key is **one act** — `inbox.share(doc, toUser)`, naming the document and the person. The app therefore **owns** its relationship graph (`src/shared/utils/connections.ts`) and, once a link is two-sided, **shares its own protected documents** with that neighbour. It does not delegate the notion of a relationship, only the **enforcement** of the isolation that follows from it.
|
||||
|
||||
What the app declares to the SDK is now **only those shares**: it declares **no identity** ([[decision_2026-08-10_the-barrier-names-no-identity]]), and it **never handles a key or an inbox address** — neither exists in app code. Sharing is also **irreversible**: the contract publishes no revocation, so an act of sharing is permanent ([[contract_polyfill-surface]]).
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Doc-debt — bdd-testing
|
||||
|
||||
> 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/test-harness/harness-ng.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/test-harness/harness.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/support/hooks.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/workshop/steps/data/multibrowser.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/e2e/evenement.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/e2e/connexion.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/home/steps/e2e/accueil-connecte-rend.steps.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -33,6 +33,7 @@ BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `A
|
||||
- [[knowledge_e2e-layer]] — the `@e2e` layer: the real app inside the iframe
|
||||
- [[knowledge_multibrowser-harness]] — several isolated browsers on the shared wallet (storageState injection); the only way multi-user is exercised
|
||||
- [[caveat_data-scenarios-share-one-wallet]] — a scenario cannot choose its identity, so all of them share one wallet that nothing empties: no per-scenario isolation
|
||||
- [[caveat_data-suite-has-no-fixtures]] — **known, not fixed**: the fixture seed writes nothing into a connected wallet, so `@data` scenarios that assumed seeded data have none
|
||||
- [[caveat_reconnexion-froide-local-vs-broker]] — a "fresh page" is not a cold start: which setup proves broker durability, and which one just re-reads local
|
||||
- [[caveat_first-time-entry-untested]] — **open**: no test proves a first-time device can get into Festipod any more; the SDK's replacement barrier publishes nothing to test against
|
||||
- [[decision_2026-03-12_headless-wallet-creation]] — why the test wallet is created through a headless UI
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Known, not fixed — the fixture seed is off for connected wallets, so every @data scenario that assumed seeded events or profiles now runs against whatever the shared wallet happens to hold; "load test data" is a no-op that reports success
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
# Caveat: the `@data` suite lost its fixtures
|
||||
|
||||
## What changed under it
|
||||
|
||||
No fixture is written into a connected wallet any more, by any route — a product decision enforced in one place (`concept data-layer`, [[knowledge_seed-data]]). The `@data` layer did not ask for that and was not adapted to it.
|
||||
|
||||
## What that does to the suite
|
||||
|
||||
The bridge's `loadTestData()` still resolves, and it reports **`seeded: false`** with no documents created. So:
|
||||
|
||||
- Scenarios that **load test data and then assert on it** (`auth/connexion-nextgraph.feature`: loading the fixtures, the "not reloaded twice" idempotence check, "the events have NextGraph identifiers") no longer have anything to assert on. The call succeeds; nothing is written.
|
||||
- Scenarios whose background **assumes a seeded wallet** ("le portefeuille contient des données de test", "un événement {string} existe" — which seeds on demand when the wallet reads empty) now depend entirely on what the shared wallet happens to already hold.
|
||||
- Nothing raises. A no-op seed reports success, which is the failure mode to expect: a green step followed by an assertion that finds nothing.
|
||||
|
||||
## What NOT to do about it
|
||||
|
||||
**Do not re-enable the seed for the tests, and do not add a test-only bypass of the enforcement point.** The switch is enforced at `bootstrapWallet` precisely so no caller can walk around it, and a harness is a caller like any other. **Do not weaken the affected scenarios into something that passes** either.
|
||||
|
||||
The suite needs scenarios that **create what they need through the app's own path** (the same `createEvent` / `joinEvent` a user drives), rather than a background that assumes a pre-populated wallet. That is the direction; it is not done.
|
||||
|
||||
## Related
|
||||
|
||||
This compounds [[caveat_data-scenarios-share-one-wallet]] — scenarios already could not choose their identity or start from a clean slate, and now they cannot furnish that slate either. Both are open.
|
||||
@@ -22,8 +22,15 @@ summary: How to add a BDD scenario/step — a tagged French .feature, steps per
|
||||
```
|
||||
Always `await` (forgetting it means asserting before the promise resolves).
|
||||
|
||||
6. **If you add a data operation**: expose the helper on `window.__testData` in **both** harnesses (`src/shared/test-harness/harness.tsx` AND `harness-ng.tsx`) — otherwise the mock fallback drifts away from the real broker.
|
||||
6. **⚠️ `waitForFunction` timeout goes in the THIRD slot, not the second.** Playwright's signature is `waitForFunction(pageFunction, arg, options)`. Passing `{ timeout: N }` where `arg` belongs is **not an error**: it is accepted as the page function's *argument*, no options are supplied, and the wait silently uses the **30 s default** while the source reads 5, 10 or 60. When there is no argument to pass, the slot must be filled explicitly:
|
||||
```ts
|
||||
// ❌ await frame.waitForFunction(fn, { timeout: 10000 }) // waits 30 s
|
||||
// ✅ await frame.waitForFunction(fn, undefined, { timeout: 10000 }) // waits 10 s
|
||||
```
|
||||
This had gone unnoticed on **seventeen** calls at once, nine of which meant to wait *less* than the default. It is worth honouring the written number: a wait that is too short fails loudly and names its step, whereas thirty seconds obtained by accident hides a real slowness and makes the source lie. Same family as the pitfall above — both are Playwright argument slots that accept the wrong thing without complaining.
|
||||
|
||||
7. **Wire up a screen under test**: if the French screen name does not resolve to its `id`, add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
|
||||
7. **If you add a data operation**: expose the helper on `window.__testData` in **both** harnesses (`src/shared/test-harness/harness.tsx` AND `harness-ng.tsx`) — otherwise the mock fallback drifts away from the real broker.
|
||||
|
||||
8. **Run**: `bun run test:cucumber` (everything) or `bun run test:data` (@data). Report: `reports/cucumber-report.html`. `@data`/`@e2e` require the test wallet (`bun run test:auth-setup` on the first go if needed, otherwise it is created automatically — see [[decision_2026-03-12_headless-wallet-creation]]).
|
||||
8. **Wire up a screen under test**: if the French screen name does not resolve to its `id`, add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
|
||||
|
||||
9. **Run**: `bun run test:cucumber` (everything) or `bun run test:data` (@data). Report: `reports/cucumber-report.html`. `@data`/`@e2e` require the test wallet (`bun run test:auth-setup` on the first go if needed, otherwise it is created automatically — see [[decision_2026-03-12_headless-wallet-creation]]).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The @data layer — Playwright drives Chromium (persistent profile) into the real broker, which loads harness-ng.tsx in an iframe; automated wallet lifecycle, window.__testData bridge, mock fallback; the harness signs in exactly as the app does, and per-scenario isolation is currently ABSENT
|
||||
last_checked: 2026-08-10
|
||||
summary: The @data layer — Playwright drives Chromium (persistent profile) into the real broker, which loads harness-ng.tsx in an iframe; automated wallet lifecycle, window.__testData bridge, mock fallback; the harness signs in as the app does, per-scenario isolation is ABSENT and the seed now writes nothing
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
# The `@data` layer (real broker)
|
||||
@@ -35,8 +35,8 @@ Cucumber → Playwright (Chromium, persistent profile)
|
||||
- **HTTP server** started in `BeforeAll` (auto-assigned port), serving the HTML plus `/harness.js` (separate files — an inline script breaks because of special characters in the bundle).
|
||||
- **The bridge is the real app path (per entity).** Since the move to *one document per entity* (concept `data-layer`, [[rule_document-per-entity]]), the `window.__testData` bridge (`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/`getEventParticipants`, `loadTestData`) **delegates to the app's data context** (`appData` through `FestipodDataProvider`) — this is the real per-entity path the screens use, not a read at root-store level. It reads `appData` through a **live ref** (a captured snapshot goes stale after a seed re-render).
|
||||
- **The harness signs in exactly as the app does.** It mounts `NextGraphProvider > FestipodDataProvider` — **no identity provider, no default login** — and awaits the single `ensureIdentity()` before exposing the bridge, mirroring the order `AuthGate` imposes (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). Nothing may read before it resolves. The low-level probes that used to reach past the app path are **gone**, along with the scenarios whose subject was the SDK rather than Festipod ([[rule_tests-validate-festipod-not-the-sdk]]).
|
||||
- **Identity before writing.** A `Participation` has a mandatory `fp:user`; the current user is **the profile document read back in the protected scope**, so it lags behind the public events. Steps wait for `ensureCurrentUser()` before `joinEvent` (otherwise the mutation refuses, or writes a participation with no user → dropped on read) and then wait (`waitForFunction`) for the participation to be read back.
|
||||
- **Identity before writing.** A `Participation` has a mandatory `fp:user`, and what goes in it is **the profile document this session owns** — resolved from the owned-document listing, and created at sign-in when there is none, so it lags behind the public events. Steps wait for `ensureCurrentUser()` before `joinEvent`, then wait (`waitForFunction`) for the participation to be read back. Waiting is no longer optional politeness: `joinEvent` and `leaveEvent` now **reject** when the profile is unresolved, so a step that fires too early fails loudly instead of passing over a write that never happened.
|
||||
- **Per-scenario isolation is currently ABSENT — read [[caveat_data-scenarios-share-one-wallet]] before trusting a green run.** The `Before` hook still mints `this.freshIdentifier` and injects it into `localStorage['festipod.account.identifier']`, and several steps re-inject it, but **nothing reads that key any more**: no published call takes an identifier. Every scenario therefore runs as the same identity on one accumulating wallet. That machinery is inert, not load-bearing — do not build new setup on it, and do not "repair" it by making the app honour the key again.
|
||||
- The old per-scenario reset (`resetDataState()`, a SPARQL DELETE on the anchor graph) was dropped for cost (up to 10 s of the `Before` hook's 60 s budget, already eaten by the broker login) and its helper is gone too.
|
||||
- The **physical** growth of the shared wallet was never bounded by any of this — see [[caveat_wallet-bloat-hang]] (profile to be moved aside when reads start to hang).
|
||||
- The connected seed stays **lightweight** (few docs): creating a document is a serial round trip, so the seed's cost is linear in the number of documents it writes.
|
||||
- **The bridge's `loadTestData` no longer writes anything.** No fixture reaches a connected wallet by any route, and the enforcement point is deliberately un-bypassable — so the call resolves, reports nothing seeded, and every scenario that assumed seeded events or profiles is now running on whatever the shared wallet already holds. Read [[caveat_data-suite-has-no-fixtures]] before diagnosing an empty assertion, and do not re-enable the seed for the tests.
|
||||
|
||||
@@ -10,6 +10,7 @@ last_checked: 2026-08-10
|
||||
|
||||
- Helper: `src/shared/test-harness/renderHelper.tsx` (installs the happy-dom globals, wraps the screen). Invoked from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`.
|
||||
- Deterministic fixtures (`src/shared/data/seedData.ts`, see concept `data-layer`): `Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, 5 events, and so on.
|
||||
- **`@ui` is untouched by the connected-wallet seed switch.** No fixture may be written into a *wallet* any more, but `@ui` renders the fixtures straight into React state through `LocalDataProvider` and writes to nothing — so these fixtures are unchanged and stay the layer's ground ([[caveat_data-suite-has-no-fixtures]] is a `@data` problem only).
|
||||
|
||||
## Good assertion patterns
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Doc-debt — data-layer
|
||||
|
||||
> 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/shared/utils/currentPrincipal.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/shapes/shex/festipodShapes.shex @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/utils/autoSeed.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/utils/ngBootstrap.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/utils/ngBootstrap.test.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/data/types.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/data/shapeAdapters.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -17,9 +17,10 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
|
||||
- [[contract_polyfill-surface]] — **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources.
|
||||
- [[knowledge_nextgraph-stack]] — the SHEX shapes, the reactive ORM bindings, `build:orm`, injection through `ngSession`
|
||||
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (seeded local state), how the provider is chosen
|
||||
- [[knowledge_entities]] — the `Fp*` types and their SHEX shapes
|
||||
- [[knowledge_seed-data]] — seed data, `CURRENT_USER_ID`
|
||||
- [[knowledge_context-internals]] — pitfalls of `FestipodDataContext` (who the current user is and when it arrives, the legacy principal space, dev auto-seed, `participantCount`, local no-op)
|
||||
- [[knowledge_entities]] — the `Fp*` types and their SHEX shapes; the generated ORM names carry no `Fp` prefix and are aliased at the import sites
|
||||
- [[knowledge_seed-data]] — the fixtures, and the master switch that keeps them out of any connected wallet
|
||||
- [[knowledge_context-internals]] — pitfalls of `FestipodDataContext` (identity vs profile, which profile is mine and when it arrives, no silent success, the legacy participation id space, `participantCount`, local no-op)
|
||||
- [[knowledge_write-rights-are-ownership]] — may I write this? is answered by the owned-document listing, in three states
|
||||
|
||||
## Write rules
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: The FpEventData type and the seed carry startDate/endDate/startTime/endTime/themes, but the Event SHEX does not define them — these fields are silently lost in connected mode (NextGraph)
|
||||
last_checked: 2026-06-15
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
# Caveat: event fields not persisted in connected mode
|
||||
|
||||
The app type `FpEventData` (`src/shared/data/types.ts`) and the seed (`seedData.ts`) carry the fields **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — but the **SHEX `Event` shape** (`src/shared/shapes/shex/festipodShapes.shex`) does **not** define them. The shape covers exactly (verified 2026-08-10 in the `.shex`): `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials`, plus an optional `inbox`.
|
||||
The app type `FpEventData` (`src/shared/data/types.ts`) and the seed (`seedData.ts`) carry the fields **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — but the **SHEX `Event` shape** (`src/shared/shapes/shex/festipodShapes.shex`) does **not** define them. The shape covers exactly: `title, description, date, location, distance, participantCount, coverImage`, plus an optional `inbox`.
|
||||
|
||||
> **No host field, and none is missing.** The shape used to carry `hostName`/`hostInitials`, and every created event was written with a fabricated value. They are gone — from the shape, the bindings, the type, the adapters, the writes and the screens — because **an event has no host**: it is only the anchor, and its declarer is not required to attend (concept `functional-domain`, [[knowledge_actors-and-concepts]]). `fp:MeetingPoint.host` stays; that one is real. Do not "restore" a host on the event.
|
||||
|
||||
> That `inbox` field is a **vestige, and it must stay unused**: it was there to publish an event's inbox address so others could deposit into it. The app no longer handles an inbox address anywhere — a deposit **names the document** (`inbox.postToDocument(doc, …)`) and the owner opens its own with `openDocumentInbox(doc)`. Writing an address into the entity would put back exactly what the surface removed ([[rule_document-per-entity]]).
|
||||
|
||||
|
||||
@@ -1,94 +1,91 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Internal pitfalls of FestipodDataContext — currentUserId is the profile document read back in the protected scope (empty until it lands), the legacy principal space resolveParticipantUser still resolves on read, OPT-IN auto-seed, owner-derived participantCount, local-mode no-op mutations
|
||||
last_checked: 2026-08-10
|
||||
summary: Internal pitfalls of FestipodDataContext — the signed-in identity and the profile are two unrelated things, "my profile" is the profile document I own, mutations reject instead of succeeding silently, participantCount is derived by the event's owner, and local mode is a no-op
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
# Internals & pitfalls of `FestipodDataContext`
|
||||
|
||||
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
|
||||
|
||||
## Who am I — `currentUserId` is a document you read back, not a value you were given
|
||||
## Identity and profile are TWO things — never join them
|
||||
|
||||
**The app names no identity of its own** (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]): `ensureIdentity()` takes nothing, and nothing switches identity afterwards. So the provider cannot *derive* a principal from an input. What the current user **is**, is the **profile document it reads back in its own protected scope**: `currentUserId` = that profile's `@id`, a doc NURI — the very same value as `currentUser?.id`.
|
||||
**The identity** is what `ensureIdentity()` returns: an opaque value, published to the tree by `src/shared/utils/currentPrincipal.ts` (a module store, not a context — the component that awaits sits *inside* the data provider, so a context it published would be invisible to its own consumer). It is **for display and log attribution only**. It is never parsed, never rendered as a name, never written into an entity, and **never passed to a data-layer call** — placement is named by scope alone, so handing it back would recreate the parameter the surface deliberately removed ([[contract_polyfill-surface]]).
|
||||
|
||||
**The pitfall that follows**: it is **empty until the protected read lands**, and empty is an ordinary string that raises nothing. Mutations needing it **refuse** (`joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` rather than writing an entity that would be dropped on read); queries keyed on it return **empty results** that render as "you have nothing". Treat `''` as *not ready*, never as *no data* — see `app-architecture`, [[caveat_identity-ids-in-screens]].
|
||||
**The profile** — pseudo, name, initials — is **Festipod's own object**, in a document the app creates and writes. `currentUserId` is that document's NURI, the same value as `currentUser?.id`, and the only value a mutation may write into a `Participation`'s `fp:user`.
|
||||
|
||||
> The `currentUser` object is picked with a **fallback** (`@mariedupont`, then `users[0]`) — a leftover of the demo seed, and a silent one: on a wallet holding several profiles it can settle on the wrong person. Worth a look whenever "the app thinks I am someone else".
|
||||
**There is no join between the two, and there must not be one.** The identity says nothing about the profile. Never compare the principal to an entity id, and never match it against a profile field to decide who the current user is.
|
||||
|
||||
## The legacy principal space — resolved on READ only
|
||||
## "My profile" is the profile document I OWN
|
||||
|
||||
A `Participation` written **today** carries `currentUserId` in `fp:user`, i.e. a profile doc NURI, so a direct `participation.userId === profile.id` join matches. Participations written under the **earlier** scheme carry a principal of the form `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
|
||||
`listMyEntityDocs('protected')` answers *which documents are mine*, and the UserProfile among them is mine. **No field of any profile takes part**: no username comparison, no normalization, no positional pick.
|
||||
|
||||
**`resolveParticipantUser`** (`FestipodDataContext`) is the single join point, and it tries, in order: (1) a **direct match** `u.id === userId` — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and match the remainder against `normalizeIdentifier(profile.username)` — the legacy space. Never join by direct comparison at a call site: the symptom of getting it wrong is every participant rendering as « participant inconnu », which shipped once already.
|
||||
A failed listing is **UNKNOWN, never "none"** — the set stays unresolved, no profile is chosen and none is created, and the failure is retried then said loudly. Reading a rejection as "I own nothing" would create a second profile for someone who already has one.
|
||||
|
||||
`USER_PRINCIPAL_PREFIX` is now **read-side only** — nothing mints it any more. It is kept so old data still resolves; it is not a shape to write against.
|
||||
Four outcomes, and *somebody else's profile* is not one of them:
|
||||
|
||||
A further id space takes **no** part in this join: the inbox deposit `uid` (`mint…`) — it identifies a **deposit** for the counter, never a user.
|
||||
|
||||
> **Horizon.** This paragraph describes **what is implemented**. The target model drops the plaintext `userId` and routes identity resolution through **reading the profile** — see [[brief_2026-07-20_attendance-set-model]], whose implementation is gated. The id-space fix is explicitly noted there as **still valid**: do not undo it in anticipation of the target.
|
||||
|
||||
### Which space each query expects (the `buildQueries` contract)
|
||||
|
||||
| Query | What it expects / returns |
|
||||
| Owned profiles | Answer |
|
||||
|---|---|
|
||||
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | filter on `participation.userId` / `friendship.userId`; their default is `currentUserId`, which is correct |
|
||||
| `getEventParticipants(eventId)` | **returns profiles** (`FpUserData` → `id` = NURI), the join being done internally |
|
||||
| listing unresolved | UNKNOWN — nothing resolved, nothing created |
|
||||
| none | I have no profile yet → one is created (below) |
|
||||
| exactly one | that is me |
|
||||
| several, none created by this session | the **first by document reference** — stable across reloads, openly arbitrary, warned about once |
|
||||
|
||||
**Screen-side impact**: `currentUserId` and `currentUser?.id` are now the same value, so filtering yourself out of a participant list works either way. What still bites is passing an id **before it resolves** — see `app-architecture`, [[caveat_identity-ids-in-screens]].
|
||||
The last row is a reloaded wallet carrying a fixture seed. The pick carries no meaning, so it is logged as demo data rather than presented as you; **every candidate is a document I own**, which is what separates it from the impersonation that was removed — that one reached for a profile by *name* and could land on a stranger's document. Delete the branch the day a profile is really created and known.
|
||||
|
||||
## Reads = `watchShape` (the SDK surface), no more bespoke machinery
|
||||
> **Two impersonation fallbacks are gone**, including one in `updateProfile` that would have written your pseudo into a stranger's document. Having no profile now resolves to *having no profile*. Do not reintroduce a "pick something plausible" fallback anywhere on this path.
|
||||
|
||||
**Since 2026-07-10**: `useNgData` reads through `useShapeQuery(shape, scope)` (a `useSyncExternalStore` binding over `watchShape`) — THREE useQuery-shaped reads (events/public, users/protected, participations/protected) + Fp adapters (`shapeAdapters.ts`). Removed: `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`, the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and `relist`. `ready` = the combination of the `isSuccess` flags. See [[rule_app-uses-sdk-surface-only]].
|
||||
## A profile is created at sign-in when there is none
|
||||
|
||||
**Immediate visibility of mutations = an OPTIMISTIC overlay** (no `registerDoc`): `createEvent`/`joinEvent`/`leaveEvent` feed `pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds`; the exposed state = merge(reactive, adds) minus removes, deduped by id (id = the doc's NURI). Reconciliation happens automatically on push (an add that shows up in the reactive state, or a remove that disappears from it, is dropped) — never a poll ([[rule_no-broker-polling]]).
|
||||
Gated on **both** the protected read having settled (`isSuccess` — synced-and-empty, not still-syncing) **and** the owned-document set being known, because "I have no profile" is only true when both have answered. Single-shot per session; on failure the guard is released so a later change retries.
|
||||
|
||||
## Dev auto-seed
|
||||
The UserProfile shape makes `name`, `initials` and `username` **mandatory**, so the profile cannot be written empty. The three fields carry **placeholders that read on screen as "not filled in yet"** — never a plausible human name, never a handle, and **never anything derived from the opaque identity**. The user replaces them through `updateProfile`.
|
||||
|
||||
**Since 2026-07-13 the auto-seed is OPT-IN and OFF by default**: it only fires if the `FESTIPOD_AUTO_SEED` env var is set (`=1`), no longer off `NODE_ENV`. Var absent → **no automatic seed at all**, even in dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts`; delivered in dev through the `/festipod-config.json` runtime route + a compile-time `define` in `build.ts`, the same mechanism as the shared wallet — see `tech-stack/knowledge_build-pipeline`). The **explicit** seed (`loadTestData()`, @data tests) is unchanged. Rationale: the repeated auto-seed was bloating the wallet (slow reads, see [[caveat_wallet-bloat-hang]]).
|
||||
## Nothing succeeds in silence
|
||||
|
||||
When it is enabled, the auto-seed fires if events AND users are both empty — **gated on `isSuccess`** (`watchShape`'s readiness), NO LONGER on a 3s `setTimeout`: we only decide "the wallet is empty" once the sync is **confirmed** (`isSuccess`), otherwise a not-yet-finished read was taken for an empty wallet → a re-seed on every reconnection (bug fixed). Remaining pitfalls:
|
||||
- **One seed at a time**: `loadTestData()` sets `hasTriedAutoSeed`, and the auto-seed re-checks it → an explicit load cancels the pending auto-seed (otherwise two concurrent `bootstrapWallet` calls write everything twice).
|
||||
- The seed writes under the **connected session**, so the session that seeds **holds** what it seeded and its protected fixtures round-trip. Seeded users are fixtures, not accounts — nobody has signed in as them, which matters because `inbox.share` refuses a recipient nobody has ever been. Only **events** get an inbox opened at seed time (`openDocumentInbox`), because events are what people deposit into.
|
||||
- **No retry**: if the seed fails, you get an empty screen + a `console.error`.
|
||||
Mutations on the create/participate path **reject** rather than returning quietly, and the screen's confirmation **follows** the write:
|
||||
|
||||
## `participantCount` — derived and owned by the owner
|
||||
- `joinEvent` refuses when no profile of mine is resolved: a `Participation` needs `fp:user`, and one written without it is dropped on read — a sign-up that wrote nothing, threw nothing, and let the screen congratulate the user. It now throws, naming the cause. `leaveEvent` likewise, because withdrawal must be authoritative ([[caveat_participation-deletion]]).
|
||||
- Idempotence is checked **authoritatively against the broker**, not against the reactive set, which can lag a just-written participation. A **failed** count is UNKNOWN and is deliberately *not* swallowed — reading it as zero is exactly how a duplicate gets written.
|
||||
- **The deposit IS the delivery.** A host-facing notification is no longer minted at join time. It used to be written into the *joiner's* own protected scope with `recipient` set to the event — a document the host can never read — and pushed into the joiner's own list, so the joiner saw a "new participant" notice addressed to someone else. Both are gone: `inbox.postToDocument(doc, …)` carries the news, and the owner builds the notification from the deposits it reads on its own event's inbox.
|
||||
- The creator signs up through the **common path** — no owner branch, no special case, the same deposit and the same derived count.
|
||||
|
||||
> ✅ **CORRECTED (2026-07-13).** The requirement is **"reliable at the owner's NEXT CONNECTION"** (the creator processes their inbox when they connect), NOT a live real-time cross-user notification. The bug was: the owner-materializer materialized **too early** (before the participant's deposit had synced) → read `active=0` → wrote 0 → **memoized that 0** → never re-processed. Fix: (1) read through `inbox.readSynced` instead of `inbox.read` — the two differ by contract, and only the former is the synced view ([[contract_polyfill-surface]]); (2) the materializer fires **directly on connection** (`[ready, ownedKey]`), no longer only on a push; (3) `materializedCountRef` no longer locks in a premature 0 (its sole role = loop guard: only write when the derived value changes); (4) **the single source of the NUMBER = `event.participantCount`** (the `participantCount: 1` literal in `CreateEventScreen` is removed → it starts at 0; the display no longer computes a local number). Kept GREEN (on a fresh profile) by `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (un-`@wip`'d). No polling ([[rule_no-broker-polling]]).
|
||||
## The legacy participation id space — resolved on READ only
|
||||
|
||||
**Since 2026-07-07**: `participantCount` is no longer mutated in place by the participant. The flow is inbox-deposit → owner-materialization:
|
||||
- `joinEvent`/`leaveEvent` **no longer** write `participantCount` on the event's doc — **only a document's owner writes to it**, so a participant cannot touch someone else's. The participant only writes their **own** participation doc (protected), then **deposits** a marker into the event's inbox (`depositRegistration` on join, `depositLeave` on leave, `src/shared/data/registration.ts`).
|
||||
- The event **owner's** session does the materializing: it watches (`inbox.watch`, no polling) the inbox of the events it owns (`ownedEventIds` = `listMyEntityDocs('public')` + freshly created events), and on every deposit it **recomputes** `participantCount` on **its own** event doc (`updateEntityField` on its own doc). It is the counter's only writer.
|
||||
- **The counter is DERIVED, not incremented**: `materializeAttendance` (registration.ts) reads the inbox and computes the **set** of distinct active sign-ups (`new-participant` deposits deduped by `uid`, MINUS those cancelled by a `leave-participant` — by exact `regUid` or by the `(eventId, userId)` fallback). `participantCount = |active set|` — **no host baseline**: the creator does not attend automatically (there is no notion of host, see concept `functional-domain`), so the counter starts at **0** on creation and only moves on real sign-ups. `createEvent` **no longer writes** a participation at creation time (it used to write a host participation and set the counter to 1); the creator sees « J'y serai » and can join/leave their own event like anyone else. Because it is a **pure function of the inbox**, a replay converges — never double-counting nor a phantom decrement (idempotence). The write is guarded (it only writes when the value changes), a loop guard. Covered by the `@data` scenario « Le créateur ne participe pas automatiquement à son événement » (us-13): counter 0 + `isParticipating(E)===false` at creation, then join→true / leave→false.
|
||||
- **Owner offline = eventual**: only the owner's session materializes; while they are disconnected, the counter does not move for anyone else (the participations and deposits stay persisted — nothing is lost).
|
||||
- The counter nevertheless remains an **aggregate**, not the list of named participants: `getEventParticipants` (named identity) is still governed by what the protected scope hands back ([[caveat_participation-deletion]] for the authoritative deletion, unchanged).
|
||||
A `Participation` written **today** carries the profile document's NURI in `fp:user`, so the direct join `u.id === userId` matches. Participations written under the **earlier** scheme carry `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
|
||||
|
||||
### Id-form invariant: match on the CANONICAL form of the event id
|
||||
**`resolveParticipantUser`** is the single join point and tries, in order: (1) the direct id match — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and compare the remainder to `normalizeIdentifier(profile.username)`. Never join by direct comparison at a call site: getting it wrong renders every participant as « participant inconnu », which shipped once. `USER_PRINCIPAL_PREFIX` is **read-side only** — nothing mints it any more; it is not a shape to write against. The inbox deposit `uid` (`mint…`) is a third space that takes **no** part: it identifies a deposit for the counter, never a user.
|
||||
|
||||
An event's `@id` **is** its document NURI (`did:ng:o:<repo>[:v:<overlay>]`). The owner's materializer matches the inbox **deposits** to the owned events **by event id**: `ownedEventIds` (what the materializer iterates over), the **deposit key** (`payload.eventId`, what the participant deposits under) and the counter's **write target** must all designate the same event.
|
||||
> **Horizon.** The target model drops the plaintext `userId` and resolves identity by reading the profile — [[brief_2026-07-20_attendance-set-model]], gated. The id-space fix is noted there as still valid: do not undo it in anticipation.
|
||||
|
||||
**Measured finding (2026-07-07)**: on the current tree these three paths carry the **same** NURI (the `:v:<overlay>` suffix included) — create-time, `listMyEntityDocs` and the `@id` read back all coincide, because `readUnion` **pins the subject to the input NURI** (lib `read-model.ts`, `63ecfee`). So matching already works, **including** for an owned event reached through `listMyEntityDocs` (validated by the @data scenario « …fait converger le compteur dérivé »). The canonicalization below is **defensive**, not the fix for an active bug. (The mismatch one investigation thought it had seen was the **seeded-but-not-owned** artifact: on a persistent wallet, the seed belonged to a `test-*` identity from an earlier run → the current session reaches it through discovery, not through `ownedEventIds` — correct behaviour.)
|
||||
## Reads = `watchShape`, writes = an optimistic overlay
|
||||
|
||||
**Rule**: match the event id on its **canonical form** — the base repo id, with any `:v:<overlay>` suffix stripped (`canonicalEventId`, `src/shared/data/registration.ts`). This canonical form is used for **matching** in `materializeAttendance` / `readRegistrationNotifications`, and for **deduplicating** `ownedEventIds` (`ownedKey`, FestipodDataContext) so that one and the same event reached through two paths is not materialized twice. **Careful**: only the **matching** uses the stripped form; the counter is always **written** to the real owned NURI (a live, openable doc) — a stripped id must never serve as a write target or an anchor. This is an **app-side** invariant (not a NextGraph detail): however the lib makes the overlay vary, the app matches on the common base.
|
||||
Reads go through `useShapeQuery(shape, scope)` — three scoped reads (events/public, profiles/protected, participations/protected) mapped by `shapeAdapters.ts`; `ready` combines their `isSuccess` flags. The app resolves, lists and re-queries nothing ([[rule_document-per-entity]] §Reads).
|
||||
|
||||
## There is no identity switch any more
|
||||
**Immediate visibility of a mutation is a pure optimistic overlay**: `createEvent`/`joinEvent`/`leaveEvent`/profile creation feed `pendingAdd*` / `pendingRemoveIds`; the exposed state is merge(reactive, adds) minus removes, deduped by id. Reconciliation is automatic on push — never a poll ([[rule_no-broker-polling]]).
|
||||
|
||||
The app settles its identity **once**, before anything renders (`ensureIdentity()` in `AuthGate`), and offers no way to change it — the surface stopped publishing one (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). So the provider carries **no identity-change reset**: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Those symbols are gone; do not reintroduce a reset for a transition that cannot happen.
|
||||
## `participantCount` — derived, and written only by the event's owner
|
||||
|
||||
> **Why there is nothing left to reset.** An identity-change reset only made sense while a single React tree could outlive a change of identity. It cannot: one page hosts exactly one identity for its whole life, so session state (the read set, the optimistic overlay, the owner-materializer's doc set) has no second identity to leak into.
|
||||
>
|
||||
> The **cross-identity isolation** behaviour is still a real Festipod requirement, but proving it needs **two genuinely separate browser contexts**, each signing in for itself. `event/isolation-deux-identites.feature` is `@wip` for exactly that reason (concept `bdd-testing`, [[rule_tests-validate-festipod-not-the-sdk]]).
|
||||
The counter is **not** incremented by whoever joins: only a document's owner writes to it. The flow is deposit → owner-materialization.
|
||||
|
||||
## `useShapeQuery` instrumentation — global spinner + timing
|
||||
- A participant writes their **own** participation document (protected), then **deposits** a marker into the event's inbox (`depositRegistration` / `depositLeave`, `src/shared/data/registration.ts`).
|
||||
- The event **owner's** session watches the inboxes of the events it owns (`inbox.watch`, no polling) and **recomputes** `participantCount` on its own event document. It is the counter's only writer, and it reads through `inbox.readSynced` — the synced view — not `inbox.read`.
|
||||
- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. Because it is a pure function of the inbox, a replay converges: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change.
|
||||
- **Owner offline = eventual.** While the owner is disconnected the count does not move for anyone else; nothing is lost. The materializer fires directly on connection, not only on a push, and it never locks in a premature 0.
|
||||
- The counter is an **aggregate**, not the list of named participants — `getEventParticipants` is governed by what the protected scope hands back.
|
||||
|
||||
`useShapeQuery` (a `useSyncExternalStore` binding over `watchShape`) instruments **every query cycle**: at the start of a cycle it registers itself in a module-level store `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, a Set of ids — idempotent, safe under StrictMode), and on the first `isPending → isSuccess|isError` transition (the "first result", the readPromise equivalent) it resolves AND logs the delay: `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (so the delay for Event/public events is visible by name). The `cycleId` is memoized on `[shapeKey, scope]` → an identity/scope switch recreates the observable AND starts a new cycle (a fresh `beginQuery`), and the cleanup resolves on unmount (never stuck). The `usePendingQueries()` hook exposes the number of pending queries; `HomeScreen` renders a `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` in `index.css`) next to the « Festipod » title as long as the count is > 0 → it only stops once **all** in-flight queries have received their first result. Any future `useShapeQuery` contributes to it automatically. The measurement lives on the app side (React-perceived delay), **not** in the polyfill.
|
||||
Which event a deposit belongs to is matched on the **canonical id-form** — see [[knowledge_write-rights-are-ownership]] §Matching, which governs every event-id comparison in this file.
|
||||
|
||||
## Logging convention — identity-first prefix, and counter before→after
|
||||
## Logging convention — identity-first, and the counter before→after
|
||||
|
||||
Every DATA log from the provider goes through **`logPrefix`**: `[<currentUserId>][app][data]` when the principal is resolved, `[app][data]` otherwise (a transient connection state). Reason: a run often drives **several sessions at once** (two tabs, a multi-browser scenario) and their lines end up read side by side — an unprefixed line does not say *whose* it is and becomes useless for diagnosing a leak or a stuck counter. **Adding a DATA log = reusing `logPrefix`**, not a bare `console.log`.
|
||||
Every DATA log goes through **`logPrefix`**: `[<currentUserId or principal>][app][data]`. A run often drives several sessions at once and their lines are read side by side, so a line must say *whose* it is. Adding a DATA log means reusing `logPrefix`, not a bare `console.log`.
|
||||
|
||||
Two measurement points are laid down **as a pair** and serve together: the owner's materializer logs `participantCount` **before → after** its write, and the display read logs the value **as exposed to the render**. Comparing them tells a stuck counter apart between a **DATA** problem (never incremented) and a **DISPLAY** problem (incremented but not re-read until the next session). Do not remove one without the other — on their own they diagnose nothing.
|
||||
Two measurement points are laid down **as a pair**: the owner's materializer logs `participantCount` before → after its write, and the display read logs the value as exposed to the render. Together they separate a **data** problem (never incremented) from a **display** problem (incremented but not re-read). Do not remove one without the other — alone they diagnose nothing.
|
||||
|
||||
## There is no identity switch, and nothing to reset
|
||||
|
||||
The app settles its identity once, before anything renders, and offers no way to change it (`app-security` → [[decision_2026-08-10_the-barrier-names-no-identity]]). One page hosts exactly one identity for its whole life, so there is no identity-change reset: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Do not reintroduce a reset for a transition that cannot happen. Cross-identity **isolation** is still a real requirement, but proving it needs two genuinely separate browser contexts (`bdd-testing` → [[rule_tests-validate-festipod-not-the-sdk]]).
|
||||
|
||||
## Mutations are no-ops in local mode
|
||||
|
||||
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a `console.log`, the state does not change) — yet the screens still show a **success toast** (« Tu participes »). Potentially misleading UX: the user believes they signed up when nothing has changed. See [[knowledge_data-modes]] for how the provider is chosen based on status.
|
||||
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a log, no state change) — yet the screens still show a success toast. Misleading UX, unchanged. See [[knowledge_data-modes]].
|
||||
|
||||
@@ -25,4 +25,4 @@ The app has **two modes**, both consumed through the `useFestipodData()` hook:
|
||||
- `connected` → `NgDataProvider` (real wallet data)
|
||||
- `error` → `LocalDataProvider` with the seed (graceful fallback)
|
||||
|
||||
> Mutations are **genuinely persisted** in connected mode (`joinEvent` writes a Participation and notifies the meeting point's host, `leaveEvent` deletes authoritatively — see [[caveat_participation-deletion]]). In local/demo mode they are no-ops (see [[knowledge_context-internals]]).
|
||||
> Mutations are **genuinely persisted** in connected mode: `joinEvent` writes a Participation into its own document and **deposits** into the event's inbox (the deposit is the delivery — no notification is written for the host), `leaveEvent` deletes authoritatively (see [[caveat_participation-deletion]]). Both **reject** rather than returning quietly when they cannot write, and the screen's confirmation follows the write. In local/demo mode they are **no-ops that still show a success toast** — see [[knowledge_context-internals]].
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The Fp* data types — Event, UserProfile, Participation, MeetingPoint and Notification are persisted in NextGraph (SHEX shapes + ORM); only Friendship stays local-only (app-TS)
|
||||
last_checked: 2026-07-03
|
||||
summary: The Fp* app types and their SHEX shapes — Event (no host), UserProfile, Participation, MeetingPoint and Notification are persisted, Friendship stays local-only; the generated ORM names carry NO Fp prefix and are aliased at the import sites
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
# Data entities
|
||||
|
||||
`src/shared/data/types.ts`:
|
||||
`src/shared/data/types.ts` holds the app's own types; `src/shared/shapes/shex/festipodShapes.shex` holds what is actually persisted.
|
||||
|
||||
| Type | Persistence | Key fields |
|
||||
|---|---|---|
|
||||
| `FpEventData` | SDK (Event shape) | id, title, date, location, distance, themes |
|
||||
| `FpUserData` | SDK (UserProfile shape) | id, name, username, bio, city, counts |
|
||||
| `FpParticipationData` | SDK (Participation shape) | eventId + userId + confirmed |
|
||||
| `FpMeetingPointData` | SDK (MeetingPoint shape) | eventId, location, time, host |
|
||||
| `FpNotificationData` | SDK (Notification shape) | kind, target, source |
|
||||
| `FpEventData` | SDK (Event shape) | title, date, location, distance, participantCount, coverImage |
|
||||
| `FpUserData` | SDK (UserProfile shape) | name, initials, username, role, isPublic |
|
||||
| `FpParticipationData` | SDK (Participation shape) | event + user + isConfirmed |
|
||||
| `FpMeetingPointData` | SDK (MeetingPoint shape) | event, host, title, place, time |
|
||||
| `FpNotificationData` | SDK (Notification shape) | recipient, type, ref, payload, timestamp, isRead |
|
||||
| `FpFriendshipData` | **local-only** | userId + friendId |
|
||||
|
||||
`MeetingPoint` and `Notification` do have real **SHEX shapes** (`src/shared/shapes/shex/festipodShapes.shex`) with generated ORM bindings (`festipodShapes.shapeTypes.ts`: `FpMeetingPointShapeType`, `FpNotificationShapeType`) and **are persisted**. A `Notification` is created in particular when signing up to a meeting point (`joinEvent`).
|
||||
**An event has no host.** `hostName`/`hostInitials` are gone from the type and the shape alike — the event is only the anchor, and the host lives one level down on the meeting point (`FpMeetingPointData.hostId`, SHEX `fp:MeetingPoint.host`). See concept `functional-domain`, [[knowledge_actors-and-concepts]].
|
||||
|
||||
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_nextgraph-stack]]).
|
||||
**A Notification is no longer created when someone signs up.** The joiner deposits into the event's inbox and the **owner** builds the notification from what it reads there — see [[knowledge_context-internals]] §Nothing succeeds in silence.
|
||||
|
||||
> Pitfall: even for `FpEvent` (which is persisted), several fields of the app type are **not** in the shape and are lost when connected — see [[caveat_event-fields-not-persisted]].
|
||||
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only ([[knowledge_nextgraph-stack]]).
|
||||
|
||||
## The generated ORM names carry no `Fp` prefix
|
||||
|
||||
The generator emits `Event`, `UserProfile`, `Participation`, `MeetingPoint`, `Notification` (and `EventShapeType`, `UserProfileShapeType`, …) — **without** the `Fp` prefix earlier bindings had.
|
||||
|
||||
**It cannot be restored at the generator.** The emitted name derives from the shape IRI, and those IRIs are the **persisted RDF classes**: renaming them to regain a prefix would rename the data. So the app **aliases at its import sites** (`… as FpEvent`, `… as FpEventShapeType`) — three of them, in the data context and the two test harnesses. That keeps the downstream names unchanged and, just as importantly, stops the DOM's own `Event` and `Notification` from being shadowed.
|
||||
|
||||
Alias at the import; never rename in the generated files, which `bun run build:orm` overwrites ([[knowledge_nextgraph-stack]]).
|
||||
|
||||
> Pitfall: several fields of `FpEventData` are **not** in the shape and are lost when connected — [[caveat_event-fields-not-persisted]].
|
||||
|
||||
@@ -17,14 +17,16 @@ Festipod persists through **`@ng-eventually/polyfill`**. What that surface offer
|
||||
|
||||
The reactive ORM (`useShape`) is built on **SHEX shapes**: `src/shared/shapes/shex/festipodShapes.shex` defines:
|
||||
|
||||
- **Event** — title, description, dates, location, themes, participants
|
||||
- **UserProfile** — name, username, bio, city, visibility
|
||||
- **Event** — title, description, date, location, distance, participantCount, coverImage. **No host**: an event is only the anchor ([[knowledge_entities]]).
|
||||
- **UserProfile** — name, initials, username, role, isPublic. The first three are **mandatory**, which is why a new profile is written with placeholders rather than empty.
|
||||
- **Participation** — links an event and a user, confirmation status
|
||||
- **MeetingPoint** — a meeting point (location, time, host)
|
||||
- **Notification** — a notification (created in particular when signing up to a meeting point)
|
||||
- **MeetingPoint** — a meeting point (event, host, title, place, time)
|
||||
- **Notification** — recipient, type, ref, payload, timestamp, isRead
|
||||
|
||||
The ORM bindings are generated in `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Regenerate** with `bun run build:orm` after any `.shex` change.
|
||||
|
||||
> **Regenerating is not a no-op, even with an unchanged `.shex`.** The committed bindings had drifted from what the generator emits, so a regeneration produces a diff beyond your own change — read it rather than assuming it is yours. And the emitted names carry **no `Fp` prefix**; the app aliases at its import sites instead, because the name derives from the shape IRI and those IRIs are the persisted RDF classes ([[knowledge_entities]]). Never hand-edit the generated files.
|
||||
|
||||
> **The canonical way to read is the reactive hook.** `useShape`/`watchShape`: you subscribe to a shape on a scope, you get the current value, and the component re-renders on every change — subscription/push, never polling; one-shot reads are the exception. The read/reactivity contract is [[contract_polyfill-surface]] and nothing else.
|
||||
|
||||
> `Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_entities]]).
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: seedData.ts provides deterministic fixtures (10 users, events, participations) with CURRENT_USER_ID = 'user-1' (Marie Dupont); used in demo mode and by the @ui tests
|
||||
summary: seedData.ts holds deterministic fixtures (14 users with CURRENT_USER_ID = 'user-1', 5 events) used by demo mode and the @ui tests; no fixture reaches a CONNECTED wallet by any route any more — bootstrapWallet is the single enforcement point of that master switch
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
# Seed data
|
||||
|
||||
`src/shared/data/seedData.ts` provides **deterministic** fixtures:
|
||||
`src/shared/data/seedData.ts` holds **deterministic** fixtures: 14 users (`CURRENT_USER_ID = 'user-1'`, Marie Dupont), 5 events, participations, meeting points and friendships.
|
||||
|
||||
- 10 users — **Marie Dupont = the current user**, `user-1`
|
||||
- Several events (dates, locations, themes)
|
||||
- Participations, meeting points, friendships
|
||||
- `CURRENT_USER_ID = 'user-1'`
|
||||
## Where they are still used
|
||||
|
||||
These fixtures serve (a) **demo mode** (`LocalDataProvider`, see [[knowledge_data-modes]]) and (b) the **`@ui`** tests, which render the screens against this predictable data (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, etc. — see concept `bdd-testing`).
|
||||
- **Demo / disconnected mode** — `LocalDataProvider` reads them straight into React state ([[knowledge_data-modes]]).
|
||||
- **The `@ui` rendering tests** — they render screens against this predictable data (`Marie Dupont`/`@mariedupont` is the current user, `Jean Durand`/`@jeandurand` exists…). Concept `bdd-testing`.
|
||||
|
||||
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seeds this data into the wallet in connected mode — triggered only by an explicit user action (« Charger données de test »).
|
||||
Neither path writes to a wallet, which is why both are untouched by the switch below.
|
||||
|
||||
## No fixture reaches a CONNECTED wallet, by any route
|
||||
|
||||
A **master switch** — `fixtureSeedEnabled()` in `src/shared/utils/autoSeed.ts` — is **off**, a product decision: no fixture is written into a connected wallet at all, neither by the opt-in automatic seed nor by an explicit "load test data" action.
|
||||
|
||||
**`bootstrapWallet` (`src/shared/utils/ngBootstrap.ts`) is the single enforcement point.** Every route into a wallet funnels through that one function, so the switch cannot be walked around by a screen, a bridge or a test harness; a caller simply gets the ordinary "nothing was seeded" answer, which is exactly true. Call sites consult the switch too, but only so they neither log nor await work that will not happen — the enforcement is not theirs. A unit test fails if a document is created after all.
|
||||
|
||||
**Off, not deleted.** The fixtures and the seeding code stay, because the two paths above need them and neither writes to a wallet. If the switch is ever turned back on, what follows still applies: the seed is **linear in the number of documents** (one document per entity, each a serial round trip), so the connected seed writes only what is needed — all events, a few profiles, and no participations, which the sign-up scenarios create live. Events are the only entities whose inbox is opened at seed time, because events are what people deposit into.
|
||||
|
||||
> **Consequence, live now**: the `@data` suite has lost its fixtures — concept `bdd-testing`, [[caveat_data-suite-has-no-fixtures]].
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: getEventOwnership answers mine / not-mine / unknown from listMyEntityDocs('public') — owning a document IS being able to write it, the ruling is rebuilt on every listing rather than accumulated, and UNKNOWN is a real third answer callers must handle
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
# Write rights are ownership, read from the owned list
|
||||
|
||||
The app never asks whether it may write a document; it asks whether it **owns** one, because [[contract_polyfill-surface]] makes those the same fact. Only an owner writes, a read key never grants a write, and no call adds a writer — so `listMyEntityDocs('public')` is the whole answer, and no probe call will be added (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]).
|
||||
|
||||
## The answer is three-state
|
||||
|
||||
`getEventOwnership(eventId)` (`FestipodDataContext`) returns `'mine' | 'not-mine' | 'unknown'`:
|
||||
|
||||
- **`mine`** — the event is in the owned set, either because a listing returned it or because this session created it and claimed it directly. Checked **first**, so a fresh creation is authoritative before any listing has answered and never loses to a stale miss.
|
||||
- **`not-mine`** — a listing has *resolved* and did not return this event, so it was genuinely looked past.
|
||||
- **`unknown`** — everything else: no listing has landed, the listing failed, or the event arrived after the last one. A rejection means UNKNOWN, never "this session owns nothing"; reading it as `not-mine` is how an owner is silently denied their own event.
|
||||
|
||||
**Callers must treat `unknown` as its own case.** It is not a polite `not-mine`, and it is not a boolean waiting to settle.
|
||||
|
||||
## The ruling is REBUILT, never accumulated
|
||||
|
||||
Every listing **re-adjudicates every visible event**: the ruled-out set is recomputed from scratch, so a later listing can overturn an earlier one. An earlier version latched the verdict into a boolean, which denied an owner their own event forever once a single listing had missed it. Do not reintroduce accumulation — add to the owned set, but rebuild the ruled-out set.
|
||||
|
||||
Re-listing is driven by **arrivals, not by time**: while some visible event is neither owned nor ruled out, one more listing is taken; the set then empties and the effect falls silent. That is a push-driven retry, not a poll ([[rule_no-broker-polling]] in `bdd-testing`).
|
||||
|
||||
## Known residual — accepted, do not paper over
|
||||
|
||||
"Not mine" is inferred from **absence**, and the reactive read and the listing are **separate mechanisms**. An event can therefore be on screen a moment before a listing can see it, and it is ruled out for exactly that window; it is re-examined only if some other unclassified event later triggers a listing. Closing the window needs a timer (forbidden) or a capability probe (ruled out). It is left visible and stated on purpose.
|
||||
|
||||
## Matching is on the canonical id-form
|
||||
|
||||
An event's `@id` is its document NURI, and the same event can be reached under two overlays (`:v:<overlay>`). Every ownership comparison — the owned set, the ruled-out set, the lookup — runs on the **canonical** form (`canonicalEventId`, `src/shared/data/registration.ts`): the base repo id with any overlay suffix stripped. **Matching only.** A stripped id is never a write target nor an anchor; the counter is always written to the real owned NURI.
|
||||
|
||||
> Two screen-side consumers, one answer: the control that **offers** the write and the route that **performs** it ask the same question and treat `unknown` the same way — `app-architecture` → [[knowledge_screen-pattern]]. Why the answer is this and will stay this: `app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]].
|
||||
@@ -44,6 +44,6 @@ So: **write = direct SPARQL into the entity's document** (immediate, per-documen
|
||||
|
||||
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: DELETE then INSERT of the triple) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
|
||||
|
||||
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the **NURI of the profile document the app read back in its own protected scope**; the app derives it from nothing, because it names no identity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before the protected read lands must refuse rather than write, which is what `joinEvent` does. See [[knowledge_context-internals]].
|
||||
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the NURI of **the profile document this session OWNS** — never the identity it signed in as, which is opaque and never written into an entity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before that document resolves must **reject** rather than write, which is what `joinEvent` and `leaveEvent` do — they throw, and the screen's confirmation follows the write. See [[knowledge_context-internals]].
|
||||
|
||||
Sharing keys off a different space: `inbox.share(doc, toUser)` names a **person**, so bilateral connections (`declareConnections`) are declared with **normalized profile handles**, not document NURIs — the data context maps each peer IRI to that key before declaring, and skips peers whose profile it cannot read (they cannot be named).
|
||||
|
||||
@@ -13,7 +13,7 @@ Vocabulary reference. Every actor is a specialization of an authenticated **user
|
||||
|---|---|
|
||||
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
|
||||
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
|
||||
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"**: the event is public, merely flagged by its declarer, who **is NOT required to attend** — at creation no participation is written, the counter starts at 0, and the declarer can join/leave like anyone else (a product decision; on the data side see data-layer/[[knowledge_context-internals]] §participantCount). The "host" remains an actor at the **meeting point** level (next row), not at the event level. |
|
||||
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. |
|
||||
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
|
||||
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
|
||||
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The product model of confidentiality and discovery — every entity lives in a SCOPE (public / protected / private) depending on who must see it; events & meeting points = public, network profile & participations = protected (network), settings = private; bilateral connections = the dialog scope; discovery = reading the public scope
|
||||
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it (events & meeting points public, network profile & participations protected, settings private, bilateral connections dialog); discovery is reading the public scope
|
||||
---
|
||||
|
||||
# Data scopes and discovery
|
||||
@@ -35,9 +35,14 @@ A user discovers the events they did not create simply by **reading the `public`
|
||||
|
||||
> **Sign-up notification (product intent).** Signing up to a meeting point notifies its host: identified if the participant is one of the host's connections, **unnamed otherwise**. This "identified if known, unnamed otherwise" falls out of scope placement — the host can read the sign-up, but not the *protected* profile it points at unless they are connected. The app states the intent; it implements no filter of its own.
|
||||
|
||||
## Settled: the event write model is OWNER-ONLY
|
||||
|
||||
Who may update a declared event was long open — owner, wiki, or immutable. It is **owner: the declarer alone**, and not as a free product choice. The data model leaves no other reading: only a document's owner writes it, a read key never grants a write, and no call adds a writer, so "wiki" is not expressible at all. The declarer's own listing of their documents is what says which events are theirs, permanently (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]).
|
||||
|
||||
This constrains **deduplication**: two declarations of the same real-world event cannot be merged by one declarer editing the other's document ([[brief_2026-06-15_event-deduplication]]).
|
||||
|
||||
## Open questions (business)
|
||||
|
||||
- **Event write model**: owner (the declarer alone) / wiki (everyone) / immutable? Central to deduplication ([[brief_2026-06-15_event-deduplication]]).
|
||||
- **The host's identity towards an ordinary user**: a meeting point is readable by all, but should its host be identifiable? (pseudonym by default, a business card per meeting point, or anonymity lifted only for connections.)
|
||||
- **Which fields of a sign-up can be edited**; **"friends of friends" discoverability**.
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ summary: What is implemented today (event + meeting point lifecycle, profiles, c
|
||||
- User profile, profile update, profile sharing
|
||||
- Friends list (connections), another user's profile
|
||||
|
||||
> Signing up to / withdrawing from a meeting point is **genuinely wired** on the data side: `joinEvent` persists a Participation, notifies the meeting point's host and creates a Notification; `leaveEvent` deletes the Participation authoritatively (see concept `data-layer`, [[caveat_participation-deletion]] on the data-layer side). Public discovery — a user seeing another user's public event — works too.
|
||||
> Signing up to / withdrawing from a meeting point is **genuinely wired** on the data side: `joinEvent` persists a Participation and deposits into the event's inbox, where its owner reads it; `leaveEvent` deletes the Participation authoritatively (concept `data-layer`, [[caveat_participation-deletion]]). Neither succeeds in silence — they reject rather than returning quietly, and the confirmation the user sees follows the write. Public discovery — a user seeing another user's public event — works too.
|
||||
|
||||
> **Updating an event is reserved to its declarer**, and the interface says so rather than discovering it late: the edit route is decided by ownership, and the confirmation follows the write instead of preceding it (concept `app-architecture`, [[knowledge_screen-pattern]]). Owner-only is not a policy choice here — it is the only reading the data model allows ([[knowledge_data-scopes-and-discovery]]).
|
||||
|
||||
> **The reconnection promise is guarded, not assumed.** "I come back later and my events and sign-ups are still there" is a product promise like any other, and it is the one whose failure would be least visible — nothing on screen distinguishes "you have nothing" from "it did not come back". The `src/modules/event/features/reconnexion-*.feature` scenarios of the `event` module are its non-regression guard; keep them meaningful, and read [[caveat_reconnexion-froide-local-vs-broker]] (concept `bdd-testing`) before trusting one of them green, because the natural setup proves less than it looks.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user