Record direct sign-up to an event as a decision, with the declarer notified and confidentiality unchanged, and amend the doctrine that forbade it

This commit is contained in:
Sylvain Duchesne
2026-11-06 23:02:36 +01:00
parent 4abab5a55a
commit 9cbe78948b
10 changed files with 228 additions and 208 deletions
@@ -17,7 +17,7 @@ last_checked: 2026-08-17
| `FpNotificationData` | SDK (Notification shape) | recipient, type, ref, payload, timestamp, isRead |
| `FpFriendshipData` | **derived** on ActivityPods (mirror of the Pod's `apods:contacts`, see [[decision_2026-10-21_contacts-are-the-pod-social-graph]]); absent elsewhere | userId + friendId |
**On ActivityPods the participation is not a document of its own.** A sign-up is a member URN in the meeting point's `apods:attendees` collection plus the meeting point's URI in the person's participations collection (linked from the profile as `http://festipod.org/participations`); the person's `http://festipod.org/participationSecret` on the profile is what lets their contacts recognise the URN. No shape describes any of these — see [[decision_2026-10-29_participation-privacy-mechanism]] and `src/shared/data/participationPrivacy.ts` (verified in code).
**On ActivityPods the participation is not a document of its own.** A sign-up is a member URN in the `apods:attendees` collection of its target — a meeting point, or the event itself for a direct sign-up (concept `functional-domain`, [[decision_2026-11-06_direct-event-registration]]) — plus the target's URI in the person's participations collection (linked from the profile as `http://festipod.org/participations`); the person's `http://festipod.org/participationSecret` on the profile is what lets their contacts recognise the URN. No shape describes any of these — see [[decision_2026-10-29_participation-privacy-mechanism]] and `src/shared/data/participationPrivacy.ts` (verified in code).
**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]].
@@ -14,7 +14,7 @@ Festipod's **functional domain**: what the product promises and the business voc
## Pivotal idea
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, never to an event.**
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, or directly to an event; being in one of an event's meeting points registers you to that event** ([[decision_2026-11-06_direct-event-registration]]).
## Scope & confidentiality
@@ -29,4 +29,4 @@ The product model of **who sees what** — personal data reserved to one's netwo
- [[decision_2026-10-29_participation-privacy-mechanism]] — the mechanism enforcing it on ActivityPods (private Join, hashed member URNs, contacts-readable participations collection)
- [[knowledge_roadmap]] — current features vs upcoming evolutions
- [[brief_2026-06-15_event-deduplication]] — open challenge of deduplicating events in P2P
- [[brief_2026-11-06_direct-event-registration]] — proposed (FP-001): direct sign-up to an event, registration derived from it or from a meeting point membership, confirmed cascade on leaving the event
- [[decision_2026-11-06_direct-event-registration]] — direct sign-up to an event (FP-001): registration derived from it or from a meeting point membership, anonymous notification of the declarer, confidentiality unchanged, confirmed cascade on leaving the event
@@ -29,5 +29,6 @@ The rule states the **what**, never the **how** — and that is intended. Which
- [[knowledge_data-scopes-and-discovery]] — the scopes model this rule tightens (participation: public as an object, the identity link is protected)
- [[decision_2026-10-29_participation-privacy-mechanism]] — the mechanism enforcing this rule on ActivityPods (private Join, hashed member URNs, contacts-readable participations collection)
- [[decision_2026-11-06_direct-event-registration]] — a direct sign-up to an event is a participation under this rule, unchanged
- [[decision_2026-11-03_participation-delivered-to-the-app]] — how that private Join reaches the backend: addressed to the app actor alone, delivered to the app's inbox
- [[decision_2026-10-21_contacts-are-the-pod-social-graph]] — what "contact" means (the Pod's bilateral `apods:contacts`)
@@ -1,196 +0,0 @@
---
type: brief
summary: FP-001 design — a user may also sign up directly to an event; event registration = direct sign-up OR membership of one of its meeting points, derived at read time and never stored; leaving an event with meeting points asks for confirmation and withdraws them too
---
# Direct sign-up to an event (FP-001)
**Status:** Proposed — not yet validated by the user. Becomes a `decision_` (and amends [[knowledge_business-model]]) once validated.
**Captured:** 2026-11-06, ticket FP-001.
## The request
Today a user signs up only to meeting points; being in at least one of an event's meeting points makes them count as registered to the event. The user asks for a **direct sign-up to an event**, keeping the automatic link:
- sign-up to a meeting point ⇒ registered to its event;
- withdrawal from a meeting point ⇒ depends on whether the user is still in another meeting point of that event;
- withdrawal from an event ⇒ if the user is in meeting points of it, ask for confirmation, saying those memberships go too.
This **amends the pivotal sentence** "you sign up to a meeting point, never to an event" ([[knowledge_business-model]], `functional-domain` overview, `AGENTS.md`, the header of `festipodDataBase.ts`): the meeting point stays the unit of value, but an event can now be joined on its own — "I'll be there" before, or without, any meeting point.
## Model: two acts stored, one status derived
| Fact | Stored? | Where |
|---|---|---|
| **Direct sign-up** to event E | yes — an act of the user | an `as:Join` naming **E itself**, through the same channel as a meeting point Join |
| **Membership** of meeting point M | yes — unchanged | the existing `as:Join` naming M |
| **Registered to E** | **no — derived at read** | `direct(E) ∨ ∃ M of E : member(M)` |
Deriving the registration, rather than writing an event-level Join as a side effect of every meeting point Join, is the core choice:
- R2 holds by construction — no second write that can fail, lag or drift from the first.
- R3 needs no decision logic at withdrawal: after the last meeting point is left, the status falls back to whatever the direct sign-up says. This is exactly the default rule given with the ticket (stays registered if signed up directly, otherwise not).
- **Existing data needs no migration**: every registration stored today is a meeting point membership, hence "derived, not direct" — after the change each user is registered to exactly the events they are registered to now.
Rejected: *writing an event-level Join on every meeting point Join* (two non-atomic writes per act, and on withdrawal of the last meeting point the app could no longer tell a direct sign-up from an automatic one without storing a flag — re-creating the derived status by hand). Rejected: *a "direct" flag on the participation* (no participation document exists on ActivityPods; the act is the record).
### On ActivityPods (the active backend)
Everything below reuses the participation mechanism unchanged ([[decision_2026-10-29_participation-privacy-mechanism]], [[decision_2026-11-03_participation-delivered-to-the-app]]); only the **target** of the act widens from "a meeting point" to "a meeting point or an event".
- **The act.** `as:Join` / `as:Leave` posted to the user's outbox, `to` = the app actor alone, `object` = the event URI. Same privacy: readable by its author and the app only.
- **The backend's target resolution** (`registration.service.js`, `meetingPointTarget` / `meetingPointOf`) accepts a stored `as:Event` **without** `as:context` as an event target whose anchor is itself. Everything else applies as is: re-read in the declarer's pod, `dc:creator` must own that pod, serialized per target, newer-act-wins ledger keyed by (target, participant), both collections read back before the act counts.
- **The member URN is the same** as in the event's meeting points: `sha256("<WebID> <event URI> <secret>")` with event URI = the event itself. So one person carries one URN across the event's own list and all its meeting points' lists — the count stays "distinct URNs", publicly computable.
- **The event's own `apods:attendees` collection** already exists: `attendees.service.js` attaches one to every `as:Event` created, plain events included (observed 2026-10-12). It receives the direct sign-ups' URNs, under the same public-read reconciliation as a meeting point's list.
- **The participations collection** receives the event URI on a direct Join (removed on its Leave). Its audience is unchanged (the `/contacts` group): listing someone's direct sign-ups stays contacts-only ([[behavior_participation-listing-is-contacts-only]]).
- **No notification for a direct sign-up.** The declarer of an event is not its host and is not required to attend ([[knowledge_actors-and-concepts]]); `notifyCreator` runs for meeting point targets only. (Open point, see Uncertainties.)
### Reads (`refineDerive.ts`, `refineData.tsx`)
- `deriveContainer` reads the attendee list of **events** too (today only meeting points' lists are embedded and mapped); a direct sign-up becomes a participation keyed by the event id.
- `eventParticipantCount` = distinct member values over the event's own list **∪** its meeting points' lists.
- "Mine": my participations collection now holds meeting point URIs **and** event URIs; an event URI there is my direct sign-up. `getUserEvents(me)` unions both; a contact's `participatedEventsByContact` likewise.
- Recognition of contacts on an event (`getEventParticipants`) includes the event's own list.
- Pending acts / read-your-writes (`PendingActs`, `settleMembershipActs`, `countsWithOverlay`) are keyed by **target id** instead of meeting point id — an event id is one more key; the logic does not change.
### Local demo and the suspended NextGraph path
- `LocalDataProvider`: the seed's `seedParticipations` are already keyed by **event** id — they read naturally as direct sign-ups; mutations stay no-ops as today ([[knowledge_context-internals]] §local).
- `NgDataProvider` (suspended, not mounted at the root): out of scope; the new mutations **reject** with an explicit "not supported on the suspended NextGraph path" error, never a silent success.
## Screen-facing contract (`festipodDataBase.ts`)
Added to `FestipodDataContextValue`, implemented by each provider, shared derivation in `buildQueries`:
```ts
interface EventRegistration {
direct: boolean; // signed up to the event itself
meetingPointIds: string[]; // the event's meeting points I am a member of
registered: boolean; // direct || meetingPointIds.length > 0 (R2, R3)
}
getEventRegistration(eventId: string): EventRegistration;
joinEvent(eventId: string): Promise<void>; // direct sign-up (R1), idempotent
leaveEvent(eventId: string): Promise<void>; // withdraws the direct sign-up AND every membership of the event's meeting points (R4)
```
- The **cascade belongs to the data context** (one domain rule, used by the event detail and by Home), the **confirmation to the screen** (a UI concern).
- `leaveEvent` posts one Leave per held target (the event if `direct`, each joined meeting point), in parallel; it resolves only when all were accepted and **rejects naming the targets that failed** — the derived status then truthfully shows the user still registered through what remains (R7).
- `registered` replaces the screens' local `joinedMeetingPointIds.size > 0` (which gates « Inviter »).
## UI (`EventDetailScreen`, Home)
- Event detail: an event-level toggle under the info block — « S'inscrire à l'événement » when not registered; « ✓ Inscrit à l'événement » + « Se désinscrire de l'événement » when registered (direct or derived).
- Withdrawal with memberships ⇒ a confirmation dialog: title « Se désinscrire de l'événement ? », body « Vous êtes inscrit aux points de rencontre suivants : <titres>. En confirmant, vous en serez aussi désinscrit. », actions « Confirmer la désinscription » / « Annuler ». Without memberships: no dialog, immediate withdrawal.
- No confirm dialog exists in `src/shared/components/sketchy/` today: add a `ConfirmDialog` component there (shared, importable by any module — `rule_module-imports`).
- Meeting point toggles are unchanged (no confirmation when leaving a meeting point — R3).
- Home's « mes événements » comes from `getUserEvents(me)`, so directly joined events appear there with no screen change.
## Business rules
- **R1 — Direct sign-up.** A user can sign up to an event directly, whether or not it has meeting points. Signing up twice changes nothing (idempotent).
- **R2 — Meeting point ⇒ event.** A member of at least one meeting point of an event is registered to that event, without any further act.
- **R3 — Leaving a meeting point.** Leaving a meeting point never asks for confirmation and never touches the other memberships. Afterwards the user is still registered to the event iff they are a member of another of its meeting points **or** signed up to it directly; otherwise they are no longer registered.
- **R4 — Leaving an event.** If the user is a member of at least one of its meeting points, a confirmation names those meeting points and says they will be left too; confirming withdraws the direct sign-up (if any) and every such membership; cancelling changes nothing. Without memberships, the withdrawal is immediate, with no confirmation.
- **R5 — One person counts once.** The event's participant count is the number of distinct persons registered to it, directly or through meeting points; a meeting point's count is its own members only (a direct sign-up does not count in any meeting point).
- **R6 — Same confidentiality.** A direct sign-up is as unlinkable as a meeting point membership: the act is readable by its author and the app only; the event's list holds an opaque member URN; listing someone's events (direct ones included) is contacts-only; the declarer learns a count, not a person.
- **R7 — Nothing succeeds in silence.** A refused or failed act shows an error; the displayed status follows the data (a partially failed withdrawal leaves the user shown as registered through what remains).
- **R8 — Withdrawal is authoritative.** After a withdrawal (from a meeting point or an event) and a real reload, the user is not registered back ([[caveat_participation-deletion]]).
## Acceptance criteria
| # | Given | When | Then | Rules |
|---|---|---|---|---|
| AC1 | not registered to E (E with or without meeting points) | taps « S'inscrire à l'événement » | shown « ✓ Inscrit à l'événement »; E in Home's events; E's count +1 | R1, R5 |
| AC2 | signed up directly to E | signs up again (API) | no second entry; count unchanged | R1 |
| AC3 | not registered to E | joins meeting point M of E | shown registered to E, with no event-level act sent | R2 |
| AC4 | member of M1 and M2 of E, not direct | leaves M1 | still registered to E; no dialog | R3 |
| AC5 | member of M1 only, not direct | leaves M1 | no longer registered to E; E leaves Home's events | R3 |
| AC6 | member of M1 only, **and** direct | leaves M1 | still registered to E | R3 |
| AC7 | member of M1 and M2 of E | taps « Se désinscrire de l'événement » | dialog lists M1 and M2 and warns they will be left | R4 |
| AC8 | AC7's dialog | « Annuler » | nothing sent, still member of M1, M2, still registered | R4 |
| AC9 | AC7's dialog | « Confirmer la désinscription » | not registered to E, member of neither M1 nor M2 | R4 |
| AC10 | signed up directly, no membership | taps « Se désinscrire de l'événement » | no dialog; no longer registered | R4 |
| AC11 | direct + member of M1, same person | — | E's count counts them once; M1's count counts them once | R5 |
| AC12 | direct sign-up by A | B, not A's contact, opens E / reads the lists | B sees a count and opaque URNs, never A; A's participations collection is unreadable to B | R6 |
| AC13 | A's contact C | opens E and A's profile | C recognises A among E's participants and sees E in A's events | R6 |
| AC14 | the Join/Leave is refused (e.g. no session) | any act | an error toast; the status shown is unchanged | R7 |
| AC15 | after AC5, AC9 or AC10 | real reload | still not registered | R8 |
| AC16 | data stored before the change (memberships only) | first load after | every user registered to exactly the same events as before; nobody shown as directly registered | migration |
## Test plan
The `@data` and `@e2e` suites of the NextGraph broker are **suspended** (`@suspendu`, excluded by `cucumber.json`) along with that workstream; on the active backend the data layer is exercised by `@e2e-activitypods` (real local stack, no browser unless `@browser`) and by the rights matrix (`bun run test:e2e-rights`). The layers below follow [[rule_test-layer-contracts]] mapped onto that reality: **`@ui`** = rendering over the seed; **"data"** = unit tests (`bun test`) of the pure derivations and the backend handler, plus `@e2e-activitypods` scenarios on the Pod collections; **`@e2e`** = one `@e2e-activitypods @browser` journey. All runs obey [[rule_tests-never-break-the-dev-environment]]: throwaway accounts only, cleaned up; nothing resets the stack; if the local stack is down, the live scenarios are reported as not run, never started by the test.
### `@ui` — `src/modules/event/features/us-7-inscription-evenement.feature` (rewritten: the `@data @suspendu` scenarios of the old event-level model stay suspended)
The seed gives the fixtures: an event Marie (current user) is directly registered to (seed participations are event-keyed), one with meeting points she is a member of (add one membership fixture to `seedParticipations`/meeting points), one she is not registered to.
- « Voir le bouton d'inscription à l'événement » — not registered → « S'inscrire à l'événement » (R1)
- « Un inscrit direct voit son inscription à l'événement » (R1)
- « Être inscrit à un point de rencontre affiche l'inscription à l'événement » (R2)
- « Se désinscrire d'un événement avec points de rencontre demande confirmation » — dialog lists the meeting point titles (R4)
- « La confirmation de désinscription précise les points de rencontre quittés » (R4)
- « Annuler la confirmation ne change rien » (R4)
- « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation » (R4)
- « Quitter un point de rencontre ne demande pas de confirmation » (R3)
- « Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre » (R2)
`@ui` renders against `LocalDataProvider`, whose mutations are no-ops: dialog content and opening are `@ui`; the outcome of confirming is data (below).
### Data — unit tests (`bun test`)
- `src/shared/context/refineDerive.test.ts`: event's own list mapped to event-keyed participations; count = distinct URNs over event list ∪ meeting point lists (same URN in both counts once — R5); a direct sign-up does not count in a meeting point (R5); overlay of a pending event act.
- `src/shared/context/festipodDataBase.test.ts`: `getEventRegistration` truth table — none / direct / one membership / two memberships / direct + membership, and the status after removing each membership (R2, R3).
- `src/shared/context/refineData.test.tsx`: `joinEvent` posts one Join naming the event, secret first; `leaveEvent` posts Leave for the event (if direct) and for each joined meeting point, and rejects naming the failed targets on a partial failure (R4, R7); `leaveEvent` with nothing held rejects.
- `backend/services/registration.service.test.js`: a Join naming a plain event records the URN computed with the event as anchor in the event's list and the event URI in the participations collection; no notification; the same participant's URN equals the one recorded in the event's meeting points (R5, R6); a Join naming a non-`as:Event` or a foreign-pod event stays refused.
### Data — `@e2e-activitypods` (`src/modules/event/features/e2e-activitypods.feature`, live stack)
- « Rejoindre puis quitter un événement met à jour sa collection apods:attendees » (R1, R8)
- « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre » (R5, R6)
- « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement » (R3)
- « Quitter le dernier point de rencontre garde l'inscription directe » (R3)
- « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre » (R4, R8)
Rights matrix (`scripts/e2e-rights-matrix.ts`): add the lines « a direct sign-up is not listable by a non-contact » and « a contact lists the direct sign-ups » (R6, AC12–13); the existing lines must still pass.
### `@e2e` — one journey, `@e2e-activitypods @browser`
- « Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation » — sign up directly, join one meeting point, leave the event, see the dialog, confirm, reload, still not registered (R1, R2, R4, R7, R8).
### Traceability
| Rule | Scenarios |
|---|---|
| R1 | « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », journey |
| R2 | « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Le bouton Inviter suit l'inscription… », `getEventRegistration` unit, journey |
| R3 | « Quitter un point de rencontre ne demande pas de confirmation », « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement », « Quitter le dernier point de rencontre garde l'inscription directe », `getEventRegistration` unit |
| R4 | « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation… précise les points de rencontre quittés », « Annuler la confirmation ne change rien », « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », journey |
| R5 | « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », `refineDerive` units |
| R6 | same URN scenario, rights matrix lines, backend unit |
| R7 | `refineData` partial-failure unit, journey |
| R8 | « Rejoindre puis quitter un événement… », « Se désinscrire de l'événement retire… », journey (reload) |
## Files to touch
- **Doctrine** (on validation): this brief → `decision_`; [[knowledge_business-model]], [[knowledge_actors-and-concepts]] (participant row), the `functional-domain` overview, `AGENTS.md` (the "on s'inscrit à un point de rencontre, pas à un événement" line), [[knowledge_roadmap]].
- **Contract & queries**: `src/shared/context/festipodDataBase.ts` (interface, `buildQueries`, header), `src/shared/context/FestipodDataContext.tsx` (local provider), `src/shared/context/ngData.tsx` (explicit rejections).
- **ActivityPods reads/writes**: `src/shared/context/refineDerive.ts`, `src/shared/context/refineData.tsx`, `src/shared/data/outbox.ts` (target URI rather than `meetingPointUri`), `src/shared/refine/providers/activitypods.ts` (event attendees `membersOf`).
- **Backend**: `backend/services/registration.service.js` (event target, no notification for it).
- **UI**: `src/modules/event/screens/EventDetailScreen.tsx`, `src/modules/home/screens/HomeScreen.tsx` (registration status from the context), new `src/shared/components/sketchy/ConfirmDialog.tsx` + `index.ts` export, stories for both screens.
- **Fixtures & tests**: `src/shared/data/seedData.ts` (one meeting point membership), the features and tests listed above, their steps in `src/modules/event/steps/{ui,e2e}/`, and `src/shared/test-harness/harness.tsx` if a `@ui` step needs a new bridge helper.
## Risks
- **Events without an attendee list.** A direct Join is skipped ("No attendees collection") on an event created before the attendee service attached lists to plain events, or whose attach failed. Check on the local stack before implementing; if such events exist, attach the list on demand in the handler (the attach helper already exists) — not designed here.
- **Partial cascade.** `leaveEvent` is N independent acts; a failure leaves some memberships. Mitigated by R7 (rejection naming the targets, derived status truthful) — not atomic, as every act already is ([[decision_2026-10-29_participation-privacy-mechanism]] §Accepted residuals).
- **Read cost.** One more collection read per event (its own list), on top of one per meeting point.
- **Doctrine drift.** "You sign up to a meeting point, never to an event" is repeated in several places, code headers included; leaving one unamended will steer later agents back to the old model.
- **Product dilution.** A direct sign-up lets an event be "joined" with no meeting point at all — the thing the business model says has no value. Accepted by the request; worth watching in the UI (e.g. still putting meeting points forward after a direct sign-up).
- **Delivery latency.** Each act holds the participant's pod request for 2.6–6.2 s ([[decision_2026-11-03_participation-delivered-to-the-app]] §Costs); a cascade over several meeting points multiplies the acts (they run in parallel, the app's inbox queue runs 5 at a time).
## Uncertainties (for the user)
- **R3 default** (stay registered after the last meeting point only if signed up directly): adopted; it is also what the derived model gives for free. The alternative ("always stay registered") would require writing an event-level act on every meeting point Join, which this design rejects.
- **Declarer notification on a direct sign-up**: none proposed (no host role at event level). To confirm.
- **Does a direct sign-up appear in a meeting point's count?** No (R5). To confirm.
@@ -5,7 +5,7 @@ summary: How sign-ups stay unlinkable on ActivityPods — a private Join handled
# Decision (2026-10-29): the participation privacy mechanism
User-validated 2026-10-29. Enforces [[behavior_participation-listing-is-contacts-only]] on the ActivityPods backend, using only patterns of the reference apps (project rule `festipod-only-reference-app-patterns`). ActivityPods itself is never modified.
User-validated 2026-10-29. Widened by [[decision_2026-11-06_direct-event-registration]]: a sign-up may also name an event itself, under this mechanism unchanged (its own list, the same URN, the event URI in the participations collection, its declarer notified anonymously). Enforces [[behavior_participation-listing-is-contacts-only]] on the ActivityPods backend, using only patterns of the reference apps (project rule `festipod-only-reference-app-patterns`). ActivityPods itself is never modified.
## What it protects: two links
@@ -0,0 +1,214 @@
---
type: decision
summary: A user signs up to a meeting point or directly to an event; registration to an event = direct sign-up OR membership of one of its meeting points, derived at read and never stored; a direct sign-up notifies the declarer anonymously and follows the participation confidentiality rules unchanged; leaving an event with meeting points asks for confirmation and withdraws them too
---
# Decision (2026-11-06): direct sign-up to an event (FP-001)
User-validated 2026-11-06 (ticket FP-001). It **amends the pivotal sentence** of the product model: the old "you sign up to a meeting point, never to an event" becomes **"you sign up to a meeting point, or directly to an event; being in one of an event's meeting points registers you to that event"** — [[knowledge_business-model]], the `functional-domain` overview, [[knowledge_actors-and-concepts]] and `AGENTS.md` carry the new sentence. The meeting point stays the **unit of value**; an event can now be joined on its own — "I'll be there" before, or without, any meeting point.
Every rule of the participation mechanism stands ([[decision_2026-10-29_participation-privacy-mechanism]], [[decision_2026-11-03_participation-delivered-to-the-app]]); only the **target** of the act widens from "a meeting point" to "a meeting point or an event". The confidentiality rules apply **without change** ([[behavior_participation-listing-is-contacts-only]]): a direct sign-up is a participation like any other.
## The request
- sign-up to a meeting point ⇒ registered to its event;
- withdrawal from a meeting point ⇒ still registered iff in another meeting point of that event **or** signed up to it directly (user-confirmed);
- withdrawal from an event ⇒ if the user is in meeting points of it, ask for confirmation, saying those memberships go too;
- a direct sign-up counts in no meeting point (user-confirmed);
- a direct sign-up **notifies the event's declarer** (user decision, replacing the "no notification" first proposed).
## Model: two acts stored, one status derived
| Fact | Stored? | Where |
|---|---|---|
| **Direct sign-up** to event E | yes — an act of the user | an `as:Join` naming **E itself**, through the same channel as a meeting point Join |
| **Membership** of meeting point M | yes — unchanged | the existing `as:Join` naming M |
| **Registered to E** | **no — derived at read** | `direct(E) ∨ ∃ M of E : member(M)` |
Deriving the registration, rather than writing an event-level Join as a side effect of every meeting point Join, is the core choice:
- R2 holds by construction — no second write that can fail, lag or drift from the first.
- R3 needs no decision logic at withdrawal: after the last meeting point is left, the status falls back to whatever the direct sign-up says.
- **Existing data needs no migration**: every registration stored today is a meeting point membership, hence "derived, not direct" — after the change each user is registered to exactly the events they are registered to now.
Rejected: *writing an event-level Join on every meeting point Join* (two non-atomic writes per act; on withdrawal of the last meeting point the app could no longer tell a direct sign-up from an automatic one without storing a flag — re-creating the derived status by hand). Rejected: *a "direct" flag on the participation* (no participation document exists on ActivityPods; the act is the record).
### On ActivityPods (the active backend)
- **The act.** `as:Join` / `as:Leave` posted to the user's outbox, `to` = the app actor alone, `object` = the event URI, delivered to the app's inbox ([[decision_2026-11-03_participation-delivered-to-the-app]]). Same privacy: readable by its author and the app only.
- **The backend's target resolution** (`registration.service.js`, `meetingPointTarget` / `meetingPointOf`) accepts a stored `as:Event` **without** `as:context` as an event target whose anchor is itself. Everything else applies as is: re-read in the declarer's pod, `dc:creator` must own that pod, serialized per target, newer-act-wins ledger keyed by (target, participant), both collections read back before the act counts.
- **The member URN is the same** as in the event's meeting points: `sha256("<WebID> <event URI> <secret>")` with event URI = the event itself. One person carries one URN across the event's own list and all its meeting points' lists — the count stays "distinct URNs", publicly computable.
- **The event's own `apods:attendees` collection** already exists: `attendees.service.js` attaches one to every `as:Event` created, plain events included (observed 2026-10-12). It receives the direct sign-ups' URNs, under the same public-read reconciliation as a meeting point's list (readable anonymously while the event is public, closed when it is switched private).
- **The participations collection** receives the event URI on a direct Join (removed on its Leave). Its audience is unchanged (the participant's `/contacts` group).
- **The declarer is notified**, exactly as a meeting point's host is: by `notifyCreator` (`pod-notifications.send`), recipient = the event's `dc:creator`, on a direct sign-up **and** on its withdrawal, only when the ledger state flips (a repeated or stale act notifies no one), **always anonymous** — no name, no WebID, no activity, the participant's contacts list never read. The notification names the event (template parameter, not the participant) and its link opens the event. A notification failure does not fail the act. Notifying gives the declarer no read right: they stay "anyone else" towards the participant unless a contact.
### Reads (`refineDerive.ts`, `refineData.tsx`)
- `deriveContainer` reads the attendee list of **events** too; a direct sign-up becomes a participation keyed by the event id.
- `eventParticipantCount` = distinct member values over the event's own list **∪** its meeting points' lists.
- "Mine": my participations collection holds meeting point URIs **and** event URIs; an event URI there is my direct sign-up. `getUserEvents(me)` unions both; a contact's `participatedEventsByContact` likewise.
- Recognition of contacts on an event (`getEventParticipants`) includes the event's own list — with the contacts' secrets only, as for meeting points.
- Pending acts / read-your-writes (`PendingActs`, `settleMembershipActs`, `countsWithOverlay`) are keyed by **target id** instead of meeting point id.
### Local demo and the suspended NextGraph path
- `LocalDataProvider`: the seed's `seedParticipations` are keyed by **event** id — they read as direct sign-ups; mutations stay no-ops.
- `NgDataProvider` (suspended): out of scope; the new mutations **reject** with an explicit "not supported on the suspended NextGraph path" error, never a silent success.
## Screen-facing contract (`festipodDataBase.ts`)
```ts
interface EventRegistration {
direct: boolean; // signed up to the event itself
meetingPointIds: string[]; // the event's meeting points I am a member of
registered: boolean; // direct || meetingPointIds.length > 0 (R2, R3)
}
getEventRegistration(eventId: string): EventRegistration;
joinEvent(eventId: string): Promise<void>; // direct sign-up (R1), idempotent
leaveEvent(eventId: string): Promise<void>; // withdraws the direct sign-up AND every membership of the event's meeting points (R4)
```
- The **cascade belongs to the data context**, the **confirmation to the screen**.
- `leaveEvent` posts one Leave per held target (the event if `direct`, each joined meeting point), in parallel; it resolves only when all were accepted and **rejects naming the targets that failed** (R8).
- `registered` replaces the screens' local `joinedMeetingPointIds.size > 0` (which gates « Inviter »).
## UI (`EventDetailScreen`, Home)
- Event detail: an event-level toggle under the info block — « S'inscrire à l'événement » when not registered; « ✓ Inscrit à l'événement » + « Se désinscrire de l'événement » when registered (direct or derived).
- Withdrawal with memberships ⇒ a confirmation dialog: title « Se désinscrire de l'événement ? », body « Vous êtes inscrit aux points de rencontre suivants : <titres>. En confirmant, vous en serez aussi désinscrit. », actions « Confirmer la désinscription » / « Annuler ». Without memberships: no dialog, immediate withdrawal.
- A shared `ConfirmDialog` in `src/shared/components/sketchy/` (none exists today).
- Meeting point toggles are unchanged (no confirmation when leaving a meeting point — R3).
- Home's « mes événements » comes from `getUserEvents(me)`: directly joined events appear there with no screen change.
- The participants shown on an event remain the viewer's **contacts** only, whatever their path (direct or meeting point); anyone else is counted, never shown.
## Business rules
- **R1 — Direct sign-up.** A user can sign up to an event directly, whether or not it has meeting points. Signing up twice changes nothing (idempotent).
- **R2 — Meeting point ⇒ event.** A member of at least one meeting point of an event is registered to that event, without any further act.
- **R3 — Leaving a meeting point.** Leaving a meeting point never asks for confirmation and never touches the other memberships. Afterwards the user is still registered to the event iff they are a member of another of its meeting points **or** signed up to it directly; otherwise they are no longer registered.
- **R4 — Leaving an event.** If the user is a member of at least one of its meeting points, a confirmation names those meeting points and says they will be left too; confirming withdraws the direct sign-up (if any) and every such membership; cancelling changes nothing. Without memberships, the withdrawal is immediate, with no confirmation.
- **R5 — One person counts once.** The event's participant count is the number of distinct persons registered to it, directly or through meeting points; a meeting point's count is its own members only — a direct sign-up counts in no meeting point.
- **R6 — The declarer is notified, anonymously.** A direct sign-up and its withdrawal notify the event's declarer, as a meeting point's sign-up notifies its host: once per change of state, never naming the participant (no name, no WebID). Leaving an event through the cascade notifies the declarer (if the sign-up was direct) and each left meeting point's host, each once. A failed notification does not fail the act.
- **R7 — Same confidentiality, unchanged.** A direct sign-up is a participation and follows exactly the rules of a meeting point participation ([[behavior_participation-listing-is-contacts-only]], [[decision_2026-10-29_participation-privacy-mechanism]]):
- **R7a — The act is private**: readable by its author and the app only — not by the declarer, not by anyone else.
- **R7b — Event → participants**: the event's own list holds opaque member URNs, never a WebID or a name; anyone reads a count; only a **contact** of the participant recognises them among the event's participants.
- **R7c — Person → events**: listing someone's events, direct sign-ups included, is reserved to the person and their contacts; anyone else — **the declarer included** — is refused.
- **R7d — No particular right for the declarer**: declaring an event grants no read on who signed up to it, as creating a meeting point grants none to its host; the notification is their only signal, and it is anonymous.
- **R7e — No cross-event correlation**: the URN of one person differs from one event to another; the same person on the event and on its meeting points carries one URN (counted once — R5).
- **R8 — Nothing succeeds in silence.** A refused or failed act shows an error; the displayed status follows the data (a partially failed withdrawal leaves the user shown as registered through what remains).
- **R9 — Withdrawal is authoritative.** After a withdrawal (from a meeting point or an event) and a real reload, the user is not registered back ([[caveat_participation-deletion]]).
## Acceptance criteria
| # | Given | When | Then | Rules |
|---|---|---|---|---|
| AC1 | not registered to E (E with or without meeting points) | taps « S'inscrire à l'événement » | shown « ✓ Inscrit à l'événement »; E in Home's events; E's count +1 | R1, R5 |
| AC2 | signed up directly to E | signs up again (API) | no second entry; count unchanged; no second notification | R1, R6 |
| AC3 | not registered to E | joins meeting point M of E | shown registered to E, with no event-level act sent | R2 |
| AC4 | member of M1 and M2 of E, not direct | leaves M1 | still registered to E; no dialog | R3 |
| AC5 | member of M1 only, not direct | leaves M1 | no longer registered to E; E leaves Home's events | R3 |
| AC6 | member of M1 only, **and** direct | leaves M1 | still registered to E | R3 |
| AC7 | member of M1 and M2 of E | taps « Se désinscrire de l'événement » | dialog lists M1 and M2 and warns they will be left | R4 |
| AC8 | AC7's dialog | « Annuler » | nothing sent, still member of M1, M2, still registered | R4 |
| AC9 | AC7's dialog | « Confirmer la désinscription » | not registered to E, member of neither M1 nor M2 | R4 |
| AC10 | signed up directly, no membership | taps « Se désinscrire de l'événement » | no dialog; no longer registered | R4 |
| AC11 | direct + member of M1, same person | — | E's count counts them once; M1's count counts them once | R5 |
| AC12 | D declared E, A is not D's contact | A signs up directly to E | D is notified of a new participant to E, without A's name or WebID | R6, R7d |
| AC13 | AC12 | A withdraws from E | D is notified of a withdrawal, anonymously | R6 |
| AC14 | A direct + member of M1 (host H) | A confirms leaving E | D notified once (leave), H notified once (leave), both anonymous | R6 |
| AC15 | the notification cannot be sent | A signs up directly | the sign-up stands | R6 |
| AC16 | direct sign-up by A | anyone reads A's Join | refused, the declarer included | R7a |
| AC17 | direct sign-up by A | B, not A's contact, opens E / reads E's list | B sees a count and opaque URNs, never A | R7b |
| AC18 | direct sign-up by A | B, not A's contact (declarer D included), reads A's participations collection | refused | R7c, R7d |
| AC19 | A's contact C | opens E and A's profile | C recognises A among E's participants and sees E in A's events | R7b, R7c |
| AC20 | A direct to E and to another event E' | anyone reads both lists | the two URNs differ | R7e |
| AC21 | the Join/Leave is refused (e.g. no session) | any act | an error toast; the status shown is unchanged | R8 |
| AC22 | after AC5, AC9 or AC10 | real reload | still not registered | R9 |
| AC23 | data stored before the change (memberships only) | first load after | every user registered to exactly the same events as before; nobody shown as directly registered | migration |
## Test plan
The `@data` and `@e2e` suites of the NextGraph broker are **suspended** (`@suspendu`); on the active backend the data layer is exercised by `@e2e-activitypods` and by the rights matrix (`bun run test:e2e-rights`). Mapping of [[rule_test-layer-contracts]]: **`@ui`** = rendering over the seed; **data** = unit tests (`bun test`) of the derivations and the backend handler, plus `@e2e-activitypods` scenarios; **`@e2e`** = one `@e2e-activitypods @browser` journey. All runs obey [[rule_tests-never-break-the-dev-environment]]: throwaway accounts only, cleaned up; nothing resets the stack; a down stack means "not run", never started by the test.
### Already written (commit 4abab5a) and still valid
- `@ui` (`us-7-inscription-evenement.feature`): « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation de désinscription précise les points de rencontre quittés », « Annuler la confirmation ne change rien », « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation », « Quitter un point de rencontre ne demande pas de confirmation », « Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre ».
- `@e2e-activitypods` (`e2e-activitypods.feature`): « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement », « Quitter le dernier point de rencontre garde l'inscription directe », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », and the journey « Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation ».
- Units: `refineDerive.test.ts`, `festipodDataBase.test.ts`, `refineData.test.tsx` as written; in `registration.service.test.js` the direct sign-up block **except** its notification test (below).
- Rights matrix group `direct sign-up to E`: `direct.join`, `direct.non-contact-refused`, `direct.contact-lists`, `direct.leave`.
### Delta for the QA — to modify
| Where | Test | Change | Rules |
|---|---|---|---|
| `backend/services/registration.service.test.js` | `a direct sign-up notifies nobody: the declarer of an event is not its host` | **replace** by `a direct sign-up notifies the event's declarer, anonymously`: one notification, recipient = the event's `dc:creator`, join wording, no name / WebID / activity / `participantName`, the participant's contacts list never read, the event named by template parameter, the link opening the event | R6, R7d |
### Delta for the QA — to add
**Backend units** (`registration.service.test.js`, in the direct sign-up block):
- `a Leave naming the event notifies the declarer, anonymously` — leave wording, same anonymity checks (R6).
- `a repeated direct Join notifies once; a Leave of someone never signed up notifies no one` — ledger-driven, as for meeting points (R1, R6).
- `a failed declarer notification does not fail the direct sign-up` (R6).
- `leaving the event and its meeting point notifies the declarer and the host, each once` — Leave on E then on M1: two anonymous notifications, one per recipient (R6).
- `a direct Join's URN on one event differs from the same person's URN on another event` (R7e).
**Rights matrix** (`scripts/e2e-rights-matrix.ts`, group `direct sign-up to E`; E's declarer must **not** be Alice's contact for the anonymity lines — Carol's event `CE` fits, or a declarer created for the group):
- `direct.notify-anonymous` — the declarer is notified of the direct Join in their own inbox, naming neither her name nor her WebID (R6, R7d).
- `direct.leave-notify-anonymous` — the same for the Leave (R6).
- `direct.activity-declarer-refused` — the declarer GETs Alice's Join naming E (URI handed out of band): refused 403 (R7a).
- `direct.list-opaque` — E's own attendee list (anonymous read) holds Alice's member URN — the one computed from her secret with E as anchor — and no WebID (R7b).
- `direct.declarer-refused-participations` — the declarer, not her contact, is refused Alice's participations collection although it lists E (R7c, R7d).
**`@ui`** (`src/modules/event/features/us-7-inscription-evenement.feature`, seed: a contact of Marie directly registered to an event, no meeting point membership):
- « Un contact inscrit directement apparaît parmi les participants de l'événement » (R7b).
- « Un participant inscrit directement qui n'est pas un contact n'est que compté » — counted, not shown (R7b).
**`@ui`** (`src/modules/user/features/us-10-profil-participant.feature`):
- « Le profil d'un contact liste les événements auxquels il est inscrit directement » (R7c).
**`@e2e-activitypods`** (`e2e-activitypods.feature`, `@inscription-evenement`):
- « Une inscription directe notifie le déclarant de l'événement sans nommer le participant » — the declarer's inbox holds a join notification about E, anonymous; then the Leave's (R6). Only if the declarer is another throwaway account; otherwise the rights matrix lines above suffice and this scenario is dropped.
### Traceability
| Rule | Scenarios |
|---|---|
| R1 | « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », journey, repeated-Join unit |
| R2 | « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Le bouton Inviter suit l'inscription… », `getEventRegistration` unit, journey |
| R3 | « Quitter un point de rencontre ne demande pas de confirmation », « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement », « Quitter le dernier point de rencontre garde l'inscription directe », `getEventRegistration` unit |
| R4 | « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation… précise les points de rencontre quittés », « Annuler la confirmation ne change rien », « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », journey |
| R5 | « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », `refineDerive` units |
| R6 | the 5 notification units, `direct.notify-anonymous`, `direct.leave-notify-anonymous`, « Une inscription directe notifie le déclarant… » |
| R7 | `direct.activity-declarer-refused` (a), `direct.list-opaque` + the two `@ui` participants scenarios (b), `direct.non-contact-refused` + `direct.declarer-refused-participations` + `direct.contact-lists` + « Le profil d'un contact liste… » (c, d), URN-per-event unit (e) |
| R8 | `refineData` partial-failure unit, journey |
| R9 | « Rejoindre puis quitter un événement… », « Se désinscrire de l'événement retire… », journey (reload) |
## Files to touch
- **Doctrine** (done with this decision): this file, [[knowledge_business-model]], [[knowledge_actors-and-concepts]], [[knowledge_data-scopes-and-discovery]], the `functional-domain` overview, `AGENTS.md`.
- **Code comments carrying the old sentence** (for the dev): the header of `src/shared/context/festipodDataBase.ts` (l. 5), `src/modules/event/screens/EventDetailScreen.tsx` (l. 45–46), `src/modules/event/steps/ui/event.steps.ts` (l. 80–82); `README.md §Modèle fonctionnel` (l. 24) says the same.
- **Contract & queries**: `src/shared/context/festipodDataBase.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/shared/context/ngData.tsx`.
- **ActivityPods reads/writes**: `src/shared/context/refineDerive.ts`, `src/shared/context/refineData.tsx`, `src/shared/data/outbox.ts`, `src/shared/refine/providers/activitypods.ts`.
- **Backend**: `backend/services/registration.service.js` (event target; `notifyCreator` for an event target: recipient the declarer, template naming the event, link to the event).
- **UI**: `EventDetailScreen.tsx`, `HomeScreen.tsx`, new `src/shared/components/sketchy/ConfirmDialog.tsx` + export, stories.
## Risks
- **Events without an attendee list.** A direct Join is skipped ("No attendees collection") on an event whose list was never attached. Check on the local stack before implementing; if such events exist, attach the list on demand in the handler.
- **Partial cascade.** `leaveEvent` is N independent acts; mitigated by R8 — not atomic, as every act already is.
- **Read cost.** One more collection read per event (its own list).
- **Notification volume for the declarer.** Every direct sign-up of a popular event reaches its declarer, who may be a mere referencer. Accepted by the user; a per-event mute belongs to the notification settings (`us-18`), not designed here.
- **Product dilution.** A direct sign-up lets an event be joined with no meeting point at all. Accepted by the request; keep meeting points forward in the UI after a direct sign-up.
- **Delivery latency.** 2.6–6.2 s per act ([[decision_2026-11-03_participation-delivered-to-the-app]] §Costs); a cascade multiplies the acts.
## Settled with the user (2026-11-06)
- R3 default: stays registered after the last meeting point only if signed up directly.
- A direct sign-up counts in no meeting point (R5).
- A direct sign-up notifies the declarer (R6) — the first proposal said no notification.
- Confidentiality applies unchanged (R7).
@@ -13,16 +13,17 @@ 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"** — 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. |
| **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. The declarer **is notified, anonymously,** of direct sign-ups to the event and their withdrawals, and gains no read right over who signed up ([[decision_2026-11-06_direct-event-registration]]). |
| **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. |
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become registered to the parent event. |
| **Participant in an event** | A user registered to an event: signed up to it directly, or a participant in one of its meeting points (derived, never stored — [[decision_2026-11-06_direct-event-registration]]). A direct sign-up notifies the declarer anonymously and follows the same confidentiality rules as a meeting point participation. |
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
## Business concepts
| Concept | Definition |
|---|---|
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you sign up to. |
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you mainly sign up to — an event can also be joined directly. |
| **Event** | The anchor. A real public event referenced in Festipod to serve as a support for meeting points. A mere pretext (title, dates, location, themes). |
| **Interest community** | A thematic grouping of users. Used mainly to discover events (through subscription) and to delimit referencing scopes. |
| **Curated list** | An editorialized list of events (by a user or a community), distinct from "the events I declared". Allows organizing/recommending. |
@@ -1,6 +1,6 @@
---
type: knowledge
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to the meeting point, not to the event
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to a meeting point or directly to the event, and a meeting point registers you to its event
---
# Business model: the grafted meeting point
@@ -13,7 +13,7 @@ The **public event** (festival, conference, trade show, exhibition…) is **only
Direct consequences for modelling:
- **You sign up to a meeting point, not to an event.** Without meeting points, a Festipod event is of no interest whatsoever.
- **You sign up to a meeting point, or directly to an event.** Being in one of an event's meeting points registers you to that event; a direct sign-up says "I'll be there" before, or without, any meeting point, and counts in no meeting point ([[decision_2026-11-06_direct-event-registration]]). The meeting point stays the value: without meeting points, a Festipod event brings little more than a head count.
- The **declarer** of an event is *not* (necessarily) its real organizer — just someone who inserted the reference into Festipod so that others can attach meeting points to it.
- The **host** of a meeting point is whoever created it; the act of creating makes you the host. Likewise the act of declaring an event makes you its declarer.
@@ -16,7 +16,7 @@ Every entity is stored in the **scope** matching who must be able to read it:
| Event (the anchor) | **public** | everyone |
| Meeting point | **public** | everyone |
| Network profile (name, avatar, bio, city, interests) | **protected** | the holder + their connections |
| Participation / sign-up to a meeting point | **public object, private link** | everyone sees *that* (and how many) participate; *who* participates is contacts-only — [[behavior_participation-listing-is-contacts-only]] |
| Participation / sign-up to a meeting point or directly to an event | **public object, private link** | everyone sees *that* (and how many) participate; *who* participates is contacts-only — [[behavior_participation-listing-is-contacts-only]] |
| Connection index | **protected** | the holder + their connections |
| Private profile (settings, email, preferences) | **private** | the holder alone |
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
@@ -40,7 +40,7 @@ The intent is the same on both backends — every public event is findable by ev
- **ActivityPods (the default backend): the Festipod backend's catalog.** An event or meeting point is public when Festipod creates it addressed to the ActivityStreams Public collection (`to: as:Public`). Such a record is readable by anyone at its URI, and the Festipod backend lists it in its catalog at once — no curation. The listing follows the record: the owner's edits update it; a deletion, or a switch to private, removes it and makes the record and its attendee list unreadable anonymously again. Only a record's owner can change its listing. Découvrir shows the user's own events plus the catalog's; opening another user's event reads that event, its meeting points and their attendee counts anonymously, by URI. Costs, accepted: a record created while the backend was not receiving the pod's notifications is never listed (no backfill), and the catalog lists meeting points too, so its own total is not an event count. Verified end to end by `bun run test:e2e-rights` (groups catalog, discovery, visibility).
- **NextGraph (backend suspended): a curated shared-index document** — [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`), no code consumes it yet. An ordinary public document the app reaches by a reference it hardcodes; anyone may deposit a reference to their event, only the index's owner curates deposits into visible entries, ordered by start date. Its accepted costs: an event is findable only once **someone curates** the index (an operator role, no fixed schedule); an event declared before its document could carry the indexed field never becomes findable; a withdrawn or corrected event **stays listed**, so a reader must tolerate a reference that no longer resolves or resolves to something changed; an entry's position is **frozen at curation**.
> **Sign-up notification.** Signing up to a meeting point, and withdrawing, notifies its host. The intent is "identified if the participant is one of the host's connections, unnamed otherwise"; on ActivityPods it is **always unnamed** for now — the host has no read right over who signed up, and the app has no trustworthy contacts signal to decide otherwise ([[decision_2026-10-29_participation-privacy-mechanism]]).
> **Sign-up notification.** Signing up to a meeting point, and withdrawing, notifies its host; signing up directly to an event, and withdrawing, notifies its declarer the same way ([[decision_2026-11-06_direct-event-registration]]). The intent is "identified if the participant is one of the host's connections, unnamed otherwise"; on ActivityPods it is **always unnamed** for now — the host has no read right over who signed up, and the app has no trustworthy contacts signal to decide otherwise ([[decision_2026-10-29_participation-privacy-mechanism]]).
## Settled: the event write model is OWNER-ONLY
+1 -1
View File
@@ -1,6 +1,6 @@
# Festipod
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre, pas à un événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
## Invariants à toujours garder