77 Commits

Author SHA1 Message Date
Sylvain Duchesne 9671345634 Wait for the event and meeting point counts together in the direct sign-up tests 2026-11-06 23:30:05 +01:00
Sylvain Duchesne ebcf5f4266 Run the direct sign-up e2e scenarios on disposable accounts deleted after each scenario 2026-11-06 23:24:57 +01:00
Sylvain Duchesne 9169a5817c Report a Join on an event without an attendees list as not recorded, and trim redundant comments 2026-11-06 23:21:38 +01:00
Sylvain Duchesne 5540d361c7 Implement direct sign-up to an event with registration derived from meeting point memberships 2026-11-06 23:16:29 +01:00
Sylvain Duchesne af0cba6920 Adapt the failing direct sign-up tests to the declarer notification decision 2026-11-06 23:09:48 +01:00
Sylvain Duchesne 9cbe78948b Record direct sign-up to an event as a decision, with the declarer notified and confidentiality unchanged, and amend the doctrine that forbade it 2026-11-06 23:02:36 +01:00
Sylvain Duchesne 4abab5a55a Write the failing tests for direct sign-up to an event 2026-11-06 22:57:46 +01:00
Sylvain Duchesne f45fc564ec Design direct sign-up to an event, with registration derived from meeting point memberships 2026-11-06 22:45:52 +01:00
Sylvain Duchesne 9e2df2478b Deliver Join and Leave to the app's inbox instead of an unaddressed outbox post
A participation activity is now addressed to the Festipod app actor alone and
handled on reception in the app's inbox, the way the ActivityPods developer
advised: use the ActivityPub protocol rather than a side channel. Any
compatible sender may deliver; the inbox accepts only the activity's own
signer, and the backend still requires the activity to lie in that actor's pod
and acts on the stored copy.

The meeting point is read again in its creator's pod before anything is
written: read through the participant's pod, a foreign server's proxy chose
the attendee list and the notification's name.

Confidentiality is unchanged: the Join stays readable by its author and the
app only. Recorded in decision_2026-11-03_participation-delivered-to-the-app,
a named exception to festipod-only-reference-app-patterns.
2026-11-03 18:31:24 +01:00
Sylvain Duchesne ea359488b6 Load the backend's env files from its own directory
The root `bun test` also runs the backend's tests, from the repository root, where
dotenv-flow found no backend/.env: they passed only while the root .env still carried
stale SEMAPPS_* lines, and failed on a clean one (HOME_URL undefined).
2026-11-02 21:50:24 +01:00
Sylvain Duchesne 223059c629 Run the pod provider from images with make start, and fix what a fresh stack exposed
- docker-compose-dev.yml: the redis host port is overridable (REDIS_PORT in the root
  .env, 6379 by default as in the sibling apps), and the pod provider, in host network
  mode, follows it for its queue and OIDC redis. All of ./data/fuseki is mounted at the
  image's SEMAPPS_FUSEKI_BASE, so account deletion removes the backups and, 24 h later,
  the secure dataset's files (it answered 500 without the mount).
- backend: GET /app served the app actor snapshot the mixin takes before it creates or
  replaces the access-need group, so a dataset's first boot lacked the link until a
  restart; the app service refreshes the snapshot from the store at the end of started().
- e2e smoke: the post-install poll sends Accept: application/ld+json; the pod answers 500
  to a container read in any other format.
- Docs: cookbook (Redis port, mounts, hot reload, dataset-named accounts, fresh-boot
  note), deployment note, env examples; claims about a parallel race are no longer stated
  as fact.
2026-11-02 21:46:37 +01:00
Sylvain Duchesne 5e3e821e2a Run locally like the sibling apps: fixed ports, the pod provider in Docker, the app outside
Festipod now follows the local convention of welcometomyplace, mutual-aid.app
and portejunes: one fixed port pair per app (Festipod defaults: backend 3005,
app 4005; pod provider 3000/5000), overridable in backend/.env and .env.
`make start` runs docker-compose-dev.yml (the local pod provider from images,
Fuseki, Redis, mailcatcher, Arena); the backend and the app run outside Docker
with `cd backend && pnpm dev` and `pnpm dev`. pnpm stays the package manager.

The old docker-compose.yml, which containerized Festipod's own frontend and
backend and could not reach the host's Fuseki and Redis, is gone; portless is
dropped from the dev script, since the backend declares a single OIDC redirect
URI. The local backend runs without a Redis cache, as the sibling apps do. The
e2e suites and validate read the app, backend and pod provider URLs from the
env files instead of hardcoding them.

The deployment and local cookbook leaves record the traps met while resetting
datasets: a dataset re-created under the same name needs a Fuseki restart
(ClosedException otherwise), the anonymous WebACL probe at boot is expected,
and a change of the backend's URL leaves persistent accounts with a
participations collection the new app identity cannot control.
2026-11-01 11:40:27 +01:00
Sylvain Duchesne 908f7ea973 Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work
Participation privacy (decision_2026-10-29_participation-privacy-mechanism):
a sign-up is a private Join naming the meeting point, addressed to nobody,
handled by the backend on emission. Attendee lists hold member URNs hashed
from (WebID, event, a secret kept in the participant's profile), so counts
stay public and only contacts recognise anyone; the creator has no read
right and is notified anonymously. Each person's participations collection,
created by the backend at the first sign-up, is readable by their /contacts
WAC group only, never world-readable. The member URN is sticky per
(meeting point, participant) and every collection write is verified by
reading back, so a refused write leaves the act unrecorded and retryable.
The EventRef mechanism and its browser-side provisioning are gone.

The backend now runs with WebACL on, as the reference apps do, on its own
secure datasets festipod / settings-festipod instead of the pod provider's
settings dataset; pod-notifications needs it. The compose header and the
deployment leaf say how to reset a stack whose datasets predate this.

The rights matrix covers the new mechanism live (148 hard lines), including
a forged contact Accept between throwaway accounts: the stranger enters the
contacts list (platform defect, reported upstream) but is refused the profile
and the participations. Tests may attack as long as they never break the dev
environment (rule_tests-never-break-the-dev-environment).

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
2026-10-30 17:06:38 +01:00
Sylvain Duchesne f90651eb9a Name the datasets as the sibling apps do
Back to the sibling apps' pattern: an overridable MAIN_DATASET /
SETTINGS_DATASET, explicit on the main stack (festipod-test /
settings-festipod-test), computed from DATASET_PREFIX and the service name when
left empty (previews). One convention across the apps is simpler for the
developers who move between them than a local variant.

Reverts the previous commit's compose change; the deployment leaf keeps one
line on why Coolify shows those two variables holding expression text.
2026-10-19 17:52:15 +02:00
Sylvain Duchesne 5b61ce9905 Compute the dataset names in the compose, with no overridable wrapper
The sibling apps wrap their dataset names in an overridable
`${MAIN_DATASET:-${DATASET_PREFIX:-…}-${SERVICE_NAME_BACKEND}}` because they keep
historical datasets. Coolify stores such a default, itself an expression, as the
created variable's value, so the new app showed MAIN_DATASET and
SETTINGS_DATASET holding what reads as unresolved text. It does resolve at
deploy time (docker compose interpolates `.env` values), so this was confusing
rather than broken — but Festipod has no historical datasets to keep, so the
names are now computed directly: festipod-backend / settings-festipod-backend,
`…-backend-pr-<n>` in a preview, the same names as before.

The comment and the deployment leaf say what was observed and what was traced,
and that a second stack on the same Fuseki needs its own DATASET_PREFIX in both
its normal and preview scopes.
2026-10-19 17:44:51 +02:00
Sylvain Duchesne a28a318c24 Deploy to the Coolify test server: docker-compose.coolify.yml
The stack Coolify builds from the repository, on the test server only for
now, modelled on the sibling ActivityPods apps: the app (root Dockerfile), the
backend (backend/Dockerfile) and a per-stack Redis; Fuseki is the shared one,
reached over the external coolify network, with a pair of datasets per stack.

One base URL per environment: the app at https://festipod.dev.reconnexion.coop,
the backend's Domains https://<host>/api,https://<host>/.well-known, with
Coolify's Strip Prefixes OFF. The Pod provider offered in one click defaults
to https://mypod.store, as for the sibling dev apps.

The deployment leaf no longer claims that nothing is deployed with this shape
and that no Coolify config exists; it records the stack, the base-path layout,
both images' build traps and the first-boot ones (set the domains before the
first deployment; restart the backend once so /app carries the access-need
group).
2026-10-19 16:44:09 +02:00
Sylvain Duchesne 738d59596a Serve the backend under a base path: one URL per environment, the backend under /api
The test deployment keeps one base URL per environment, as the sibling
ActivityPods apps do, so a pull-request preview lives on a single host: the
app at the root, the backend under /api on the same host, its /.well-known
routes at the origin root, the proxy passing paths through unchanged.

SemApps already works that way — it mounts every route it adds under the base
URL's path and keeps /.well-known at the root — but Festipod's own pieces were
written for a backend at the root of its own origin: its routes (/app,
/as/actor/app, /catalog) were mounted at fixed paths, the public-surface
allow-list matched fixed paths (so under /api every request would have been
refused), and the Solid-OIDC client_id joined the raw request path to the base
URL, which would have repeated the base path (/api/api/...).

base-path.js derives the base path from SEMAPPS_HOME_URL; the routes are
mounted under it; the allow-list matches paths relative to it, the two
/.well-known endpoints against the full path; the client_id is the requested
URI rebuilt from the base URL as written, equal byte for byte to the actor
URI and to what the front end derives. At the root every gate decision is
identical to before (compared over ~8M method/path cases). The rights matrix
probes /.well-known at the backend's origin root.

Verified: bun test (424 pass); the full gate at the root; and the built images
behind a local proxy routing /api and /.well-known unstripped with the Host
header kept — login flow, rights matrix 98/98, revocation.
2026-10-19 16:44:09 +02:00
Sylvain Duchesne 8d0670a637 The backend image starts: approve the native builds, compile sharp against the system libvips
Built and run for the first time (until now the dockerized path had only been
checked with `docker compose config`): the image built, and the container
exited at start with `Cannot find module '../build/Release/sharp-linux-x64.node'`.
pnpm 10 skips dependency build scripts unless they are approved, so sharp,
bcrypt and sodium-native were installed without their binaries. A local
node_modules can carry binaries from an earlier manual build, which is how it
stayed invisible. `pnpm.onlyBuiltDependencies` now approves the three.

Approved, sharp's installer then downloaded its prebuilt libvips and binary
from GitHub, and that download timed out on every in-build attempt (3 of 3),
failing the build, while the same download from a plain container was
instantaneous. With libvips-dev installed, sharp detects a system libvips and
compiles itself instead; npm_config_nodedir points node-gyp at the headers the
image already ships. Verified with GitHub and nodejs.org blackholed: the build
passes, sharp loads against libvips 8.14.1, bcrypt falls back to compiling.

Cost: a larger image; a multi-stage build keeping only libvips42 at runtime
would drop several hundred MB.
2026-10-19 16:44:09 +02:00
Sylvain Duchesne 812844e99d Never show a raw identifier as a person's name, handle or initials
A participant's profile can resolve and still carry an empty name, a WebID,
an actor path or another identifier-shaped value. safeUserName,
safeUserUsername and safeUserInitials turn those into a neutral placeholder,
and every screen that shows a person goes through them: event participants,
participant list, friends, invitations, meeting points, connect, profile,
share profile, edit-profile avatar and someone else's profile.

An absent user renders as before, blank while loading. Your own profile never
titles itself « Participant » for an empty name; other people do. Initials
derived from an identifier become '?'.

The check is start-of-value only: a leading `word:` counts as a scheme, so a
name such as « DJ:Kool » is masked too, and an identifier embedded mid-string
is not. Both are known and accepted for now. The edit-profile form still
prefills its inputs with the raw values.

Screen-level tests render each screen with identifier-shaped and empty names,
and turn red when any sanitizer call is reverted.
2026-10-13 20:58:06 +02:00
Sylvain Duchesne 6ee95c9e68 Record the public-data and rights-matrix work in the handoff, the usage declaration and the doctrine
The handoff gets the closed state of the T05 work with its commits, the new gate commands, the user decisions (attendee lists stay world-readable for now, a revoked app logs the user out, the local network is trusted), the checks kept manual, the platform defects to report upstream, the documented limits of the backend hardening and the open follow-ups. The usage declaration records that Festipod drives the app-status probe, relies on the installed field and on sending to through create, and the subscribe Forbidden friction; the discovery leaf and the local-platform cookbook match the current design and bring-up.
2026-10-13 18:11:34 +02:00
Sylvain Duchesne 9381c206df One sign-in drive for every e2e, the public surface and visibility in the rights matrix, and the stack gates in validate
e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
2026-10-13 18:11:34 +02:00
Sylvain Duchesne 88f23b7023 Harden the backend's trust surface: an allow-listed public surface, decisions taken on data re-read from the pod
The backend served its settings dataset (including the app actor's private key) to anonymous callers, through the SPARQL endpoint and through the semapps LDP catch-all route, since WebACL is off. An allow-list mounted on every route now exposes only what the app and the pod need (/app, /as/actor/app and its Client ID Document, /catalog, the public key, access needs, the JSON-LD context, nodeinfo, the app inbox and the webhook endpoint); everything else answers 404.

Every watcher-fed service (public read, catalog, attendees, contacts, registration) now decides on the activity and object re-read from the pod as the app, checks they belong to the outbox owner and that the stored object has the expected type, and never acts on the notification body. Decisions for one object are serialized and taken on a read made when acting, with a 30 s bound and an abort check before every write. The anonymous read follows the event's visibility both ways, attendees collection included; Festipod records the grants it makes and revokes only those, leaving any other public share untouched; an unreadable ACL changes nothing. A participant's attendance follows their latest act by the pod's timestamp, not the order notifications arrive. Known limits (no conditional write on the pod, a write in flight at the timeout) are documented in the code.
2026-10-13 16:30:52 +02:00
Sylvain Duchesne d38f8a7384 Add the end-to-end rights matrix as a runnable gate: bun run test:e2e-rights
Three fresh users plus an anonymous reader, on the live local stack: login, consent and app install are driven through the UI and asserted at setup; reads, refused writes, profile privacy, discovery, opening a foreign event, join and leave with their counts, isolation of each user's own pod, and catalog listing are each a named line with expected and actual, printed as a report. The exit code is non-zero iff a hard line fails. Known platform behaviours (attendee lists world-readable, an anonymous PATCH accepted on attendees collections) are tracked as non-fatal lines that flag a change; paths that need a crafted request are recorded as manual checks.
2026-10-13 14:49:39 +02:00
Sylvain Duchesne 6bcf40306b Keep a join or a leave on screen until a read taken after it confirms it
After leaving a meeting point, the view could stay at "1 inscrit" while the collection was already empty (caught by the rights matrix, 1 run in 5): a read issued before the act was taken as confirmation, the overlay was dropped, and nothing re-read afterwards. Now only a read issued after the act confirms it; while unconfirmed, only the collection the act changed is re-read (immediately, then at 1.5, 3, 6, 12, 20 and 30 s, the last read wins), and a pre-act read never undoes it. The join direction had the mirror race. Foreign records are read one URI at a time.
2026-10-13 10:57:21 +02:00
Sylvain Duchesne f14cc37f4c List only Festipod-public events in the catalog, and only let an owner change their entry
The catalog indexed every as:Event written to any user's outbox and served private bodies (other apps' events, friend-addressed ones); since the index is persistent, they survived restarts. It now lists only objects addressed to the Public collection in to (cc-only is unlisted: readable, never listed), using the same predicate module as the public-read grant; stored entries that do not qualify are filtered at every read and swept at startup by a conditional delete. A create, update or delete is applied only when the object lies under the pod of the actor whose outbox emitted it (same origin, path prefix with a / boundary): one user could previously remove another's entry with a Delete carrying an embedded Tombstone. Catalog handlers never throw into the webhook path, whose listener does not await them.
2026-10-12 17:08:24 +02:00
Sylvain Duchesne fbb1f6f61b Open another user's event from Découvrir: detail, meeting points, counts and join
Tapping a catalog event used to show "Événement non trouvé" because the detail only searched the user's own events. An event is now read by URI, its meeting points come from the catalog, and each meeting point's count comes from its attendees collection (event count = sum); joining a foreign meeting point goes through the existing outbox flow and the counts move at once and survive a reload. Foreign records are read anonymously by direct HTTP: the session token only goes to the session's own origin, each read is bounded and independent, and one malformed catalog item, record or collection no longer takes the whole read down. Own counts were always 0 before, because the pod never embeds apods:attendees; they are now read the same way. Catalog-only cards show no count instead of a misleading zero.
2026-10-12 17:00:42 +02:00
Sylvain Duchesne a0d2cb8f73 Make events and meeting points readable by anyone only when Festipod marks them public
Festipod's create path now addresses events and meeting points to the ActivityStreams Public collection (to: as:Public), and the backend grants acl:Read to foaf:Agent on a created as:Event only when it carries that public intent (one predicate, hasPublicIntent, in backend/services/public-intent.js). Before this, every as:Event written to a user's outbox would have been made world-readable, including other apps' events and events addressed to a single friend. The grant is idempotent (it reads the ACL first) and never throws into the webhook path.

The attendees service overrides the collections mixin so that an event posted with apods:attendees already attached no longer reaches new URL(undefined) and takes the whole backend process down. Attendee lists stay world-readable as the pod creates them; whether they should is an open product question.
2026-10-12 16:59:00 +02:00
Sylvain Duchesne d4fcb7e311 Log out when the app was removed from the pod instead of re-asking consent
A registration read as installed:false means the user removed Festipod from their pod, often on purpose: re-running registerApp sent them to consent on every load with no way to sign out. The probe now logs out through the provider and lands on the picker with a notice that the authorization was removed and they can sign in again; Back and reload stay there. upgradeNeeded:true keeps the automatic consent redirect. The revocation e2e asserts the new path and fails on the previous one.
2026-10-12 16:27:02 +02:00
Sylvain Duchesne f32e166601 Persist the catalog index across backend restarts and de-index deleted events
Each indexed event is one entry in the backend's settings dataset, default graph (an undeclared named graph is kept in memory by fuseki and lost on restart): the item JSON verbatim plus its first-index time, written by one atomic upsert, so concurrent re-adds stay one entry. The catalog now has its own delete matcher, because the platform one compares formerType as:Event to the compacted Event and never fired: an owner's deletion removes the entry, also after a restart. GET /catalog keeps its shape.
2026-10-12 16:15:58 +02:00
Sylvain Duchesne 4fc856fa7e Detect a revoked or stale app registration and leave the broken connected view
While a session exists, the app probes getAppStatus() on load and on focus/visibility, at most once a minute (a trigger inside the minute is deferred, not dropped), failing open on errors. installed:false or upgradeNeeded:true re-runs registerApp, whose consent redirect is the recovery; meanwhile the app view is replaced by a renewal screen, and an unrecoverable registration lands on the picker with a notice. bun run test:e2e-revocation proves both sides on the live stack: a current registration stays usable, a revoked one leaves the connected state and its writes no longer land.
2026-10-12 16:07:14 +02:00
Sylvain Duchesne e21c408dc1 List other users' events in Découvrir: own pod events joined with the backend catalog
Découvrir (/events) now shows the union of the signed-in user's own-pod events and the backend catalog's events, de-duplicated by URI, own first. The catalog read lives beside Refine (CRUD-only per the contract) as an unauthenticated, fail-open GET on the backend's /catalog, in connected mode only; meeting points carried by the catalog (as:context) are filtered out. Opening a foreign event is not wired yet.
2026-10-12 15:22:30 +02:00
Sylvain Duchesne 706ee132da Bank the full login flow as an e2e gate and un-suspend join-to-collection: 5/5 smoke, programmatic app install for the test user 2026-10-12 14:02:36 +02:00
Sylvain Duchesne a61c8704c9 Close the login loop end-to-end: client_id is the app actor uri, actor path serves the OIDC client document via a boot-safe service, startup heals the access-need-group link, stack aligned on pod provider v2.3.12 2026-10-12 13:12:38 +02:00
Sylvain Duchesne 18fc71ff36 Record the verify drift shim and the reinstall step in the handoff 2026-10-12 11:24:50 +02:00
Sylvain Duchesne 6b48b24350 Shim the app-registrations verify for the pod provider drift: satisfiesAccessNeed lives on DataGrants 2026-10-12 11:23:45 +02:00
Sylvain Duchesne 77d567b445 Cap the sign-in column on desktop — full-width buttons stay right on mobile 2026-10-12 11:04:18 +02:00
Sylvain Duchesne 799de590aa Record the cleared client_id half of the app-install blocker in the handoff 2026-10-12 11:02:21 +02:00
Sylvain Duchesne 3aa9131a01 Serve a Solid-OIDC Client ID Document at /app for the OIDC adapter fetch, keeping the app actor for SAI 2026-10-12 11:01:34 +02:00
Sylvain Duchesne 84a0882d32 Document the root .env copy for manual app serving — without it the app falls back to the local demo 2026-10-12 10:37:35 +02:00
Sylvain Duchesne 659d7652c4 Move the Festipod app to :4000 — :5000 belongs to the pod provider frontend in the local stack 2026-10-12 10:33:07 +02:00
Sylvain Duchesne 8dfc574346 Close the adversarial review round: auth hardening, data fidelity against the platform context, backend grants and docker coherence 2026-10-10 09:54:53 +02:00
Sylvain Duchesne 13d9f179ef Record the executed state of the ActivityPods integration at the T04 tree close 2026-10-09 21:21:27 +02:00
Sylvain Duchesne f8c2e3b809 Suspend the NextGraph suites, and smoke the ActivityPods stack 2026-10-09 21:18:51 +02:00
Sylvain Duchesne c195211629 Rewrite FestipodDataContext over the Refine seam, meeting-point centric 2026-10-09 21:04:35 +02:00
Sylvain Duchesne b09884d586 ActivityPods Solid-OIDC auth flow in AuthGate with pod provider picker and /login callback 2026-10-09 14:07:47 +02:00
Sylvain Duchesne e17398d86d T04.f: backend Festipod ActivityPods + dockerisation
- Ajoute la configuration et les services backend (app, actors, registration,
  attendees, contacts, catalog, core auth/nodeinfo/proxy/notification).
- Corrige le démarrage : désactive resourcesWithContainerPath pour éviter le
  conflit entre l'URI de l'acteur applicatif (/as/actor/app) et la route /app.
- Corrige l'URL du dépôt de shapes : https://shapes.activitypods.org/.
- Ajoute backend/Dockerfile et docker-compose.yml racine (frontend + backend).
- Met à jour .env.example racine avec les variables SEMAPPS_* et VITE_BACKEND_*.
- Gates verts : bunx tsc --noEmit (0 erreur) et bun test (50 pass).
2026-10-09 13:51:02 +02:00
Sylvain Duchesne e27cfa4e0a The ActivityPods backend registers as the default behind the seam 2026-10-09 13:37:28 +02:00
Sylvain Duchesne 9823a45bab Decide the participation arbitration route, and document the local platform setup 2026-10-09 11:28:46 +02:00
Sylvain Duchesne 21b1223b84 Add ActivityPods refine-providers dependency and backend package 2026-10-09 11:12:55 +02:00
Sylvain Duchesne b1add6d8c8 Record the registration precedent, and the event-in-event feasibility without new shapes 2026-10-09 10:26:25 +02:00
Sylvain Duchesne 184998a343 Record the ActivityPods-first directives, and what the reference reconnaissance established 2026-10-09 09:48:52 +02:00
Sylvain Duchesne df1db790ad The URL says which provider set runs, and the seam stops claiming otherwise
The seam arrived carrying a switching design that no longer holds. It presented
runtime hot-switching as what shaped it — selection in state, a key on <Refine>
to drop the previous backend's cache — and it read the backend from a build
global whose comment claimed build.ts defined it. Nothing ever did, and nothing
could have: this project serves from src/ in production too, so that define
block never reaches what runs.

What replaces it is a URL. The default provider set lives at the root, another
behind a path prefix naming it, and moving between them is a link. The reload is
the mechanism rather than a cost to avoid: it discards the cache and the session
with no code to undo them. So the switch helpers are gone and the file is
smaller than what it replaced.

The resolution is a pure function with its own tests, including the case that is
true today — an empty registry, where every URL is the default. The router does
NOT yet strip the prefix, deliberately: it is a shared primitive every screen
depends on, the suites that would prove a change to it are not run here, and no
URL carries a prefix while one backend is registered. That half lands with the
second backend, which is what makes it testable.

Three claims the move to this base falsified are corrected too. The inbox the
count comment points at is local here, not on another branch. The worked
provider implementation is not in this branch's ancestry. And the MeetingPoint
shape exists here, so its binding stops being optional — a backend that cannot
carry one of the four entities cannot serve the app, and nothing optional should
let a half-backend register quietly.

Also: both usage declarations are named usage_festipod.md, so the stem is now
ambiguous as a link. They are referred to by folder path, and both are listed
under publish so each provider can re-find its own if it moves.
2026-10-08 10:08:40 +02:00
Sylvain Duchesne e282384e00 The Refine seam travels to this base, deliberately unwired
The seam that puts Festipod's persistence behind interchangeable Refine backends
was built on `festipod-refine`, over a base this one has since overtaken
everywhere else. Carrying the seam rather than the branch keeps the evolved data
layer intact and costs nothing: the backend contract, the registry and the
provider that mounts Refine depend only on the domain types, so they compile
here untouched once `@refinedev/core` is present — the one dependency this adds,
installed through pnpm as this project requires.

Nothing imports them and the registry is empty, which is the intended state.
Wiring the seam means rewriting `FestipodDataContext` against this base's
version of it — its own decision, with its own failure modes, and not one to
smuggle into a transfer. Landing the files first makes that decision reviewable
against real code instead of a diff between two branches.

The doctrine travels with the code, because the seam is unreadable without it:
the reconstructed observation of `@activitypods/refine-providers` — a package
that publishes no engagement at all, hence `type: reconstruct` and a pin on the
commit it was read at — our usage declaration beside it, and the brief that
arbitrates one data shape for two rights models. So the manifest grows a
`publish:` section: this repo still offers no interface, but it does now publish
what it consumes, which is the only channel a provider has for learning that.

The brief was written where three of the leaves it cites did not exist, and
named them in prose to say so. Here they exist, so it links them; and its open
deduplication point now reads against this repo's own `materializeAttendance`
instead of against a sibling branch. It stays open, and the finding is
unchanged: the active set is keyed per deposit, which buys replay idempotence
and not person identity, so the count counts deposits rather than people.

Two comment zones came over knowingly superseded — runtime hot-switching as what
shapes the provider, and a sign-in barrier waiting on something `AuthGate` no
longer does. They are recorded as data-layer doc-debt instead of being quietly
rewritten, so the rewrite lands with the wiring that settles what they should
say.
2026-10-05 18:46:32 +02:00
Sylvain Duchesne c7cda38235 Name the script for what it does, now that it serves two providers
`link-polyfill.ts` handled one provider. It now handles two through a registry,
one of which is not a polyfill, so both the filename and the `link:` verb said
something false — and a name that is short but wrong misleads more than an opaque
one would.

It becomes `overlay-local-checkout.ts`, which is the operation: it copy-overlays a
local package checkout into node_modules for development. "link" also implied a
symlink, which is precisely what the script exists NOT to do — its own header
spends a paragraph on why.

No behaviour changed: same registry, same copy, same `--once`, same watch loop,
same assertion. Both providers exercised afterwards, each reporting the singletons
it must preserve — one package for the data layer, two for the indexing package,
whose own checkout carries `file:` links and would otherwise smuggle in a second
verifier.

Seven references repaired by rewriting the sentence that carried them rather than
swapping a path, across the script, `package.json`, `.env.example` and four
doctrine leaves. No compatibility alias left behind: an old name kept "just in
case" would restore exactly the ambiguity being removed.
2026-08-17 12:35:28 +02:00
Sylvain Duchesne 7124750874 The indexing package installs, and there is still one verifier
`1.0.1` moves the data layer from `dependencies` to `peerDependencies`, satisfied
by the application's own copy. The path that broke `1.0.0` survives only in
devDependencies, which pnpm never installs for a git dependency -- verified at the
tag's commit rather than taken on trust.

One verifier, proven: the data layer and `@ng-org/web` each resolve to the same
realpath from the application and from inside the new package, and exactly one
`@ng-org/web` exists in the whole store. No overrides, no resolutions, no hoisting
flag, no `.npmrc` -- the peer dependency did it alone, which is the point: the
singleton becomes structural instead of something checked afterwards.

The runtime surface matches the engagement exactly -- five values exported, every
other published name type-only and erased. Nothing undeclared, nothing missing.

Local wiring stays ONE script rather than a sibling: a provider registry drives
the same copy-overlay, the same `--once`, the same watch loop, only the paths
differ. Its single-instance assertion became a loop over every package a provider
shares with the application, which matters here because that provider's own
checkout carries `file:` links -- the very symlink hazard the script exists for.

Its header no longer promises what the old one did: a running dev server never
picks up a refreshed node_modules package, not even across a real rebuild.
2026-08-17 12:30:25 +02:00
Sylvain Duchesne 319e7082cc Re-pin the indexing engagement on the version that can be installed
`1.0.0` declared the data layer as a dependency resolved through a path that
existed only in its author's working copy, so no application could install it --
not partially, but at the install itself, which is why none ever ran it. `1.0.1`
declares that package a PEER, supplied by the application. That is the shape that
makes a single verifier structural rather than something verified afterwards.

Nothing exported moved, which is what makes it a patch: the only thing that
changed for a caller is a requirement it could never have satisfied.

Two facts the new engagement adds, both worth having before wiring anything. The
package is published to no registry and distributed as TypeScript source, so
whatever builds the application compiles it -- our image installs from git and
Bun compiles at serve time, which fits, but a JavaScript-only toolchain could not
consume it. And `1.0.0` is superseded rather than withdrawn: the tag stays and
keeps resolving, because a pinned reference is never taken away from under a
consumer -- a policy the provider holds even for a version that never worked.
2026-08-17 12:26:21 +02:00
Sylvain Duchesne 279faa4541 Retire a caveat the fix made false, and turn a stale warning into a technique
Four of the five fields the caveat said were silently lost are now persisted, so
the caveat is retired rather than trimmed. What was genuinely unique in it -- the
inbox predicate being a vestige that must stay unused -- moved into the Event
bullet where the shape is described; what merely duplicated the entity leaf went.
Only `themes` remains unpersisted, and since nothing reads it back it is a note
rather than a hazard.

The regeneration warning is reframed rather than deleted. It said the ORM
generator had drifted from the committed bindings; that no longer reproduces --
regenerating from the unchanged shape gives byte-identical output. What survives
is the technique that established it: run the generator on unchanged input first,
so an ORM diff separates cleanly into the author's change and the generator's own.
A warning became a way of working. The same stale claim sat independently in the
commands leaf and was found by grep, not by the briefing.

Recorded where the screen hazards live: the edit form seeds its inputs on first
render, so mounted before its event lands in the reactive set it stays blank --
the same class as a value that is empty until a read arrives.

Verified rather than assumed: the misleading French date example flagged for
correction exists nowhere in the doctrine. It lives in a shape comment and the
binding generated from it, both outside this pass, and is noted as such.
2026-08-17 11:40:33 +02:00
Sylvain Duchesne 9e4374b678 Persist the dates the form always collected and always threw away
The creation form has always offered real date and time pickers, and the write
kept only the label derived from them. The user chose a date; the broker never
saw it. Five fields went that way, on create and on update alike, because the
shape defined nowhere for them to land.

`startDate`, `endDate`, `startTime` and `endTime` are now on the Event shape and
carried end to end. `date` is untouched -- it is the label the lists display, and
nothing derives one from the other. `themes` is deliberately left out: it is a
repeated value, so it needs a cardinality decision rather than one more optional
string, and nothing consumes it.

Proven on the running application, both paths, asserted only after a reconnect
because the optimistic overlay shows the values before anything is durable:
written 2026-12-05 / 09:15, read back identical; edited to 2027-01-22 / 08:05,
read back identical. An event missing the fields renders clean -- the time line
simply absent, nothing thrown.

The recorded generator drift no longer reproduces. Regenerating from the
UNCHANGED shape produced bytes identical to the committed bindings, which is how
the additive diff was separated from the generator's own; that separation was the
point of running it twice, and it turned a warning into a measurement. The
doctrine still carries the old warning and is now wrong about it.

Also worth having: the form composes its label from the ISO values joined, not
from the French wording the doctrine gives as its example.
2026-08-17 11:30:57 +02:00
Sylvain Duchesne 32c2302c91 Deployed, and it signs people in
First deployment carrying the injected wallet and the external data layer. A user
signs in and sees their own data.

That settles the one link in the chain nothing here had ever exercised: a
deployed origin CAN be embedded in the hosted broker's iframe. The identity flow
runs the application inside that iframe, so data reaching a signed-in user is
proof the embedding works for this origin.

It is settled by the deployment, not by a test, and the doctrine now says so:
no scenario covers it, so a change of origin, of proxy, or of the broker's
embedding policy would be found by a person rather than by the suite.

Worth stating plainly what this deployment is: the first one able to sign anybody
in at all. The production bundle never asked for its runtime configuration under
NODE_ENV=production, so it had neither password nor wallet to hand the data
layer, and the failure was invisible from outside — the server answered, the page
rendered, and nothing was wrong except that nobody could get in.
2026-08-17 10:48:35 +02:00
Sylvain Duchesne 61cbe6905d Trigger a redeploy: first run with the injected wallet and the external data layer
No change. This commit exists to give the deployment something to pick up.

What it deploys, already committed above: the data layer as a real external
dependency pinned to a commit and installed from Gitea, and the wallet arriving
through FESTIPOD_SHARED_WALLET_FILE_BASE64 instead of a file nothing ever put in
the image. Both were verified in a locally built image before this push.

The deployment environment must carry FESTIPOD_SHARED_WALLET_PASSWORD and
FESTIPOD_SHARED_WALLET_FILE_BASE64, and must NOT carry
FESTIPOD_SHARED_WALLET_FILE -- a path always wins over the base64 form and does
not fall through, so a leftover path variable would serve 404 for the wallet and
no one could sign in, with nothing saying why.
2026-08-17 10:34:49 +02:00
Sylvain Duchesne 95479ebe77 The image builds, serves the injected wallet, and asks for its configuration
Built and run for the first time in this shape. `pnpm install --frozen-lockfile`
fetches the external data layer from Gitea inside the install stage and succeeds
-- the step that refused an hour ago. Run with only the password and the base64
wallet set, and no path variable, the container serves 810 bytes at
/shared-wallet.ngw with the same sha256 as the original file.

The check that mattered most: the production bundle contains exactly one call to
/festipod-config.json. That call being absent under NODE_ENV=production is what
made the deployed application unable to sign anybody in, and its absence is
invisible from outside -- the server answers, the page renders, the endpoint
works, and nothing asks it anything.

Two doctrine claims the build disproved, corrected rather than left standing:

The `bun` peer shim the deployment notes describe as a live hazard does not
appear at all in a built image -- `bun` is absent from node_modules/.bin and
resolves to the base image's binary. The `onlyBuiltDependencies` approval is
currently inert. Kept, since it costs nothing and the shape can return, but the
paragraph now says dormant instead of describing a mechanism that is not running.

Asset paths are emitted as `/../chunk-*.js`. Browsers normalise that at the root
and the existing deployment already passes it through its proxy, so it works --
but a proxy that treats `..` segments differently would break asset loading, and
the symptom would be a blank page with 404s on chunks, naming nothing.
2026-08-17 10:22:07 +02:00
Sylvain Duchesne c3d64555d9 The data layer is a real external dependency, pinned to a commit
`package.json` named `#main`, where the renamed `packages/polyfill` does not
exist, and `pnpm-lock.yaml` still named the package as it was called in July.
`pnpm install --frozen-lockfile` -- the Dockerfile's install step -- refused on
exactly that mismatch, so the image could not build at all. Both now name the
commit the branch was pushed at, and the frozen install passes.

The dependency is now genuinely external: installed from git into pnpm's store,
with no local overlay anywhere in the resolution. The invariant that justified
the overlay in the first place holds -- `@ng-org/web` resolves to the same
realpath from the application and from the installed package, so one instance
and one verifier. A git-installed package does not carry its own `@ng-org`,
where a symlink to the checkout would have.

`.project/contracts.yaml` names the same commit as the dependency, so the surface
the app is coded against and the package it installs designate one state, and a
drift between them becomes visible instead of silent. A tag will replace the
commit once the provider settles a convention; the invariant is that the two
agree, whatever the ref.

Deployment doctrine now carries what a deployer needs and did not have: the
pinning discipline, that the frozen install only verifies, the wallet arriving
through the environment with its one-directional precedence and its loud failure
on a malformed value, that neither the wallet nor its password is a secret, that
tailwindcss is a devDependency the server needs at serve time, and that a
deployed origin's embedding in the broker's iframe is covered by no test.

Nothing has been deployed in this shape yet, and the doctrine says so.
2026-08-17 10:17:53 +02:00
Sylvain Duchesne ac29735d20 A deployment can carry the wallet in its environment, not on a mounted disk
The contract requires the application to serve a wallet file and hand its URL
and password to the data layer. Until now the only source was a filesystem
path, which no container has: `*.ngw` is gitignored, `COPY . .` brings none, and
nothing mounts one -- so a deployed instance served 404 where the contract
expects bytes.

`FESTIPOD_SHARED_WALLET_FILE_BASE64` carries the file itself. It is 810 bytes,
and it is not a secret: by design the application hands it to every user who
opens the app, so provisioning persistent storage would be guarding something
public. One channel for both values, nothing to mount, and a new host needs only
its variables.

Precedence is deliberate and one-directional: the path always wins when set, and
an unreadable path does NOT fall through to the base64 form. A deployment sets
exactly one; both set is a leftover, not a fallback chain. Local development and
the test harness only ever set the path, so they are untouched.

A malformed value answers 500 and names the variable. Answering 404 would have
made "configured wrong" indistinguishable from "not configured at all" -- the
confusion this codebase has spent two days removing.

Proven by serving the same wallet from each source in turn and comparing:
identical size, identical sha256. The first attempt at that proof silently
exercised the path branch, because Bun auto-loads `.env` and the variable was
already there; the checksums matched for the wrong reason. Caught, cleared, and
measured again.
2026-08-17 09:34:19 +02:00
Sylvain Duchesne e780c5246c The count defect was two things; only the one that is not ours is left
`bug_participant-count-stays-at-zero` described a failure that no longer exists
and an open question that turned out to be the same failure seen from the other
side. It becomes `caveat_participant-count-one-connection-lag`, and the type
change is the point: what remains is a bounded delay whose cause sits entirely
outside the application, not a defect this repo can act on.

Gone from it: the multi-inbox race, "never converges", and the withdrawal
asymmetry recorded as unexplained. That asymmetry WAS the race — with one inbox
per document both paths now share the identical one-connection lag, so nothing
is left unaccounted for.

What it says now: the count needs one connection more than the write that
produced it, because the layer does not notify you of your own actions — a
deposit into an inbox you watch raises no push, and a write to your own document
is not re-read in the writing session. Both measured, both raised with the
provider, and neither compensated here: a retry or a poll is precisely what the
doctrine forbids.

Repaired alongside: the leaves that still described DELETE-then-INSERT, the
materializer's old shape, and the probe cookbook's tally of open defects. The
two new primitives are recorded where the shared utilities are listed, marked
unit-tested.
2026-08-17 00:10:16 +02:00
Sylvain Duchesne 0d925c7cb9 One inbox per document: the sign-up's deposit now lands where the owner is looking
Creating an event resolved its inbox four times at once -- from `createEvent`,
from the materializer, from the watch callback and from the watch wiring --
with nothing serialising them. Three inboxes were registered for that one
document inside 0.3 s, so the owner watched one while the sign-up deposited into
another. That is the whole of the asymmetry: on any later connection nothing
re-registers, both sides agree, and withdrawal converged immediately while a
sign-up never did.

Measured before: 2 of 3 fresh sign-ups NEVER converged, the deposit unfindable
on every later connection. Measured after, twice: one inbox, one caller joining
the in-flight resolution instead of opening a second, the deposit read back, and
nothing failing to converge.

The fix is two primitives rather than a lock in the middle of the data context,
each unit-tested on its own: a resolve-once-per-key whose rejection is NOT
memoized (unknown is not absent), and a serial task whose mid-run requests
coalesce into one follow-up and which a failure cannot wedge. The single-flight
wrapper is now the only caller of the underlying entry, so every call site is
covered without touching any of them.

Also closed on the same path: the write guard carries a monotonic cycle number,
so a cycle from an earlier effect run cannot overwrite a fresher count; the
field update is one statement instead of DELETE-then-INSERT, closing the window
where a reader saw the field absent and read zero; and the materializer's
before-value comes from a ref instead of a stale closure.

What is NOT fixed, deliberately: the count still takes one connection to appear.
A deposit you make into an inbox you watch produces no push, and neither does a
write to your own document -- both are questions for the provider, and any
app-side substitute would be the polling the doctrine forbids.
2026-08-17 00:02:27 +02:00
Sylvain Duchesne ff26f26e60 Close the fixed blocker, and record what withdrawal converging faster does NOT mean
Six variations against the real application, one thing varied at a time: the
sign-up-breaks-the-next-connection defect is gone. New identity, immediate
reload or delayed; an identity already used for several cycles; and the very
identity poisoned by the pre-fix code, which now reconnects cleanly three times
out of three. So nothing needed healing -- the fix does not merely stop writing
the bad state, it makes what was already written harmless. The leaf is deleted
rather than graduated: its reproduction method, the stale-server trap it led to,
and "a provider gap is not worked around here" all live elsewhere already.

The count defect stays open, with its shape now known: it does converge across
connections, but takes one more than expected -- the first reconnect still shows
zero, the second shows the value.

And a correction worth keeping. The explanation offered for why withdrawal
converges in a single connection -- that it writes straight to the owner's
document instead of depositing -- is contradicted by the code: it deposits its
own marker into the same inbox, drained by the same owner routine, "symmetric"
by the code's own comment. The asymmetry is real and recorded as verified; its
cause is recorded as unknown rather than filled with a plausible story. Same
mechanism, same inbox, two connections against one, is a lead worth having
honestly.

The dev-server caveat is upgraded from inferred to verified, and it is worse
than it read: an edit to application source triggers a genuine rebuild, new
bundle hash and all, and that rebuild still carries the stale dependency. Only a
restart works, and "touch a file to force a rebuild" is now explicitly ruled out
as a substitute.
2026-08-16 22:40:08 +02:00
Sylvain Duchesne ac55dc96a4 A refreshed data-layer package never reaches a running dev server
`link:polyfill` overlays the package into node_modules and keeps it current.
That is all it does. A `bun run dev` already running goes on serving what it
loaded at startup, however many times the overlay is rewritten underneath it:
node_modules is excluded by file watchers, so the change lands in the one place
nothing is looking.

The script promised the opposite -- that `bun --hot` would reload the copied
file live. That promise is now removed, and replaced by the instruction to
restart.

It cost about an hour. A defect had been fixed upstream, the overlay refreshed,
and a probe on a freshly launched server confirmed the fix 3 runs out of 3. The
same sequence by hand reproduced the defect at once. The two observations looked
irreconcilable and the hunt went to the wallet, to prior state, to timing.

The dev server had been up for six days. It predated the package rename and the
whole migration; the browser was running a different application from the one
under test. Nothing warned: a stale server looks exactly like a current one, and
the script's own header ruled out the true cause for anyone who trusted it.

The reflex is written down with the leaf: when a fix seems not to take, or when
a hand-run and an automated run disagree, check how long the server has been up
before anything else. One command, cheapest hypothesis first.
2026-08-16 22:06:52 +02:00
Sylvain Duchesne 4148df8fcb Record what running the flow proved, and fix the leaf that caused one defect
The create-and-participate flow was driven in a real browser for the first time.
It had been called correct by construction -- typecheck, build, reading -- and
the probe found three defects none of those could see.

Two open bugs, both major, both filed rather than worked around:
signing up to your own event makes the NEXT connection fail outright
(`ensureIdentity()` rejects inside the data layer's own inbox processing, 3/3,
reproduced on a fresh origin and identity), and the participant count does not
converge in the same session (2/2, 120 s and 75 s). Whether it converges at the
next connection is recorded as UNKNOWN and unmeasurable, because the first bug
stops the app from getting there.

The doctrine defect is the one worth the trouble. `knowledge_build-pipeline`
said production builds into `dist/`; `knowledge_deployment` said the container
runs `bun run start` from `src/`. Both were written down, they contradicted each
other, and the code followed the wrong one -- which is how a deployed app that
could sign nobody in was shipped. The three paths now live in one table whose
discriminating column is what is actually served: dev `src/`, production `src/`,
and `dist/` served by nothing at all. A build artefact nobody serves is a trap
for the next reader who assumes otherwise.

The probe method itself is written down: the suite cannot run, a targeted probe
can, and the difference is worth knowing before concluding that nothing is
measurable.

Stated once where a reader meets it: honest steps do not add up to an honest
flow. Every gesture in the sign-up reports correctly, and the user is still told
they participate while the count never moves and the next connection fails.
2026-08-16 16:46:10 +02:00
Sylvain Duchesne fa934ccdc6 Production serves from source, so it must fetch its config like everything else
The deployed application could never sign anybody in.

`loadRuntimeConfig` skipped fetching `/festipod-config.json` under
`NODE_ENV=production`, reasoning that a production build has the value inlined
by `build.ts`'s `define`. This project's production does not build: the
container copies the sources and runs `bun run start`
(= `NODE_ENV=production bun src/index.ts`), serving from `src/` exactly as dev
does. Nothing serves `dist/` at all. So the one step that could supply a wallet
was skipped, `ensureIdentity()` threw for want of one, and the endpoint sat
there — served, and never asked.

The condition tested the wrong thing. "Was the value inlined?" is a question the
global itself answers; "am I in production?" only ever stood in for it, and the
stand-in was false on the very path that matters.

Verified on the served bundle rather than on the endpoint: under
`NODE_ENV=production` it now carries the fetch, where it carried none.

The same combination is what `hooks.ts` spawns for the @e2e app server, so that
layer could not sign in either.
2026-08-16 16:27:00 +02:00
Sylvain Duchesne cebd54c978 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.
2026-08-16 15:25:34 +02:00
Sylvain Duchesne 13eb2c4a15 Waits that say ten seconds now wait ten seconds, and editing checks you may
Two things that announced what they had not verified.

Seventeen `waitForFunction` calls passed their timeout in Playwright's ARGUMENT
slot instead of its options slot, so every one of them silently used the 30 s
default while the code read 5, 10, 15 or 60. The inventory said sixteen: one was
a false positive and two more were found that it never listed.

All seventeen are corrected, including the nine whose written value is SHORTER
than the default. Honouring the author's number is the point: a wait that is too
short fails loudly and names its step, where thirty seconds obtained by accident
hides a real slowness and reads as a lie in the source. Which of them need
raising is a question for the day the suite can run again -- it will be answered
on an honest number.

The event edit screen awaited nothing: the success toast fired and the screen
navigated away whether or not the write resolved. It now confirms after the
write, keeps the user on their edits when it fails, and says so.

That route was also unguarded -- anyone reaching the URL got the form, for any
event. It is now decided by ownership, read from the list of my own documents,
with the same three-state answer the pencil icon uses. UNKNOWN renders neither
the form nor a bounce: both would present a guess as a fact, and the guess that
matters here is telling a genuine owner their event is not theirs.
2026-08-16 15:16:46 +02:00
Sylvain Duchesne db3dbba294 No fixture seed, no event host, and the edit affordance stops lying
Three changes the product model asked for.

The fixture seed no longer writes anything into a connected wallet, by any
route. `bootstrapWallet` is the single enforcement point -- both call sites
funnel through it -- so the switch cannot be walked around by a screen or a
bridge. The fixtures, the seeding code, the demo path and the rendering tests
are untouched; a unit test now fails if a document is created after all.

An event has no host. The domain says so -- the meeting point has a host, the
event is only the anchor -- while the shape carried `hostName`/`hostInitials`
and every created event was written with the fabricated `'Moi'` / `'MD'`. Gone
from the shape, the ORM bindings, the type, the adapters, the writes and the
screens. `fp:MeetingPoint.host` stays: that one is real.

Regenerating the ORM revealed the committed bindings had drifted from what the
generator emits -- stylistic, verified predicate by predicate, plus the loss of
the `Fp` prefix. The prefix cannot be restored at the generator: the name comes
from the shape IRI, and those IRIs are the persisted RDF classes. Aliased at the
three import sites instead, so nothing downstream moved and the DOM `Event` and
`Notification` types are never shadowed.

Write rights are ownership, read from the list
The contract leaves no other reading -- only an owner writes, and no call adds a
writer -- so `listMyEntityDocs('public')` is what says which events are mine.
The hard-coded `isOwner = true` is replaced by a three-state answer, and the
UNKNOWN state renders neither a pencil nor a greyed one: a disabled look-alike
invites a dead click.

Two adversarial passes refuted the first attempt and both defects are fixed. A
latched boolean denied an owner their own event forever once a listing had
missed it; the ruling is now rebuilt rather than accumulated, so a later listing
overturns an earlier one.

Residual, deliberate and commented: "not mine" is inferred from absence, and the
reactive read and the listing are separate mechanisms, so a freshly arrived
event is ruled out for the window between them. Closing it needs a timer, which
the doctrine forbids.
2026-08-16 14:50:34 +02:00
Sylvain Duchesne 9740841820 A demo wallet keeps working: pick one of my own profiles, and say it is arbitrary
Resolving "my profile" by ownership left one case refusing: several profile
documents are mine and none was created by this session, which is exactly a
reloaded wallet carrying the fixture seed. Sign-up then rejected — in the very
flow being built.

The first by document reference is now used, stable across reloads and openly
arbitrary. While the profile is not a built feature, "which of my fixtures am
I" has no true answer and does not need one.

This is not the impersonation that was removed. That one reached for a profile
by NAME and could land on a document belonging to somebody else; every candidate
here is a document I own. The invariant that matters holds: the app never
presents another person's profile as mine.

Downgraded to a warning, and reworded: the log now says the name shown as yours
is demo data rather than announcing a refusal that no longer happens.
2026-08-16 13:53:00 +02:00
Sylvain Duchesne df971df135 Who I am comes from signing in; my profile is the document I own
The app derived its identity from a profile lookup and, when nothing matched,
picked somebody else. That is backwards: signing in returns who I am, and the
profile is looked up by it.

- Identity and profile are now two things. The identity is what
  `ensureIdentity()` returns: opaque, never rendered, never written, never
  passed to a data-layer call. The profile is Festipod's own object -- pseudo,
  name, initials -- in a document we create and write.
- "My profile" is the profile document I own, resolved through
  `listMyEntityDocs('protected')`. No username matching, no positional pick. A
  failed listing leaves the answer UNKNOWN rather than collapsing to "none".
- Having no profile now resolves to having no profile. Two impersonation
  fallbacks are gone, including one in `updateProfile` that would have written
  your pseudo into a stranger's document.
- A profile is created at sign-in when none exists. The shape makes name,
  initials and username mandatory, so it is written with placeholders that read
  as instructions -- never a plausible human name, never anything derived from
  the opaque identity.

Nothing succeeds in silence any more
`joinEvent` used to return without writing and without throwing when it could
not attribute the participation, while the screen announced success. It rejects
now, and the confirmation follows the write. Withdrawal likewise -- the doctrine
requires it to be authoritative. The host notification stops being written into
the joiner's own store, where its recipient could never read it, and the
optimistic notice shown to the wrong person goes with it.

The creator signs up through the common path: no owner branch anywhere, no
special case, the same deposit and the same derived count.
2026-08-16 13:50:50 +02:00
Sylvain Duchesne 53c0e095cf Code against the polyfill's published contract, and nothing else
The data layer is now reached through one pulled, version-pinned engagement
(`.project/concepts/data-layer/contract_polyfill-surface.md`, @1ecf511e9d).
That copy is the only reference: the provider's sources are never opened, and
what the contract does not answer is a gap raised with it, never worked around
here.

Surface
- `@ng-eventually/sdk` -> `@ng-eventually/polyfill`, one entry point.
- `configure` loses `getSession`, `normalizeId`, `currentUser`; the session
  belongs to the package and its own `init` captures it.
- Placement is named by scope alone -- a session is one user, so the app no
  longer passes an identity it had no way to obtain. This removes a constant
  that made every user collide on one owner's document.
- `init(...)` then `await ensureIdentity()`, in that order, as one sequence:
  React runs child effects first, so the two calls sat in the wrong order and
  the contract now makes that throw.
- `sessionId` relayed as `string | number`, `materialize` -> `read`.

A rejection means "unknown", never "absent"
Four places treated a caught error as an empty result. The worst wrote a
duplicate participation: an unknown count read as zero defeated the idempotence
guard of `joinEvent`. Also fixed: a per-document count, a silently dropped
notification shown optimistically anyway, and a failed listing that left the
owned-event set empty and disabled the materializer for the whole session.

Shared identity is not a Festipod notion
A browser context is one user. The per-scenario identity plant is deleted at
its source and its five sites; what stays is the deployment's wallet file,
which the contract requires an application to serve.

Documentation
The doctrine no longer describes how the data layer works underneath: five
leaves whose subject was internals are gone, a dozen more are re-founded on the
contract's own words, and two frozen arbitrations about a deleted screen were
removed rather than left to mislead a future session.

Test harness
It can sign in at last: cucumber runs under node, which does not load `.env`,
so the harness never received the wallet material and every scenario silently
fell back to an empty local mode. A failed sign-in is now loud on both sides.
The suite also releases what it opens and exits on its own -- runs were still
resident hours after reporting, holding a browser and two servers.

Known red: `@data` cannot be measured. The served wallet accumulates and
nothing resets it; moving the browser profile aside does not, since the data
lives in the wallet file, not the profile.
2026-08-16 12:33:14 +02:00
Sylvain Duchesne 47af46fd09 Probe a protected ENTITY, not the protected store, in the connections scenario
The scenario reads "an account not connected to another does not read its
protected ENTITY, then reads it after connecting", but the probe was reading
`did🆖${protected_store_id}` — the STORE document — and writing its test
entities straight into it.

Under an ACL that shortcut was harmless. Under key possession it is wrong, and
for a reason the model states outright: sharing a store capability would hand
over everything the store contains, present and future. The unit of sharing is
the document. `declareConnections` therefore shares the keys of entity
documents, the store is not one of them, and the reader legitimately saw
nothing. The code was right; the probe was standing in the store for an entity.
Writing several entities into a store-level document also broke this repo's own
one-document-per-entity rule.

The probe now creates a real protected entity document, writes the entity there,
and mounts its subscription on THAT document.

A second defect surfaced while fixing the first: `connect()` asserted both
directions from the READER's session, but a capability can only be shared by
whoever holds it, and `capFor` answers for the connected identity alone — so the
owner-side call returned early having shared nothing. Each direction is now
asserted from its own session, and the reader drains its inbox afterwards.

The reader is a genuine second identity (per-run identifiers give it its own
account, stores, inbox and keyring), not the same one in disguise — a test that
passes because the state is unreal proves nothing. Checked by breaking it on
purpose: without `connect` it fails with `expected +0 to equal 1`.

Also recorded, and worth knowing before writing another probe: `resetCaps()`
clears the "a capability was issued" flag, which disarms the read filter
entirely — it has to run BEFORE the first mint, or reads go straight through and
the reader sees everything.

tsc 0, @ui 7/7, target scenario green, read-filter not regressed.
2026-08-03 14:10:44 +02:00
Sylvain Duchesne c1817607b4 Migrate Festipod onto the rebuilt @ng-eventually/client surface
The SDK was rebuilt: reading is possession instead of an ACL, `Nuri` and
`ReadCap` are template literal types, the cross-account fan-out is gone, and so
is the global discovery index.

Repair the typecheck gate FIRST — it was checking nothing. Under TypeScript 6 the
deprecated `baseUrl` is reported as an ERROR that aborts compilation, so
`tsc --noEmit` exited 0 having verified nothing, behind a single line that reads
like a harmless warning. Dropping `baseUrl` (paths resolve relative to the file
since 4.4) makes the gate real again — and it immediately surfaced 33 errors,
three of which had been dormant for a long time.

Types: 18 sites fixed AT THE SOURCE — the functions that produce a NURI now
return `Nuri` — with `isNuri` guards only at genuine boundaries (an `@id` read
back from a document, an argument coming from a Cucumber step). No cast, no
`@ts-ignore`: silencing the compiler here would have removed the very guarantee
the new types provide.

Capabilities: the ACL is gone. `grantRead`/`protectedDocsOf`/`canRead`/
`makePublic` give way to `capFor`/`shareCap`/`publishRepoLink`, and `open` loses
its `owner` argument. `declareConnections` now shares the caps of its OWN
protected documents to each neighbour's wallet inbox.

Discovery is REMOVED, not postponed: there is no discovery in the target model,
a reader reaches a document only by following a link it was given. The module and
its call sites are gone; the scenario is suspended with a comment saying what
will bring it back — a Festipod DIRECTORY document, whose link the app knows.
Kept rather than deleted: the product need has not gone away.

Verification, and a correction to how it was measured. The @data baseline (20/22)
had been taken on a bloated test wallet: 93 MB against a threshold documented
around 99 MB, with the run stretching from 18 to 23 minutes. Restarting from a
fresh profile drops it to 9m37 and turns BOTH baseline failures green — including
the cold-reconnection one, which confirms the SDK's claim that a fresh session
reads its own documents back with nothing re-declared. So the reference itself was
degraded, on both sides of the comparison.

Real state: typecheck 0, @ui 7/7, @data 20/21. The single failure is understood
and left standing: the protected-connections probe reads the protected STORE
document as a stand-in for an entity. Sharing a store cap would hand over its
entire contents, present and future — precisely the gesture the model refuses. The
scenario's own title says "the protected ENTITY"; the probe is what took the
shortcut, and it is what has to change.
2026-08-03 13:53:32 +02:00
333 changed files with 39990 additions and 7912 deletions
+48 -17
View File
@@ -1,40 +1,71 @@
# Festipod — variables d'environnement (exemple)
#
# Copier en `.env` (chargé automatiquement par Bun) et renseigner les valeurs.
# En dev (`bun run dev`) ET en prod (`bun run start`), l'app sert depuis src/ et
# Copier en `.env` et renseigner les valeurs.
# En dev (`pnpm dev`) ET en prod (`bun run start`), l'app sert depuis src/ et
# lit ces variables au RUNTIME (via l'endpoint /festipod-config.json de src/index.ts).
# Sans elles, l'app tombe en mode dégradé : la barrière d'accès n'affiche que le
# champ identifiant, sans l'assistance de chargement du portefeuille partagé.
#
# REQUIS POUR LES TESTS. La suite Cucumber tourne sous `node` (pas sous Bun), qui
# ne charge pas `.env` tout seul : le harness le lit explicitement et LÈVE UNE
# ERREUR NOMMÉE si le mot de passe ou le fichier manquent. Or `.env` ET `*.ngw`
# sont tous deux gitignorés — un clone frais n'a donc ni l'un ni l'autre et ne
# peut pas exécuter `@data`/`@e2e` tant que ces deux valeurs ne sont pas fournies.
# ── Portefeuille partagé (stopgap staging) ─────────────────────────────────
# Mot de passe du portefeuille partagé.
# VIDE => hasSharedWallet() faux => la barrière n'affiche QUE le champ identifiant
# (pas les 3 étapes « télécharger + importer le portefeuille »). REQUIS en staging
# pour l'onboarding d'un appareil qui n'a pas encore de wallet.
# VIDE => rien n'est passé à `configure({ sharedWallet })` => le SDK refuse de
# signer l'entrée et l'app affiche son panneau d'erreur au lieu de démarrer.
# REQUIS en staging (onboarding d'un appareil sans wallet) ET pour les tests.
FESTIPOD_SHARED_WALLET_PASSWORD=
# Chemin ABSOLU vers le fichier portefeuille partagé (.ngw). Servi en
# téléchargement à /shared-wallet.ngw depuis la barrière d'accès.
# Chemin vers le fichier portefeuille partagé (.ngw), absolu ou relatif à la
# racine. Servi en téléchargement à /shared-wallet.ngw. C'est la forme du DEV
# LOCAL et du harness de TESTS : le fichier vit sur le disque de la machine.
FESTIPOD_SHARED_WALLET_FILE=/chemin/absolu/vers/festipod-wallet.ngw
# Contenu du portefeuille partagé (.ngw), encodé en base64 — deuxième source
# pour le même fichier. C'est la forme des DÉPLOIEMENTS (conteneur) : *.ngw est
# gitignoré, donc `COPY . .` n'en embarque aucun et rien n'en monte un ; le
# fichier n'étant pas un secret (l'app le sert à quiconque ouvre l'app), il
# voyage comme une variable de config. `FESTIPOD_SHARED_WALLET_FILE` est
# prioritaire quand les deux sont renseignées — voir le commentaire dans
# src/index.ts. Générer la valeur avec, p.ex., `base64 -w0 festipod-wallet.ngw`.
FESTIPOD_SHARED_WALLET_FILE_BASE64=
# ── Seed automatique (opt-in) ──────────────────────────────────────────────
# Non vide => l'app amorce des données de démo dans un wallet VIDE au 1er login.
# OFF par défaut : laisser vide en usage normal.
FESTIPOD_AUTO_SEED=
# ── Serveur ────────────────────────────────────────────────────────────────
# Port HTTP du serveur (défaut 3000).
PORT=3000
# Port HTTP du serveur (4005 : l'app Festipod, paire 3005/4005 des apps sœurs).
PORT=4005
# NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide.
NODE_ENV=
# Le backend lit son propre backend/.env (voir backend/.env.example).
# ── Infrastructure locale (`make start`) ───────────────────────────────────
# docker compose lit aussi ce fichier. Port hôte du Redis de docker-compose-dev.yml,
# vide = 6379. Le changer si un autre Redis occupe ce port, et reporter la même
# valeur dans SEMAPPS_QUEUE_SERVICE_URL de backend/.env.
REDIS_PORT=
# ── Frontend / Backend coupling ────────────────────────────────────────────
# URL du backend telle que vue par le navigateur.
VITE_BACKEND_URL=http://localhost:3005
# Client ID OAuth/Solid du backend (laisser vide pour dériver `${VITE_BACKEND_URL}/as/actor/app`).
# Le client_id DOIT être l'URI de l'acteur app : le pod enregistre l'app sous cette URI et
# ne sert l'en-tête `Link: rel="interop:registeredAgent"` (découverte de l'enregistrement
# par le auth provider) que si le client_id égalise l'acteur.
VITE_BACKEND_CLIENT_ID=
# Pod provider ActivityPods par défaut (optionnel). Vide → l'écran de login demande une URL.
VITE_POD_PROVIDER_URL=http://localhost:3000
# ── Outillage dev (facultatif) ─────────────────────────────────────────────
# Override du chemin local du polyfill @ng-eventually/client pour `pnpm run
# link:polyfill` (lien local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/client.
# Override du chemin local du polyfill @ng-eventually/sdk pour `pnpm run
# overlay:polyfill` (overlay local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/sdk.
NG_EVENTUALLY_LOCAL=
# ── Build only (build.ts / `bun run build`, PAS le runtime) ────────────────
# ACCESS_GATE_DISABLED=1 => build SANS barrière d'accès (l'app démarre directement).
# Réservé à un build de démo/no-gate ; ne pas utiliser pour un déploiement réel.
ACCESS_GATE_DISABLED=
+6
View File
@@ -44,3 +44,9 @@ storybook-static
dist-staging/
*.ngw
.tasks/
# Local data of the dev stack (docker-compose-dev.yml): fuseki, redis, pod provider
/data
# Per-developer contract access map (canonical provider → local checkout) — never committed.
.project/contracts.local.yaml
@@ -19,7 +19,8 @@ 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` (principal) vs `currentUser.id` (profile NURI): two id spaces that are not interchangeable
- [[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)
- `tech-stack` — build, Bun bundler, commands
@@ -0,0 +1,25 @@
---
type: caveat
summary: The standalone boot (app opened directly, not in the broker iframe) is covered by NO test, and it broke SILENTLY once — a blank page with no error, because nothing started a session and ensureIdentity() then settled neither way. Fixed by making the session start unconditionally; still untested, so break it and you will not hear about it.
last_checked: 2026-08-10
---
# Pitfall: nothing tests the app booting outside the broker iframe
## What happened, VERIFIED
`AuthGate` awaits `ensureIdentity()` and renders **nothing** until it settles. `NextGraphProvider` used to start the NextGraph session **only inside the broker iframe** — standalone, the session was started by the user pressing "Entrer" on the app's own access screen.
That screen was deleted the same day (the SDK shows the barrier now, see [[decision_2026-08-10_sdk-renders-the-barrier]]), and the iframe-only condition survived it. Standalone, the result was: no session ever started → the `getSession` thunk never returned → `ensureIdentity()` **neither resolved nor rejected** → `AuthGate` returned `null` forever. **A blank page with nothing in the console.**
Note the shape of the failure, because it is the instructive part: a rejection would have been *shown* (`AuthGate` renders a named error panel). What produced silence was a promise that never settled at all — the one outcome no error path catches. Found by a human opening the app, not by any suite.
The fix: the session starts unconditionally, in the iframe and standalone alike, through one `startSession()` in `NextGraphContext`. Standalone, `initNg()` redirects to the broker — that redirect **is** the sign-in flow now that nothing is left to click.
## What is still true
**No test exercises this path.** `@data` runs the harness inside the broker iframe; `@e2e` drives the real app inside the broker iframe too. The standalone top-level boot — the one a developer uses every day with `bun run dev`, and the one a first-time visitor hits — is covered by nothing.
So: a change to `AuthGate`, to `NextGraphProvider`, or to what `configure()` receives can break the app's entry completely while every suite stays green. If you touch any of them, **open the app yourself** before believing the tests — and drive a whole flow rather than looking at the page, which is what a throwaway probe is for (`bdd-testing` → [[cookbook_live-probe]]).
Two related pieces: [[caveat_first-time-entry-untested]] (the wallet-import journey, same blind spot seen from the user's side) and [[caveat_shared-wallet-global-before-gate-import]] (a missing wallet password now makes `ensureIdentity()` throw, which at least fails loudly).
@@ -1,28 +1,36 @@
---
type: caveat
summary: A screen juggles TWO ids for the current user that are not interchangeable — currentUserId (principal urn:festipod:user:…) for participation/friendship queries, currentUser.id (profile NURI) to compare against rendered profiles; getting it wrong raises no error, it just yields an empty list or counts you as an unknown participant
last_checked: 2026-07-27
summary: Values a screen must not read as data once — currentUserId is EMPTY until my profile document resolves, an ownership answer can be UNKNOWN, and a useState seeded from an unresolved read (UpdateEventScreen) freezes blank; all look like ordinary values, none mean "no"
last_checked: 2026-08-17
---
# Pitfall: two ids for the current user inside a screen
# Pitfall: "not answered yet" looks exactly like an answer
`useFestipodData()` exposes **two** identifiers for the current user. They live in **different spaces** and are **never equal in connected mode**:
Two things a screen receives can be *unresolved*, and in both cases the unresolved form reads like an ordinary value. Nothing throws.
| Value | Space | What it is for |
|---|---|---|
| `currentUserId` | stable **principal** derived from the login identifier (`urn:festipod:user:<key>`) | this is what **participations** and **friendships** store |
| `currentUser.id` | **NURI of the profile document** (`did:ng:…`) | this is what rendered **profiles** carry |
## `currentUserId` is empty until my profile resolves
In seed/demo mode the two coincide (`user-1`) — **the pitfall only shows up when connected**, and never as an error: just a wrong result.
`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 rule
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.
- Queries that **filter participations/friendships** — `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` — expect the **principal**. Their default value (`currentUserId`) is correct; **do not pass them** a profile `user.id`, or the list comes back **empty**.
- `getEventParticipants(eventId)` returns **profiles**. Any comparison over its result (typically "remove myself from the list") therefore goes through **`currentUser?.id`**, never `currentUserId`.
- 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.
## What the mistake costs (observed)
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*.
- Comparing `participant.id !== currentUserId` to filter yourself out **removes nothing**: you show up in your own list, and since the row is no longer recognized it renders as « participant inconnu ».
- Symmetrically, a screen displaying **another user's** events from their **profile id** (`getUserEvents(viewedUser.id)`) yields an empty list when connected — same cause.
## An ownership answer can be UNKNOWN
The participation→profile join itself is **not** the screen's business: it is done in the provider (`resolveParticipantUser`), through the normalized identifier. 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]].
## A `useState` seed freezes on whatever the first render saw
`UpdateEventScreen` reads `const event = eventId ? getEvent(eventId) : undefined;` from the reactive data plane, then seeds every editable field from it: `useState(event?.title ?? '')`, and likewise for `startDate`, `endDate`, `startTime`, `endTime`, `location`, `description`. A `useState` initializer runs **once**, at mount — unlike a value read directly in the render body, it does not track `event` afterwards.
If the screen mounts before the event has landed in the reactive set — a direct navigation to the edit route, a slow reconnect — every field seeds to `''` and **stays blank**: the later, successful read of `event` never reaches state that already initialized. Nothing throws and nothing looks wrong; the form is simply empty. Same hazard as `currentUserId` and the ownership answer above — "not ready yet" reads as an ordinary value — just caught by `useState` instead of by a query result. Pre-existing, not fixed.
@@ -1,7 +1,7 @@
---
type: knowledge
summary: src/app/ is the app's real shell — App.tsx stacks the providers (Theme > NextGraph > Account > FestipodData > Router), AuthGate keeps every routed screen behind the access barrier, and the shell switches screens according to the route
last_checked: 2026-07-27
summary: src/app/ is the app's real shell — App.tsx stacks the providers (Theme > NextGraph > FestipodData > Router), AuthGate makes the one unconditional ensureIdentity() await and renders nothing of its own until it settles, and the shell switches screens according to the route
last_checked: 2026-08-10
---
# App shell
@@ -16,28 +16,22 @@ last_checked: 2026-07-27
```
ThemeProvider
└ NextGraphProvider (NextGraph connection cycle — concept data-layer)
└ AccountProvider (current identity = the identifier — concept app-security)
└ FestipodDataProvider (data, connected/demo mode — concept data-layer)
└ RouterProvider (current route + navigate)
└ div.app-container
├ AuthGate (access barrier)
│ └ AppContent (switch route.page → screen)
└ ToastContainer
└ NextGraphProvider (NextGraph connection cycle — concept data-layer)
└ FestipodDataProvider (data, connected/demo mode — concept data-layer)
└ RouterProvider (current route + navigate)
└ div.app-container
├ AuthGate (the one ensureIdentity() await; renders nothing of its own)
│ └ AppContent (switch route.page → screen)
└ ToastContainer
```
`AppContent` reads `useRouter()` to resolve `route.page` → the screen to render.
`AppContent` reads `useRouter()` to resolve `route.page` → the screen to render. **There is no identity provider**: the app names no identity of its own (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]), so there is nothing to hold above the data provider.
### Ordering invariants (what breaks if you move a layer)
- **`AccountProvider` sits ABOVE `FestipodDataProvider`.** The data provider calls `useAccount()` to derive its principal (`currentUserId`) *and* to reset its session when the identity changes. Reversing the order breaks the whole identity resolution, silently.
- **`AuthGate` sits INSIDE the router**: it reads `useRouter()`/`useNavigate()` to leave the logged-out landing route once connected **and** identified. Moving it out of `RouterProvider` breaks it.
- **`AuthGate` wraps EVERY routed screen.** As long as the wallet is not open **or** the identifier is not resolved, `AccessGateScreen` is rendered **instead of** `AppContent`. Consequence: **no screen may assume it is reachable without an identity** — unless the barrier is disabled (see below).
- **`ToastContainer` sits OUTSIDE `AuthGate`** (but inside `.app-container`): it is mounted regardless of the barrier's state.
### Disabling the barrier (two consumers)
`AuthGate` is **ON by default**; it steps aside only if `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true`, set either by `build.ts` (from `ACCESS_GATE_DISABLED=1`, barrier-free build) or by the test harness via `addInitScript` for the `@e2e` tests (which exercise the screens, not the auth flow). **Impact**: the barrier flow is therefore **not** covered by the `@e2e` tests — its guards are `@ui` tests (concept `bdd-testing`).
- **`AuthGate` sits INSIDE the router**: it reads `useRouter()`/`useNavigate()` to leave the logged-out landing route once identified. Moving it out of `RouterProvider` breaks it.
- **`AuthGate` wraps EVERY routed screen**, and it holds them behind **one** condition: the single `await ensureIdentity()` (`@ng-eventually/polyfill`) it fires unconditionally on mount has resolved. Until it does, `AuthGate` renders **nothing at all** — there is no Festipod screen standing in for `AppContent` while it waits (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]). `AuthGate` does not read `useNextGraph()` — no `status`, no `connect()`, no error branch of its own. The identity await is not decoration: `ensureIdentity()` also does the connection work (restoring what others shared with us), so a screen mounted before it resolves would read as an identity that is not yet settled. Consequence: **no screen may assume it is reachable without a settled identity**, and there is no longer a way to disable the barrier — see [[caveat_boot-unverified-outside-broker]] for the one path this leaves unverified.
- **`ToastContainer` sits OUTSIDE `AuthGate`** (but inside `.app-container`): it is mounted regardless of whether identity has settled.
## Entry points
@@ -45,6 +39,6 @@ ThemeProvider
|---|---|
| `src/index.ts` | `Bun.serve()` — HTTP server, serves `index.html` + the cucumber report |
| `src/index.html` | HTML entry point, loads `src/app/frontend.tsx` |
| `src/app/frontend.tsx` | React root, renders `<App />` |
| `src/app/frontend.tsx` | React root — pulls the runtime config, sets the wallet global, **then** dynamically imports and renders `<App />`; the order is the point (`tech-stack` → [[knowledge_build-pipeline]], `app-security` → [[caveat_shared-wallet-global-before-gate-import]]) |
The build and the bundler (Bun + Tailwind, alias `@/* → ./src/*`) are documented in the `tech-stack` concept.
@@ -30,11 +30,11 @@ Each module may contain:
| Directory | Contents |
|---|---|
| `components/` | UI component library (see [[knowledge_screens]]) |
| `context/` | `ThemeContext`, `NextGraphContext`, `AccountContext` (current identity — concept `app-security`), `FestipodDataContext` (concept `data-layer`); their **stacking order** is constrained, see [[knowledge_app-shell]] |
| `context/` | `ThemeContext`, `NextGraphContext`, `FestipodDataContext` (concept `data-layer`); their **stacking order** is constrained, see [[knowledge_app-shell]]. There is no identity context — the app names no identity of its own (concept `app-security`) |
| `data/` | User stories, `features.ts` (auto-generated), `seedData.ts`, `types.ts` |
| `hooks/` | `useShapeWithDefaults` (NextGraph) |
| `hooks/` | empty — the reactive read binding lives in `data/useShapeQuery.ts` (concept `data-layer`) |
| `shapes/` | SHEX + ORM bindings (see concept `data-layer`) |
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts` |
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts`, `storeRegistry.ts`, `connections.ts`, `identifier.ts`, `resolveOnce.ts` (single-flight resolution per key, unit-tested), `serialTask.ts` (a task that never runs concurrently with itself, unit-tested) |
| `steps/`, `support/` | Shared Cucumber step definitions and hooks (concept `bdd-testing`) |
| `lib/` | Helpers (`cn`, etc.) |
@@ -0,0 +1,15 @@
---
type: knowledge
summary: Signed in on a Pod, the app is held behind ProfileCompletionGate — a profile with an empty name shows the profile form in onboarding mode, a Pod with no readable profile shows an explanatory message; the only completion flag is the presence of a name
---
# Profile completion gate
`FestipodDataContextValue.profileStatus` (`'loading' | 'ready' | 'missing'`, absent on providers where it does not apply) is computed by the refine provider from the actor document and the profile read. `ProfileCompletionGate` (in the app shell, inside the data provider) uses it:
- `loading` → nothing is rendered yet;
- `missing` → a blocking, honest message: the Pod links no readable profile and Festipod cannot create or link one (outside the pinned contract);
- `ready` with an empty `currentUser.name` → `UpdateProfileScreen` in `onboarding` mode (explanation, no way out until saved, no pre-filled fiction);
- otherwise the routed screen.
The flag is the name itself — nothing is stored locally, so the form disappears for good once a name is saved and the profile re-read.
@@ -1,6 +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 on hub screens), feedback via showToast, hard-coded French labels
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
@@ -17,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 (
@@ -34,10 +36,11 @@ export function MyScreen() { // named function, NEVER any props
## Invariants
- **Zero props**: a screen receives nothing; everything comes from context/hooks (`useFestipodData`, `useNavigate`, `useParams`). Two exceptions, of different kinds:
- `WelcomeScreen` does not use `useFestipodData` (intro) — but still takes no props. (`LoginScreen`/`ConnexionScreen` no longer exist.)
- **`AccessGateScreen` is the only genuine exception to the zero-prop rule**: it is **not a routed screen**, it is rendered by `src/app/AuthGate.tsx`, which passes it `status`/`error`/`initialIdentifier`/`onEnter`. It therefore sits **outside the registry and outside the route table**, and has access to neither the router nor the data. See [[knowledge_screens]] and [[knowledge_app-shell]].
- **Identity: two id spaces.** `currentUserId` (principal) and `currentUser.id` (profile NURI) are **not** interchangeable depending on the query — see [[caveat_identity-ids-in-screens]] before comparing an id inside a screen.
- **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 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.
- **Reading what the session has not read yet is asked for from an effect.** A screen about one event calls `openEvent(eventId)`, a screen about someone else's events calls `openUserEvents(userId)` (`UserProfileScreen`); both are state changes, so they run in a `useEffect` keyed on the id, never during render, and the getters (`getEvent`, `getUserEvents`) answer with what has landed. They are idempotent and are no-ops on providers whose read set is already complete (local fixtures); `openUserEvents` is also a no-op for oneself and for anyone who is not a contact. Verified in `festipodDataBase.ts` (the context contract), `refineData.tsx` (the ActivityPods implementation).
- **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,6 +1,7 @@
---
type: knowledge
summary: Inventory of screens per module, central registry src/screens/index.ts, and the component library under shared/components/sketchy/ — whose NAME is kept but which renders a modern theme (not hand-drawn)
summary: Inventory of screens per module, central registry src/screens/index.ts, and the component library under shared/components/sketchy/ — whose NAME is kept but which renders a modern theme (not hand-drawn); the auth module now holds only WelcomeScreen, no access screen of its own
last_checked: 2026-08-10
---
# Screens and components
@@ -30,11 +31,9 @@ Screens per module (IDs = registry keys):
- **home/**: `welcome`, `home`, `settings`
- **event/**: `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
- **user/**: `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
- **auth/**: `WelcomeScreen` (intro, routed at `/`) and `AccessGateScreen` — the **access barrier** (NextGraph login + identifier entry), rendered by `src/app/AuthGate.tsx`, **outside the registry and outside routing** (it is not a routed screen) and **driven by props** (`status`/`error`/`initialIdentifier`/`onEnter`), the only exception to the zero-prop rule ([[knowledge_screen-pattern]]). The former `LoginScreen`, then `ConnexionScreen`, have been removed (see concept `app-security`, [[knowledge_authentication]]).
- **auth/**: `WelcomeScreen` (intro, routed at `/`) is the only screen left in this module. Festipod renders **no access screen of its own** any more: `AccessGateScreen`, its route and its registration are deleted, along with the `LoginScreen`/`ConnexionScreen` that preceded it. Signing in is `src/app/AuthGate.tsx`'s single `await ensureIdentity()`; whatever a user sees or does while that resolves is drawn entirely by the SDK, outside the registry, outside routing, and outside this app's component tree (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]).
Structurally, this screen does **not** render a standard screen layout but a **choice between three mutually exclusive access branches**, driven by `status` + the presence of a shared wallet. **Impact**: a new access case is added as a branch here, **not** as a route. The content and ordering of the branches are `app-security` doctrine ([[knowledge_authentication]]) — do not redefine them from here.
> The path → screen mapping lives in [[knowledge_routing]]. Most screens consume `useFestipodData()` (concept `data-layer`); exceptions: `WelcomeScreen` and the `AccessGateScreen` barrier.
> The path → screen mapping lives in [[knowledge_routing]]. Most screens consume `useFestipodData()` (concept `data-layer`); the exception is `WelcomeScreen`.
## Pitfall: incomplete registry
+11 -6
View File
@@ -2,7 +2,7 @@
type: _overview
summary: Festipod's security & privacy — isolation between scopes is enforced by the data SDK, the app trusts it and carries no authorization logic in the screens; wallet-based authentication; target authorization matrix still incubating
triggers:
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, anonymat, pseudonyme, traçage, corrélation, overlay, cap-less, identité, login, scope, isolation]
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, anonymat, pseudonyme, traçage, corrélation, identité, login, scope, isolation]
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
---
@@ -10,17 +10,22 @@ triggers:
Festipod's **security, privacy and authorization** model.
- **Enforced model** — **isolation between scopes** (public / protected / private) is **enforced by the data SDK** (`@ng-eventually/client`), 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]].
- **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 (read BEFORE designing anything "anonymous")
## Pitfalls
- [[caveat_stable-overlay-pseudonym]] — a cap-less reference exposes a **permanent pseudonym** of the person; a single cross-reference de-anonymizes their entire history **retroactively**, and no rotation is known
- [[caveat_shared-wallet-global-before-gate-import]] — since the shared wallet is the only mode, a password global set **after** the barrier has been imported makes it unusable (config error screen, no field at all)
- [[caveat_shared-wallet-global-before-gate-import]] — a wallet-password global set **after** `sharedWallet.ts` has been imported makes `ensureIdentity()` throw and the app render nothing, silently
> **Before designing anything "anonymous"**, read the closing section of [[knowledge_trust-model]]: the contract guarantees isolation, never anonymity, so a Festipod action that circulates a reference to someone's document is pseudonymous at best.
## Links
- [[knowledge_trust-model]] — the app delegates isolation to the SDK, no access control in the screens
- [[knowledge_authentication]] — wallet-based auth, everyone authenticated, no anonymous access
- [[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
@@ -1,21 +1,22 @@
---
type: caveat
summary: The shared wallet password is captured at the EVALUATION of src/modules/auth/sharedWallet.ts; now that the shared wallet is the only mode, a value missing at that instant no longer yields a degraded form but a configuration error screen WITHOUT any identifier field — every entry point that renders AccessGateScreen must set the global BEFORE the module is first imported
last_checked: 2026-07-27
summary: The wallet password is captured at the EVALUATION of src/shared/utils/sharedWallet.ts — a value set after that first import is never re-read, and the fetch that sets it must never be gated on NODE_ENV (production serves from src/). Missing it: ensureIdentity() throws, AuthGate shows the reason.
last_checked: 2026-08-16
---
# Pitfall: set the shared-wallet global BEFORE importing the barrier
# Pitfall: set the wallet-password global BEFORE the module is first imported
**The invariant.** `src/modules/auth/sharedWallet.ts` reads `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` **exactly once, at module evaluation** (the `SHARED_WALLET_PASSWORD` constant, surfaced by `hasSharedWallet()`). A value set *after* that first import is never re-read.
The contract requires a deployment to **serve a wallet file and pass its URL and password to `configure`** ([[contract_polyfill-surface]]). Festipod does that from one module, and *when* that module is evaluated decides whether the value arrives at all.
**Why it became blocking.** As long as "no shared wallet" was a fallback mode, a missing global degraded into a still-usable form — evaluation order was cosmetic. Since [[decision_2026-07-20_shared-wallet-only-mode]], `hasSharedWallet() === false` is a **configuration error**: `AccessGateScreen` renders an error block **with no identifier field**. The barrier becomes a dead end, not a degraded login.
**The invariant.** `src/shared/utils/sharedWallet.ts` reads `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` **exactly once, at module evaluation** (the `SHARED_WALLET_PASSWORD` constant, surfaced by `hasSharedWallet()`). A value set *after* that first import is never re-read. This module used to be `src/modules/auth/sharedWallet.ts`; that file, and `AccessGateScreen` which was its only reason to sit in the `auth` module, are both deleted — the surviving copy lives in `shared/utils/` and is imported by `src/shared/utils/ngSession.ts`, which reads `hasSharedWallet()` to decide whether to pass a `sharedWallet` config into the SDK's `configure()`.
**Why it still matters, and how the consequence changed.** `hasSharedWallet() === false` is a **misconfiguration**, not a degraded mode: the contract makes serving a wallet file and passing its URL and password a deployment requirement, so an app without them cannot sign anyone in. With no `sharedWallet` passed, `ensureIdentity()` **throws**, and `AuthGate` shows its named error panel — loud, which is the point. What must never come back is a silent fallback that renders screens anyway: a session that failed looks exactly like an account that owns nothing.
## Impact — if I touch X, Y breaks
- **Static import = trap.** A static `import` of `AccessGateScreen` (or of any module that transitively reaches `sharedWallet.ts`) from an entry point that sets the global itself is **hoisted above the assignment** → empty password → error screen, with no JS error to signal it. The remedy is a **dynamic import** (`await import(...)`) executed after setting the global.
- **Entry points concerned today**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports the app dynamically — mechanics detailed in tech-stack → [[knowledge_build-pipeline]]) and the `@ui` harness that renders the barrier (`src/modules/auth/steps/ui/barriere-acces.steps.ts`, same set-then-lazy-import sequence). A bundle produced by `build.ts` is **not** concerned: there the value is inlined by `define`.
- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` serves **no** working barrier at all — by design (fail loudly). Treat it as a configuration outage, not as a screen bug.
- **Static import = trap.** A static `import` reaching `ngSession.ts` (hence `sharedWallet.ts`) from an entry point that sets the global itself is **hoisted above the assignment** → empty password → the failure mode above, with no JS error at the import site to signal it. The remedy is a **dynamic import** (`await import(...)`) executed after setting the global.
- **The real entry point that must get this right**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports `App` dynamically — mechanics in `tech-stack` → [[knowledge_build-pipeline]]). That fetch may be skipped on **one** condition: the global is already set, which only a `build.ts` bundle's `define` does. **Never on an `NODE_ENV` test** — this project's production serves from `src/` exactly like dev, so gating the fetch on "production" is what removed the wallet from the deployed app and left it unable to sign anybody in.
- **`@ui` reaches the module too, but harmlessly today.** `screens/index.ts` eagerly imports every screen including `SettingsScreen`, which imports `ngSession.ts` — so any `@ui` test already evaluates `sharedWallet.ts` with the global unset. This does not currently break anything because no `@ui` path calls `ensureIdentity()` (`renderScreen()` bypasses `AuthGate`/`NextGraphProvider` entirely); see `bdd-testing` → [[knowledge_ui-layer]] for the detail and for what would make it stop being harmless.
- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` cannot sign anyone in, and it **says so** — `ensureIdentity()` rejects and `AuthGate` renders its named error panel carrying the reason. Verified live on a rejected sign-in: a refusal shows the reason, not a blank page (`bdd-testing` → [[cookbook_live-probe]]). The failure mode still worth fearing is the **silent** one: a promise that never settles either way renders nothing at all and logs nothing — `app-architecture` → [[caveat_boot-unverified-outside-broker]].
**Verified (2026-07-27)**: capture at evaluation time in `sharedWallet.ts`, and the `!hasSharedWallet()` guard as the first branch of `AccessGateScreen`.
> Caveat: the header of `sharedWallet.ts` still describes the old fallback ("the gate falls back to the plain flow") — an obsolete comment; what `AccessGateScreen` actually renders is authoritative.
**Verified (2026-08-10)**: capture at evaluation time in `src/shared/utils/sharedWallet.ts`; the `sharedWallet: hasSharedWallet() ? {...} : undefined` branch in `ngSession.ts`'s `configure()` call; the `throw` in `ensureIdentity()` when no `sharedWallet` config is present; `AuthGate`'s `.catch(err => console.error(...))` with no fallback UI.
@@ -1,41 +0,0 @@
---
type: caveat
summary: Any cap-less reference to a person's protected document exposes the `:v:` of their store — a STABLE AND PERMANENT pseudonym, identical everywhere and forever. It does not say who, but a single cross-reference RETROACTIVELY de-anonymizes all of their past and future references. It is the very same bit of information that makes anonymous dedup possible. No known rotation.
last_checked: 2026-07-27
---
# Pitfall: the `:v:` of a cap-less reference is a permanent pseudonym
**Read this before designing anything that circulates cap-less references** (registrations, invitations, mentions, indexes, notifications).
## The fact
A NURI is written `did:ng:o:{document}:v:{overlay}`. The `:v:` segment does **not** come from the document but from **its store** — and a person has **exactly one** *protected* store. Therefore:
> **All** cap-less references to **any** of a person's protected documents carry the **same** `:v:`. Everywhere, and forever.
VERIFIED in `nextgraph-rs` (the details and the pointers live on the polyfill side, `docs/readcap-and-nuri-model.md`): the value injected when a document is created is the overlay of the containing store; a `Repo` carries no overlay of its own, and every block access of a `Store` goes through **its** `overlay_id` — a per-document overlay is therefore structurally impossible, not merely absent.
## Why this is a pitfall and not just a limitation
This `:v:` **does not say who** — it is a non-invertible `BLAKE3` of the store id. The temptation is therefore to treat it as opaque, hence harmless. It is not: it is a **constant handle**.
- **Correlation** — anyone collecting cap-less references can link together all those belonging to one and the same person, without ever identifying them. Recurring presence, memberships, rhythm.
- **Retroactive de-anonymization** — this is the real danger. **One single** cross-reference, **one single time** (a person naming themselves, a channel that leaks, a match against outside data), is enough for `:v:X` to become attached to an identity. At that instant, **all** of the history tied to that `:v:` flips at once — including what was published years earlier in the belief that it was anonymous.
- **No way out** — VERIFIED, along four axes: no overlay rotation (the outer one is a pure hash of the store id, with no secret); the store id is generated once at identity creation and never regenerated; there is no migration path for content towards a new store; and no form of reference allows locating a document without exposing its store's overlay. Renewing capabilities would only change the *inner* overlay — the outer one, the only one present in cap-less NURIs, would survive it. **The only way out is to abandon the entire identity**, which carries none of the content along. Reported upstream as a possible design flaw (`orm-tests/INBOX/2026-07-27-outer-overlay-permanent-pseudonym-no-rotation.md`, see [[rule_nextgraph-inbox]]).
## The coupling you must not hope to break
That very same `:v:` is what makes it possible to **deduplicate without reading** — two references sharing a `:v:` come from the same person, and that is the basis of the anonymous participant counter ([[brief_2026-07-20_attendance-set-model]] on the `data-layer` side).
**It is the same bit of information.** Anonymous dedup and untraceability are not two requirements to be reconciled: they are two readings of one and the same piece of data. You cannot obtain one by removing the other. The only real dial is **how the stores are carved up** — which shifts the trade-off without making it disappear.
And this is **not** an artifact of the polyfill: the property survives into real NextGraph.
## What to do about it
- **Never present an action to the user** as "anonymous" without a caveat if it circulates a cap-less reference. It is **pseudonymous**, and the pseudonym is permanent.
- **Count** the occurrences of a `:v:` that you expose: every additional context in which it appears widens the cross-referencing surface.
- **Recheck** this caveat if NextGraph introduces overlay rotation or an indirect form of reference — it would then become moot, which would be good news.
Links: [[knowledge_trust-model]], [[brief_2026-05-18_authorization-matrix]], data-layer ([[brief_2026-07-20_attendance-set-model]], [[rule_capture-nextgraph-findings]], [[rule_nextgraph-inbox]]).
@@ -1,29 +0,0 @@
---
type: decision
summary: The identifier of the virtual space is entered at the access barrier (AccessGateScreen), in the same act that opens the wallet; the separate "perceived login" screen (ConnexionScreen, « choisissez un nom d'utilisateur ») is removed; the identifier is a lowercase-normalized technical id, not a Festipod username
---
# Decision (2026-07-06): identifier entered at the access barrier
## Context
The earlier stopgap flow (decision of 2026-06-15, a note that disappeared along with the `nextgraph-platform` concept — see `git log`) chained **two screens**: (1) `AccessGateScreen`, the access barrier (the real NextGraph login, opening the shared wallet); (2) `ConnexionScreen`, a "perceived login" where the user picked a **username**. That application-level identity was in fact the key of the **virtual wallet** (shim account / cap owner key), not a product username — so the "username" framing was misleading (confusing `setUsername` logic).
## Decision
The user enters their **identifier** directly in `AccessGateScreen`, **in the same act** that opens the wallet (« Entrer » records the identifier, then triggers `connect()`). `ConnexionScreen` is **deleted**. The identifier:
- is a **technical id** that names the virtual space (a nickname in practice, **not** a Festipod username);
- is **normalized** on entry (trimmed, `@` stripped, **lowercased**) and persisted before the broker redirect (so it survives the round-trip);
- **is** the identity id handed to the SDK (`setCurrentUser`), and the key for the caps and the shim account — no more mixed-case handle to reconcile.
`AuthGate` therefore shows the barrier as long as the wallet is not open **or** the identifier is not set, then the app directly — with no intermediate screen.
## Rejected alternatives
- **Keeping both screens**: the second, "username" screen perpetuated the confusion between product identity and wallet identifier, and added a step with no value.
- **Deriving the identifier from the wallet** (no entry at all): impossible here — there is a single shared wallet; the identifier is precisely what distinguishes the virtual spaces inside that wallet (emulation, see concept `data-layer` and the `@ng-eventually/client` SDK).
## Scope
Supersedes the "screen 2 / perceived login" part of the 2026-06-15 stopgap flow (opening the shared wallet through the broker is unchanged). Current state of the flow: [[knowledge_authentication]].
@@ -1,32 +0,0 @@
---
type: decision
summary: The shared wallet is the ONLY operating mode (the @ng-eventually/client polyfill relies on it as its data backend); the "no shared wallet" fallback is removed — misconfiguration → a blunt error screen, no more bare form. Reaffirms that the barrier's identifier = the wallet/space id, distinct from the profile username.
---
# Decision (2026-07-20) — the shared wallet is the only mode; identifier ≠ profile username
## Context
Observed regression: on opening, the app landed on a **bare form asking for an identifier**, without the wallet-loading assistance. Cause: `FESTIPOD_SHARED_WALLET_PASSWORD` undefined in the server environment → `hasSharedWallet()` false → `AccessGateScreen` switched to its fallback mode. But that mode is a **dead end**: a device with no wallet cannot connect once the import assistance is hidden. In parallel, the old notion of "username" was still lingering to designate the **wallet identity**, which conflated it with the real profile username.
## Decision
1. **The shared wallet is the only supported mode.** Festipod does not work without it — the `@ng-eventually/client` polyfill uses it as its data backend (see [[knowledge_authentication]], `rule_app-uses-sdk-surface-only`). `hasSharedWallet() === false` is therefore **not a functional mode**: it is a **misconfiguration** → `AccessGateScreen` displays a **blunt error screen** (« Portefeuille partagé non configuré, définir `FESTIPOD_SHARED_WALLET_PASSWORD` »), never the dead-end bare form.
2. **The barrier's identifier ≠ the profile username.** The identifier entered in `AccessGateScreen` is the **technical id of the wallet/space** (lowercase-normalized, carried by the `?id=` URL param), not a username. The **username** is a distinct concept living in `UserProfile` (`@handle`, predicate `http://festipod.org/username`). Code and tests must no longer label the wallet identity "username/user" (renamed to `identifier`). Reaffirms and extends [[decision_2026-07-06_identifier-at-access-barrier]].
## Consequences
- `AccessGateScreen`: three-branch rendering (config error / assisted import flow when not connected / identifier field alone when already connected).
- `username → identifier` rename of the wallet identity across the test infrastructure (`freshScenarioIdentifier`, `freshIdentifier`), `registration.ts`, `ngSession`, plus comments; **`UserProfile.username` untouched** (profile, seed, display, SHEX).
- `.env.example` added at the root to make the configuration explicit (including `FESTIPOD_SHARED_WALLET_PASSWORD`, `FESTIPOD_SHARED_WALLET_FILE`).
## Rejected alternative
Keeping the wallet-less fallback as a future "own-wallet flow": rejected **for now** — no own-wallet flow in the near term, and the silent fallback created a misleading dead end. To be reintroduced **explicitly** the day an own-wallet mode (each user with their own NextGraph wallet) exists, outside the stopgap.
## Links
- Shared-wallet stopgap: `decision_2026-06-15_shared-wallet-login-flow` (referenced by `AccessGateScreen`/`AccountContext`).
- [[decision_2026-07-06_identifier-at-access-barrier]] — the identifier at the barrier.
- [[knowledge_authentication]], [[knowledge_trust-model]].
@@ -0,0 +1,26 @@
---
type: decision
summary: Festipod deleted its own access-gate screen (AccessGateScreen, its route, its wallet module) and relies entirely on the SDK's ensureIdentity() to show whatever a first-time device needs to see; cost accepted: the app can no longer test that path itself, from any layer
---
# Decision (2026-08-10): the SDK renders the barrier, Festipod renders none
## Context
[[decision_2026-08-10_the-barrier-names-no-identity]] settled *what* the barrier asks (nothing — no identifier). It left open a separate question: *who draws the screen* a device sees while `ensureIdentity()` resolves — a Festipod component still fed by SDK state, or nothing on Festipod's side at all.
## Decision
**Festipod renders no access screen of its own.** `AccessGateScreen`, its route, its registration, and `src/modules/auth/sharedWallet.ts` (the wallet re-export whose only consumer it was) are deleted. `src/app/AuthGate.tsx` makes a single unconditional `await ensureIdentity()` and renders nothing until it settles — it no longer couples to `useNextGraph()`'s status, `connect()`, or error state. Whatever a user has to see or do while the wallet loads onto a first-time device belongs to the SDK, which shows it: the library owns that flow end to end and absorbed it precisely so consumer applications can delete theirs (see [[contract_polyfill-surface]] on `ensureIdentity`). `src/shared/utils/sharedWallet.ts` keeps the one surviving copy of the wallet material (file URL, password, import URL) and hands it to the SDK through `configure({ sharedWallet })` — Festipod's only remaining involvement is supplying those three values, never displaying them.
## Cost accepted
Festipod now has **no test at all** proving a first-time device can get in. The contract publishes no testid, no DOM contract and no call for a test to interact with the SDK's barrier, so the scenario that used to drive `AccessGateScreen`'s own DOM ("Parcours humain — le testeur importe le portefeuille fourni par Festipod et se connecte", `workshop/multibrowser-harness.feature`) had nothing left to assert and was deleted rather than rewritten. See [[caveat_first-time-entry-untested]] (concept `bdd-testing`). Raised with the provider.
## Rejected alternative
**Keep a thin Festipod wrapper around the SDK's state** (a `status`/`error`/`onEnter`-driven screen, still Festipod-rendered). Rejected: it would recreate the exact code the library moved out of consumer applications, for a flow already declared owned by the SDK — a wrapper an application must still write, test and delete at migration is not an absorption, it is the old cost with new labels.
## Scope
Distinct from [[decision_2026-08-10_the-barrier-names-no-identity]] (that one settles *what* the barrier asks; this one settles *who draws it*). Current state of the flow: [[knowledge_authentication]].
@@ -0,0 +1,34 @@
---
type: decision
summary: The access barrier no longer takes an identifier — the SDK surface stopped letting an application name its own identity, so signing in is one ensureIdentity() call that takes nothing; supersedes the identifier half of the 2026-07-06 and 2026-07-20 arbitrations
---
# Decision (2026-08-10): the barrier names no identity
## Context
Earlier arbitrations put an **identifier** at the access barrier: the user typed it in the same act that opened the wallet, and the application handed it to the data layer. They rested on a premise the provider has since withdrawn — that an application **names its own identity**. (Those leaves were deleted on 2026-08-16, with everything else that described the data layer's internals; `git log` has them.)
The pulled [[contract_polyfill-surface]] removes that premise explicitly. `ensureIdentity()` takes **no identifier**, and the contract states why: naming your own identity is *"the gesture that inverts the model"*, so a "set my identity" call was removed rather than renamed. There is no successor call — the capability is gone, not relocated.
## Decision
**Festipod does not name, persist or switch its own identity.** Concretely:
- The barrier asks for nothing but the wallet: « Entrer » triggers the broker redirect and nothing else.
- Signing in is **one await on `ensureIdentity()`**, in `src/app/AuthGate.tsx`, before any screen renders.
- All app-side identity machinery is deleted: the identity context, the `?id=` URL param that carried it across the broker round-trip, the localStorage key, the app-level (faux) logout. The only logout left is the **wallet session** one.
- **Who the current user is** is no longer derived from an input; it is **the profile document read back in the app's own protected scope**.
## Consequences accepted with it
- **Multi-identity on one page is no longer expressible**, and that is correct rather than missing: it was a property of *one wallet hosting several identities*, i.e. emulation scaffolding. Multi-user is exercised as it is lived — several browser contexts, each signing in as itself ([[rule_tests-validate-festipod-not-the-sdk]] in bdd-testing).
- **The `@data` layer lost its per-scenario determinism**, which the app used to provide by planting a fresh identity per scenario. The app cannot restore it — choosing which identity comes up is exactly what the surface no longer allows. Open, with the provider: [[caveat_data-scenarios-share-one-wallet]].
## Rejected alternative
**Keeping an app-side identifier and mapping it onto the SDK behind the scenes.** Rejected: it would teach the application a model it must unlearn, and it would convert a deliberate provider decision into an app-side workaround nobody revisits ([[rule_app-uses-sdk-surface-only]]).
## Scope
Supersedes every earlier arbitration that put an identifier at the barrier. Current state of the flow: [[knowledge_authentication]].
@@ -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]].
@@ -1,24 +1,37 @@
---
type: knowledge
summary: A user's identity = their NextGraph wallet; every user is authenticated (no anonymous access); auth is delegated to the SDK, the app has no application-level accounts or passwords
summary: A user's identity = their NextGraph wallet; every user is authenticated (no anonymous access); the app never names, persists or switches its own identity, and renders no access screen of its own — AuthGate awaits ONE unconditional ensureIdentity() before anything renders
last_checked: 2026-08-10
---
# Authentication
**A user's identity = their NextGraph wallet.** There is **no anonymous access** to the app: every user is authenticated (see concept `functional-domain`). There is **no application-level account/password system** — authentication is **delegated to the data SDK** (`@ng-eventually/client`): opening your session means opening your wallet.
**A user's identity = their NextGraph wallet.** There is **no anonymous access** to the app: every user is authenticated (see concept `functional-domain`). There is **no application-level account/password system** — authentication is **delegated to the data SDK** (`@ng-eventually/polyfill`): opening your session means opening your wallet.
## Flow
## The flow — one act, no screen of Festipod's own
- The **access barrier** (`AccessGateScreen`, rendered by `src/app/AuthGate.tsx`) is the real NextGraph login: it opens the shared wallet through the broker redirect. **In the same act**, the user enters an **identifier** that names their virtual space (`onEnter`). There is **no separate "perceived login" screen any more** (the former `ConnexionScreen`, « choisissez un nom d'utilisateur », has been removed — see [[decision_2026-07-06_identifier-at-access-barrier]]; supersedes the two-screen flow of the 2026-06-15 stopgap).
- **The shared wallet is the ONLY supported mode**: `AccessGateScreen` has **three branches** — (1) *configuration error* if no shared wallet is configured (no more dead-end bare form), (2) the **assisted import** flow as long as the session is not connected, (3) the **identifier field alone** once connected. See [[decision_2026-07-20_shared-wallet-only-mode]], and the evaluation-order pitfall [[caveat_shared-wallet-global-before-gate-import]] (the password global must be set before the screen is first imported, otherwise you land on branch 1).
- **Vocabulary in the code**: the wallet identity is called `identifier` everywhere (`registration.ts`, `ngSession`, hooks and test steps) — **never** `username`, which exclusively designates the profile handle `UserProfile.username`. Do not relabel one as the other: they are two distinct identity spaces.
- This **identifier is a technical id** (a nickname in practice, **not** a Festipod username): it is **normalized** (trimmed, `@` stripped, **lowercased**) then persisted (`AccountContext` → `IdentityStore`), so a reload — or another device reopening the same shared wallet — lands back on the same space. It is this id that is handed to the SDK (`setCurrentUser`) and on which the caps and the shim account are keyed.
- **Carried across the boundary by a URL PARAM `?id=`** (source of truth), NOT by localStorage. The app runs in two contexts — **top-level** (`127.0.0.1:3000` directly, `window.self === window.top`, where the barrier is displayed) and **iframe** (embedded under `nextgraph.net` after the broker round-trip, `window.self !== window.top`). The browser **partitions storage by top-level site**: the top-level's localStorage and the iframe's are **two distinct partitions** → localStorage CANNOT carry the identity from one context to the other (observed symptom: two diverging values depending on the context). The SDK redirects via `location.href = broker + encodeURIComponent(window.location.href)` (embedding the full app URL, query string included, into the `o=` that is reloaded in the iframe), so a **URL param does cross over**. `AuthGate` writes `?id=<identifier>` (`history.replaceState`) **before** `connect()`; `AccountContext` resolves the identifier by priority: **(1) `?id=` from the URL** then **(2) localStorage** (prefill/convenience within the same partition only). localStorage key: `festipod.account.identifier`.
- **Entered ONLY ONCE on first access + prefilled on return.** On a top-level reload the NG session is not restored automatically (`NextGraphContext` starts back at `disconnected`): `AuthGate` shows the barrier again as long as `status !== 'connected'`, but the `AccessGateScreen` field is **prefilled** (`initialIdentifier` prop) — never a bare, empty field. Regressions guarded by `src/modules/auth/features/{barriere-acces-identifiant,identifiant-resolution}.feature` (@ui) — all the more useful because the barrier flow is **disabled** in the @e2e tests (`__FESTIPOD_ACCESS_GATE_DISABLED__`), and therefore invisible at that layer.
- Once the session is open, the current user and their access to the per-scope stores are provided by `NextGraphContext`.
**Signing in is `src/app/AuthGate.tsx`'s single, unconditional `await ensureIdentity()`.** It fires on mount, with no dependency on `NextGraphContext`'s connection status. **Nothing of the app renders before it resolves**: `ensureIdentity()` settles who we are *and* does the connection work (restoring what others shared with us). A screen mounted earlier would read as an identity that is not yet settled.
**Festipod renders no access screen of its own.** `AccessGateScreen`, its route and its registration are deleted; whatever a user has to see or do while the SDK resolves — opening the shared wallet, loading it onto a first-time device — is drawn entirely by the SDK. The library owns that flow and absorbed it precisely so consumer applications can delete theirs. See [[decision_2026-08-10_sdk-renders-the-barrier]].
**The application never names, persists or switches its own identity.** `ensureIdentity()` takes **no identifier**, deliberately, and the contract states that **no other call takes one either** ([[contract_polyfill-surface]]). There is consequently **no** app-side identity state at all: no identity context, no `?id=` URL param, no localStorage identity key, no "set my identity" call. See [[decision_2026-08-10_the-barrier-names-no-identity]].
**Festipod's only remaining involvement is supplying the wallet material, never displaying it.** `src/shared/utils/sharedWallet.ts` holds the one copy of the file URL, password and import URL this deployment hands out, and passes them to the SDK through `configure({ sharedWallet })` in `src/shared/utils/ngSession.ts` — the contract makes that a deployment requirement. The one hazard left around that module is an evaluation-order trap, [[caveat_shared-wallet-global-before-gate-import]]. Misconfiguration (no password set) makes `ensureIdentity()` throw, and `AuthGate` shows its named error panel instead of any screen.
**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 — the identity and the profile are two things
**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.
## The test wallet
The `@data`/`@e2e` tests open a real wallet (`festipod-tests`, persistent profile) — see concept `bdd-testing`. These are **plaintext test credentials**, with no security stake, dedicated to staging.
The `@data`/`@e2e` tests open a real wallet (`festipod-tests`, persistent profile) — see concept `bdd-testing`. These are **plaintext test credentials**, with no security stake, dedicated to staging. Since no call takes an identifier, a scenario cannot choose which identity it comes up as: every scenario in a run shares that one wallet, which keeps growing — [[caveat_data-scenarios-share-one-wallet]] (bdd-testing).
> The authorization model that will build on this identity (bilateral connections, personal data = network, host anonymity) is incubating: [[brief_2026-05-18_authorization-matrix]].
@@ -1,21 +1,27 @@
---
type: knowledge
summary: Isolation between scopes (public/protected/private) is enforced by the data SDK; the app trusts it and only displays what it returns — no access control in the screens, all privacy rests on the SDK
last_checked: 2026-07-06
summary: Isolation between scopes (public/protected/private) is enforced by the data SDK; the app trusts it and only displays what it returns — no access control in the screens, and the only thing it declares is which of its own documents it shares with whom
last_checked: 2026-08-10
---
# Trust model
**Stance:** the app reads data through the ORM subscriptions of the `@ng-eventually/client` SDK and displays it **with no app-side authorization logic** (`src/shared/context/FestipodDataContext.tsx`, `useNgData`).
**Stance:** the app reads data through the ORM subscriptions of the `@ng-eventually/polyfill` SDK and displays it **with no app-side authorization logic** (`src/shared/context/FestipodDataContext.tsx`, `useNgData`).
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.
3. **The relationship between users ("connections") is an application-level notion, not an SDK primitive.** NextGraph has no bilateral connection/friendship primitive; on the SDK side there is only a **directed read grant** towards an identity. The app therefore **owns** its relationship graph (`src/shared/utils/connections.ts`) and **translates** it into per-document directed grants handed to the SDK — it does not delegate the notion of a relationship to the SDK, only the **enforcement** of the isolation that follows from it. What the app declares to the SDK stays minimal: **its identity** (the identifier, see [[knowledge_authentication]]) and **those grants**; it still carries no access logic in the screens.
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]]).
## The point to watch
Because the app **displays everything it receives**, privacy rests entirely on the SDK exposing only what is legitimate. It is a deliberate choice (the app stays thin), but it means **never reintroducing on the screen side a piece of data that the scope should not have let through**.
**And never promise anonymity.** The contract guarantees isolation per document; it guarantees **no anonymity** — nothing per reader on a public document, no revocation, and a reference that names a person's document remains comparable wherever it travels. So a Festipod action that circulates such a reference (a sign-up, an invitation, a mention, an index entry) is **pseudonymous at best**: do not label it "anonymous" in the interface, and count the contexts in which you expose the same reference.
> To check when in doubt: `useNgData` in `FestipodDataContext.tsx` contains no identity-filtering branch — that is intentional, isolation comes from below.
+8 -3
View File
@@ -2,7 +2,7 @@
type: _overview
summary: BDD Cucumber/Gherkin in French across 3 layers (@ui, @data, @e2e) — setup, layer contract (what to test where), real broker harness, and the source-grep leftovers pitfall
triggers:
keywords: [cucumber, gherkin, bdd, feature, scenario, scénario, step, steps, "@ui", "@data", "@e2e", playwright, broker, harness, wallet, world, hooks, renderHelper, multibrowser, multi-navigateur, "@multibrowser", "@private-wallet", "@shared-wallet", storageState, "@wip"]
keywords: [cucumber, gherkin, bdd, feature, scenario, scénario, step, steps, "@ui", "@data", "@e2e", playwright, broker, harness, wallet, world, hooks, renderHelper, multibrowser, multi-navigateur, "@multibrowser", "@shared-wallet", storageState, "@wip", "@humain"]
paths: ["src/modules/*/features/**", "src/modules/*/steps/**", "src/shared/steps/**", "src/shared/support/**", "src/shared/test-harness/**", "cucumber.json"]
---
@@ -10,7 +10,7 @@ triggers:
BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `Alors`) across **3 layers** of increasing cost.
**Read before writing a test:** [[rule_test-layer-contracts]] — each layer answers a distinct question; mixing them produces brittle tests. That is the rule which decides *where* an assertion belongs.
**Read before writing a test:** [[rule_test-layer-contracts]] — each layer answers a distinct question; mixing them produces brittle tests. That is the rule which decides *where* an assertion belongs. And [[rule_tests-validate-festipod-not-the-sdk]] — which decides *whether the assertion belongs here at all*.
## The 3 layers
@@ -26,12 +26,17 @@ BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `A
## Links
- [[rule_test-layer-contracts]] — what to test at each layer (the contract)
- [[rule_tests-validate-festipod-not-the-sdk]] — the subject under test is Festipod's behaviour, never the SDK's; no shortcut past the published surface
- [[knowledge_cucumber-setup]] — config, layout, scripts, auto-generated files
- [[knowledge_ui-layer]] — the `@ui` layer: render helper, fixtures, good and bad patterns
- [[knowledge_data-layer-broker]] — the `@data` layer: broker harness, wallet lifecycle, bridge
- [[knowledge_e2e-layer]] — the `@e2e` layer: the real app inside the iframe
- [[knowledge_multibrowser-harness]] — several isolated browsers × wallet model (private/shared), storageState injection
- [[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
- [[caveat_source-grep-vestiges]] — leftovers from the "source analysis" era in `world.ts`
- [[cookbook_add-scenario]] — adding a scenario/step (layers, `evaluate` serialization pitfall, `@wip`)
- [[cookbook_live-probe]] — verifying a flow for real when the suite cannot answer: a throwaway Playwright probe on the real app, what it must collect, and why its findings must be written down the same day
@@ -0,0 +1,25 @@
---
type: caveat
summary: A @data scenario cannot choose which identity it comes up as — no published call takes an identifier — so every scenario in a run shares one identity and one physical wallet, which nothing empties. Per-scenario isolation is GONE, and the wallet grows for the whole run.
last_checked: 2026-08-10
---
# Pitfall: one wallet, one identity, nothing that empties it
## What is verified
**No scenario can name the identity it opens as.** [[contract_polyfill-surface]] is explicit: `ensureIdentity()` takes no identifier, *"and no other call takes one"*. So a scenario gets whatever identity the wallet in `.playwright-profile` resolves to — the same one, every time.
Machinery from when this was not true is still in the tree and is now **inert**: the `Before` hook mints a unique identifier (`freshScenarioIdentifier`, `src/shared/support/hooks.ts`) and injects it via `addInitScript` into `localStorage['festipod.account.identifier']`, and several steps re-inject it. Nothing consumes that key. **Do not build new setup on it, and do not "repair" it** by making the app read it again — naming your own identity is exactly what the surface refuses.
## What follows, and gets worse
**Everything a run writes lands in ONE wallet, and nothing removes it.** There is no per-scenario reset: the old one (`resetDataState()`, a SPARQL DELETE on the anchor graph) was dropped for cost and its helper is gone. So each scenario leaves its documents behind for every later scenario to carry — within a run, and across runs. That is the source of [[caveat_wallet-bloat-hang]].
The practical signature: hook timeouts on `__testData.ready` that appear **partway through a run** and get worse the longer the profile has lived, **with no console error at all**. Silence is the tell — a wallet that has stopped answering just stops answering.
So: a scenario failing on **stale data from an earlier scenario** is expected, not a surprise — scenarios are not isolated. A scenario **timing out in `Before`**, especially the fifth one onward, is the wallet, not the assertion. Move the profile aside and re-measure before diagnosing anything else ([[caveat_wallet-bloat-hang]]).
## What is missing
A way to start a scenario from a clean slate. The surface publishes no teardown and no throwaway-wallet call, and there is nothing to fake here: it is a **gap to raise with the provider**, stated as the need — *a scenario must be able to begin on an empty space*. Until then, per-scenario determinism is not available at the `@data` layer, and scenarios must be written so they do not depend on it.
@@ -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.
@@ -0,0 +1,29 @@
---
type: caveat
summary: No test proves a first-time device can get into Festipod — the scenario that drove AccessGateScreen's own DOM was deleted with the screen, and the SDK's replacement barrier publishes no testid or contract to write a new one against
last_checked: 2026-08-10
---
# Caveat: first-time entry has no test, and none can be written from here
## What is gone
`workshop/multibrowser-harness.feature` used to carry « Parcours humain — le testeur importe le portefeuille fourni par Festipod et se connecte »: a fresh browser opened the staging app, `AccessGateScreen` offered the wallet file and password, the file was downloaded **from the screen** (`[data-testid=shared-wallet-download]`), the password checked against the wallet's own (`[data-testid=shared-wallet-password]`), imported on `nextgraph.eu`, then an identifier typed (`[data-testid=identifier-input]`) and « Entrer » clicked — landing on the connected home screen. Every step drove **Festipod's own DOM**.
`AccessGateScreen` is deleted (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]), and with it every testid the scenario asserted on, the steps that drove them (`src/modules/workshop/steps/data/multibrowser.steps.ts`), and the helpers built only for this scenario (`pool.ensureStagingApp`, `pool.importWalletViaFile`, `findE2eWalletFile`, the `dist-staging` build in `hooks.ts`).
## Why it cannot be rewritten, not just why it was deleted
The scenario was not migrated to assert against something else, because there is nothing to migrate it to: `ensureIdentity()` (`@ng-eventually/polyfill`) is a plain async function with no published testid, no documented DOM contract, and no call a test could make to drive or observe what it shows a first-time device. [[contract_polyfill-surface]] (concept `data-layer`) states only the call's signature and behaviour, not a UI shape — by design, since that UI is exactly the part the SDK owns and Festipod must not couple to.
## What is true today
**No test at all — `@ui`, `@data`, `@e2e`, or `@humain` — proves that a first-time device can sign into Festipod.** The `@shared-wallet` multi-browser scenario ([[knowledge_multibrowser-harness]]) injects the wallet via `storageState`, bypassing the import entirely; every `@data`/`@e2e` scenario runs on a persistent profile that is already signed in before `ensureIdentity()` ever runs ([[caveat_data-scenarios-share-one-wallet]]), so none of them exercises the path a genuinely new user takes either.
## What would close it
A test contract published by the SDK for its own barrier (a testid, an event, a promise a test can await) — this is a gap in what Festipod consumes, not in what Festipod tests. Raised with the provider. Until one exists, this path is verified only by hand.
## Links
[[knowledge_multibrowser-harness]] — where the deleted scenario lived. Concept `app-architecture` → [[caveat_boot-unverified-outside-broker]] — the related, narrower question of whether the boot even completes outside the broker iframe.
@@ -1,7 +1,7 @@
---
type: caveat
summary: A "fresh page" opened via ctx.newPage() on the PERSISTENT Chromium context NEVER proves broker durability — it re-reads the local IndexedDB of the very same profile. Only a non-persistent context spawned from freshBrowser, seeded solely by the storageState captured at BeforeAll, settles broker-vs-local.
last_checked: 2026-07-27
summary: A "fresh page" on the PERSISTENT context never proves broker durability — it re-reads the same profile's IndexedDB; only a non-persistent context spawned from freshBrowser, seeded solely by the BeforeAll storageState, settles broker-vs-local
last_checked: 2026-08-10
---
# Pitfall: a "fresh page" is not a cold start (local vs broker)
@@ -12,7 +12,7 @@ last_checked: 2026-07-27
| Setup | Where | What it proves | What it does NOT prove |
|---|---|---|---|
| `this.page!.context().newPage()` — fresh page on the **persistent** context (`.playwright-profile`) | `reconnexion.steps.ts` (@data), `reconnexion-persistance.steps.ts` (@e2e) | a new broker login → **fresh verifier session** (empty memory), full remount of the providers | nothing about **broker durability**: the profile **still holds the local repos** in IndexedDB, so a "fresh" reader may well reopen **from local** |
| `this.page!.context().newPage()` — fresh page on the **persistent** context (`.playwright-profile`) | `reconnexion.steps.ts` (@data), `reconnexion-persistance.steps.ts` (@e2e) | a new broker login and a full remount of the providers, with nothing carried over in memory | nothing about **broker durability**: the profile **still holds local data** in IndexedDB, so a "fresh" reader may well read **from local** |
| `spawnContext('shared')` — **non-persistent** context spawned from `freshBrowser` | `reconnexion-froide-sans-local.steps.ts` (@data) | that the data **reached the broker** (or did not) | nothing about the real UI journey (this is the harness, not the app) |
**Invariant.** Any assertion of the form "the write is durable broker-side" **requires** the second setup. Making that assertion on a fresh page of the persistent context produces a false green (or a red blamed on the broker when it is really local/timing).
@@ -27,24 +27,21 @@ Three conditions, all met in `reconnexion-froide-sans-local.steps.ts`:
> **Impact if you touch the storageState capture** (`hooks.ts` `BeforeAll` → `pool.sharedWalletState`): moving it later, re-capturing it per scenario, or adding a warm-up that writes data **silently invalidates** the verdict of every "cold, no local" scenario — they would turn green by re-reading the snapshot. The step **fails outright** when `sharedWalletState` is missing (by design: no verdict beats a false verdict).
## Reconnection is not isolation — the identifier decides
## Reconnection vs isolation — the identifier no longer decides anything
`isolation.steps.ts` and `reconnexion.steps.ts` set up **the same machinery** (fresh page plus an identifier injected into `localStorage['festipod.account.identifier']` via `addInitScript`, before any script, on every origin). Only one thing tells them apart:
`isolation.steps.ts` and `reconnexion.steps.ts` set up **the same machinery** (fresh page plus an identifier written into `localStorage['festipod.account.identifier']` via `addInitScript`). That identifier used to be the **only** thing telling them apart — same value re-injected = reconnection, new value = a distinct identity B.
- **reconnection**: `this.freshIdentifier` is re-injected — **the SAME identity** as the writing page.
- **isolation**: a **new** identifier is minted → a distinct identity B.
Changing that identifier therefore silently turns a reconnection test into an isolation test (and the other way round). `this.freshIdentifier` is set by the `Before` hook in `hooks.ts` for **every** single-browser `@data`/`@e2e` scenario.
**It decides nothing now**: nothing reads that key, so both setups yield the **same** identity. The reconnection sense still holds (a fresh page on the same wallet is genuinely a reconnection); the **isolation** sense is gone — the setup can no longer produce a second identity at all, which is why `event/isolation-deux-identites.feature` is `@wip`. Proving isolation now needs **two genuinely separate browser contexts**, each signing in for itself ([[rule_tests-validate-festipod-not-the-sdk]]). Background: [[caveat_data-scenarios-share-one-wallet]].
## Reads stay reactive, even when "waiting a long time"
The reconnection `Then` steps read the **reactive** state (`homeEventTitles` on the bridge, via `waitForFunction`) — never a broker re-read loop ([[rule_no-broker-polling]]). The long diagnostic step (« … en laissant jusqu'à 60 secondes à la barrière avec rechargements ») does loop, but over the **reactive state already pushed** plus **full page reloads** (each reload = new mount = new sync-barrier attempt): that is the pragmatic fallback the rule explicitly allows, not broker polling. The distinction to keep in mind — *observing the reactive state* versus *re-issuing a broker read*.
The reconnection `Then` steps read the **reactive** state (`homeEventTitles` on the bridge, via `waitForFunction`) — never a broker re-read loop ([[rule_no-broker-polling]]). The long diagnostic step (« … en laissant jusqu'à 60 secondes à la barrière avec rechargements ») does loop, but over the **reactive state already pushed** plus **full page reloads** (each reload = a new mount, hence a fresh attempt at reaching a synced state): that is the pragmatic fallback the rule explicitly allows, not broker polling. The distinction to keep in mind — *observing the reactive state* versus *re-issuing a broker read*.
## Current state of the scenarios
`reconnexion-froide-sans-local.feature`, the `@reconnexion-pause` scenario of `reconnexion-meme-identite.feature` and `reconnexion-persistance-e2e.feature` are **`@wip`**: they are **diagnostic instruments** (they print a verdict to stdout / as a Cucumber attachment), not regression guards. `@wip` is excluded from the default run (`cucumber.json`) — run them explicitly with `--tags`. The **non-`@wip`** scenario of `reconnexion-meme-identite.feature`, on the other hand, is a genuine guard and must stay green.
> The *why* on the NextGraph side (what a write must clear to be durable, socket behaviour, repo reopening) belongs to the `@ng-eventually/client` SDK — not to this repo. Here we only describe **the test setup that produces a readable verdict**.
> This leaf describes **the test setup that produces a readable verdict**, and nothing else. What a write has to clear to be durable is not this repo's to explain — if a verdict comes back negative, that is a finding to raise with the provider, not a mechanism to write up here.
## Links
@@ -1,21 +1,37 @@
---
type: caveat
summary: The shared test wallet (.playwright-profile) accumulates data on every run; past a threshold, sparql_query calls anchored to the private store hang (>15s) and the whole @data suite fails during setup — starting from a fresh profile restores ~1s reads
last_checked: 2026-07-06
summary: The @data suite degrades within a run and across runs, silently — later scenarios time out in Before with nothing in the console. Moving .playwright-profile aside does NOT reset the data (the served wallet file is what holds it), so two "fresh profile" runs measure the same accumulated state; the only real lever is serving a new wallet file, which nothing here does yet.
last_checked: 2026-08-16
---
# Pitfall: the test wallet bloats and makes @data reads hang
# Pitfall: the test wallet saturates — within a run, and across runs
The persistent Chromium profile `.playwright-profile` (at the root of the working tree) carries the **shared wallet** opened by the whole `@data`/`@e2e` suite. That wallet **accumulates data on every run**: shim accounts (one per scenario, through the fresh identifier `freshScenarioIdentifier`), seeded entity docs, historical inbox deposits… The private store is the **anchor point of the shim** (account resolution) and is queried by **every** read and write (`resolveAccount`, `listMyEntityDocs`, …).
The persistent Chromium profile `.playwright-profile` (at the root of the working tree) carries the **wallet** the whole `@data`/`@e2e` suite opens. Every scenario reads and writes through it, so anything that slows that wallet down slows everything.
**Symptom.** Past a certain volume (observed around 99 MB of profile), a `sparql_query` **anchored to the private store** stops returning within 15 s — it hangs. Since account resolution sits on the path of **every** read/write, **the entire @data suite fails during setup** (0 events loaded, timeouts), with no explicit error. Verified diagnosis: on a fresh wallet the same query comes back in **~1.5 s** and the seed completes normally.
Two distinct phenomena, and the first is the one that bites today.
**Workaround.** Move the bloated profile aside and let the auth hook (beforeAll) recreate a fresh one:
## Within a single run — the binding constraint
```bash
mv .playwright-profile /tmp/festipod-bloated-$(date +%s)
```
**Symptom, VERIFIED.** On a **fresh** profile, on an idle machine, per-scenario duration climbs monotonically (observed 7 s → 53 s across the six that pass), then every later scenario dies in the `Before` hook on `frame.waitForFunction` at its 30 s cap. **Silently** — no error, no rejection, nothing in the console. Reproduced twice with identical results (6 of 14 passing, 8 min 34 s and 8 min 37 s).
The per-scenario fresh identifier (`freshScenarioIdentifier`) bounds the account *registry* but **not** the physical growth of the shared private store — hence the recurrence. Durable hygiene (periodic purge / throwaway wallet per run) still has to be put in place; until then, if the `resolveAccount failed` errors and timeouts come back, start again from a fresh profile.
**What it is NOT.** Runs that never exit leave a Chromium and two servers resident (see below), and it was reasonable to suspect that pressure. **Ruled out by measurement**: one of the two runs above happened with four leaked browsers and two leaked servers alive, the other on a cleaned machine — same pass count, same duration. Leaked processes are a real defect and not this cause.
> The *why* on the broker side (how an anchored query reaches the private store repo) belongs to the `@ng-eventually/client` SDK, not here — this caveat only describes the consequence on the test side.
**The likely mechanism, INFERRED.** Every scenario in a run writes into the **same wallet**, and nothing removes what it wrote ([[caveat_data-scenarios-share-one-wallet]]) — so each one leaves behind documents that every later scenario carries. That is not something tidying the test code can fix. What would settle it is a reset the surface does not publish (a teardown call, or a throwaway wallet per run): raise it with the provider rather than faking one here.
**Practical reading.** A `Before` timing out, especially from roughly the sixth scenario onward, is the wallet — not the assertion below it, and not the step definition. Diagnose the run's shape before diagnosing the scenario.
## Moving the profile aside does NOT reset the data — corrected 2026-08-16
The reset this leaf used to prescribe (`mv .playwright-profile …`) gives a fresh **browser profile**, not fresh **data**. The suite's data lives in the wallet file the deployment serves (`FESTIPOD_SHARED_WALLET_FILE`, a fixed `.ngw` at the working-copy root), which is the same file on every run and whose state persists outside the profile entirely. Recreating the profile makes the harness build a new broker-side wallet to get *into* the broker; the app then opens the same served wallet as always.
This matters beyond the inconvenience: two measurements taken "on a fresh profile" are **not** two measurements on fresh data. A pair of identical numbers from them proves reproducibility and nothing about accumulation — a conclusion drawn from exactly that mistake had to be withdrawn.
**The lever we actually have** is the served wallet file: it is the application's own deployment parameter, not something the provider controls. Serving a new one gives genuinely empty data. Nothing in this repo does that yet.
Until it does, treat any `@data` number as **relative to whatever that wallet already holds**, and do not compare two runs taken days apart as if they measured the same thing.
## The leak that makes it worse
A Cucumber run prints its summary and then **does not exit**, leaving a Chromium and two servers alive (runs observed still resident 2-3 hours after reporting). It does not cause the degradation above, but it fills the machine and forces manual cleanup. Kill the process after reading the summary until the teardown releases what it opens.
> This caveat describes only what is observable on the test side. Why a saturated wallet stops answering is not this repo's to explain.
@@ -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]]).
@@ -0,0 +1,30 @@
---
type: cookbook
summary: How to verify a flow for real when the suite cannot answer — a throwaway Playwright probe that boots the REAL app in a real browser against the real broker and drives the UI as a user does; what it must collect, and why its findings must land in doctrine the same day.
last_checked: 2026-08-16
---
# Driving the real app with a throwaway probe
A **probe** is a one-off Playwright script, outside Cucumber — no World, no hooks, no fixtures — that boots the **real app** in a real browser against the **real broker** and drives its interface the way a user does. You write it, you run it, you read it, you delete it.
## When to reach for one
Before believing a flow works. The create-and-participate flow had been declared *correct by construction* on typecheck, build and reading; the first probe ever run against it found **three defects** none of those could see — all three now fixed, though one left a residual one-connection display lag whose cause sits outside the app ([[caveat_participant-count-one-connection-lag]] in `data-layer`).
Reach for it when the suite cannot answer the question: the `@data` run dies silently from around its sixth scenario ([[caveat_wallet-bloat-hang]]), its scenarios have no fixtures ([[caveat_data-suite-has-no-fixtures]]), and entry paths are covered by nothing ([[caveat_first-time-entry-untested]], `app-architecture` → [[caveat_boot-unverified-outside-broker]]).
## Method
1. **Drive the app's own interface, never a bridge.** A probe that calls into the data context proves the data context. The whole point is the collaboration between the layers, so the only inputs are the ones a user gives — clicks, typing, waiting — and the only outputs are the ones a user sees.
2. **Reuse the boot the `@e2e` layer already documents** ([[knowledge_e2e-layer]]) rather than inventing one: the app server on its own port, the broker round-trip, the app in its iframe. Do not build a second way in.
3. **Collect `pageerror` and `console` from the first navigation.** The findings that matter surface as a rejection raised *inside a layer you never called* — invisible on screen except as a panel saying something failed.
4. **Time the steps you assert on.** "The toast landed after the write" and "1.8 s" are two different findings; the second is what makes a later regression legible.
5. **Keep watching after the confirmation, then reconnect.** A step that reports honestly can still leave the flow wrong. Give the state a real interval (minutes, not a tick), then come back through a fresh load — most of what a probe finds lives after the point where a test would have asserted green.
6. **Say what state you started from.** A **brand-new origin with a brand-new identity** is what separates a real defect from accumulated wallet state, and a finding reported without it is not yet a finding. Report the run count too (*"3 of 3"*).
## What a probe is not
It is **not a regression guard**: nothing re-runs it, and a deleted script protects nothing. Its whole value is converted at the end of the run, into doctrine or a `bug_` leaf, the same day — a probe run that is not written down bought nothing. Recording an observation, mark **VERIFIED** (seen, with the run count) apart from **INFERRED** (the explanation you reached for); a real symptom does not certify its diagnosis.
> **The lesson that pays for the method: honest steps do not add up to an honest flow.** Every step of the sign-up reports truthfully — the mutation rejects rather than lying, the confirmation follows the write — and the flow as a whole still announces a success it does not obtain. No layer can see that from inside itself; only exercising the whole thing end to end shows it.
@@ -1,7 +1,7 @@
---
type: knowledge
summary: The @data layer — Playwright drives Chromium (persistent profile), which logs into the real NextGraph broker that loads harness-ng.tsx in an iframe; automated wallet lifecycle (creation + bootstrap login), window.__testData bridge, mock fallback; per-scenario isolation through a fresh virtual identifier (this.freshIdentifier), no more per-scenario purge
last_checked: 2026-07-27
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)
@@ -33,10 +33,10 @@ Cucumber → Playwright (Chromium, persistent profile)
- **Chromium flags** (`--disable-web-security`, `--allow-insecure-localhost`, Private Network Access turned off): necessary because the public broker loads a `http://127.0.0.1` harness in an iframe.
- **Persistent profile** `.playwright-profile/` (gitignored, wallet in localStorage) — requires the real Chrome binary, not `chrome-headless-shell`.
- **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. The harness therefore mounts the **`AccountProvider`** and logs in by default (`@mariedupont`) to establish the current identity (without it the ReadCap filter would only let public data through). It reads `appData` through a **live ref** (a captured snapshot goes stale after a seed re-render).
- Low-level probe paths are kept (root-store scope `protectedNuri`) for the ReadCap/isolation scenarios that *govern* that document: `rawJoin`/`rawParticipations`, `governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`.
- **Identity before writing.** A `Participation` has a mandatory `fp:user`; since reading the profile can lag behind the public events, the steps wait for `ensureCurrentUser()` before `joinEvent` (otherwise a participation is written without a user → dropped on read, and never makes the round trip) and then wait (`waitForFunction`) for the participation to be read back.
- **Per-scenario isolation = a fresh virtual identifier, NOT a purge.** The @data `Before` hook mints a unique identifier per scenario (`freshScenarioIdentifier` in `hooks.ts`), exposes it as `this.freshIdentifier` on the World, and injects it via `addInitScript` into `localStorage['festipod.account.identifier']` **on every origin** (including the harness iframe on 127.0.0.1) — before any script. The shim then serves a **fresh, empty virtual account**, whose registry starts empty *by construction*: **nothing to purge**. The old per-scenario reset (`window.__testData.resetDataState()`, a SPARQL DELETE of the `urn:ng-eventually:shim:Account` records on the anchor graph) is **no longer called** — it cost up to 10 s taken out of the 60 s budget of the `Before` hook, already eaten by the broker login. The helper still exists on the bridge (`harness-ng.tsx`) but is no longer on the default path: do not put it back into the `Before` hook without measuring.
- **What the fresh identifier does NOT bound**: the *physical* growth of the shared wallet — see [[caveat_wallet-bloat-hang]] (profile to be moved aside when anchored reads start to hang).
- `this.freshIdentifier` is also what distinguishes a **reconnection** test (same identifier re-injected) from an **isolation** test (new identifier) — see [[caveat_reconnexion-froide-local-vs-broker]].
- The connected seed stays **lightweight** (few docs) because each `docCreate` is a serial broker round trip of about 2s.
- **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`, 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 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.
@@ -1,7 +1,7 @@
---
type: knowledge
summary: The @e2e layer — Playwright boots the REAL app (not a harness) inside the broker iframe, interacts through appFrame.evaluate()/locator(), reuses setupBrokerPage() from @data; tests navigation/redirects/clicks, no mock fallback; per-scenario identity (this.freshIdentifier) plus the access barrier disabled by init script; "close and reopen" idiom for reconnection scenarios
last_checked: 2026-07-27
summary: The @e2e layer — Playwright boots the REAL app inside the broker iframe, driven through appFrame.evaluate()/locator(); no mock fallback; there is no more access-gate-disable flag, and no scenario has had to drive the SDK's own barrier because the persistent profile comes up already signed in
last_checked: 2026-08-10
---
# The `@e2e` layer (real app)
@@ -43,21 +43,20 @@ Navigation: `window.history.pushState` plus a `popstate` dispatch (path-based ro
> **Do not re-check in `@e2e` what `@ui` already covers** — `@e2e` must break when the *collaboration* between layers breaks, not when an icon changes (see [[rule_test-layer-contracts]]).
## Scenario identity + access barrier
## Scenario identity, and why no scenario drives the SDK's barrier
Two settings applied by the `Before` hook in `hooks.ts` govern **every** `@e2e` scenario:
The `Before` hook still plants `this.freshIdentifier` — a unique identifier minted per scenario (`freshScenarioIdentifier`) and injected via `addInitScript` into `localStorage['festipod.account.identifier']` on the **persistent** context. **Nothing consumes it**: no published call takes an identifier, so a scenario cannot choose who it opens as. Treat it as inert machinery, not as a determinism lever — [[caveat_data-scenarios-share-one-wallet]].
- **`this.freshIdentifier`** — a virtual identifier **unique to each scenario**, injected via `addInitScript` into `localStorage['festipod.account.identifier']` on **every** origin before any script. The real app therefore boots straight into that identity, and each scenario starts from an empty space. This is the **same** machinery as in `@data` (same World field).
- **Access barrier disabled** — `browserContext.addInitScript` sets `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` on the **persistent** context: `@e2e` sees the app, not the `AccessGateScreen`. **Fresh** contexts (`@humain`, see [[knowledge_multibrowser-harness]]) do not inherit that setting → the barrier is ON for them.
**There is no more access-gate-disable flag.** `AccessGateScreen` and the `__FESTIPOD_ACCESS_GATE_DISABLED__` global it used to check are both gone. What keeps every `@e2e` scenario from having to drive the SDK's barrier is simply that the **persistent profile already carries an open wallet session** — the automated broker login in the shared `@data`/`@e2e` setup put it there. **Fresh** contexts (multi-browser, see [[knowledge_multibrowser-harness]]) carry no such session, but no scenario left loads the real app through a fresh context — and none could assert against that barrier anyway ([[caveat_first-time-entry-untested]]).
> **Impact:** any page opened by hand inside a step (`ctx.newPage()`) must **re-apply both init scripts itself** — the context's `addInitScript` only applies to pages of that context, and the identifier must be written **before** the app's first script.
> **Impact:** any page opened by hand inside a step (`ctx.newPage()`) does **not** inherit page-level init scripts — `addInitScript` applies only to the pages of the context it was called on.
## The "close and reopen" idiom (reconnection scenarios)
`reconnexion-persistance-e2e.feature` / `src/modules/event/steps/e2e/reconnexion-persistance.steps.ts` reproduce the "I create, I close, I come back" journey inside the REAL app:
1. **Creation through the real form** — the step drives the actual creation wizard at DOM level (3-step wizard, selection by *placeholder*: event name, venue; submit button by its label). ⚠️ **These steps are coupled to the French labels of the creation screen**: renaming a placeholder or the submit button breaks the scenario, not the app.
2. **Reopening** — a second page on the **same** persistent context, with the **same** `this.freshIdentifier` and the barrier disabled, then `pool.setupBrokerPage(page, pool.appUrl!)` → new broker login, fresh verifier session.
2. **Reopening** — a second page on the **same** persistent context, replanting `this.freshIdentifier` on it (page-level `addInitScript` only covers the page it is called on), then `pool.setupBrokerPage(page, pool.appUrl!)` → new broker login, same identity.
3. **Proof** — the step captures the console of **both** pages and publishes a summary through `this.attach` (Cucumber attachment) plus stdout; a raw dump of the connection/sync lines is **opt-in** through the `RECO_RAW_DUMP=1` environment variable (noisy, off by default).
> **Limitation to know about**: this setup proves the reconnection *of the journey*, **not** the broker durability of the write — the second page shares the IndexedDB of the persistent profile. See [[caveat_reconnexion-froide-local-vs-broker]] for the setup that does settle broker-vs-local.
@@ -1,26 +1,22 @@
---
type: knowledge
summary: Multi-browser harness along TWO orthogonal axes — number of browsers (the machinery, isolated fresh contexts spawned from a non-persistent freshBrowser) AND wallet model (own/@private-wallet vs shared/@shared-wallet); shared is provisioned by storageState injection (test-only); an @humain e2e validates the REAL product mechanism through the real staging app (.ngw file downloaded from the screen → nextgraph.eu "Import a Wallet File" → Entrer → connected); @wip convention excluded through cucumber.json
last_checked: 2026-06-16
summary: Multi-browser harness — isolated contexts spawned from a non-persistent freshBrowser, all carrying the shared wallet by storageState injection (test-only); the scenario that once drove the real access screen end to end is gone with the screen, and nothing replaces it
last_checked: 2026-08-10
---
# Multi-browser harness (private-wallet vs shared-wallet)
# Multi-browser harness (shared wallet)
The ability of the `@data`/`@e2e` harness to drive **several isolated browsers** within a single scenario, along **two orthogonal axes**. It makes it possible to test both the "everyone has their own wallet" model (`@private-wallet`) and the "wallet shared between browsers" model (`@shared-wallet`).
The ability of the `@data`/`@e2e` harness to drive **several isolated browsers** within a single scenario. This is also the **only** way multi-user is exercised now: each browser context signs in **as itself**, since nothing lets a single page hold two identities ([[rule_tests-validate-festipod-not-the-sdk]]). That capability is not yet fully used: `isolation-deux-identites.feature` needs exactly this — two real contexts, each connecting for itself — and is currently `@wip` because it still assumes the old single-page identity switch (product-level statement of the gap: concept `functional-domain` → [[knowledge_roadmap]]).
## The two axes (orthogonal)
| Axis | What it decides | Expressed by |
| Concern | What it decides | Expressed by |
|---|---|---|
| **Number of browsers** (machinery) | 1..N isolated named contexts | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
| **Wallet model** | distinct vs shared NG identity | **step phrasing + tag** (see below) |
| **Wallet model** | which wallet a context carries | the `WalletModel` argument (`'own'` \| `'shared'`) |
Do **not** confuse `@multibrowser` (several browsers) with `@shared-wallet` (same wallet): we run multibrowser **in private** (everyone with their own wallet) **and in shared** (shared wallet), and compare both setups with the **same** behavioural steps.
## Wallet model — one is exercised, one is dormant
## Wallet model: phrasing + tags
- `Étant donné un navigateur "A" avec son propre wallet` → **own** model, tag `@private-wallet`.
- `Étant donné un navigateur "A" avec le wallet partagé` → **shared** model, tag `@shared-wallet`.
- `Étant donné un navigateur "A" avec le wallet partagé` → **shared** model, tag `@shared-wallet`. This is what every scenario uses.
- The **own-wallet** model (`'own'`, an empty partition with no wallet) still exists in `spawnContext`, but **no scenario exercises it**: the two `@private-wallet` scenarios were **deleted** because what they proved — Playwright's storage partitioning — is a property of the tooling, not a Festipod behaviour. Keep the machinery, do not re-add scenarios whose subject is the isolation of the tooling.
- Umbrella tag `@multibrowser` (whole feature).
## Architecture (where things live)
@@ -34,25 +30,21 @@ Do **not** confuse `@multibrowser` (several browsers) with `@shared-wallet` (sam
- **own**: empty `newContext()` → distinct NG identity / no wallet.
- **shared**: `newContext({ storageState })`, where `storageState` is **captured once** at `BeforeAll` from the persistent profile (warm-up through `setupBrokerPage`, then `browserContext.storageState()`), exposed as `pool.sharedWalletState`. **Empirically verified (2026-06-16)**: the `nextgraph.eu` and `nextgraph.net` origins round-trip into the fresh contexts, and two **shared** browsers both reach the app **connected** to NextGraph (`window.__testData.ready`) **without any manual login**.
> This provisioning is **test-only** — distinct from the **product** mechanism (FILE-assisted import). The shared-wallet scenario using storageState **bypasses the import**; to validate the REAL mechanism, see the `@humain` e2e below.
> This provisioning is **test-only** — distinct from the **product** mechanism (FILE-assisted import). The shared-wallet scenario using storageState **bypasses the import**, and nothing left validates that import end to end: see [[caveat_first-time-entry-untested]] (concept `bdd-testing`).
## Human journey — e2e of the product mechanism (green)
## No scenario left drives the real app through a fresh context
The `@humain` scenario validates the REAL wallet distribution flow **end to end, through the real app**, not through test injection. A blank browser opens the staging app → the `AccessGateScreen` offers the **file** and the **password** → the file is downloaded **from the screen**, the displayed password is checked to **equal** the wallet's own → import on `nextgraph.eu` "Import a Wallet File" → back to the app → an **identifier is typed in**, then a click on « Entrer » (naming the space and opening the wallet are a single act, see concept `app-security` [[decision_2026-07-06_identifier-at-access-barrier]]) → app connected, landing straight on the home screen (no more separate « nom d'utilisateur » screen).
There used to be a `@humain` scenario here that drove `AccessGateScreen` end to end on a fresh context: download the wallet file from the screen, import it on `nextgraph.eu`, come back, type an identifier, land connected. `AccessGateScreen`, its testids (`shared-wallet-download`, `shared-wallet-password`, `identifier-input`), and every helper built only for that scenario (`pool.ensureStagingApp`, `pool.importWalletViaFile`, `findE2eWalletFile`, the `dist-staging` build) are **deleted** along with the screen itself (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]) — nothing of Festipod's own is left to assert against. What this leaves unproven: [[caveat_first-time-entry-untested]].
- **e2e wallet**: a `.ngw` file (`festipod-e2e-tests`, password = identifier) placed **at the root of the worktree**; `findE2eWalletFile()` locates it (`*.ngw`). Gitignored → each environment has to add it (otherwise a clear error is raised).
- `pool.ensureStagingApp()` (`hooks.ts`) — an **isolated** build `bun run build.ts --outdir=dist-staging` (access barrier **ON by default**; password baked in and the **file copied** to `/shared-wallet.ngw`, see `build.ts`), served statically. Memoized and lazy (only `@humain` pays for it).
- **Barrier bypass for `@e2e`**: the harness calls `browserContext.addInitScript` on the **persistent** context to set `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (which applies to the app iframe before its scripts) → `@e2e` sees the app directly, not the barrier. Fresh contexts (`@humain`) leave it alone → barrier ON. The old `/login` `LoginScreen` has been removed.
- `pool.importWalletViaFile(page, filePath, password)` — `nextgraph.eu/#/wallet/login` → `setInputFiles('input[type=file]')` (wait for the SPA to render, otherwise `EncryptionError`) → password field → unlock.
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)` — the "broker login" half extracted from `setupBrokerPage`. **Robust waiting**: after the (multi-hop) redirect, it waits for either the app iframe or the "Click here to login with your wallet" link, then unlocks with the password. Since the broker session is **not** persisted between launches, this wallet login is required on every run (warm-up + `@e2e` + `@humain`).
The `@shared-wallet` scenario above is unaffected — it never drove the import, and it loads the **harness** (`loadAppInBrowser(name, 'harness')`), not the real app, so it never touched `AccessGateScreen` or `ensureIdentity()` either.
> **The e2e is what guarantees it works for a real human**: Festipod hands out the RIGHT file plus password, and importing that file yields a working wallet on a blank device. The `@shared-wallet` scenario (storageState) remains a test provisioning shortcut, it does not validate the import.
## Isolation (guaranteed at 3 levels, proven by the scenarios)
## Isolation of the contexts (a property of the harness, not a tested behaviour)
1. `freshBrowser` runs in a **separate process** from the persistent profile carrying the wallet → an **own** browser starts **with no wallet**.
2. Every `newContext()` is a **hermetic storage partition** (Playwright guarantee).
3. Isolation is proven not only on the **local** origin (`127.0.0.1`) but also on the **broker origin** `nextgraph.net` **where the wallet actually lives** (a localStorage probe written in A is absent from B).
3. That holds on the **local** origin (`127.0.0.1`) and on the **broker origin** `nextgraph.net` **where the wallet actually lives**.
These three are what makes a cold-start verdict meaningful ([[caveat_reconnexion-froide-local-vs-broker]]). They are **no longer asserted by scenarios** — they were, and those scenarios were deleted: their subject was the tooling.
## Files
@@ -68,3 +60,4 @@ The `@humain` scenario validates the REAL wallet distribution flow **end to end,
- [[knowledge_data-layer-broker]] — the single-browser `@data` layer (persistent profile) that this capability extends.
- [[cookbook_add-scenario]] — the `@wip` convention, step pitfalls.
- [[caveat_first-time-entry-untested]] — the hole left by the deleted `@humain` scenario.
@@ -1,7 +1,7 @@
---
type: knowledge
summary: The @ui layer — renderHelper.tsx renders any screen inside LocalDataProvider + happy-dom, world.renderCurrentScreen() invokes it on every navigateTo, assertions run against the rendered DOM with the deterministic seed fixtures; pitfall of screens reading a global injected at build time (access barrier → lazy import mandatory)
last_checked: 2026-07-27
summary: The @ui layer — renderHelper.tsx renders a screen inside LocalDataProvider + happy-dom, assertions run against the rendered DOM; there is no access screen left to render, and a dormant module-evaluation-order trap around sharedWallet.ts survives, currently harmless
last_checked: 2026-08-10
---
# The `@ui` layer
@@ -10,6 +10,7 @@ last_checked: 2026-07-27
- 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
@@ -31,17 +32,10 @@ expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
- `currentScreenId: string | null` — the current screen.
- Assertion helpers: `getDomText()` (DOM text), `hasText(t)`, `hasField(name)`, `hasElement(selector)` — they **prefer the rendered DOM** but **fall back to the screens' source** for unmigrated steps (a leftover, see [[caveat_source-grep-vestiges]]).
## ⚠️ Screens that read a global injected at **build** time (access barrier)
## ⚠️ No `@ui` module renders an access screen — there is none left to render
`src/modules/auth/sharedWallet.ts` **captures, at module evaluation time**, a global set by `build.ts` (`__FESTIPOD_SHARED_WALLET_PASSWORD__`). The `@ui` harness runs under Node **without going through the build** → that global is missing, `hasSharedWallet()` returns false, and since the **shared wallet is the only supported mode** (concept `app-security`), `AccessGateScreen` renders its **configuration error** branch: **no identifier field at all** in the DOM → every barrier step fails with a misleading message ("field not found").
Festipod deleted its own access screen (`AccessGateScreen`) entirely; signing in is now one `ensureIdentity()` call, entirely SDK-owned (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]). The two features that used to cover the identifier field and its resolution were **deleted** with the screen — there is nothing left for a `@ui` scenario to render or assert here, and `renderElement()` (the helper `renderHelper.tsx` used to expose for prop-driven components like that screen) is gone too.
**The mandatory setup** (applied in `src/modules/auth/steps/ui/barriere-acces.steps.ts`):
1. set the global **at the top of the steps module**, before any import of the screen;
2. **import the screen lazily** (memoized `await import(...)`) — a static `import` would be **hoisted above** the assignment and `sharedWallet.ts` would capture an empty value.
> **Impact if you touch this:**
> - Adding a static `import` of `AccessGateScreen` (or of any module that reaches `sharedWallet.ts`) in **any** `@ui` steps file re-introduces the bug — Cucumber loads every steps module, so the screen would be evaluated before the global is set.
> - The current determinism relies on **this file being the only** `@ui` module that reaches `sharedWallet.ts`. A second entry point would make the evaluation order unguaranteed → the global injection would then have to move into the shared support, not be duplicated.
**A dormant trap survives, unrelated to the screen's deletion.** `src/shared/utils/sharedWallet.ts` (the module used to be `src/modules/auth/sharedWallet.ts`, now deleted — the surviving copy moved) still **captures, at module evaluation time**, a global set by `build.ts` (`__FESTIPOD_SHARED_WALLET_PASSWORD__`). The `@ui` harness runs under Node **without going through the build**, and it reaches this module regardless of which screen a scenario renders: `screens/index.ts` eagerly imports every screen including `SettingsScreen`, which imports `src/shared/utils/ngSession.ts`, which imports `sharedWallet.ts` — so `hasSharedWallet()` is always `false` under `@ui`. This is currently **harmless**: `configure()` just runs with `sharedWallet: undefined`, and no `@ui` path ever calls `ensureIdentity()` (`renderScreen()` bypasses `AuthGate`/`NextGraphProvider` entirely). It stops being harmless the day a `@ui` scenario does call `ensureIdentity()` — full mechanics: `app-security` → [[caveat_shared-wallet-global-before-gate-import]].
> The `app-*` classes confirm the modern theme (see `app-architecture`). Anti-patterns (regexes over the source, implementation details) are banned by [[rule_test-layer-contracts]]. To write a new scenario, see [[cookbook_add-scenario]].
@@ -1,12 +1,12 @@
---
type: rule
summary: NEVER poll the broker (re-reading in a loop "is it there yet?"). NextGraph is subscription-based — data arrives by PUSH, and the first `State` of a `doc_subscribe` is the deterministic sync barrier (after it — presence guaranteed, absence definitive). Tests AND app wait for the push / for the reactive state to settle, never a broker re-read loop.
summary: NEVER poll the broker (re-reading in a loop "is it there yet?"). The read surface is push-based and says itself when a scope has finished syncing — `isPending` differs from `isSuccess` with empty `data`. App and tests wait for the push, never a broker re-read loop.
last_checked: 2026-07-09
---
# Never poll the broker — wait for the subscription
NextGraph is **subscription-based (reactive)**. A read is NOT "query in a loop until it shows up"; it is "subscribe, react to the push". The **first `State`** of a `doc_subscribe` marks the end of the initial synchronization (a synchronous barrier): after it, the **presence** of a piece of data is **guaranteed** and its **absence** is **definitive**. Contract verified empirically on the SDK side (`@ng-eventually/client`, e2e test « CONTRAT 3 »).
The published read surface is **push-based**: `watchShape` resolves a scope, pushes on every change, and carries its own readiness — `isPending` (still syncing) is distinct from `isSuccess` with empty `data` (synced and genuinely empty). A read is therefore never "query in a loop until it shows up"; the surface already answers *"has it finished?"*, and a loop that re-asks the question is asking something the answer is already available for.
## The anti-pattern to ban
@@ -14,14 +14,14 @@ NextGraph is **subscription-based (reactive)**. A read is NOT "query in a loop u
for (i = 0; i < N; i++) { if (await authParticipationCount(...) === X) break; sleep(500); }
```
Any loop that **re-queries the broker** (repeated `authParticipationCount`, `listMyEntityDocs`, `sparql_query`) in order to "wait" for data is forbidden: it hides the real mechanism, makes the test brittle (guessed timeout), and directly contradicts the NextGraph model. That remark is what caused the deletion of the old caveat which wrongly held polling up as a practice.
Any loop that **re-queries the broker** (repeated `authParticipationCount`, `listMyEntityDocs`, `sparqlQuery`) in order to "wait" for data is forbidden: it hides the real mechanism, makes the test brittle (guessed timeout), and contradicts the surface the app is built on. That remark is what caused the deletion of the old caveat which wrongly held polling up as a practice.
## What to do instead
Wait for the **reactive push**. In practice (app AND test): the reactive state (`AD().*` fed by `subscribeDoc` in the data context) updates **on push**. We wait for THAT state to reflect the expectation — we **observe the settled reactive state**, we do NOT re-issue a broker read. The data mechanism is the subscription; waiting only **observes the reactive result**.
Wait for the **reactive push**. In practice (app AND test): the reactive state updates **on push**. We wait for THAT state to reflect the expectation — we **observe the settled reactive state**, we do NOT re-issue a broker read.
- App: the screen is already reactive (`subscribeDoc` → re-render on push) — no application-level polling, no spinner driven by a guessed timeout (if a waiting state is wanted, it comes from the native subscription barrier, not from an added signal).
- Test: **a helper that reliably waits for the push/barrier is welcome** (it makes things reliable without making them brittle). What is banned is the **re-read loop**, not waiting for a signal.
- App: the screen is already reactive (re-render on push) — no application-level polling, no spinner driven by a guessed timeout (if a waiting state is wanted, it comes from the surface's own readiness flags, not from an added signal).
- Test: **a helper that reliably waits for the push/readiness is welcome** (it makes things reliable without making them brittle). What is banned is the **re-read loop**, not waiting for a signal.
- **Pragmatic fallback**: if strictly waiting for the push/signal turns out to be brittle one way or another, a **short interval** (`setInterval` / closely spaced re-checks) that **observes the ALREADY updated reactive state** (the local state fed by the subscription — NOT a broker re-read) is acceptable: it is as close as it gets to what the user experiences, simply **waiting** for the (reactive) screen to update. The red line is invariant: **never re-query the broker in a loop**; observing the settled reactive state, yes.
See also [[caveat_wallet-bloat-hang]] (another source of @data flakiness, orthogonal to this one). The non-polling mechanism on the library side (`open-repo`: subscribe + wait for the first State + read) lives in the `@ng-eventually/client` repo, not here.
See also [[caveat_wallet-bloat-hang]] (another source of `@data` flakiness, orthogonal to this one).
@@ -0,0 +1,12 @@
---
type: rule
summary: Tests are free in what they exercise — attacks, forged or spoofed activities included — with one restriction: nothing a test does may break the dev environment (pod provider and its datasets, the backend's datasets, Redis, persistent test accounts)
---
# Tests never break the dev environment
**Rule:** (user directive 2026-10-30) A test may exercise anything, attacks included — a forged `Accept`, a spoofed or malformed activity, a crafted write — as long as it cannot break the dev environment. Concretely: an attack runs only between the throwaway accounts the run creates, and the run still deletes them; a test never writes another user's data, never alters a persistent account (the smoke's `alice`) beyond what the app itself does, never deletes, recreates or flushes a Fuseki dataset or Redis, and leaves the stack as it found it.
**Why:** the local stack is the shared state every suite and every manual session rely on; a broken pod provider, dataset or cache costs a reset (slow sign-ups, lost app registrations). An earlier reading — "no forged activity is ever sent to the stack" — was stricter than intended and left real attack paths unproven: the forged-contact line of the rights matrix is the only live proof that the participations audience resists a forged contact ([[decision_2026-10-29_participation-privacy-mechanism]]).
**How to apply:** before adding an attack line, check its blast radius: it touches only accounts created in this run, and its cleanup still runs when it fails. A MANUAL-CHECK line that is manual only because it would forge an activity may be promoted to a live line under the same condition. Read through the user's rules-allow-judgment: what is protected is the environment's integrity, not a list of forbidden activity types.
@@ -0,0 +1,26 @@
---
type: rule
summary: Festipod's tests validate FESTIPOD's behaviour — multi-user included — never the SDK's, and they take NO shortcut past the published surface. Multi-user is exercised the way it is lived, several browser contexts each signing in as itself, since no published call lets one page hold two identities.
---
# The tests validate Festipod, not the SDK — and they take no shortcut
## The rule
Stated by the project owner on 2026-08-10, when the app moved onto the pulled [[contract_polyfill-surface]]:
1. **Festipod is a consumer entirely ignorant of how the SDK is implemented, and its tests may take no shortcut.** No deep import into the package, no reaching for a symbol the contract does not publish, no fixture that reaches past the published surface to get to a state faster.
2. **The subject under test is Festipod's behaviour — multi-user included — never the SDK's.** An assertion whose subject is "the capability was learned", "the store served the key", "the inbox holds two deposits" is testing the provider. It does not belong here; if it is worth having, it belongs in the provider's own suite.
3. **Multi-user is tested the way it is lived**: several browser contexts, each signing in as itself through `ensureIdentity()`. Each actor obtains what it consumes through the application, under its own session.
## Why
The contract publishes no way to name or switch identity: signing in is one call that takes **no identifier**, and *"no other call takes one"*. A session is one user's. So "play two identities on one page" is not a capability that went missing — it is something no published call offers, and a test that manufactured it would be exercising something below the surface and would keep passing while the real behaviour rotted; worse, it would hand one actor's values to another through a shared variable, which is exactly the shape that once hid a real bug behind a green test (see [[multi-actor-tests-obtain-not-receive]]).
The rule also protects the thing the contract exists for. Every shortcut past the surface is a place the app learns something it must unlearn, and it silently converts a **provider gap** — which should be written down and raised — into an app-side workaround nobody revisits.
## How to apply
The tell is mechanical: a test import that is not `@ng-eventually/polyfill`, or an assertion naming an SDK concept rather than something a Festipod user would observe.
When a scenario cannot be written without a shortcut, that is a finding, not an obstacle to route around: the missing thing is either a **product behaviour Festipod does not expose yet** (build it) or a **gap in the provider's contract** (raise it with the provider and leave the scenario unwritten or `@wip` meanwhile — [[rule_app-uses-sdk-surface-only]]). Deleting a scenario whose subject turns out to be the SDK is the correct outcome, not a loss of coverage.
+24 -21
View File
@@ -1,40 +1,43 @@
---
type: _overview
summary: How Festipod persists its data through the @ng-eventually/client SDK — entities stored as documents placed by scope, direct SPARQL writes + union-model reads, SHEX stack, connected/demo modes, seed
summary: How Festipod persists its data through the @ng-eventually/polyfill SDK — entities stored as documents placed by scope, direct SPARQL writes + reactive shape reads, SHEX stack, connected/demo modes, seed
triggers:
keywords: [nextgraph, "@ng-eventually", polyfill, union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, overlay, ReadCap, WriteCap, cap-less, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité, déconnexion, reconnexion, durabilité, outbox, SerializationError]
paths: ["src/shared/shapes/**", "src/shared/data/readEntities.ts", "src/shared/data/entityWrites.ts", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
keywords: [nextgraph, "@ng-eventually", polyfill, watchShape, useShape, useShapeQuery, SHEX, shape, scope, "@graph", NURI, inbox, share, sparql, seed, wallet, FestipodData, ngSession, ngGraph, storeRegistry, bootstrap, document, entité, déconnexion, reconnexion, refine, provider, activitypods, participation, WAC]
paths: ["src/shared/shapes/**", "src/shared/data/**", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/*", "src/shared/data/seedData.ts", "src/shared/refine/**"]
---
# Data layer
How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-end encrypted). The data SDK is **`@ng-eventually/client`**: we treat it as a finished NextGraph SDK — every entity is a **document** placed in the store of its **scope** (public / protected / private). A **write** is direct SPARQL into the entity's own document; a **read** is the **union model** (resolve the documents on demand → open/sync → **one** unanchored `sparql_query` over the union → re-query on signal), not a fan-out reactive ORM subscription (which *hangs*). See [[rule_document-per-entity]]. The mapping *which entity → which scope* is a **product** fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]); this concept describes the **persistence mechanics**.
How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-end encrypted). The data SDK is **`@ng-eventually/polyfill`**: every entity is a **document** placed in its **scope** (public / protected / private). A **write** is direct SPARQL into the entity's own document; a **read** is the SDK's **reactive shape surface** (`watchShape(shape, scope)` → the app's `useShapeQuery` binding), which resolves the scope itself and pushes on change — the app resolves, lists and re-queries nothing. See [[rule_document-per-entity]]. The mapping *which entity → which scope* is a **product** fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]); this concept describes **how Festipod uses the surface**.
> **SDK boundary.** Festipod's data SDK is `@ng-eventually/client` — initialized/injected **exactly once** through `ngSession.configure(...)`. We write against it as a **finished** NextGraph SDK: never document NextGraph's current state here (constraints, workarounds, broker internals) — that lives in the `@ng-eventually/client` repo. See [[knowledge_nextgraph-stack]].
> **SDK boundary.** `@ng-eventually/polyfill` is injected **exactly once** through `ngSession.configure(...)`. The pulled contract is the whole of what this repo knows about it: never describe here how the data layer is implemented underneath. See [[rule_app-uses-sdk-surface-only]].
## Interfaces (one folder per interface, engagement + our declaration)
Each external interface this concept consumes lives in **its own folder**, holding the provider's engagement (pulled, version-pinned) and — once Festipod actually consumes it — our own declaration beside it.
- `polyfill-surface/` — [[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. Beside it, `polyfill-surface/usage_festipod.md` — what the app *actually* calls, the conditions it needs, and the frictions measured against the engagement. **Frictions are how a need reaches the provider**: put it there, then signal it out of band.
- `indexing-layer/` — [[contract_indexing-layer]], the `@ng-helpers/indexing` engagement, PULLED and pinned on `v1.0.0`: creating an index, depositing references into it, curating it, reading it back. **Nothing consumes it yet** — no declaration is authored beside it, deliberately, since an empty one would say nothing.
- `activitypods-providers/` — [[contract_activitypods-providers]], a RECONSTRUCTION rather than a pull: that package publishes no engagement, so the leaf is **our own pinned observation** of it, to be re-verified at every version bump. Beside it, `activitypods-providers/usage_festipod.md`, our declaration toward it. Nothing is wired yet — the Refine seam that would use it (`src/shared/refine/`) carries no backend.
Two declarations now share the name `usage_festipod.md`, so refer to either by its folder path rather than by wikilink — the stem alone no longer says which.
## Model & data
- [[knowledge_sdk-surface]] — **the data contract**: the `@ng-eventually/client` surface the app codes against (reads, writes, documents, inbox, discovery, capabilities, identity) and what may / may not be assumed of each
- [[knowledge_nextgraph-stack]] — the `@ng-eventually/client` SDK, SHEX shapes, reactive ORM, `build:orm`, injection through `ngSession`
- [[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` (currentUser, **two id spaces** principal ↔ profile NURI, dev auto-seed, `participantCount` cache, reset on identity change, 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
- [[rule_document-per-entity]] — every entity gets **its own document** (per scope), never one at store level; this is what makes the SDK's per-document isolation possible
- [[rule_app-uses-sdk-surface-only]] — the app behaves as if NextGraph were finished; every workaround lives in the polyfill
- [[rule_document-per-entity]] — every entity gets **its own document** (per scope), never one at store level; access is granted per document, so this is what makes isolation possible
- [[rule_app-uses-sdk-surface-only]] — the pulled contract is the only reference; a gap in it is raised with the provider, never worked around here
## What leaves this repo (two destinations, don't confuse them)
- [[rule_capture-nextgraph-findings]] — established **knowledge** about how NextGraph actually works → the **polyfill**'s reference docs, at the moment of discovery
- [[rule_nextgraph-inbox]] — a NextGraph **malfunction**, or a **gap** we need and emulate in the meantime → a note in `orm-tests/INBOX/`, which tracks upstream progress and says what to remove from the polyfill
## Pitfalls (read before touching deletions / event fields)
## Pitfalls (read before touching deletions / the participant count)
- [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… not covered by the Event shape → lost when connected
- [[caveat_write-durability-across-disconnect]] — a write made just before an idle period / socket drop can be **lost** (no broker durability); the account survives. Open incident → post-mortem in the polyfill
- [[caveat_participant-count-one-connection-lag]] — `participantCount` lags one connection behind the write that produced it; cause is outside the app, no app-side compensation
> Confidentiality (scope isolation, trusting the SDK): concept `app-security`. Product scopes per entity + discovery: concept `functional-domain`.
@@ -0,0 +1,100 @@
---
type: contract
summary: The Refine data, auth and live providers @activitypods/refine-providers exposes to an application over a Solid Pod — what each operation does, what a record looks like, and what the package does not offer
built_from: https://github.com/activitypods/refine-providers.git/src
built_version: 66616484b96fb2227cd8cdf02321aacfb4811365
built_at: 2026-10-02
---
# contract_activitypods-providers — `@activitypods/refine-providers`
**This is a RECONSTRUCTION, not the provider's promise.** The package publishes no engagement and no change policy, so this document is an observation made once, at adoption time, and pinned to the commit named in `built_version`. Nothing here is committed to by its authors: any release may move any of it. Re-observe at each upgrade — the pin is what makes a difference visible.
## Scope
Three Refine providers over a Solid Pod running ActivityPods: a `dataProvider` (CRUD over LDP containers), an `authProvider` (Solid-OIDC sign-in plus the app-registration dance ActivityPods requires), and a `liveProvider` (push over the Solid Notifications Protocol). Resources are not addressed by a path the caller chooses: each configured resource is mapped at runtime to an LDP container discovered in a WebID's Solid TypeIndex.
Two Ant Design components also ship (`antd-auth-page`, `antd-background-checks`) and are outside this scope — `antd` and `@ant-design/icons` are optional peer dependencies, so an app with its own interface consumes the three providers without them. `react-router` is likewise an optional peer dependency.
Package `@activitypods/refine-providers`, version 0.1.5 at the pinned commit. Peer dependency `@refinedev/core ^5.0.12`. Full typed shape: the package's shipped declarations, `./types` (`dist/types.d.ts`) and `./index` (`dist/index.d.ts`).
## Surface
```ts
dataProvider({ resources, authProvider, jsonContext? }): DataProvider
authProvider({ clientId, redirectUri?, allowAnonymous?, appStatusCheckInterval? }): AuthProvider
liveProvider({ resources, authProvider, jsonContext? }): LiveProvider
type ResourceConfig = { shapeTreeUri?: string; types?: string | string[] }
type ResourcesConfig = Record<string, ResourceConfig> // keyed by Refine resource name
type JsonContext = string | string[] | Record<string, any>
type AuthSession = { token: string; webId: string }
```
`AuthProvider` is Refine's `AuthProvider` plus four members: `getSession(): AuthSession | undefined` (synchronous), `handleCallback(): Promise<{ webId, redirect }>`, `registerApp(webId?): Promise<string | undefined>`, `getAppStatus(): Promise<AppStatus>`. Its `login`/`register` take `{ issuer, redirect?, isSignup? }` — the Pod provider's base URL is a caller-supplied parameter, so the app must let the user pick one.
`DataProvider` implements `getList`, `getOne`, `getMany`, `create`, `update`, `deleteOne` and `getApiUrl`. Nothing else.
Utility functions are exported from the package root as well (notably the TypeIndex discovery and container resolution used internally); they are not part of what this document engages.
## Guarantees
Observed at the pinned commit.
**A record is a JSON-LD resource with an `id`.** `id` is the resource's own URI — `id` or `@id` of the fetched document, whichever is present; a resource carrying neither raises. The record also carries `@context` (the container's, on a listed record) and every other predicate verbatim. There is exactly ONE identifier: `getOne`, `update` and `deleteOne` take that same `id` and use it directly as the URL they fetch.
**`getList`** resolves the resource's container, fetches it whole, and maps `ldp:contains`. Refine's filters and sorters are then applied **in memory**, and pagination slices the result; `total` is the count before slicing. The filter set is broad (equality, `in`, substring, prefix/suffix, ordering, `between`, null checks, and `and`/`or` nesting), string comparison being case-insensitive unless the case-sensitive variant is used, and a filter field may be a dotted path.
**`create`** POSTs `{ "@context": jsonContext, "@type": <resolved types>, ...variables }` to the container, requires a `Location` response header, and returns the record re-fetched from it. The `@type` is the resource's configured `types` expanded through `jsonContext`, or, when only `shapeTreeUri` is configured, the `sh:targetClass` of the shape the shape tree points at.
**`update` merges — a partial write is safe.** It fetches the current resource, drops its `id` and `@context`, and PUTs `{ "@context": jsonContext, ...current, ...variables }`. So values the caller omits survive. The merge is **shallow**: a nested object in `variables` replaces its counterpart whole. It returns the record re-fetched after the write.
**`deleteOne`** DELETEs the resource and returns `{ id }` only.
**A configured resource's container is discovered, not configured.** Resolution reads the WebID document, then its `solid:publicTypeIndex`, then best-effort its `pim:preferencesFile` → `solid:privateTypeIndex`, and takes the first type registration whose `solid:forClass` matches the resource's resolved types and which carries a `solid:instanceContainer`. No match raises, naming the WebID and the types. A `shapeTreeUri` is fetched over plain HTTP, as is the shape it points at — so a shape tree the caller hosts itself works, provided it is reachable and carries `st:shape` → `sh:targetClass`.
**`jsonContext`** is sent as `@context` on every create and update, and is what expands configured CURIE types. It defaults to the ActivityStreams 2 context; an application with its own vocabulary passes its own.
**`liveProvider` pushes — the caller never polls.** It opens a `notify:WebSocketChannel2023` channel per topic, discovering the subscription endpoint from the Pod origin's `.well-known/solid`. Watching specific records subscribes to each record and reports `Update` → `updated`, `Delete`/`Remove` → `deleted`. Watching a list subscribes to the **container** and reports `Add` → `created`, `Remove` → `deleted`. Unsubscribing closes the socket and deletes the channel best-effort. A resource absent from `resources` yields a no-op unsubscribe rather than an error.
**Auth is Solid-OIDC with PKCE.** `login` redirects to the issuer's authorization endpoint with scope `openid webid offline_access`; `handleCallback`, called from the redirect route, completes the code grant and stores the ID token. The session's `webId` is the token's `webid` claim, and an expired token reads as no session. `logout` clears local state. `check` reports unauthenticated with `redirectTo: "/login"` unless `allowAnonymous` is set. `getIdentity` returns `{ id: webId, name, avatar }`, name and avatar read from the WebID document and the profile it links — ActivityPods' own profile vocabulary, unrelated to any vocabulary the app defines for its own user records. `registerApp` returns the AppRegistration URI when the app is registered and its grants current, and otherwise redirects the browser to the user's authorization agent and resolves `undefined`.
**Failures throw, and carry the HTTP status.** Every Pod call that returns non-2xx raises an `Error` with `status` and `body` attached, its message taken from the response body's error message when there is one. `onError` maps a 401 to logout.
## Non-guarantees
**Every read and write is scoped to the logged-in user's own Pod.** Container resolution always runs against the session's own WebID — in `getList`, in `create`, and in a list subscription alike. There is no parameter, filter or option through which an operation reaches another user's resources: `getList` returns what the current user holds, and nothing else. (`getOne`/`getMany` fetch a URI directly and so are not bound this way, but they require the caller to already hold that URI.)
**No server-side querying.** Filtering, sorting and pagination are applied after the whole container has been fetched, so cost grows with the container, not with the page.
**`getManyReference` is not implemented**, despite the data provider's own documentation mentioning it. Neither are `createMany`, `updateMany` and `deleteMany`.
**`getMany` drops what it cannot fetch.** Failed fetches are filtered out silently, so the returned array may be shorter than `ids` with no indication of which are missing.
**Caches are module-level and never invalidated** — shape trees by URI, type registrations by WebID, subscription endpoints by origin. A container that becomes available after a first lookup (the user granting access, for instance) is not seen until the page reloads, which is what the registration flow expects.
**`authProvider` installs process-global timers it never removes.** Unless `appStatusCheckInterval: false` is passed, construction registers a `setInterval` and a `visibilitychange` listener, with no teardown; constructing the provider more than once per page accumulates them.
**One session per page.** The token lives at a fixed `localStorage` key, so two concurrent sessions cannot coexist.
**A list subscription does not report field changes.** A container notifies about membership only, so a member's own predicates changing is invisible to a list; the record has to be watched directly.
**`types` wins over `shapeTreeUri` when both are set** — the opposite of what the configuration type's own documentation states. Configure exactly one. Likewise, when several type registrations match a resource's types, the first encountered wins, with no stated ordering.
**Unregistered apps read and write nothing.** An ActivityPods app must hold an AppRegistration before any data operation succeeds, and keeping it current is the app's business, through `registerApp` and `getAppStatus`.
## Change policy
None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here.
## Re-baseline 2026-10-12 (authorized one-time re-read of the installed 0.2.0)
The user authorized a single re-read of the installed package to resolve the login-loop investigation; these paragraphs pin what the first reconstruction had not observed. They stand until the next re-baseline.
**`registerApp` discovers the registration through a Link header, and the discovery key is the requester's identity.** `registerApp(webId)` fetches the user's WebID document, reads `interop:hasAuthorizationAgent`, then fetches the auth-agent document and parses its `Link` response header for `rel="http://www.w3.org/ns/solid/interop#registeredAgent"`; the first link's anchor is the AppRegistration URI. The pod provider only emits that header when the requesting app's identity — the access token's `azp`, i.e. the Solid-OIDC `client_id` — equals the registration's `registeredAgent`. The pod stores `registeredAgent` under the app actor URI (its authorize flow resolves the client_id document to the actor and passes the actor as `appUri`). CONSEQUENCE, verified end-to-end 2026-10-12: the `client_id` configured in `authProvider({clientId})` must be the app actor URI — not a separate document URI — or the lookup never matches and `registerApp` re-redirects to the consent screen forever.
**`registerApp`'s fallback is a browser redirect, not an error.** When no registration is found (or the granted needs are stale), it stores the current path in `localStorage` under a return-to key, then sets `window.location` to the auth-agent's `interop:hasAuthorizationRedirectEndpoint` with a `client_id` query parameter, and resolves `undefined`. The app resumes at the stored path after the consent dance; that path is what the app's callback stage must parse.
**`getAppStatus()` is a plain HTTP GET, and its shape is small.** It calls `GET /.well-known/app-status` on the pod provider's origin with the session token and returns the JSON body; the member the provider itself acts on is `upgradeNeeded` (boolean). A status probe that throws is treated by the provider's own upgrade check as fail-open (no upgrade). This resolves the previously documented gap on the `AppStatus` shape: `{ upgradeNeeded: boolean }` plus whatever the pod adds.
**The built-in status drive exists and is on by default.** Unless `appStatusCheckInterval: false` is passed, the auth provider installs a periodic + `visibilitychange` check that silently re-runs `registerApp` (→ consent redirect) when `upgradeNeeded` turns true. Festipod currently opts out (`appStatusCheckInterval: false`, friction 1 in the usage declaration) and drives the check on its own schedule instead.
@@ -0,0 +1,80 @@
---
type: usage
summary: What Festipod consumes from @activitypods/refine-providers as one of its interchangeable Refine backends, and the conditions its own vocabulary and confidentiality model put on that backend
against: 66616484b96fb2227cd8cdf02321aacfb4811365
---
# usage_festipod — Festipod on `@activitypods/refine-providers`
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, naming the meeting point and addressed to the Festipod app actor alone, so that only its author and the app can read it; the pod delivers it to the app's inbox. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
## Consumed surface
### Provider construction
- `authProvider({ clientId, redirectUri, appStatusCheckInterval: false })` — one call site, per backend selection. `appStatusCheckInterval` is disabled deliberately, see friction 1; Festipod drives the check itself.
- `dataProvider({ authProvider, resources, jsonContext })` — one call site, handed the `authProvider` above.
- `liveProvider({ authProvider, resources, jsonContext })` — same, so Refine can subscribe behind every query.
All three are built together, **per backend instance**, inside the seam's `build()`.
### Auth members
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
Of the `getAppStatus()` result Festipod reads `upgradeNeeded` (pinned by the 2026-10-12 re-baseline) and `installed`, not pinned (the reconstruction records the pod's body, `{ upgradeNeeded }` "plus whatever the pod adds"): `true` while the app is registered, `false` once the user removed it — then Festipod logs the user out to the picker, never calling `registerApp`. See constraint 9, need 3.
### Resources and operations
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
`update` of the own profile is also how the participation secret is stored: before the first Join the app re-reads its profile explicitly (`getOne`, refetched, never trusting a cached copy), and when no secret is there writes `http://festipod.org/participationSecret` through `update` with the **full IRI** as the key, then reads the stored value back from the answer. The pod stores and returns such a predicate under the full IRI (verified live 2026-10-29 with the app's token), whatever the `fp` prefix registration.
`create` of an event or a meeting point carries `to: as:Public` among its variables: Festipod's declaration that the record is public, on which the Festipod backend's public read and discovery listing depend. See constraint 10.
### Record handling
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
### Not consumed
- `shapeTreeUri` — exactly one of the two resource identifiers must be configured, and `types` names Festipod's classes directly, without a shape tree to host and keep reachable.
- `getIdentity` — the signed-in identity and a Festipod user profile are two unrelated things in this app; the profile is read as an ordinary record, and only `getSession`'s `webId` is needed.
- `getMany`, `getApiUrl`, `allowAnonymous`, and both Ant Design entry points — Festipod has its own interface.
## Constraints
What must keep holding for this backend to serve the app.
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person**: an attendee list holds opaque member URNs, and only a connection, who reads the secret on the person's profile, can recognise who a URN is — never the list itself. The provider alone cannot satisfy it (need 1).
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
9. **`getAppStatus()` must keep telling a removed registration from a current one** (`installed`). Otherwise a user who revoked the app stays in a connected view whose writes cannot land — or a current user is logged out.
10. **`create` must keep sending the caller's variables unchanged, `to` included.** A provider dropping or rewriting `to` would make every new event and meeting point private and undiscoverable, with no error.
## Frictions
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule — on load and on focus or visibility change, at most once a minute, with its own teardown. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely.
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
5. **The member shape of an `apods:attendees` collection is not pinned anywhere Festipod may read at work time.** The reconstruction documents the container discovery, not what a membership record is. Read against a local platform (verified 2026-10-12), the collection is its own resource, linked from the meeting point by URI and never embedded; its items are, in Festipod, opaque member URNs recorded by the Festipod backend, and the participation binding maps each to `{ id, userId, isConfirmed: true }` (membership IS the attendance), `eventId` being supplied by the reader — the meeting point the collection was read from. The `userId` of such a record is a member URN, not a person: a person is recognised only by hashing (see constraint 4).
6. **The live provider's `subscribe` rejects with an uncaught `Forbidden` when the Pod refuses its push channel.** On this platform the channel creation (`POST /.notifications/WebSocketChannel2023`) answers 403 for the app's session, and each refusal surfaces as one unhandled rejection in the page — raised inside the provider, where the app cannot catch it; Festipod's end-to-end checks recognise it by its throw site. Reporting the refusal through the subscription's error path would let the app handle it.
## Needs
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID, so `getList('events')` returns only the current user's own events. To let a user **discover other people's public events** and see **how many people signed up**, Festipod now carries a workaround beside the provider: other users' events are found through the Festipod backend's catalog (a cross-Pod index of public records), and their records and attendee collections are read by URI with plain anonymous HTTP. Neither gets Refine's cache or live updates. The capability sits one layer down (the package's TypeIndex discovery takes a WebID; the public TypeIndex needs no grant), so the request stands: a way to scope a `getList` (and its list subscription) to a given WebID, or a set of them, through the data provider.
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
3. **`installed` pinned in the app status.** Festipod depends on it (constraint 9); stating it as part of the status, `false` once the user removed the app, would stop an upgrade from silently taking it away.
@@ -1,186 +0,0 @@
---
type: brief
summary: Implementation design (historical) — make reads REACTIVE via doc_subscribe (per-document, without the ORM fan-out that hangs) and replace the mutated-in-place participantCount with the Option-B flow (the participant deposits into the event's inbox, the owner materializes and increments their own doc). READ IN THIS LIGHT — the app-side doc_subscribe wiring was later SUPERSEDED by the SDK's watchShape/useShapeQuery surface, and the « reactive with no reload » framing was RETRACTED for « the owner processes their inbox at their next connection »; Option B (P4-P5) is still pending
---
# Reactive reads + correct participant count (Option B)
Implementation brief, anchored in the current code. Goal: two coupled evolutions of Festipod's data layer (connected mode / `@ng-eventually/client`).
1. **Cross-session reactive reads** — replace the one-shot `readUnion` + `bumpRead` (manual re-query, local-only) with real broker-pushed reactivity, **never polling** and **without the ORM fan-out that hangs**.
2. **Correct participant count (Option B)** — remove the current isolation violation (the participant writes `participantCount` on the event doc, which is not theirs) and replace it with the inbox-deposit → owner-materialization flow.
This brief describes **what to build and in what order**. No code change is made here.
Cross-cutting references: [[knowledge_context-internals]], [[rule_document-per-entity]], [[caveat_participation-deletion]], `functional-domain/knowledge_data-scopes-and-discovery`, `app-security/knowledge_trust-model`, and the `@ng-eventually/client` SDK contract (`docs/sdk-reference.md`, `docs/read-model.md`, `docs/nextgraph-current-state.md`).
---
## 0. Current state (the starting point, file:function)
### Reads (one-shot, manual re-query)
`src/shared/context/FestipodDataContext.tsx` → `useNgData()`:
- The set of docs to read **on demand** is two `useState`s: `publicDocs` / `protectedDocs` (lines 232-233). It is fed by (a) the listing effect (lines 302-332) which calls `listMyEntityDocs(owner, 'public'|'protected')` (bounded to my own account) + `readDiscoveredEvents()` (the global index), and (b) `registerDoc(scope, nuri)` (lines 251-255) which adds a freshly created doc.
- The **actual read** (lines 347-364): `readEntities(allReadDocs)` → `readModel.readUnion(docs)` (one `sparql_query` anchored per doc, in parallel, per-doc tolerant). It **re-runs** when `allReadDocs` changes **or** when `readTick` changes.
- `readTick`/`bumpRead` (lines 236-237) = a **manual re-query signal**, bumped after every mutation. **There is NO signal coming from the broker**: a write made by ANOTHER session never increments this session's `readTick` → **no cross-session reactivity**. That is the gap this brief fills.
- `listTick`/`relist` (lines 246-247) replays the listing effect after a seed.
### Writing the counter (the violation to remove)
- `joinEvent` (lines 597-668): after writing its own `Participation` (protected doc, lines 621-631), it calls `updateEntityField(eventId, eventId, 'participantCount', int(next))` on **the event's doc** (lines 635-640) — but that doc belongs to the **event's owner**, not to the participant. That is an out-of-scope write. It *also* deposits into the inbox via `depositRegistration` (line 652) — that deposit is the right channel; it is the direct `participantCount` write that must go.
- `leaveEvent` (lines 670-712): symmetrically, decrements `participantCount` on the event's doc (lines 705-710) after the authoritative DELETE of the participation.
- `caveat_participation-deletion`: the participation DELETE must remain **authoritative** (SPARQL DELETE-WHERE via `deleteParticipation`, `src/shared/data/registration.ts` lines 260-334, verified `remaining === 0`) — this brief does not change that contract.
- [[knowledge_context-internals]] already documents that `participantCount` is a **cache mutated in place**, never recomputed, and "not a source of truth". Option B turns it into a value **derived and owned by the owner**.
### Display (already "count + anonymous", to be kept)
`src/modules/event/screens/EventDetailScreen.tsx`:
- `joined = isParticipating(eventId)` (line 20).
- `participants = getEventParticipants(eventId)` (line 21) → in the context, `getEventParticipants` (FestipodDataContext lines 108-111) filters the known `participations` by `eventId` and joins the **readable** `users` (so only my connections, per the protected cap).
- `knownParticipants = participants.filter(p => p.id !== currentUserId)` (line 33).
- The label **« Participants ({event.participantCount}) »** (line 146) displays the **derived count**, and `knownParticipants.length < event.participantCount` renders the **« voir tous les participants » placeholders** (lines 163-170) — exactly the intended "count + anonymous" model. **This display does not change**: Option B only makes `participantCount` correct and reactive, and `knownParticipants` remains governed by the protected read cap.
### The lib's polling watchers (to be replaced)
Confirmed by reading the lib (`packages/client/src/`):
- `inbox.watch(target, onDeposits, {intervalMs=1000})` (`inbox.ts:195-223`) = **`setInterval` polling**, firing only when `deposits.length` changes.
- `discovery.watchIndex(onEntries, {intervalMs=1000})` (`discovery.ts:163-187`) = the same **`setInterval` polling**.
- `useShape` (`use-shape.ts:12`) IS push-based/reactive, but **only safe on ONE already-open document** — the `graphs:[…]` fan-out hangs (§2).
- **No `doc_subscribe` wrapper is exposed today** in `docs.ts` (which only exposes `docCreate` / `sparqlUpdate` / `sparqlQuery`). The `ng.doc_subscribe` primitive is reachable *untyped* through the `ng` proxy (`ng-proxy.ts:54-56` passthrough), but there is **no typed layer** → **the lib must add one** (§A).
---
## 1. The platform primitives (nextgraph-rs, verified)
- `doc_subscribe(repo_o: String, session_id, callback)` (`sdk/js/lib-wasm/src/lib.rs:1907`) is **per-document**: a single repo NURI, a single callback. It mounts a subscription on **one branch** of the doc (`verifier.rs:352` `create_branch_subscription`), first pushes a `TabInfo` + initial `State` (`verifier.rs:470-477`), then a stream of `Patch`es on every commit.
- The push: on every verified transaction on a branch B, the verifier calls `push_app_response(&B, AppResponse::…)` (`verifier.rs:252`) on the `Sender` registered in `branch_subscriptions[B]` (`verifier.rs:115`). **Unit of subscription = one branch of one doc.**
- The **ORM fan-out** lives elsewhere: `orm_start_graph(scope.graphs[], …)` (a single call over an array). There, a **single** unsynchronized repo in the array makes `open_for_target → resolve_target` return `RepoNotFound` (`request_processor.rs:147-171`, and above all the `initialize.rs:125-128` loop where the `?` **aborts the whole subscription**). The `readyPromise` then never resolves → **~75s hang** (`nextgraph-current-state.md` § *The ORM fan-out hang*, quoted in `read-model.md:93-98` and the header of `read-model.ts:24-31`). **Corollary: per-doc `doc_subscribe` does NOT have this flaw** — it is not subject to fan-out, so a missing doc only breaks its own subscription.
- **Writes are membership-bound, with no append** (confirmed, `repo.rs:584` `verify_permission`: a non-member author → `PermissionDenied`; `commit.rs`: a transaction requires `WriteAsync`/`WriteSync`, obtainable only via a grant from the owner; **there is no `Append` variant in `PermissionV0`**). ⇒ **Option A is impossible**: a participant cannot write to / increment a counter on someone else's public doc. Hence Option B through the inbox.
- **The inbox is a real platform primitive** (`server_broker.rs:826` `inbox_post`: no membership check on the sender; the message is sealed to the inbox's key, readable only by the registered *readers*). That is exactly the "anyone deposits, only the owner drains" channel. Today the lib emulates it over the shared wallet (`inbox.ts` post/read RDF), the native one being deferred.
---
## A. Reactive reads — the design
### Principle: per-doc `doc_subscribe` as a **change signal**, `readUnion` remains the reader
We do **not** make `readUnion` reactive and we do **not** introduce an ORM fan-out. We keep the documented pattern (`read-model.md:100-110`):
> a lightweight reactive subscription (`doc_subscribe`, or the ORM on a single already-open store — never a per-entity fan-out) on the synchronized docs; on its change signal, replay the bounded set of per-doc `sparql_query` calls (`readUnion`).
Concretely:
1. **The lib exposes a typed `doc_subscribe` wrapper.** It does not exist today. Add to `packages/client/src/docs.ts` (or a new `subscribe.ts`) a function, e.g.:
```ts
// returns an unsubscribe; onChange called on the initial State then on every Patch
export function subscribeDoc(nuri: Nuri, onChange: (r: AppResponse) => void): () => void
```
which wraps `ng.doc_subscribe(nuri, sessionId, cb)` and normalizes the AppResponse (initial + patches) plus stream teardown. It is **per-document** (a single NURI), hence immune to the fan-out hang.
- Also expose a helper to subscribe to **a set** of docs by mounting **one subscription per doc** (a `nuri → unsubscribe` map), with **per-doc error isolation**: a `RepoNotFound` / unsynchronized doc only fails ITS OWN subscription (retry/skip), never the others. That is the key point that avoids reproducing the fan-out. The SDK contract (`sdk-reference.md`) will need to document this wrapper.
2. **The data context (FestipodDataContext) mounts a per-doc subscription over the set it already reads.** The `allReadDocs` set (union of `publicDocs` ∪ `protectedDocs`) is already bounded and on-demand. A new effect in `useNgData()`:
```
useEffect(() => {
const unsubs = allReadDocs.map(nuri => subscribeDoc(nuri, () => bumpRead()));
return () => unsubs.forEach(u => u());
}, [allReadDocs]);
```
→ on **any** patch of a subscribed doc (written by THIS session OR another one), `bumpRead()` re-triggers the existing `readUnion` (lines 347-364). **`readTick`/`bumpRead` stay** — they stop being "manual after my own mutation" and become "pushed by the broker". The shape of the context (`events`/`users`/`participations` values in `useState`) **does not change**; screens keep reading through `useFestipodData()` unmodified.
3. **NEW docs entering the subscribed set, without a fan-out hang:**
- **A newly discovered event**: reactive discovery replaces `discovery.watchIndex` (setInterval) with a **`doc_subscribe` subscription on the global index doc** (the index inbox, a single doc — `resolveInboxAnchor`-style). On every patch of the index → re-read `readDiscoveredEvents()` → the new `doc` NURIs enter `publicDocs` (via `setPublicDocs`), which **grows `allReadDocs`**, which **remounts the per-doc subscription** (the new `useEffect` above) → the new event is read AND from then on subscribed. No fan-out: each doc is subscribed **individually**, as it enters.
- **A new inbox deposit** (new participant, host notification): likewise, replace `inbox.watch` (setInterval) with a **`doc_subscribe` subscription on the relevant inbox doc** (a single doc). A patch → re-materialize (§B).
- **A doc I just created**: `registerDoc` keeps adding it to `publicDocs`/`protectedDocs` → it enters `allReadDocs` → it gets subscribed. (An immediate `bumpRead` keeps perceived local latency at zero.)
4. **The lib replaces its polling watchers**: `inbox.watch` and `discovery.watchIndex` become `doc_subscribe` wrappers on the inbox doc / index doc respectively (one doc each — no fan-out). The public signature is preserved (callback + unsubscribe) so callers do not break; the implementation moves from `setInterval(read)` to `subscribeDoc(anchor, () => read().then(onX))`.
### What does NOT change
- `readUnion` stays one-shot, per-doc, tolerant (a failing doc → `[]`, never an abort).
- The `readEntities` mapping (`src/shared/data/readEntities.ts`) is unchanged.
- **No per-entity `useShape({graphs:[…]})` is introduced** — the only remaining `useShape` is the test harness's `FanoutProbe` (whose very purpose is to *demonstrate* the hang), not an application path.
---
## B. Participant count — Option B (deposit → owner materialization)
### The documents / inboxes involved
- **The participant's participation doc**: protected, **owned by the participant** (already created by `joinEvent`, `createEntityDoc(owner,'protected')` + `writeEntity(ENTITY_TYPE.participation, …)`). Readable in plaintext only by the participant's **connections** (protected cap + `declareConnections`).
- **The event's inbox**: resolved by `hostInboxNuri(eventId)` → `resolveInboxAnchor()` (today a single anchor; after migration, one inbox doc per event — `hostInboxNuri` already reserves the `eventId` param). That is where the participant **deposits the participation link**.
- **The event's doc**: public, **owned by the owner**. It is **the owner** who writes `participantCount` there — never the participant.
- **A (reference) recorded by the owner**: an entry linking the incremented count to the deposit (idempotence + audit); it can live in the event's doc (reference to an already-materialized deposit) or in a protected doc of the owner's.
### The flow (who writes what)
1. **Participant — `joinEvent`** (modified):
- Writes its own `Participation` (protected, theirs) — **unchanged**.
- **Deposits into the event's inbox** a `{ kind:'new-participant', eventId, participationDoc, participantId, uid }` payload via `depositRegistration` (today `inbox.post(target, {from:null, payload})`, `registration.ts:110-125`). `from` stays anonymous at the transport level (the SDK binds `from` to the identity and rejects a spoof — see `registration.ts:106-108`); the domain identity travels in the payload. **The deposit carries the NURI of the participation doc** (`participationDoc`) so that the owner, if they are a connection, can read it in plaintext.
- **REMOVES the `participantCount` write on the event's doc** (current lines 635-640). The participant never writes to someone else's doc again.
2. **Owner — materialization (when connected)**: the owner's session is subscribed (`doc_subscribe`, §A.3) to their event's inbox doc. On a new `new-participant` deposit:
- dedup via `uid` (idempotence: do not re-count an already materialized deposit — check the recorded (reference));
- **increments `participantCount` on THEIR OWN event doc** (`updateEntityField(eventDoc, eventDoc, 'participantCount', int(next))`) — **the owner writing their own doc**, not a read privilege nor an out-of-scope write;
- records the **(reference)** of the materialized deposit (idempotence marker).
- This logic replaces/extends the existing **notification materialization** effect (FestipodDataContext lines 443-479, `readRegistrationNotifications`): today it only surfaces notifications; it also becomes the point where the counter is incremented. The trigger moves from implicit polling to the `doc_subscribe` subscription on the inbox.
3. **Other sessions see the count change**: the event's doc is **public**, so **every** session that has it in its `allReadDocs` is subscribed to it (§A). The owner's write produces a patch → `bumpRead()` → `readUnion` re-reads → `event.participantCount` updated → `EventDetailScreen` re-renders « Participants (N) » **with no reload and no user action**. That is the complete reactive path, cross-session.
### Withdrawal (symmetric, authoritative)
- `leaveEvent`: keeps the **authoritative DELETE** of the participation (`deleteParticipation`, verified `remaining === 0`) — [[caveat_participation-deletion]] intact (it must not come back to life).
- **Removes the direct decrement** of `participantCount` by the participant (lines 705-710). Instead, the participant **deposits a `leave`** (`{ kind:'leave-participant', eventId, uid }`) into the event's inbox; the owner materializes → **decrements their own doc** (idempotent via `uid`, `max(0, n-1)`, and refuses to re-decrement an already processed `uid` so as not to "resurrect" a wrong count).
- **Owner-offline case = eventual behaviour, ACCEPTED**: if the owner is not connected, the deposit stays in the inbox; the count is **not** updated until they reconnect and materialize. **This is accepted behaviour** (eventual consistency, local-first). Others see the count correct itself when the owner comes back. To be stated as such in the product contract.
### Identity (C)
- A participant is shown **by name** only if the viewer is a **connection** of that participant: the participation doc + the participant's profile are protected, so they are readable in plaintext only through the cap granted by `declareConnections` (`src/shared/utils/connections.ts` → `grantRead(protectedDocsOf(owner), neighbour)`). Otherwise the doc stays unreadable → the participant does **not** appear in `getEventParticipants` (which joins on the `users`/`participations` that were read) → they fall into the **« inconnu » placeholders** of `EventDetailScreen` (lines 163-170), the derived count remaining visible through `participantCount`.
- **No privileged read for the host**: the owner does not read participations; they only **count deposits** and write their own counter. They see a named participant only if they are a connection of theirs — exactly like any other viewer. This matches `functional-domain/knowledge_data-scopes-and-discovery` ("identified if known, anonymous otherwise") and `app-security/knowledge_trust-model` (no application-level access control; isolation is per-document and delegated to the SDK).
---
## D. Test plan (real e2e, no polling)
### D.1 — POLYFILL low-level: `doc_subscribe` really does react
Goal: prove the reactive primitive works, independently of Festipod.
- Location: a unit/integration test of the lib (`packages/client`) — or a Festipod `@data` test if the broker harness is required.
- Setup: two "views" of the **same** doc (two subscriptions, or one subscription plus a write through another path). Mount `subscribeDoc(nuri, onChange)`, write to the doc via `sparqlUpdate`.
- **Assertion**: `onChange` is called (initial State) **and then** called again after the write, **without polling** (no `setInterval`; the assertion waits on an event, not on a timeout). Check that a write on **another** doc does NOT trigger `onChange` (per-branch isolation). Check that an unsynchronized doc which fails **does not abort** the other subscriptions (per-doc).
### D.2 — FESTIPOD app-level: 2 real browsers, with no reload and no action from A
Goal: B signs up → A's `EventDetailScreen` shows `participantCount` incremented **and** an "unknown participant", **without A reloading or acting**.
- Extend `src/modules/event/features/e2e-multibrowser.feature` (`@multibrowser @shared-wallet`) and `src/modules/event/steps/e2e/multibrowser-features.steps.ts`.
- New scenario (French Gherkin sketch):
```
Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload
Étant donné un navigateur "A" avec le wallet partagé
Et un navigateur "B" avec le wallet partagé
Et le navigateur "A" charge l'application via le broker
Et le navigateur "B" charge l'application via le broker
Et le navigateur "A" est connecté à NextGraph
Et le navigateur "B" est connecté à NextGraph
Et le navigateur "A" crée l'événement "Apéro réactif"
Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif"
Et le compteur de participants affiché dans "A" pour "Apéro réactif" vaut 1
Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif"
Alors sans recharger, le compteur de participants affiché dans "A" pour "Apéro réactif" passe à 2
Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif"
```
- **Exact assertions**:
1. `participantCount` **on A's side** goes from 1 to 2 — asserted via `frame.waitForFunction` on the context's reactive state (`__testData.events` → the event → `participantCount === 2`) **and then** confirmed on the rendered DOM (the « Participants (2) » label of `EventDetailScreen`), **with no `loadAppInBrowser`/reload call** between B's join and A's assertion.
2. **Unknown placeholder**: `knownParticipants.length < participantCount` → assert the presence of the « Voir tous les participants » block (or an anonymous count = `participantCount − knownParticipants.length ≥ 1`), B not being a connection of A → not named.
3. **Negative, no-polling**: the 1→2 transition arrives through the subscription (event-driven); the test waits on the event, and must not depend on a fixed `waitForTimeout` as the *source* of the update (a guard timeout remains tolerated to let the broker sync, as in the existing withdrawal scenario, line 131).
- **Harness helpers required** (in `harness-ng.tsx`, exposed on `window.__testData`, and replicated in BOTH harnesses — see `bdd-testing/cookbook_add-scenario`):
- a getter for an event's reactive `participantCount` (already reachable via `__testData.events`).
- a way to reach A's **rendered** `EventDetailScreen` **without manual navigation**: either mount the real app on the detail route (the @e2e path), or expose `knownParticipants` / the anonymous count. Reuse `createEventReal` (line 232), `appJoinEvent` (line 245), `readInboxDeposits` (line 283), `authParticipationCount` (line 302).
- a "the owner has materialized" hook: since A is the owner AND connected, their inbox subscription must increment their own doc — the test observes the outcome (count 2) without driving materialization by hand.
- **Withdrawal symmetry**: extend the existing scenario « la désinscription ne ressuscite pas » (lines 36-48) with a reactive assertion: after B's leave, `participantCount` on A's side **goes back to 1 without a reload**, and `authParticipationCount === 0` (already covered).
---
## E. Risks / open questions
1. **The fan-out hang** (risk #1). The design avoids it **by construction**: **per-document** subscription (`doc_subscribe`), never `orm_start_graph(graphs:[…])`. To be kept as an invariant: every new doc enters through an **individual** subscription with per-doc error isolation — an unsynchronized doc must never be able to abort the other subscriptions nor block `readUnion` (which stays per-doc tolerant). Residual risk: the **volume** of per-doc subscriptions (one per doc read) — to be validated against the real broker; failing that, cap/prioritize the subscribed docs (current event + its inbox + my own docs) rather than the whole union.
2. **Owner-offline count = eventual — DECIDED (2026-07-06).** As long as the owner is not connected, no deposit is materialized → `participantCount` stays stale for everyone else (the participation itself is persisted broker-side — nothing is lost, only the aggregate waits for the host to reconnect). Accepted for V1. **Later, a SERVICE will take over** when the owner is disconnected (the deferred `@ng-eventually/service` package — the "curator" mentioned in the lib's inbox docs): an always-available actor will materialize the inbox in the host's stead. No « N+ en attente » fallback in V1.
3. **Per-doc `doc_subscribe` — DONE (lib `c0498a6`).** The lib now exposes `subscribeDoc`/`subscribeDocs` (per-doc error isolation, no ORM fan-out), `inbox.watch`/`discovery.watchIndex` have moved to `doc_subscribe` (no more polling), and the contract is in `sdk-reference.md`. Validated against the real broker (the callback crosses the iframe RPC and fires on change). Remaining: wire the subscription into the app's read path (P3).
> **The SDK's reactive hooks** (clarification): NextGraph's React adapter exposes `useShape` (reactive RDF shapes) and `useDiscrete` (discrete CRDT docs) — there is no `useQuery`. The lib re-exposes `useShape`. For a UNION read over N docs (Festipod's case), `useShape`/the ORM in fan-out *hangs*; the app's reactive path therefore goes through `subscribeDocs` (per-doc) + a re-`readUnion`, possibly wrapped into a reactive read hook on the lib side (to be decided in P3).
Other points to settle:
> ⚠️ **REFRAMED + CORRECTED (2026-07-13).** The claim below, "Proven by the D.2 e2e … with no reload", was **FALSE** (the "green" came from a bloated wallet). But more importantly the framing "reactive / no reload / cross-session push" was an **OVER-FRAMING**: the real spec is **"the owner reliably processes their inbox at their NEXT CONNECTION"** (not a live real-time notification between two connected users). The bug fixed under that framing: the materializer read the inbox **before it had synced** (→ a memoized 0). Fix = inbox read **gated on a barrier** (`inbox.readSynced` = `ensureRepoOpen` + `read`) + triggering on connection + a single source of truth, `event.participantCount`. The `event/e2e-multibrowser.feature` scenario was **reframed as "at the next connection" and un-`@wip`'d, GREEN on a fresh profile** (a reconnection/re-materialization by A is the accepted mechanism). Details: [[knowledge_context-internals]] §participantCount. The phasing plan below must be re-read in that light ("no reload" is no longer the requirement).
- **Phasing order:** ~~(P1) lib: `subscribeDoc` + multi-doc variant + D.1 tests~~ **DONE (`c0498a6`)**; ~~(P2) lib: replace `inbox.watch`/`discovery.watchIndex` with `doc_subscribe`~~ **DONE (`c0498a6`)**; ~~(P3) app: wire the per-doc subscription into `useNgData` (pushed bumpRead) + reactive discovery~~ **DONE, then SUPERSEDED** — P3 first wired an app-side `subscribeDocs(allReadDocs, …)` effect + a reactive discovery effect on top of the one-shot `readUnion`. That app-side wiring **no longer exists**: the read path has since moved entirely behind the SDK surface (`watchShape` bound by `useShapeQuery`), with no doc set, no `bumpRead` and no per-doc subscription left in the app (verified 2026-07-28 — see [[rule_app-uses-sdk-surface-only]] and [[rule_document-per-entity]] §Reads). **Validation — the earlier « proven by the D.2 e2e, with no reload » claim is RETRACTED**: per the REFRAMED + CORRECTED box above, that green came from a bloated wallet, and « live cross-session push with no reload » was never the spec. What `e2e-multibrowser.feature` covers is the reframed contract — **the owner reliably processes their inbox at their NEXT CONNECTION** (un-`@wip`'d, green on a fresh profile). So P3 is delivered as *the app reads through a reactive SDK surface*, **not** as *a proven reload-free live push*. ; (P4) app: Option B join (remove the participant's counter write, owner materialization); (P5) app: symmetric Option B leave; ~~(P6) e2e D.2~~ **DONE with P3** (the reactive scenario above; the reactive withdrawal symmetry remains to be added with P5). P1→P3 deliver reactivity; P4→P6 the correct counter. P1–P3 can ship before P4–P6.
- **Materialization idempotence**: the per-deposit `uid` (`RegistrationPayload.uid`, `registration.ts:56`) is the pivot; the (reference) recorded by the owner must be consulted before any increment/decrement so as never to double-count (sync replay) nor "resurrect" a count.
- **Native inbox migration**: today the inbox is emulated over the shared wallet (`inbox.ts` post/read RDF). On migration to the native broker inbox (`inbox_post`/`inbox_pop_for_user`, sealed), the Option B flow **remains valid** (non-member deposits allowed, reads reserved to the *readers* = the owner), but the `subscribeDoc` wrapper on the inbox will have to target the native deposit-notification mechanism. To be checked at migration time.
@@ -1,104 +0,0 @@
---
type: brief
summary: Target model for sign-ups — a Participation READABLE by everyone (event ref + `active` boolean + cap-less did to the participant's profile), deposited into the event's inbox; the creator processes the inbox, dedups on the overlay without knowing who, files the reference into a Set on the event and PURGES the cancelled ones; count = Set.size with no filtering (accepted upper bound); only connections hold the profile cap and recognize the person. Supersedes Option-B (mutated counter + plaintext userId).
---
# Brief (2026-07-20, revised 2026-07-27) — Set-based sign-ups
## The model
Laid down and refined by the PO on 2026-07-27. Everything is **keys and URLs** — no roles, no membership, no allow-list.
1. The participant creates a **Participation** object, **readable by everyone**, holding: the **reference to the event**, an **`active` boolean**, and a **cap-less did to their *protected* profile**. **Nothing else** — no description for now.
2. They deposit the **Participation's did** into the **event's inbox**.
3. The **creator** processes their inbox **automatically**, as soon as they are online.
4. They **dedup** (see below) — **without knowing who the participant is**: they hold the profile's did, not its cap.
5. They file a **reference** to the Participation into a **Set** carried by the event's document.
6. Anyone reads **`Set.size`** → the number of participants.
7. Someone **connected** to the participant holds their profile's cap, reads it, and **recognizes** the person.
**Withdrawal**: the participant sets `active` to false **on their own object**. The creator sees it when reading, and **purges** — removing the reference from the Set.
Three properties follow: **anonymous attendance by default** (even the creator cannot see who); **nobody modifies anyone else's sign-up** (only the participant holds the write key to their own object); **no plaintext `userId`** ever travels.
### The principle that holds it all together: the truth is in the object, messages are only hints
The object **controlled by the participant** is what counts. Any message — an inbox deposit, a purge notification — is only a **hint** that triggers a check, never an authority.
Consequence: **forgery becomes structurally harmless**. A fake « purge X » leads the creator to read X, find it still active, and do nothing. That is why inbox deposits **need not be signed** — which is just as well, since NextGraph does not offer that (see table).
### Why a flag rather than a deletion
A **deletion** is **not detectable** without the read key (VERIFIED: append-only, encrypted tombstone). A **readable** object carrying a **flag** transforms the problem: the cancellation no longer has to be *detected*, it is simply *read*. The blocker disappears instead of being worked around with a forgeable message.
### Why the identity pointer targets the existing profile
No need for a second document per participation: the participant's **protected profile** already plays that role, and their connections **already** hold its cap — that is the very definition of being connected. A third party sees an opaque did.
The advantage over an encrypted field inside the Participation: **adding a connection rewrites nothing**. The profile's cap is sealed to them once, durably. An encrypted field would require re-sealing to N recipients and rewriting the Participation on every new connection. *(Incidentally, an encrypted field is not a NextGraph primitive: the encryption granularity is the document, all-or-nothing.)*
## What this rests on — facts established in NextGraph
Verified by reading `nextgraph-rs`. Details and pointers live on the polyfill side (`docs/readcap-and-nuri-model.md`) — see [[rule_capture-nextgraph-findings]].
| Fact | Status | Role here |
|---|---|---|
| The **overlay** (`:v:`) is **store-scoped**, never document-scoped | VERIFIED | **The dedup key** |
| A cap-less NURI **names without granting read access** | VERIFIED | The profile's did points without disclosing |
| A cap is **sealed durably** to a recipient (no ACL re-declared) | VERIFIED | The profile's cap, sealed once to the connections |
| Without the key, blocks remain **ciphertext** | VERIFIED | The creator genuinely cannot read the profile |
| A **deletion** is **NOT** detectable without the key | VERIFIED | **Why this is a flag, not a deletion** |
| An inbox deposit is **NOT authenticated** (anonymous sealed box) | VERIFIED | **Why messages must stay hints** |
| Author signature verification **is not implemented** at runtime, and would require decrypting | VERIFIED | Rules out the « signed inbox deposit » alternative |
## The dedup: on exactly what
**Validated by the PO (2026-07-27).**
A NURI's `:v:` segment comes **not from the document** but from **its store**. And a person has a single store per scope. So **all their Participations carry the same `:v:`**, however many objects they create. That is what the creator dedups on: two references with the same `:v:` in the Set of a single event = the same person. **Without ever knowing who.**
This is the **robust** criterion — more so than the profile's did, which a participant could multiply by creating several profile documents in their store.
Design consequence: the Set is **keyed by `:v:`** — at most one reference per `:v:`. `Set.size` = the number of distinct `:v:` = the number of distinct people.
### The trade-off — a standing reservation, not to be lost
> **It lives in `app-security/`[[caveat_stable-overlay-pseudonym]]**, not here. This brief is meant to be dissolved when it graduates; the reservation must outlive it.
In short: this `:v:` is a **stable, permanent pseudonym** for the person, present in every cap-less reference to their documents. It does not say *who*, but a **single** cross-reference de-anonymizes their whole history **retroactively** — and **no way out exists** (no rotation is possible, VERIFIED). It is **the same bit of information** that makes it possible to dedup without reading and to trace from one event to the next: the two cannot be separated. Making the Participation public **increases the collection surface** for this pseudonym.
## Trade-offs deliberately accepted (PO, 2026-07-27)
- **No filtering on read.** The count is `Set.size`, **without** checking the `active` flags. We accept the **staleness risk**: a cancelled participation still counts until the creator has purged. `Set.size` is therefore an **upper bound**, exact once purged. *Rationale: keep reads simple and O(1).*
- **Purging is the creator's job.** No curator service, no catch-up by readers.
- **No description** in the Participation for now. *(To be reopened when the need arises: whatever we put there would become public.)*
- **Creator offline**: the Set does not move until they have processed their inbox. Accepted.
## What changes vs the current implementation (Option-B)
What exists today ([[brief_2026-07-06_reactive-reads-and-attendance]]) derives a `participantCount` **mutated in place** from inbox markers carrying the **plaintext `userId`**.
- **Drop the `userId`** from inbox deposits → only the **Participation's did** remains.
- **Count distinct references** (by `:v:`), no longer `userId`s.
- **The mutated `event.participantCount` goes away**, replaced by `Set.size`.
- **Identity resolution** now goes through **reading the profile** (hence through its cap), no longer through the marker.
- **Withdrawal stops being a deletion** → `active` set to false + a purge by the creator. See [[caveat_participation-deletion]], whose requirement (« authoritative, must not come back ») still holds but changes mechanism.
Still valid as-is: **reactive reads**, **re-arming on reconnection**, and the **id-space fix** already shipped.
## Open points
- **Participation scope** — it becomes **public**, whereas current product doctrine places it in *protected* ([[knowledge_data-scopes-and-discovery]], concept `functional-domain`). That leaf describes **what is implemented**: do not change it until this brief has graduated, but **do update it at that point**.
- **Recognition by connections** (step 7) — how the profile's cap gets sealed, and what happens to a broken connection (revocation is a coarse, non-retroactive re-key). Explicitly deferred to a second stage.
- **Public reads are not recursive** — this is the principle the whole model rests on, and it deserves to be stated on its own: *an item in the **public** store is public — whoever has the URL reads the content.* But **not recursively**: public content may **reference** private content, and **that is exactly our case**. So the creator reads the Participation (public) and **cannot** follow the reference to the profile (protected). That is what yields both readability by the creator and anonymity towards them — with no additional mechanism.
## Dependencies
- **Blocking**: the **polyfill's caps emulation**. Today `caps.ts` models an **ACL** (a set of principals per document) where the reality is **key possession**, and the content stays readable in plaintext (`sparqlQuery` and `inbox.read` bypass the filter). Until that is fixed, coding anonymity on the Festipod side would produce code that **claims** to isolate without isolating. Polyfill brief `2026-07-20-caps-emulation-alignment`, batch P1.
- **Parked**: **identity terminology** (wallet / user / profile) — see `.project/to-discuss.md`.
## Status: model settled, implementation gated
The model is **settled** (PO, 2026-07-27) and its foundations are **verified**. What remains gated is the **implementation**: it is waiting on the polyfill's P1 batch. **Do not remove Option-B** in the meantime.
Links: [[brief_2026-07-06_reactive-reads-and-attendance]] (superseded), [[caveat_participation-deletion]], [[rule_capture-nextgraph-findings]], [[rule_document-per-entity]], app-security ([[caveat_stable-overlay-pseudonym]], [[brief_2026-05-18_authorization-matrix]], [[knowledge_trust-model]]), polyfill `readcap-and-nuri-model.md` + `docs/vision.md`.
@@ -0,0 +1,40 @@
---
type: brief
summary: ActivityPods is the sole priority provider — everything that can goes through the Refine providers, a backend for actions on behalf of a user is to be added, the NextGraph+polyfill backend is suspended (temporary); reference material and open questions recorded 2026-10-09
---
# Brief (2026-10-09) — ActivityPods first, and a backend for on-behalf actions
Directives recorded at the resumption of the hand-off (`.project/handoff/refine-provider-abstraction.md`), to anchor the ActivityPods integration workstream.
## Directives
1. **ActivityPods is the priority.** The Refine backend over NextGraph plus its polyfill is **suspended, temporarily** — it is not abandoned (the two-rights-models structure still targets both), but no work goes into it while ActivityPods is being wired.
2. **Everything that can must go through `@activitypods/refine-providers`.** The Refine boundary rule from the hand-off applies: what Refine can express is a Refine resource; what it cannot (collection creation, WAC, groups, outbox posting) happens beside Refine, in the seam or in the new backend. Capability differences stay inside the provider layer, never in the app.
3. **A backend must be added for actions "on behalf of a user".** Festipod needs server-side actions executed for the account of a user (the pattern used by the reference apps below). Its mechanism, packages and deployment shape are the first design question of this workstream.
4. **Minimise new shapes — interoperability first (directive 2026-10-09).** A new shape is a last resort, not a default: anything an existing vocabulary already carries must reuse it. A MeetingPoint is therefore modelled as **an event within an event** — an `as/Event` distinguished by an **optional `parentEvent` reference** to its anchor event, not a new `fp:MeetingPoint` type. Whether an existing ActivityStreams predicate (e.g. `as:context`) can carry that link, or the `as/Event` shape must gain an optional property, is part of the survey. For Participation, the existing fediverse vocabulary must be surveyed before anything is created.
## Reference material (user-designated 2026-10-09)
Reading these at this stage is adoption-time reading, explicitly directed — the standing constraint against reading provider sources at work time is unaffected (`@activitypods/refine-providers` remains specified by its pinned reconstruction, `activitypods-providers/contract_activitypods-providers.md`).
- **Example apps** — the reference apps under `~/projects/activitypods/` (`welcometomyplace`, `mutual-aid.app`, `portejunes`), the only allowed source of pod-interaction patterns (project rule `festipod-only-reference-app-patterns`).
- **ActivityPods platform source** — `/home/sylvain/projects/activitypods/activitypods`; Semapps lives in a sibling folder.
- **Shapes** — `/home/sylvain/projects/activitypods/shapes`: readily usable, and **evolvable by us** if Festipod's entities require it (that repo is ours to adapt).
## Constraints on record
- A Festipod CLASS with its own shape tree needs `fp:` registered at prefix.cc: the pod registers the ontology of every class it creates a data registration for, and refuses one whose prefix it cannot find (`registerOntologyFromClass` in the pod provider). Shape trees must sit at public URLs, one per entity, no sharing cascade. A Festipod PREDICATE on an existing resource does not need it: the pod stored and returned a `http://festipod.org/` predicate on a profile, `fp` unregistered (verified live 2026-10-29: written with the app's token, returned under the full IRI).
- The Refine providers cover CRUD only; `custom` is not implemented — no escape hatch through Refine.
- `getMany` silently drops failed fetches — it erases the "unreadable reference" state the model needs (a candidate contribution).
## Reconnaissance answers (2026-10-09)
- **On-behalf mechanism — ANSWERED.** An app backend is an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer); it signs pod requests as the app actor and the pod impersonates the user after checking registration + grants. Frontend calls use the Solid-OIDC dual-identity token instead. No unattended per-user backend token exists.
- **Registration/Participation — production precedent found.** welcometomyplace persists NO registration resource: a join is an `as:Join` activity posted to the user's outbox, the backend service handles it and adds the actor to the event's `apods:attendees` collection (attached via `attachPredicate`, `PodCollectionsHandlerMixin`); a leave is `as:Leave`, same path in reverse. Membership is read via `pod-collections.getItems`. mutual-aid uses none of this (comments, likes, announces only). Festipod's adaptation, where participants must stay unidentifiable to non-contacts: [[decision_2026-10-29_participation-privacy-mechanism]].
- **Event-in-event — feasible without touching any shape.** The `as/Event` and `as/Profile` SHACL shapes are open (no `sh:closed`), and the generic ActivityStreams vocabulary already defines `as:context`, `as:partOf`, `as:inReplyTo`. A meeting point is a plain `as/Event` carrying an `as:context` link to its parent event — the presence of the link is what distinguishes it, nothing else. Shapes are consumed for access needs and container setup, not server-side resource validation, so nothing breaks.
## Open questions
- The backend's deployment shape (service alongside the app? a platform app?).
- Whether the three pointed-to provider contributions (from the hand-off) are still the right set once the backend exists — some may move from "contribution to the providers" to "our backend's job".
@@ -1,17 +0,0 @@
---
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
---
# 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 only covers: `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials` (to be checked in the `.shex`).
## Consequence
In **connected mode** (SDK), the mapping (`mapEvent` in `FestipodDataContext.tsx`) only reads/writes the shape's fields. Fields outside the shape are **silently lost**: filled with defaults, or empty. Yet screens **do display them** (e.g. `startTime`/`endTime` in `EventDetailScreen`) — so in demo mode (the local seed) they show up, but when connected they vanish. The discrepancy is only observable in actual use.
## To fix it (if we want them persisted)
Add the fields to `festipodShapes.shex`, then `bun run build:orm`, and extend `mapEvent`. Until that is done, **do not rely on the date/time/theme fields in connected mode**.
@@ -0,0 +1,37 @@
---
type: caveat
summary: After a sign-up (or a withdrawal) the participantCount a bystander sees needs one connection more than the write itself — written on the first reconnect, displayed on the second. Cause is outside the app, in the layer not notifying you of your own actions; no app-side compensation, deliberately.
last_checked: 2026-08-17
---
# Caveat: the participant count lags one connection behind the write that produced it
In the create-and-participate flow — declare an event, sign up to it — the event's `participantCount` **stays at 0 for the rest of the session**, VERIFIED over two-minute intervals, while the button already reads « ✓ Je participe ». The count starting at 0 on creation is correct and is not this caveat ([[knowledge_context-internals]] §participantCount: no host baseline).
## The convergence, VERIFIED
The count does converge, but **one connection later than the write**: the first reconnect after the sign-up still reads 0; the count only reaches the true value on the **second** reconnect. The same one-connection lag applies to a withdrawal — earlier it looked like withdrawal converged immediately while sign-up never did, but that asymmetry was the multi-inbox race below, not a separate mechanism: with one inbox per document, both paths share this same lag.
## Two measured causes, both about the layer not notifying you of your own actions
- A deposit you make into an inbox **you watch** produces no push — so the owner's own materializer, sitting on its own inbox, is not woken by its own sign-up.
- A write to **your own document** is not re-read by `watchShape` in the writing session — so the materializer's own count write does not come back on the load that made it, only on the one after.
Both are gaps in [[contract_polyfill-surface]], raised with the provider ([[rule_app-uses-sdk-surface-only]] in this concept) — not something to work around in the app.
## Why nothing is done about it here
Any retry or short-interval poll to paper over the gap is exactly what `bdd-testing` → [[rule_no-broker-polling]] forbids. The count is not lost — the materializer fires directly on connection, not only on a push, so it always catches up on the second reconnect — so there is nothing to compensate for beyond the one connection of delay.
## What this is not
Not data loss, not a race: [[knowledge_context-internals]] §participantCount describes the concurrency-safety the flow now has (one inbox per document, one materialize cycle at a time, a monotonic guard against a stale write). This caveat is the residual display delay that mechanism does not close, because its cause sits below it.
## Reproduce
1. Connect, declare an event (the count shows 0 — correct).
2. Sign up to it; the button reaches « ✓ Je participe ».
3. Stay on the page and watch the count for a couple of minutes — it stays at 0.
4. Reconnect once — still 0. Reconnect a second time — now correct.
Method: `bdd-testing` → [[cookbook_live-probe]]. Watching *after* the confirmation, over a real interval, and across two reconnects, is what makes this visible at all.
@@ -0,0 +1,16 @@
---
type: caveat
summary: Several coherence-pass features were built and unit-tested but never played against a real pod — invitations, image upload, bio/city/photo round trip, profile completion — plus a few known limits of the read side
last_checked: 2026-10-21
---
# Caveat: built but not verified on a real pod
Unit and UI-layer tests are green; none of the following has run against a live ActivityPods stack. Treat as unverified, and re-check at the first run on a pod:
- **Invitations**: that the `Announce` is delivered to the chosen contacts' Pods.
- **Image upload** ([[decision_2026-10-21_images-are-uploaded-to-the-pod]]): container resolution, the `Location` header, the public-read grant, and the re-consent for the File access need.
- **Profile round trip**: bio, city and photo (`image`) written and read back through the provider.
- **Profile completion**: the onboarding gate on a pod whose profile has an empty name; a pod linking no profile at all only gets a message, since creating and linking an `as:Profile` is outside the pinned contract.
Known limits of the read side, by design or by backend: catalogue entries carry no participant count (so cards of events never opened show none, and gain one once opened); an empty catalogue is indistinguishable from an unreachable one; another person's events are not listable (`getUserEvents` answers for the signed-in user only).
@@ -1,17 +0,0 @@
---
type: caveat
summary: An entity written just before an idle period / socket drop can be silently lost (never made durable broker-side); the account survives (no fork). Observed on Firefox. The SDK neither confirms durability nor reconnects on its own.
last_checked: 2026-07-14
---
# Pitfall: a write made just before a disconnect is not guaranteed durable
**Product symptom.** The user creates an entity (an event), it appears to succeed, then a **period of inactivity** follows; on reload / reconnection, the entity has **disappeared**. The scope reads back **empty**. The **identity/account survives** — this is NOT a fork, it is a write that was never made durable.
**Mechanism (summary, not settled).** The broker socket can die spontaneously while idle (`SOCKET IS CLOSED … SerializationError`). The write was in the local outbox; on return, the replay fails (`Err(TopicNotFound)`) and the entity is abandoned. **Observed on Firefox only** so far. A cold @data test (2026-07-14) also showed that a **fresh** session (no local state, same account A) does **not** recover A's own scope from the broker: the @data reconnection test that "passed" was in fact re-reading the **local** IndexedDB. Still to be settled: **loss at write time** vs **failure to rehydrate from cold** (two distinct mechanisms) — see the post-mortem in the polyfill.
**Why the app does not see it.** `NgStatus` is derived from the initial session **exactly once** → blind to drops that happen mid-session. The SDK's `disconnections_subscribe` channel does fire on the failure but **is not consumed** (neither by the polyfill nor by the app). No API confirms that a write reached the broker.
**Do not document NextGraph internals here.** SDK boundary (see [[knowledge_nextgraph-stack]]): the root cause, the causal chain (socket, reconnection still TODO) and the fix leads live in the `@ng-eventually/client` repo → `docs/incidents/2026-07-14-write-loss-on-disconnect.md`. This note keeps only the **consumer-side impact** + the pointer.
**Status: open, not addressed (2026-07-14).** To revisit when the core/SDK addresses reconnection or exposes a durability confirmation — this caveat will then fall away. See also the cold-read vs real-loss debate in [[brief_2026-07-06_reactive-reads-and-attendance]] (@data's `BARRIER timed-out` is a distinct signature, not confirmed to be this bug).
@@ -0,0 +1,73 @@
---
type: cookbook
summary: Bring up a local ActivityPods pod provider for Festipod development — ports, the reproducible signup → login → create-as/Event flow, the fresh-pod container behaviour, teardown
---
# Cookbook — Local ActivityPods platform
Festipod's local setup follows the sibling ActivityPods apps (welcometomyplace, mutual-aid.app, portejunes): a local pod provider and its infrastructure run from images through `make start`, and the app's own backend and frontend run outside Docker, one command each.
## Ports
One fixed port pair per app on localhost, the pod provider keeping 3000/5000:
| Service | Port |
|---|---|
| pod provider (OpenID + `/auth` signup/login) / its frontend | 3000 / 5000 |
| welcometomyplace backend / frontend | 3002 / 4002 |
| mutual-aid backend / frontend | 3003 / 4003 |
| portejunes backend / frontend | 3004 / 4004 |
| **Festipod backend / app** (committed defaults) | **3005 / 4005** |
| fuseki (admin/admin) | 3030 |
| redis | 6379 (`REDIS_PORT`) |
| mailcatcher | 1080 (SMTP 1025) |
| arena | 4567 |
A developer whose ports differ overrides them in the two gitignored env files, never in committed code: `backend/.env` (`SEMAPPS_PORT`, `SEMAPPS_HOME_URL`, `SEMAPPS_FRONT_URL`, the Redis and Fuseki endpoints) and the root `.env` (`PORT` for the app, `VITE_BACKEND_URL`, `VITE_POD_PROVIDER_URL`, and `REDIS_PORT`, which docker compose reads from that file). The e2e suites and `scripts/validate.ts` read the same values from the environment (`PORT` → app URL, `VITE_BACKEND_URL`, `VITE_POD_PROVIDER_URL`), falling back to 3005 / 4005 / 3000. The app origin (`FRONT_URL`) is the only registered OIDC redirect origin, so the app must be served exactly there. The pod provider frontend on :5000 must run whenever Festipod signs a user in through the browser: the pod's login and app-consent screens are its pages (the UI smoke spawns its own app and does not need it; `test:e2e-flow`, `test:e2e-rights` and `test:e2e-revocation` do).
## Bring up
1. Once: `cp backend/.env.example backend/.env` and `cp .env.example .env` (both gitignored; fill the shared-wallet values in the root one), then `pnpm install` at the root and in `backend/`.
2. Pod provider and infrastructure: `make start` (`docker-compose-dev.yml`: `activitypods/backend` in host network mode on 3000, `activitypods/frontend` on 5000, `semapps/jena-fuseki-webacl` on 3030 with `./data/fuseki`, `redis` on `REDIS_PORT` (6379 by default) with `./data/redis`, mailcatcher, `activitypods/arena` configured by `docker/arena-dev.json`). Other targets: `make stop`, `make config`, `make upgrade`, `make attach-activitypods`. `./data/` is gitignored.
3. Festipod backend: `cd backend && pnpm dev`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s). `pnpm dev` hot-reloads a changed service file; restart the backend after editing one instead of trusting the reload. After a reload of `services/app.service.js`, pod webhooks still answered 200 but were no longer processed (no attendees collection, no Join handling; observed 2026-11-02, cause not established).
4. Festipod app: `pnpm dev` at the root (the frontend lives at the root, not in a `frontend/` directory; the root scripts run Bun, pnpm only launches them).
`backend/.env` follows the sibling layout: own dataset `festipod` and accounts dataset `settings-festipod`, `SEMAPPS_REDIS_CACHE_URL=` empty (no cache locally), queue on `redis://localhost:6379/15` (the siblings use dbs 12, 13 and 14). dotenv-flow does not override the process environment, so a variable exported in the shell wins over the file. If host port 6379 is already taken, set `REDIS_PORT` in the root `.env` and put the same port in `SEMAPPS_QUEUE_SERVICE_URL` of `backend/.env`. The compose publishes redis on that port and points the pod provider's queue and OIDC URLs at it. The pod provider runs in host network mode, so it reaches redis only through the host port: its image's own default is `localhost:6379`, which the compose replaces with `REDIS_PORT`.
## Account and resource flow (all VERIFIED 2026-10-09)
- Signup: `POST http://localhost:3000/auth/signup`, `Content-Type: application/json` (NOT JSON-LD), body `{"username":"alice","email":"alice@test.com","password":"Test1test","name":"Alice","schema:knowsLanguage":"en"}` → 200 `{"token":"...","webId":"http://localhost:3000/alice","newUser":true}` (~10 s, pod-creation hooks).
- Login: `POST http://localhost:3000/auth/login`, same content type, `{"username":"alice","password":"Test1test"}` → 200 with token; wrong password → 401.
- Read the actor: `GET http://localhost:3000/alice`, `Authorization: Bearer <token>`, `Accept: application/ld+json` → 200; gives `pim:storage` (`http://localhost:3000/alice/data`).
- Create an event: `POST <storage>`, Bearer + `Content-Type: application/ld+json`, body `{"@context":"https://activitypods.org/context.json","type":"Event","name":"...","startTime":"...","endTime":"..."}` → 201 + `Location`.
- Re-read `Location` with Bearer → 200 full JSON-LD; without token → 403 (WAC: private by default).
## Fresh-pod behaviour
On a fresh pod, an `as:Event` POST lands at the pod root (`/alice/data/<uuid>`): typed containers such as `/alice/data/as/event` exist only after a user installs an app whose access needs declare that shape tree. Do not expect typed containers before app registration.
## Data location (hard-won, 2026-10-26)
- **Fuseki's data dir (`./data/fuseki`) must stay on the SSD** (`/home`, NVMe). Measured: a signup is ~10 s on the SSD, 150–200 s on the spinning HDD (`/hdd-home`) — TDB2 fsyncs per write and the seek time dominates. The HDD mount also hosts the bulk of the host's files; keep fuseki off it.
- **Never replace `data/fuseki` with a symlink after the container exists.** Docker resolves the bind source when the container is CREATED; moving the data away and leaving a link behind leaves the running container with a broken/empty mount (symptom: signup 500 « Error when creating secure dataset … FileNotFoundException: /fuseki/system_files/… »). `docker restart fuseki` re-resolves and heals — until the next behind-its-back move.
- The e2e suites delete the accounts they create at the end of every run (user decision 2026-10-26): `DELETE http://localhost:3000/.account/:username` with the user's token (a user may delete only their own account). The platform tombstones the account, sends the Delete activity, and drops the fuseki dataset on a delay — so test data no longer accumulates and the periodic full wipe is no longer needed. `diag-invite.ts` is the only suite left without cleanup (manual diagnostic).
- The pod provider deletes files from disk, at its image's `SEMAPPS_FUSEKI_BASE` (`../data/fuseki`, i.e. `/app/data/fuseki` in the container):
- at once, the account's backups (`backups/`);
- 24 h later, its secure dataset (`databases/<user>`, `<user>Acl`, `<user>Mirror`, `configuration/<user>.ttl`), since Fuseki's own delete leaves those files behind.
`docker-compose-dev.yml` therefore mounts all of `./data/fuseki` there. Without any mount, every deletion answers 500 (`ENOENT … scandir '../data/fuseki/backups'`, VERIFIED 2026-11-02), the account stays tombstoned with its dataset in place, and the suites still pass. With only `backups/` mounted, the deletion answers 200 but the dataset files stay on disk (about 40 MB per test account).
- Never sign up a local pod account named after an existing dataset (`festipod`, `settings-festipod`, another app's): per the pod provider's code, the pod is written into that dataset, and deleting the account deletes the dataset's files and backups. The provider reserves `sparql`, `auth`, `common`, `data` and `settings`: of the dataset names, only `settings` is protected.
## Backend fresh-boot note
The Festipod backend has its OWN datasets, `festipod` (main, created SECURE at first boot — WebACL is on) and `settings-festipod` (accounts), named like the sibling apps' (`welcometomyplace`/`settings-wtmp`…). Never point it at the pod provider's `settings` dataset, and never delete or recreate `settings`: it holds the pod provider's accounts. A backend dataset reset: delete the two datasets and the secure dataset's files on disk (`./data/fuseki`), and empty the backend's Redis queue database. After a reset, pod accounts that had installed the app must install it again: the new backend knows none of their registrations. A deleted dataset re-created under the same name needs a Fuseki restart in between (`docker restart fuseki`), else every query fails with `ClosedException: already closed`. Changing `SEMAPPS_HOME_URL` (a new port, say) changes the app actor's URI, hence the app's identity: a persistent account keeps a participations collection whose `acl:Control` belongs to the previous app actor, so the backend refuses every Join of that account ("the pod refused the read grant of the contacts group…") — remove the `http://festipod.org/participations` link from that account's profile with its own token (`DELETE DATA` of the exact triple; `DELETE WHERE` answers 400) and the next Join creates a new collection.
`GET /app` serves a snapshot of the app actor that the `@activitypods/app` mixin takes at boot BEFORE it creates or replaces the access-need group (VERIFIED 2026-11-02 in the mixin's code and live). On a fresh dataset (first boot, or after a wipe), that snapshot lacks `interop:hasAccessNeedGroup`, even though the store has it. On a boot that changes the access needs, it names the group just deleted, which answers 404. Either way it stayed wrong until the next restart. On the first `make start` stack, the smoke and the rights matrix failed on their "access-need group" prerequisites. `backend/services/app.service.js` refreshes the snapshot from the store at the end of its own `started()`.
The same handler first checks the link in the store and attaches the group when it is missing, polling up to 60 s. On a fresh dataset (2026-10-26), consents registered zero grants and `GET /app` linked a group that 404s. A one-shot version of that check found no group. The cause was not established: the mixin creates the actor, then the group, one after the other. The 404 matches the stale snapshot above. Before running consented flows on a fresh dataset, check that `GET /app` links a group that resolves 200.
## Teardown
Stop the two dev processes (`pkill -f moleculer-runner`, and the app's `bun --hot`), then `make stop`. Data persists in `./data/` (gitignored); delete that directory to fully reset. With the suites cleaning up after themselves (above), a full reset should now be a rare event, not a maintenance ritual.
Current stack test account: `alice` / `Test1test` on localhost:3000.
@@ -0,0 +1,33 @@
---
type: decision
summary: Public events become findable through a shared index (@ng-helpers/indexing) rather than a direct read of the public scope, which never actually unioned every user's store; the package's append-only, curation-gated, never-refreshed shape is accepted as-is, with four costs named rather than solved
---
# Decision (2026-08-17): discovery through a shared index
## Context
[[knowledge_data-scopes-and-discovery]] (concept `functional-domain`) named "reading the `public` scope" as the primary discovery axis. [[contract_polyfill-surface]] shows why that never delivered cross-user discovery: `storeRegistry` places and lists documents **per session** (`listMyEntityDocs`, `resolveScopeGraph` — both scoped to "this session's own"), and no published call unions every user's public store into one readable set. A declared event was therefore reachable by its own declarer only, and the whole cross-user sign-up flow — the product's premise — was unreachable.
## Decision
Festipod adopts **`@ng-helpers/indexing`**, pinned at `1.0.0` ([[contract_indexing-layer]]), as the mechanism that makes a public event findable by someone other than its declarer.
An index is an ordinary public document that the package builds on top of the polyfill: nothing marks it as one, so Festipod will hardcode its reference in the app's own source. Depositing a reference to an event into the index (`refer`) is open to anyone; only the index's owner turns deposits into visible entries (`curate`); `read` returns those entries ordered by one declared field, compared **as strings**. Festipod indexes on the event's **ISO-8601 start date** specifically because string comparison then sorts entries chronologically for free — that field is being added to the event shape by other work in parallel and is not yet written by any create/update path.
**No code consumes the index today.** This decision records the arbitration and its accepted costs ahead of the wiring: which identity owns and curates Festipod's index, and where `refer`/`curate`/`read` are called from, are not yet decided.
## Consequences accepted with it
- **Curation is a role, not a line of code.** Nothing lands in the index until its owner curates the deposits, and the package schedules no curation run — there is "no timing and no delivery promise" ([[contract_indexing-layer]] → Non-guarantees). Someone, or something, must be relied on to curate; that is an operator commitment this decision takes on, not a gap left for later code to close.
- **An event declared before its document could carry the indexed field can never be indexed.** `read` refuses a document that declares no field at all, and curating a reference to an object missing the field reports `skipped: "no-field"` — every run, forever, since a deposit is never consumed and an already-written document does not retroactively gain a field it was not written with. There is no way back into the index for those events short of a fresh index.
- **A withdrawn or corrected event stays listed.** The package removes nothing "at any level, ever" — the only answer to a bad entry is a fresh index, not a fix to this one. Whatever eventually reads Festipod's index must tolerate an entry whose object no longer resolves, or resolves to something changed; that tolerance is the app's to build, the package provides none of it.
- **An entry's position is frozen at the moment it was curated.** The index never re-reads an already-indexed object, so the value it sorts by is whatever that object held at curation time — a later correction to the real event's start date does not move its entry. `read`'s ordering is faithful to the index, not to the live object.
## Rejected alternative
**Wait for the polyfill to publish a cross-store read** — a call that would union every user's `public` scope into one set, restoring the assumption the app started on. Rejected: nothing in [[contract_polyfill-surface]] offers this and none is signalled as coming, and the app cannot leave its central discovery flow unreachable while waiting on a capability nobody has committed to.
## Scope
Applies to **event** discovery only — the axis this decision replaces. Meeting-point and profile discovery are unaffected. Product framing and the four costs restated for a domain reader: concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. Package surface and guarantees: [[contract_indexing-layer]].
@@ -0,0 +1,26 @@
---
type: decision
summary: Festipod keeps no friend model; contacts are the Pod's own apods:contacts collection, a contact request is an Offer{Add{my profile}} posted from the browser to my own outbox, invitations are Announce{event} activities, and nothing is built for acceptance or push
---
# Decision (2026-10-21): contacts are the Pod's social graph
## Decision
- **No home-made friend model.** A user's network is the `apods:contacts` collection linked from their actor document (the app falls back to the profile record), managed by the Pod — mutual when both went through the request/accept flow, though not guaranteed (see the platform defects below). In the app, `friendships` is only a mirror of that collection and `getFriends` reads it. Contacts are resolved to profiles through the same path as event participants.
- **Contact request** (`addFriend`): an `Offer{ object: Add{ object: { id, type: 'Profile' } }, to, target }` addressed to the other person's actor, posted to the requester's **own outbox with their own token**, from the browser (`src/shared/data/contacts.ts`). The `type: 'Profile'` is embedded in the activity: the platform's `CONTACT_REQUEST` matcher requires it, and it must not dereference the profile — a protected profile 403s anonymously and a bare-URI object was silently ignored (fixed 2026-10-22). The recipient lands it in `apods:contactRequests` and accepts on their Pod provider's own network page. **Festipod builds no acceptance screen.**
- **Sharing my connection** (the primary flow — see [[decision_2026-10-22_connection-sharing-is-the-invite-capability]]): an **invite capability** (VerifiableCredential) issued by the Pod (`POST {webId}/vc/v0.3/credentials/issue`), shared as `{frontendUrl}/invite/{capability}`; the recipient's contact request carries a VerifiablePresentation of it and is **auto-accepted** by the inviter's Pod. Strictly better than the bare request: no manual acceptance, and the invitee sees the inviter's profile (name, photo) through the grant.
- **Invitation to an event** (`inviteToEvent`): an `Announce{ object: <event>, to: [chosen contacts] }` posted the same way. The invitable set is the real contacts only.
- **No backend service added.** A user-initiated request needs nothing the browser cannot do; a backend handler (notifications for people without the app) would be speculative without a pod to test it on. Push notifications stay the Pod provider's job.
- Providers with no Pod (the disconnected demo, the suspended NextGraph path) **reject** `addFriend` and `inviteToEvent` instead of simulating state.
## Channels
Participations are **public objects** (anyone sees that there are participants), but the **identity link** is protected — the profile is readable by contacts only, and the carved rule [[behavior_participation-listing-is-contacts-only]] reserves listing a person's events to their contacts, for every client. How contacts recognise participants and list a contact's events is [[decision_2026-10-29_participation-privacy-mechanism]]; the app reads no one else's outbox.
## Consequences
- "Demande envoyée" is session-local: the target's request collection is not readable by the sender, so after a reload the button offers the request again.
- There is no `removeFriend`.
- The `apods:contacts` list is a display, not a grant: the pod adds the sender of a received `Accept` of a contact request without checking a request was sent (a sender whose profile it cannot read is turned away; one who first sent a request of their own is added — verified live 2026-10-29), and keeps a removed contact in the `/contacts` WAC group (both platform defects reported upstream 2026-10-29). Festipod never derives a read right from the list — rights follow the group, see [[decision_2026-10-29_participation-privacy-mechanism]].
- The request/accept round trip is verified live by the rights matrix (2026-10-29): the request posted from the requester's outbox, its acceptance naming the stored `Offer`, both lists then naming each other.
@@ -0,0 +1,18 @@
---
type: decision
summary: Images (event cover, profile photo) are uploaded as binary LDP resources to the Pod's file container from the browser, then made publicly readable; the URL is stored in coverImage / photo; existing users must re-consent for the new File access need
---
# Decision (2026-10-21): images live in the user's Pod
## Decision
- `useUploadImage` (`src/shared/hooks/`) resolves the Pod's `file` container, POSTs the file binary with the user's token and reads the new URL from the `Location` header, then calls `grantPublicRead` (`src/shared/utils/`): a best-effort WAC ACL PATCH granting `foaf:Agent` read. Without the grant, an image nobody else can read is a dead feature. Without a session (the demo) the upload rejects.
- The URL is stored as `coverImage` on an event and `photo` on a profile. On ActivityPods the cover maps to the AS2 term `image` and the profile photo to `vcard:photo` — the term both reference apps read (mutual-aid, welcometomyplace), after verifying the `vcard:` prefix is present in the platform context. Interop cross-apps : une photo stockée sur un autre vocabulaire serait invisible partout.
- The app declares the `shapetrees/File` access need (read + write) in `backend/services/app.service.js`. **Users who registered the app before this change must re-consent** for uploads to be allowed.
- `resolveContainerUri` and `fetchJson` come from `@activitypods/refine-providers/utils`, which the pinned contract does not list: this use is outside the contract and must be declared in the usage declaration / raised to the provider.
- The event cover falls back to a neutral gradient; the old stock-photo map is gone.
## Consequences
Covers and avatars are visible to anyone who can read the event or profile record, as the `grantPublicRead` makes the file itself public. Never verified end to end on a real pod — see [[caveat_t06-unverified-on-a-real-pod]].
@@ -0,0 +1,158 @@
---
type: contract
summary: The API @ng-helpers/indexing exposes to an application — creating an index, depositing references into it, curating it, and reading it back
pulled_from: https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git/.project/concepts/indexing/indexing-layer/contract_indexing-layer.md
pulled_version: 2ce21131575f66af09f28ee109ad712aa41173ae
pulled_at: 2026-08-17
---
# contract_indexing-layer — `@ng-helpers/indexing`
## Scope
This package builds an **index** on top of NextGraph: an ordinary public document that holds one entry per indexed object, keyed by that object's NURI and carrying its value for a single declared field.
It covers creating an index, handing an index a reference to an object (open to anyone), the owner resolving those references and adding what it can, and reading the entries back in order.
It does not cover NextGraph itself — documents, identity, sharing, inboxes, transport — all of which reach it through a port you supply. It does not cover search, filtering, pagination, or querying by anything but the index's own field. It **never removes anything**, from anywhere, and that is a property of the engagement rather than a missing feature.
### Deployment requirements
An application using this package must:
- have a NextGraph session already open under the identity it wants to act as, and build the port from it — `polyfillPort({ sessionId })`, where `sessionId` is what `@ng-eventually/polyfill`'s own `init(…)` hands its callback;
- reach a broker, since every operation here is a document read, a document write, or an inbox deposit;
- **supply `@ng-eventually/polyfill` itself.** This package declares it a *peer*, not a dependency: the application names it among its own dependencies and decides which copy it gets. That copy must be the very one the application's own code calls, because everything this package does passes through it — and that package requires exactly one instance of itself in an application, for reasons its own contract states.
- **hardcode the index's NURI in its own source.** Nothing marks a document as an index; the reference is what makes it one, and it is the only way anyone reaches it.
One handle is one identity: the port carries a session and no call takes an identifier. Two users mean two handles.
**Obtaining it.** This package is not published to npm, nor to any other package host, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. `@ng-eventually/polyfill` is distributed the same way. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and what you must provide alongside it.
## Surface
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures:
```ts
// ── wiring: one handle, one identity ─────────────────────────────────────────
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort;
export interface PolyfillPortOptions { readonly sessionId: string | number }
export function indexing(port: NextGraphPort): Indexing;
// ── addressing (re-exported so you import them from here) ────────────────────
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type { PrincipalId, UnionSubject, NextGraphPort, IncomingDeposit, ObjectResolution };
// ── everything this package does ─────────────────────────────────────────────
export interface Indexing {
/** Creates an index in THIS identity's public store and opens its inbox. Any user may.
* `field` is the predicate an indexed object must carry, declared once and for good;
* an empty or blank one throws. Returns the NURI to hardcode. */
createIndex(field: string): Promise<Nuri>;
/** Deposits a bare reference into the index's inbox. Open to ANYONE. Nothing lands in
* the index until its owner curates. Throws if the index has no inbox. */
refer(index: NuriLike, object: NuriLike): Promise<void>;
/** OWNER only — resolves the references received and adds what it can. */
curate(index: NuriLike): Promise<CurationReport>;
/** The entries, ordered by value. Sugar over `readUnion([index])`. */
read(index: NuriLike): Promise<IndexEntry[]>;
}
// ── what an index holds ──────────────────────────────────────────────────────
export interface IndexEntry { readonly object: Nuri; readonly value: string }
export interface IndexDescriptor { readonly field: string }
// ── what curating reports ────────────────────────────────────────────────────
export type CurationOutcome =
| { readonly result: "indexed"; readonly object: Nuri; readonly value: string }
| { readonly result: "unchanged"; readonly object: Nuri }
| { readonly result: "skipped"; readonly object: Nuri; readonly reason: SkipReason }
| { readonly result: "unresolved"; readonly object: Nuri; readonly reason: string }
| { readonly result: "foreign"; readonly reason: string };
export type SkipReason = "no-field" | "several-values" | "self-reference";
export interface CurationReport {
readonly index: Nuri;
readonly outcomes: readonly CurationOutcome[]; // one per deposit, in deposit order
}
// ── what travels from a depositor to a curator ───────────────────────────────
export type IndexDeposit = Nuri; // the reference IS the whole payload
export function decodeReference(payload: unknown): Nuri | null; // untrusted input
// ── the IRIs, for a reader going straight to `readUnion` ─────────────────────
export const INDEX_FIELD: string; // on the index's own subject: the field it indexes by
export const ENTRY_VALUE: string; // on an entry: that object's value for the field
```
## Guarantees
**An index is an ordinary public document, and nothing marks it as one.** It lives in its creator's public store, so any reader opens it from the reference alone; its creator owns it, and any user may create one.
**The field is declared once, inside the document, and cannot be changed.** `createIndex` refuses an empty or blank field at the door, because nothing here deletes and an index created on a useless field is useless for good. Declaring it in the document rather than in an application's source is what stops two applications curating the same index on two different fields.
**`createIndex` opens the index's inbox itself.** Only the owner can, and creation is the one moment the owner is present, so it is not left to a later call to remember.
**Depositing is open to anyone; writing is the owner's alone.** `refer` is a deposit into the index document's inbox — not a write — so a stranger can contribute to an index they do not own. `curate` reads that inbox and writes the document, and both are refused to anyone but the owner. The deposit is a **bare reference**: it carries no operation, no index reference (the inbox address already identifies the index), and no copy of the indexed value. What the object itself says is what goes in.
**An index ONLY EVER GROWS.** There is no call that removes an entry, for anyone including the owner, and none is planned. This package cannot express a removal at all. The only answer to "this entry must go" is a fresh index.
**Curation is convergent and order-independent.** Deposits are never consumed, so every run sees every deposit again; re-applying one re-resolves the reference and lands on the same result. An already-indexed object is skipped outright as `unchanged`. Nothing depends on the order references arrived in.
**A reference that does not resolve costs nothing and is reported.** It comes back as `unresolved`, nothing is written for it, and nothing already in the index is touched — a later deposit adds it. Every unresolved reference appears in `CurationReport.outcomes`: harmless is not the same as invisible.
**Reading is per-entry tolerant.** `read` returns entries ordered by value, ties broken on the object NURI, so two readers of the same index always see the same order. Values are compared **as strings** — an index whose field holds ISO-8601 dates therefore comes out in chronological order. A subject that is not a NURI is skipped, never thrown on, and only own properties are read: one stray triple cannot make every real entry unreadable.
**An entry carrying several values keeps the smallest, deterministically** — which two curation runs racing each other can produce. The entry stays visible and every reader agrees on it.
**`read` refuses a document that declares no field at all**, rather than answering "an empty index". An unreadable document and an empty one arrive as the same empty result, so an empty answer would be a failure wearing the shape of a fact. Retry before concluding the document is malformed.
**An index declaring SEVERAL fields refuses to CURATE, loudly and permanently — and stays readable.** Picking one would leave a single list ordered by two different properties, because entries already written are never re-read. Existing entries stay visible and correct; nothing new is added. The refusal cannot be undone, and it says so instead of suggesting a retry.
**Reading needs nothing from this package.** An application that knows the NURI can call the polyfill's `readUnion([index])` and get the entries as subjects — one per indexed object, keyed by its NURI — plus the index's own subject declaring its field, which `read` drops. `INDEX_FIELD` and `ENTRY_VALUE` are published for exactly that reader.
**Every inbox payload is untrusted.** Anyone may deposit anything; `decodeReference` returns `null` for everything that is not a reference, and such a payload is reported as `foreign` rather than crashing curation.
## Non-guarantees
**No removal, at any level, ever.** Not an oversight and not "not yet": it was deliberately never built. Do not design around a future delete.
**No refresh.** An already-indexed object is never re-read, so an object whose field value changes later keeps its original value in the index, indefinitely.
**No private data.** Indexing is limited to objects the curator can open itself. An object the index's owner cannot read is simply `unresolved`.
**`unresolved` does not tell you why.** Gone, unreadable, and "the read failed" arrive identically and are deliberately not distinguished. Never read it as "the object does not exist".
**The narrow behaviours are open questions, not promises.** An object carrying nothing for the field is `skipped: "no-field"`; one carrying several values is `skipped: "several-values"`; a raced entry keeps the smallest value. Each is implemented in its narrowest form and reported rather than generalised, and each may change.
**No stable error text.** What a throw or an `unresolved` reason reads is for a human reading a report. Do not parse it or branch on it.
**No timing and no delivery promise.** A deposit is not in the index until the owner curates, and nothing here schedules curation. There is no notification, no queue depth, and no ordering between a deposit and a read.
**The report grows with the inbox.** Since deposits are never retired, `CurationReport.outcomes` has one entry per deposit ever made, not per change.
**No cross-broker reach.** A NURI resolves for users of the same broker.
**No depositor authentication or rate limit.** Anyone may deposit any number of payloads into any index's inbox.
## Change policy
**Semver, and majors are the normal case.** This layer sits on a polyfill that is itself converging on a NextGraph that does not ship yet, and several of its own behaviours are declared above as open questions. Settling one of them narrows this surface — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
What each level means here, in this package's own terms:
- **major** — an exported symbol is removed or renamed, **or** an existing call narrows: it now throws where it returned, or reports a state you did not have to handle before. Settling an open question counts, and so does adding a `CurationOutcome` variant or a `SkipReason` — an exhaustive `switch` in your code stops being exhaustive. A signature change a caller must react to counts; one that only accepts more than before does not.
- **minor** — a symbol is added and nothing existing moves: a new read helper, a new optional option.
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees`, including the text of a throw, which is explicitly disclaimed above.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**The tag is bare — `v1.0.1` — because this repository publishes exactly one engagement**, so there is nothing for a prefix to disambiguate. Should a second one ever ship here, tags take the package name from that point on (`indexing/v…`), because a bare tag stops saying which surface it froze the day two versions move independently. Bare tags already laid stay valid as history.
`1.0.0` was a baseline, not a claim of maturity: it was the number that made your pin mean something. Nothing was released before it. **It could not be installed, however**, and `1.0.1` supersedes it. `1.0.0` declared `@ng-eventually/polyfill` as a dependency resolved through a path that existed only in one working copy, so every attempt to install it from anywhere else failed outright — not on some operations but at the install itself, which is why no application ever ran it. `1.0.1` declares that package a peer, which the application supplies. Nothing exported moved, which is what makes this a patch and not a major: the only thing that changed for a caller is a requirement it could never have satisfied before, so there is no working arrangement for it to break.
**`1.0.0` is superseded, not withdrawn.** The tag stays where it is and keeps resolving, because no pinned reference is ever taken away from under you — this contract's policy holds even for a version that never worked. Nothing forces an upgrade; it is simply that an installation pinned there cannot have succeeded, so there is nothing to migrate.
This engagement is cut on `main`, so `1.0.1` is what you pin, and your `usage_` leaf anchors `against:` on that exact string — `against: @ng-helpers/indexing@1.0.1`. Had you pinned a pre-release, `against:` would carry that string, pre-release suffix included.
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
@@ -1,98 +1,96 @@
---
type: knowledge
summary: Internal pitfalls of FestipodDataContext — currentUserId = a stable principal derived from the identifier, TWO id spaces joined through the normalized identifier (resolveParticipantUser / USER_PRINCIPAL_PREFIX), OPT-IN auto-seed (FESTIPOD_AUTO_SEED, OFF by default), Option-B derived participantCount (reliable at the owner's connection because it reads under the synced-view contract; single source = event.participantCount), session reset on identity change (overlay + caps), useShapeQuery instrumentation (spinner + timing) + identity-first logs, mutations that are no-ops in local mode despite the toast
last_checked: 2026-07-27
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-17
---
# Internals & pitfalls of `FestipodDataContext`
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
## Resolving `currentUser` (NG mode)
## Identity and profile are TWO things — never join them
In connected mode, the currentUser's **principal** (`currentUserId`) is **not** `CURRENT_USER_ID` ('user-1', local mode), nor the IRI of the profile that was read. When an identifier is logged in, it is an id **derived from that identifier and stable**: `urn:festipod:user:<normalized-identifier>`, available immediately (without depending on reading the protected profile) and invariant for the session — the same key used by `setCurrentUser`, the owner cap and the shim account (see [[rule_document-per-entity]], identity corollary). Remaining pitfalls:
- The `currentUser` object (the displayed profile), by contrast, is resolved by `users.find(u => normalizeIdentifier(u.username) === identifiant)` with a **fallback** to `@mariedupont` then `users[0]` — a silent fallback if the identifier matches no profile (the identifier is a space id, not necessarily the `username` of a seeded profile).
- With no identifier logged in (dev/demo), `currentUserId` falls back to the IRI of the profile that was read (or `''` if the wallet is empty → a `Participation` with `user: ''`, which is invalid): only create a participation once the principal is resolved.
**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]]).
## TWO id spaces meet — joining a participation to its profile
**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`.
**Invariant.** A `Participation` stores its user as a **principal** (`urn:festipod:user:<normalized-identifier>`, = `currentUserId`), whereas a `UserProfile` has as its `id` the **NURI of its document** (`did:ng:…`). In connected mode, **these two values are never equal**. So a raw `participation.userId === profile.id` join **never** matches — a symptom that shipped and was then fixed (2026-07-27): every participant displayed as « participant inconnu ». Every participation→profile join goes through **`resolveParticipantUser`** (`FestipodDataContext`), never through a direct comparison.
**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 **bridge** between the two spaces is the **normalized identifier**: `principal − prefix` == `normalizeIdentifier(profile.username)` (the same equality that resolves `currentUser`). Hence the order in which `resolveParticipantUser` tries: (1) a **direct match** `u.id === userId` — the demo seed's space, where both sides hold the same bare id (`user-1`) and where the seeded username `@mariedupont` would *not* normalize to that id, so the direct match must come first; (2) failing that, a **match on the normalized identifier** after stripping the prefix.
## "My profile" is the profile document I OWN
**`USER_PRINCIPAL_PREFIX` is the single source of the prefix**, shared by the **write** side (deriving `currentUserId`) and the **read** side (`resolveParticipantUser`). If you change the shape of the principal, change it **there**: otherwise write and read drift apart silently and the join falls back to « inconnu » without raising an error.
`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.
A **third** id space exists and takes **no** part in this join: the inbox deposit `uid` (`mint…`) — it identifies a **deposit** for the counter, never a user.
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.
> **Horizon.** This paragraph describes **what is implemented** (Option-B). 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.
Four outcomes, and *somebody else's profile* is not one of them:
### Which space each query expects (the `buildQueries` contract)
| Query | What it expects / returns |
| Owned profiles | Answer |
|---|---|
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | **expect the principal** (they 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**: filtering yourself out of a participant list compares against **`currentUser?.id`** (the profile NURI, the same space as the rendered items), **not** against `currentUserId` (the principal) — otherwise you do not remove yourself and you see yourself appear as one more participant. Conversely, passing a **profile id** to `getUserEvents`/`isParticipating` returns an **empty** list in connected mode. 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 the polyfill's `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]]). Cleared on identity change.
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 is **owned by the current identity** (`bootstrapWallet(…, owner)`): the seeded protected entities go through the owner's per-document read cap.
- **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 (Option B)
- `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 under the **synced-view contract** — `inbox.readSynced` instead of `inbox.read`, so a deposit already synced by another identity IS seen from a cold session (the two differ by contract, see [[knowledge_sdk-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 Option B (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 (that would be an isolation violation — the participant writing someone else's doc; NextGraph writes are membership-bound, with no append). 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 is subscribed (`inbox.watch`, `doc_subscribe`, no polling) to the inbox of the events it owns (`ownedEventIds` = `listMyEntityDocs(owner,'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 broker sync 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/deposits stay persisted — nothing is lost; a future service will materialize in their stead).
- The counter nevertheless remains an **aggregate**, not the list of named participants: `getEventParticipants` (named identity) is still governed by the protected read cap ([[caveat_participation-deletion]] for the authoritative deletion, unchanged). See the brief `brief_2026-07-06_reactive-reads-and-attendance` §B.
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.
## Reads = `watchShape`, writes = an optimistic overlay
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.
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).
**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.)
**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]]).
**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.
## `participantCount` — derived, and written only by the event's owner
## Identity change = a fresh session (isolation)
The counter is **not** incremented by whoever joins: only a document's owner writes to it. The flow is deposit → owner-materialization.
> **History of the symptom** (the paragraph that follows describes the setup of the time — the bespoke read set `publicDocs`/`protectedDocs`/`readTick` **no longer exists** since the move to `watchShape`). It is kept because it explains *why* the reset rule exists; the **current mechanism** is described further down.
- 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`.
- **One inbox per document, whatever the concurrency.** `openDocumentInbox` (`storeRegistry.ts`) resolves at most once per document per session (`resolveOncePerKey`, `src/shared/utils/`, unit-tested): several callers racing for the same event's inbox — create, materializer, watch wiring, watch callback — all await the same in-flight resolution instead of minting a second address. A rejection is not memoized (unknown, not absent), so a later call genuinely retries.
- **One materialize cycle at a time.** The owner's connection trigger and its inbox-push trigger both call into a `createSerialTask` (`src/shared/utils/`, `serialTask.ts`, unit-tested): a cycle in flight absorbs every request that arrives during it into a single follow-up, so two read-derive-write passes never race on the same document. Each cycle carries a monotonic sequence number, and a write only lands if no fresher cycle has already written — a stale cycle can no longer clobber a newer value.
- **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. Being a pure function of the inbox, a replay is *designed* to converge: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change, and lands in **one** SPARQL statement (`updateEntityField`: `DELETE … INSERT … WHERE`), closing a window where a reader could see the field briefly absent and read zero.
- **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.
The on-demand read set (`publicDocs`/`protectedDocs`) **accumulated** the current identity's scope docs (so as not to lose a just-created doc before the re-listing). But the shared-wallet stopgap keeps **a single React tree** across a fake logout + re-login under a **different identifier** (no page reload — `AccountContext.login` merely rewrites the identifier in localStorage, and `AuthGate` remounts nothing). Without a reset, **the previous identity's PROTECTED docs (its participations) survive in the new identity's read set and leak** through the union read: the cap gate cannot filter them out when the (in-memory) cap registry does not govern that doc in *this* session (a doc persisted from an earlier run, or a fresh load where the caps are empty). Symptom observed: a user B saw A's participation (and A's event appeared on B's **home screen**, since home = `getUserEvents(currentUserId)`, see concept `app-architecture`).
> A live run still shows the count **one connection later** than this design implies — not a race, not data loss, a layer that does not notify you of your own actions: [[caveat_participant-count-one-connection-lag]].
**Rule**: treat **any identifier change** as a **fresh session**. A `useEffect([identifier])`, **ref-guarded** (it does not fire on first mount, only on a genuine value change), resets **all session state carried by the app**. Isolation remains per-document/emulated (concept `app-security`, [[knowledge_trust-model]]); this reset only removes the carry-over of state between identities.
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.
**Current mechanism** (since reads go through `watchShape`): the **read** side has nothing left to reset — `watchShape` re-resolves its scope against the new `getCurrentUser()` on the next push. What the effect clears is the **app-side** state: `ownedEventIds` (the owner materializer's set), the `joinUids` map (the current session's deposit uids), the **optimistic overlay** (`pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds` — otherwise the old identity's mutations bleed into the new one's reads), then `resetCaps()` + `resetRegistryCache()`.
## Logging convention — identity-first, and the counter before→after
> **Impact — the invariant not to break**: **any new session state** added to the provider (a cache, a `useRef`, the overlay, a doc set) must be added to that effect. Forgotten state **leaks from one identity to the next** with no error — exactly the class of bug the regression guard below covers.
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`.
**Mechanism confirmed empirically (2026-07-07)**: the leak reproduces ONLY when TWO conditions coincide — (a) the read set still carries A's PROTECTED doc across the switch (no reset), AND (b) the in-memory cap registry does not govern that doc (`resetCaps()` already fired / caps empty for a doc persisted from a session earlier than the reload). Then A's participation makes it through B's union read (the per-document filter has no cap to check). With the reset fired, A's doc left B's read set BEFORE the cap-less read could expose it → no more leak whatever the state of the caps (at the time via `setProtectedDocs([])`; today it is `watchShape` that re-resolves the scope, and the reset now carries only the app-side state listed above). **Regression guarded** by the `@data` scenario « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature): A creates E and signs up to it, B (a fresh page on the same wallet, with a distinct identifier) has NEITHER E on their home screen (`getUserEvents(B)`), NOR `isParticipating(E,B)`, AND reads NO participation carrying A's principal. The historical symptom « B voit "Je participe" » mostly occurred when B **reused an identifier already used by A** (the same normalized principal) on a **bloated** wallet (docs persisted from an earlier run, empty caps).
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.
## `useShapeQuery` instrumentation — global spinner + timing
## There is no identity switch, and nothing to reset
`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.
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]]).
## Logging convention — identity-first prefix, and counter before→after
## On ActivityPods the context is `refineData.tsx`, and read-your-writes is confirmed by a collection
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: with the shared wallet, **two identities share the same console** (two tabs / a multi-browser run) — 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`.
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.
The ActivityPods implementation of the same context (`src/shared/context/refineData.tsx`) holds a join or leave as a pending act on top of the reads and releases it only when a read issued after the act confirms it — the person's own participations collection for membership, the meeting point's attendee list for the count — or when a bounded re-read schedule runs out (verified in code). A read that merely shows the asserted state is not a confirmation: it may predate the act. Details: [[decision_2026-10-29_participation-privacy-mechanism]].
## 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]].
@@ -1,19 +1,19 @@
---
type: knowledge
summary: Two modes (connected = the @ng-eventually/client SDK, disconnected/demo = seeded local state); FestipodDataContext picks the provider based on connection status, and every screen goes through useFestipodData()
summary: Two modes (connected = the @ng-eventually/polyfill SDK, disconnected/demo = seeded local state); FestipodDataContext picks the provider based on connection status, and every screen goes through useFestipodData()
---
# Data modes & contexts
The app has **two modes**, both consumed through the `useFestipodData()` hook:
1. **Connected** — ORM shapes from the `@ng-eventually/client` SDK (P2P, encrypted, local-first)
1. **Connected** — ORM shapes from the `@ng-eventually/polyfill` SDK (P2P, encrypted, local-first)
2. **Disconnected / Demo** — local React state seeded from `seedData.ts` (see [[knowledge_seed-data]])
## NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
- Connection cycle: `disconnected` → `connecting` → `connected` | `error`.
- Provides the session (the current user and their access to the per-scope stores).
- That status is what the data provider below keys on; the app holds no session of its own.
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
@@ -25,4 +25,6 @@ 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]].
>
> **Per-call honesty is not flow-level honesty.** Every one of those calls tells the truth about itself; the sign-up flow driven end to end still shows a bystander a stale `participantCount` for one connection longer than the write itself — not a lie, a layer that neither pushes you your own deposit nor re-reads your own write in the same session, see [[caveat_participant-count-one-connection-lag]]. Do not read the paragraph above as "the count updates instantly".
@@ -1,24 +1,36 @@
---
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 has no shape of its own; the generated ORM names carry NO Fp prefix and are aliased at the import sites
last_checked: 2026-08-17
---
# 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 |
| `FpFriendshipData` | **local-only** | userId + friendId |
| `FpEventData` | SDK (Event shape) | title, date, startDate, endDate, startTime, endTime, location, distance, participantCount, coverImage |
| `FpUserData` | SDK (UserProfile shape) | name, initials, username, role, isPublic, bio, city, photo (all optional; on ActivityPods `photo` is the AS2 `image`) |
| `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` | **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 |
`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`).
**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).
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_nextgraph-stack]]).
**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]].
> 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]].
**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.
`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]]).
> `themes` is on `FpEventData` and the seed but **not** on the Event shape: a repeated value needing a cardinality decision before it can be one more optional string. Nothing reads it back today, in any mode, so its absence in connected mode is not yet observable — see [[knowledge_nextgraph-stack]] for the shape's actual field list.
@@ -1,36 +1,32 @@
---
type: knowledge
summary: The data SDK is @ng-eventually/client (treated as a finished NextGraph SDK) — injected exactly once through ngSession.configure; reactive useShape ORM over the festipodShapes SHEX shapes, bindings regenerated with build:orm; never document NextGraph's current state here
summary: The data SDK is @ng-eventually/polyfill, injected exactly once through ngSession.configure; reads go through the reactive useShape/watchShape surface over the festipodShapes SHEX shapes, whose ORM bindings are regenerated with build:orm
---
# Data stack (the `@ng-eventually/client` SDK)
# Data stack (SHEX shapes over the `@ng-eventually/polyfill` surface)
Festipod persists through **`@ng-eventually/client`** — the NextGraph SDK the app consumes. We treat it as a **finished, mature SDK**: documents per entity placed by scope, capabilities, inboxes, a reactive ORM.
> **It is a polyfill, and that word carries its whole job**: closing the gap between the SDK **as it should be** and what NextGraph provides **today**. The app codes against the target and **ignores the current state entirely**; the polyfill absorbs the difference. The contract itself — which surfaces exist and what may be assumed of them — is written down in this repo: [[knowledge_sdk-surface]]. See [[rule_app-uses-sdk-surface-only]].
```
@ng-eventually/client # THE app's data SDK (reactive useShape ORM, docs, scopes, inbox)
```
Festipod persists through **`@ng-eventually/polyfill`**. What that surface offers, and what it refuses to promise, is written down in one place: [[contract_polyfill-surface]], pulled into this repo and version-pinned. See [[rule_app-uses-sdk-surface-only]].
## SDK boundary (the golden rule)
- The app **depends on `@ng-eventually/client` only** for data.
- The SDK is **initialized/injected exactly once** through `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — a single injection point. Everything else in the app (data plane, lifecycle, login, types) goes through the lib.
- **Never document NextGraph's current state in this repo** (constraints of the underlying SDK, workarounds, broker/verifier internals): that lives in the `@ng-eventually/client` repo. Here we describe only **how Festipod uses that SDK**.
- The app **depends on `@ng-eventually/polyfill` only** for data.
- It is **initialized/injected exactly once** through `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — a single injection point. Everything else in the app (data plane, lifecycle, login, types) goes through it.
- **Never describe here how the data layer is implemented underneath.** This concept covers only **how Festipod uses the surface**.
## ORM & SHEX shapes
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, startDate, endDate, startTime, endTime, location, distance, participantCount, coverImage, plus an **inbox** field. **No host**: an event is only the anchor ([[knowledge_entities]]). `startDate`/`endDate`/`startTime`/`endTime` are the ISO/HH:MM values the form collects, carried end to end alongside `date` (the display label); they are all optional, so an event written before these fields existed reads as one without them rather than one with blank strings. `themes` is **not** on the shape: a repeated value needing a cardinality decision before it can be one more optional string, and nothing reads it back today. `inbox` is a **vestige** and must stay unused: 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]]).
- **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.
> **Recommended way to read = the SDK's reactive hook.** The canonical way to read is `useShape`: you subscribe to a shape on a scope, you get the current value, and the component re-renders on every change (local **or** remote once synchronized) — subscription/push, never polling; one-shot reads are the exception. The SDK's full reference (read/reactivity contract + where the current emulation still diverges) lives on the lib side: `packages/client/docs/sdk-reference.md` in `@ng-eventually/client`. Do not copy NextGraph internals here.
> **Regenerating an unchanged `.shex` reproduces the committed bindings byte-for-byte** — verified by running the generator twice: once before touching the shape, to confirm a no-op diff, then again after the shape edit, so what shows up is the shape change alone. Run it that way on every `.shex` change — it is what keeps an ORM diff reviewable, since nothing separates your edit from a generator side effect if you only ever run it once. 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,95 +0,0 @@
---
type: knowledge
summary: The `@ng-eventually/client` contract Festipod is written against, as a FINISHED NextGraph SDK — reactive reads (`watchShape`, `useShape`), document writes (`docs`), per-scope placement (`storeRegistry`), `inbox`, `discovery`, capabilities (`capFor` / `inbox.shareCap` / `publishRepoLink`), identity — with what the app MAY and MAY NOT assume of each, so no agent ever needs to open the SDK's own repo.
---
# The SDK surface Festipod codes against
This is Festipod's **data contract**: what `@ng-eventually/client` offers, and what the app is entitled to rely on. It describes the SDK **as it should be** — a finished NextGraph SDK — because that is what the app is written against ([[rule_app-uses-sdk-surface-only]]). It says **nothing** about NextGraph's or the package's implementation state, on purpose: the app ignores that entirely, and any gap is the package's to absorb, never the app's.
Everything below is exported from the SDK entry `@ng-eventually/client`, **except** the few items explicitly marked `/polyfill` — the bootstrap subpath `@ng-eventually/client/polyfill`, the one part that disappears at migration. Injection happens exactly once, in `ngSession` ([[knowledge_nextgraph-stack]]).
## Reactive reads — the canonical path
**`watchShape(shapeType, scope) -> ShapeObservable`** — the read Festipod uses. It observes one SHEX shape over one **logical scope** (`'public' | 'protected' | 'private'`) and yields a `useQuery`-shaped snapshot: `{ data, isPending, isSuccess, isError, error }`. Bind it with `useSyncExternalStore` (`src/shared/data/useShapeQuery.ts`).
May assume:
- `data` is **always an array**, never `undefined`; its items are `UnionSubject` (`{ subject, graph, props }`) — raw per-subject property bags, mapped to `Fp*` types by `src/shared/data/shapeAdapters.ts` ([[knowledge_entities]]).
- `isPending` and `isSuccess` are **mutually exclusive**, and a synchronized-but-empty scope is `isSuccess` with `data: []` — the distinction the surface exists for. Never guess emptiness with a timer.
- The snapshot reference is **stable** until the value actually changes (safe for `useSyncExternalStore`).
- Reactivity is **push**: the snapshot updates on any change in scope, local or remote, and on any change to what the current identity may read. Never polling.
- The observable is **inert until first `subscribe()`** (or `refetch()`); the last unsubscribe tears everything down. `refetch()` forces a re-resolve and is idempotent w.r.t. subscriptions.
- `isError` fires **only** on a real thrown exception, never on a slow or absent peer.
**`useShape(shapeType, scope) -> DeepSignalSet<T>`** — the ORM hook, for **one already-known document NURI** as scope. Returns a live reactive set that re-renders on every change. Festipod uses it in the `@data` harness; screens go through `watchShape`.
May not assume: any ordering of `data`; that a value seen once stays; that a document the identity holds no capability for will ever appear (it silently does not).
## Writes — one document at a time
**`docs.docCreate(sessionId, crdt, cls, dest, store?)`** creates one document and returns its NURI. **`docs.sparqlUpdate(sessionId, query, anchor)`** writes into it: a SPARQL `INSERT`/`DELETE` scoped to the **anchor document's** graph. **`docs.sparqlQuery(sessionId, query, base?, anchor?)`** is the one-shot, non-reactive read.
May assume:
- One document = one repo = one entity ([[rule_document-per-entity]]); a write is a change on that document, and every observer of it is pushed.
- A write **targets exactly one document**. There is no "write to the union", and no primitive by which a non-owner appends to someone else's document — surfacing data to another identity goes through the **inbox**, or through each identity owning its own document.
May not assume: that `sparqlQuery` is reactive (it is a snapshot — to stay live, use `watchShape`); that an unanchored update means anything.
## Placement by scope — `storeRegistry`
**`storeRegistry.createEntityDoc(id, scope)`** — create the entity's own document in the right scope, and record it as the identity's. **`storeRegistry.listEntityDocs(scope)`** / **`listMyEntityDocs(id, scope)`** enumerate documents in a scope, all or mine. **`resolveWriteGraph(id, scope)`**, **`resolveScopeGraph(scope)`**, **`resolveReadGraphs(scope)`**, **`resolveInboxAnchor()`** resolve the NURIs a call needs. **`ensureAccount(id)`**, **`resolveAccount(id)`**, **`allAccounts()`** yield `AccountRecord`s (`{ id, docPublic, docProtected, docPrivate }`).
Festipod's own glue (`src/shared/utils/storeRegistry.ts`) adds only the **domain mapping** entity kind → scope; placement itself belongs to the SDK.
May assume: the SDK owns NURI construction and placement. May not assume: that the app may build a NURI by hand, or read/write a scope's container document directly.
## Inbox — delivery to an identity
**`inbox.post(targetInbox, { payload, from?, ts? })`** deposits into a document's inbox. `from` omitted defaults to the current identity; **`from: null` is an explicit anonymous deposit**, and naming another identity is rejected as a spoof. **`inbox.read(targetInbox)`** returns every `Deposit` (`{ from, payload, ts }`) sorted by ascending `ts`. **`inbox.watch(targetInbox, onDeposits)`** fires once on the initial state and again on every change; it returns an unsubscribe. **`inbox.readSynced`** is the same read under a stronger contract: it returns once the deposits synced to that inbox are visible, where `read` returns what is known locally right now. **Choose by need, not by habit**: `read` inside a session already watching the inbox, `readSynced` whenever correctness depends on a cold session seeing another identity's deposit. `inbox.materialize` is an alias of `read`.
May assume:
- **Any identity — even anonymous — can deposit** into an inbox it knows. That is the only way data reaches an identity that cannot write your documents. See [[rule_nextgraph-inbox]].
- `watch` is **push, never polling**; its `intervalMs` option exists for signature compatibility and is ignored.
- `payload` is **opaque to the SDK** — Festipod defines its own kinds (`src/shared/data/registration.ts`).
May not assume: exactly-once delivery semantics, or that a deposit is removed once read.
## Discovery — the global index
**`discovery.submitToIndex(ref, opts?)`** makes a reference discoverable; `SubmitOptions.from` follows the same identified/anonymous rule as `inbox.post`, and `SubmitOptions.doc` names the document being announced. **`discovery.readIndex()`** returns `IndexEntry[]` (`{ ref, from, ts }`, deduplicated). **`discovery.watchIndex(onEntries)`** is the push-based observer.
May assume: the index admits a document only if it was **published** as a repo link — announcing something past the reach you chose for it is refused. May not assume: that `ref` means anything to the SDK (it is app-defined), or that being indexed grants any read.
## Capabilities — reading is key possession
The model has **no authorization list**. You hold a document's `ReadCap` (a NURI carrying a `:k:` segment) and you read it, or you do not. A bare `Nuri` **names** a document without granting anything.
- **`capFor(nuri): ReadCap | undefined`** (`/polyfill`, also `getCaps().capFor`) — do I hold this document's key? Nothing derives a key from a bare reference; it is either in your keyring because you created the document, or it was delivered to you.
- **`inbox.shareCap(cap, toInbox)`** — the act of sharing: **one document, to one recipient inbox**. Several recipients means several calls. Recipients are addressed as **inboxes**, never as principals.
- Receiving a capability needs **no dedicated call**: it arrives as an inbox deposit, is applied inline by `inbox.read`/`watch`, and the resulting keyring change **re-triggers the reads that were empty for want of it** — a `watchShape` view fills in on its own.
- **`getCaps().publishRepoLink(nuri)`** (`/polyfill`) — publish a document as a shareable link; that link, not the bare NURI, is what goes into anything discoverable. **`getCaps().open(nuri, scope)`** records a document as mine in a scope (publishing it when `public`).
- **Public is readable by whoever has the link, and NOT recursive**: a public document may *reference* a private one without disclosing it. Festipod relies on exactly that.
- Key rotation **redelivers** through the same inbox channel; access is deferred to the next connection, never lost. The app implements nothing to "keep" an access.
May not assume: that a store-level key grants its documents (it does not — isolation is per document); that `Nuri`/`ReadCap` are compile-time-branded (they are plain strings, checked at runtime); that revocation is retroactive.
## Identity and lifecycle
**`accounts.IdentityStore`** / **`accounts.browserIdentityStore(key?)`** persist the current identity id over an injected `AccountStorage`; it is an opaque id, with no notion of password or login step. `/polyfill` adds **`setCurrentUser(id)`**, **`getCurrentUser()`**, **`resetCaps()`**, **`configure(...)`** and **`configureStoreRegistry(...)`** — the bootstrap. **`init` / `initNg`** are the lifecycle entry points, and **`ng`** is the raw SDK object, both re-exported from the SDK entry.
May assume: switching identity **switches** keyrings, it does not wipe them — a delivered capability is durable across sessions.
## SPARQL safety
**`escapeLiteral(value)`**, **`escapeIri(value)`**, **`assertNuri(nuri)`** — the app reuses the SDK's own escaping whenever it builds SPARQL by interpolation. Any untrusted value crossing into a query goes through one of them; never hand-roll quoting.
## Types re-exported for the app
`Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `UnionSubject`, `ShapeQuery`, `ShapeObservable`, `IndexEntry`, `SubmitOptions`, `Deposit`, `PostOptions`, `AccountRecord`, `RegistrySession`, `AccountStorage`, `DocChange`, `Unsubscribe` — plus `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet` and `NG`, so the app never imports from `@ng-org/*` directly.
## Exported, but not for the app
`readModel.readUnion`, `subscribeDoc` / `subscribeDocs` / `docChangeType`, and `docs.sparqlQuery` used as a listing primitive are **lower-level** surfaces. Festipod reads through `watchShape` and does **not** assemble its own reactivity on top of them ([[rule_app-uses-sdk-surface-only]]). In demo mode none of this is reached at all ([[knowledge_data-modes]]).
@@ -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]].
@@ -0,0 +1,150 @@
---
type: contract
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
pulled_from: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git/.project/concepts/app-contract/contract_polyfill-surface.md
pulled_version: a33fb8a21464194227668fd703edd35f685bb3c1
pulled_at: 2026-08-16
---
# contract_polyfill-surface — `@ng-eventually/polyfill`
## Scope
This package is a polyfill of NextGraph's SDK.
This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
### Deployment requirements
An application using this package must:
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first.
## Surface
Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type is published only when a published signature uses it. The load-bearing signatures:
```ts
// ── bootstrap ────────────────────────────────────────────────────────────
export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web
useShape: UseShapeLike; // `useShape` from @ng-org/orm
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
// ── identity — one await before the application renders ──────────────────
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
// ── addressing ───────────────────────────────────────────────────────────
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type Scope = "public" | "protected" | "private";
// ── placement: where an application's documents live ─────────────────────
export const storeRegistry: { // no identity parameter — a session is one user's
createEntityDoc(scope: Scope): Promise<Nuri>;
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
resolveScopeGraph(scope: Scope): Promise<Nuri>;
resolveWriteGraph(scope: Scope): Promise<Nuri>;
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
};
// ── reading ──────────────────────────────────────────────────────────────
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
export function watchShape(query: ShapeQuery): ShapeObservable;
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
// ── low-level document / SPARQL primitives ───────────────────────────────
export const docs: {
// `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`).
// It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it
// fails for real (`Deserialization error of session_id JsValue("1")`).
docCreate(sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown): Promise<Nuri>;
sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
// Returns the commits the update produced, as upstream does (it typed this `void` until
// 2026-08-14 while already relaying the value). A caller that ignores it is unaffected.
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
};
// ── inbox: giving to read, and depositing ────────────────────────────────
export const inbox: {
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
// an alias with no call site, and no counterpart upstream. Use `read`.
};
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
// ── the wrapped SDK objects ──────────────────────────────────────────────
export const ng: NG; // call this instead of the `ng` passed to `configure`
// `NG` is upstream's own type (`@ng-org/web`), 88 typed
// members; it was `Record<string, any>` until 2026-08-14
export function init(...args: any[]): any; // likewise — not the `init` passed to `configure`
export function initNg(...args: any[]): any;
```
## Guarantees
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
A returned reference carries no key — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
What was shared with you becomes readable after `ensureIdentity()`.
`readUnion` returns one entry per distinct subject present in a document. `subject` is that subject's IRI exactly as written, and is a `string`, because a subject may be any IRI; `graph` is the document reference you passed in, and is the `Nuri` to hand back to this surface. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Several objects in one document are allowed. Recommended placement is one document per business entity: access is granted per document.
`urn:ng-eventually:` is reserved. Triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned.
Only a document's owner writes to it. Holding its read key never grants a write.
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, or a queue could not be drained, it throws instead of returning. So a resolved call means everything shared with you is readable — and a rejected one must not be rendered past, since the interface would show an empty account rather than an empty screen.
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it never appears. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
## Non-guarantees
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
**No revocation.** `inbox.share` cannot be undone.
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
**No mailbox model.** Do not build on the raw deposit list.
**No cross-broker reference.** A returned reference resolves for users of the same broker.
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
## Change policy
This surface changes, and shrinks. The package does not offer semantic-version stability.
Re-pull this contract at every upgrade.
@@ -0,0 +1,84 @@
---
type: usage
summary: What the Festipod application actually calls in @ng-eventually/polyfill, the conditions it needs beyond the call list, and the five frictions it has measured against the engagement
against: a33fb8a21464194227668fd703edd35f685bb3c1
---
# usage_festipod — Festipod on `@ng-eventually/polyfill`
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through this package — there is no second data path, no server of its own, and no direct use of the SDK underneath. Two kinds of caller live in this repo and both are declared here: the **application** (screens, data context, write helpers) and the **test harness** (a browser-side bridge the BDD suites drive). The harness is a caller like any other; what it calls is part of what we consume.
The list below is what we actually call, derived from the call sites, not from what the engagement offers. Anything not listed is offered-but-unused and free to evolve without us.
## Consumed surface
### Bootstrap and session
- `configure(c)` — **one call site**, once per page load, with every published field: `ng`, `useShape`, `init`, `initNg`, `debugAccessLog`, and `sharedWallet: { fileUrl, password, importUrl }`. All three `sharedWallet` fields are supplied, `importUrl` included.
- `init(callback, true, [])` — this package's `init`, not the one handed to `configure`. We read `event.session` off the callback and keep it for the whole page.
- From that session object we read **two** members: `session_id`, relayed unconverted (`string | number`) into every `docs` call, and **`session.user`**, a string user id passed to `ng.session_stop`. `session.user` reaches us only through the session's open index signature — the engagement names `session_id` and nothing else, so this is a **declared dependency on an unpublished member**: if the session stops carrying `user`, our sign-out breaks.
- `initNg(ng, session)` — called from inside that same callback.
- `ng` — exactly one member: `ng.session_stop(userId)`. Nothing else of the 88 is touched.
- `ensureIdentity()` — awaited before the interface renders (auth gate and app entry), and again by the data context, the principal resolver, and the harness. Its return is treated as opaque: never parsed, split, or rendered.
### Placement — `storeRegistry`
- `createEntityDoc(scope)` — one document per entity, on create.
- `listMyEntityDocs(scope)` — the owned-document listing; it is also **how we answer "may I write this?"**, since no call answers that question.
- `resolveScopeGraph(scope)` — the anchor for every SPARQL call.
- `openDocumentInbox(doc)` — through **one app-side wrapper** that collapses concurrent calls for the same document into a single resolution, keyed on the document's canonical form, for the session's lifetime. The raw entry is deliberately not re-exported, so no call site can reach it directly. That wrapper exists only because of friction 1.
- `resolveWriteGraph` — **imported and re-exported, never called.** Declared because the import is real: removing the symbol breaks our build even though no behaviour depends on it.
### Reading
- `watchShape<T>(shapeType, scope)` — **two positional arguments plus a type parameter** (see friction 5). Wrapped once, in the single React binding that couples the app to the reactive read; every screen reads through that binding. `ShapeObservable`'s `getSnapshot`, `subscribe` and the `ShapeQuery` state it yields are all consumed.
- `useShape(shapeType, scope)` — the read-filtered view, in the write path and in the `@data` step definitions.
- `UnionSubject` — its `subject`, `graph` and `props` are read and adapted into the app's own entity types.
- **Not consumed:** `readUnion`, `subscribeDoc`, `subscribeDocs`.
### Low-level document / SPARQL primitives
- `docs.sparqlUpdate(sessionId, query, anchor, label)` — every write the app makes, always anchored, always labelled.
- `docs.sparqlQuery(sessionId, query, base, anchor, label)` — authoritative re-reads on the write path (what a reactive read must not be asked to settle) and in the harness.
- **Not consumed:** `docs.docCreate` — documents are created through `storeRegistry.createEntityDoc`.
### Inbox
- `inbox.share(doc, toUser)` — granting a connection the read of a protected document.
- `inbox.postToDocument(doc, { from, payload, ts })` — reaching a document's owner. We pass `from: null` **deliberately** (a sign-up is unnamed unless the host is already a connection), a structured `payload`, and our own `ts`.
- `inbox.read(targetInbox)` and `inbox.readSynced(targetInbox)` — the owner materialising its deposits; `readSynced` is what the count path uses, because a read before the sync barrier returns a premature empty.
- `inbox.watch(targetInbox, onDeposits)` — subscribed by the owner; the returned unsubscribe is called on teardown.
- `inbox.readForDocument(doc)` — harness only.
- `Deposit` — **all three fields** consumed: `payload`, `ts` (sorting and identity), `from`.
- **Not consumed:** `inbox.post` (we always address a document, never a raw inbox), `inbox.processInbox`.
### Types imported
`Nuri`, `NuriLike`, `PrincipalId`, `NG`, `UnionSubject`, `ShapeQuery`, `ShapeObservable`, `DeepSignalSet`.
Two of these are not underwritten by the engagement document as it stands. `ShapeQuery` and `ShapeObservable` are *named* by `watchShape`'s published signature but never defined there, and we use both **generically** (`ShapeQuery<T>`, `ShapeObservable<T>`) while the published signature is not generic. `DeepSignalSet` is named by **no** published signature at all — the harness imports it on the strength of the package exporting it, which by the engagement's own rule ("a type is published only when a published signature uses it") means we depend on something unpublished.
## Constraints
- **The session is the package's, and there is exactly one identity per page.** No call of ours takes an identifier, and we never build a session. Anything that made a page carry two identities would break the whole app, starting with the inbox wrapper's session-long memo.
- **`ensureIdentity()` must reject rather than resolve early.** We render the entire interface past that await. A resolve that did not actually finish restoring what was shared would show a signed-in user an empty account — worse than an error — so we rely on the rejection being real and we never render past one.
- **The barrier is the package's to mount and take down.** The app renders nothing of its own around sign-in and does not reload its own page; a barrier that leaked past the broker round-trip, or one the app had to dismiss itself, would need app-side machinery we deliberately do not have.
- **A rejection means "unknown", never "absent".** Every place we ask whether something exists (a document's record, a document's inbox) treats a throw as unknown and retries or surfaces it. A call that quietly returned "nothing" instead of throwing would make us provision a second set of documents for a user who already has them.
- **`sessionId` is relayed, never converted.** We pass through whatever the session carries, `string | number`, because stringifying it fails for real downstream.
- **Isolation is the package's, not ours.** No screen and no data helper implements an access check: we place each entity in its scope and trust the scope. If reading stopped being "possession of the key", the app would have no barrier of its own to fall back on.
- **Writes must be authoritative on our own document.** A withdrawal must not come back. We re-read with `sparqlQuery` rather than trusting a reactive read to settle it — the reactive surface is a view, not the authority.
- **We do not poll the broker.** No retry loop and no short-interval re-read papers over a missing push. So every gap in the reactive path stays visible as a delay in the product, which is why the frictions below matter rather than being absorbed.
- **A public store must serve its read key to whoever asks.** Discovery of other people's events is a plain read of the public scope, with no grant step. If that stopped holding, the product's primary discovery axis would be gone.
- **One deployment parameter is ours, not yours:** the wallet file we serve and its password. We pass them; the package reads no environment of its own.
## Frictions
**1. Resolving a document's inbox is not idempotent under concurrency.** The engagement states that resolving an inbox "throws rather than handing back a second one". It does not. **Four concurrent calls for one document produced three inboxes.** The four are ordinary and unavoidable: creating an event opens its inbox, the materialiser opens it to read, the watch opens it to subscribe, and the watch callback re-enters the materialiser — all within a fraction of a second, none aware of the others. The consequence is silent and total: the owner watches one inbox while sign-ups land in another, and a sign-up is simply never seen. We now funnel every call through one wrapper that de-duplicates in-flight resolutions per document for the session's lifetime. That wrapper is compensation for this friction, not a design of ours, and it only protects a single session — two sessions racing are still unprotected, because nothing on this surface makes the resolution idempotent where it actually lives.
**2. A deposit into an inbox you watch yourself produces no push. Verified twice.** The depositor's own session never materialises it. This is the normal case for us, not an edge: the host of a meeting point is often also the actor whose deposit must be processed, and its materialiser sits on its own inbox. So the owner is not woken by its own action, and the deposit waits for the next connection.
**3. A write to your own document is not re-read by the reactive read in the writing session.** Three observations of sixty seconds each: the value stays stale for the whole session. Combined with friction 2, this is what makes a participant count lag **one full connection** behind the write that produced it — the first reconnect after a sign-up still reads the old value, and only the second reads the true one. We compensate nowhere: papering over it would mean polling, which we forbid.
**4. There is no way to reset a test wallet.** The suite's data lives in the wallet file the deployment serves; nothing on this surface empties it, and recreating the browser profile does not touch it — two runs "on a fresh profile" measure the same accumulated state. Every scenario writes into that wallet and nothing removes what it wrote, so per-scenario duration climbs monotonically within a run and later scenarios die in their setup hook at its cap, silently, with nothing in the console. **The suite degrades to zero passing scenarios.** No reset primitive is published — no teardown call, no throwaway wallet — so there is nothing to call, and we will not fake one by bypassing our own enforcement point. This is the friction that costs us the most: it makes the `@data` layer's results non-reproducible, which is a property of the harness we cannot fix from here.
**5. The published signature of `watchShape` does not match the call that works.** It is published as `watchShape(query: ShapeQuery): ShapeObservable` — one argument, non-generic, and naming two types (`ShapeQuery`, `ShapeObservable`) that the engagement document never defines. What works, and what every read in the app goes through, is the **two-positional-argument** form with a type parameter: `watchShape<T>(shapeType, scope)`. Lower than the four above — we have a working call — but the document as written cannot be coded against for the single most-used read on the surface.
@@ -1,35 +1,25 @@
---
type: rule
summary: The app IGNORES NextGraph's implementation state entirely and is coded against the SDK as it SHOULD BE — the contract written down in this repo ([[knowledge_sdk-surface]]). @ng-eventually/client is a POLYFILL whose mission is to COMPENSATE THE GAP between that target SDK and what NextGraph provides today (the virtual wallet being the largest piece, not the whole mission). When something breaks, the question is never "how do we work around it in the app" but "what must the polyfill compensate".
summary: The app codes against the engagement the provider publishes — [[contract_polyfill-surface]], pulled into this repo and version-pinned — and that copy is the ONLY reference. Never open the provider's sources or its node_modules copy; never describe or reason about how the data layer is implemented; what the contract does not answer is a GAP, raised with the provider and never worked around here.
---
# The app uses the SDK surface only — never the polyfill's internals
# The app uses the published surface only
## The rule
The Festipod app treats `@ng-eventually/client` as a **finished, flawless NextGraph SDK**. Concretely:
Festipod is a consumer of **one published contract** and is entirely ignorant of how it is honoured.
1. **Reactive reads = `useShape`** (the SDK-shaped surface provided by the polyfill, **scoped to the virtual wallet**). The app does NOT read through the polyfill's internals (`readModel.readUnion`, `subscribeDoc`, a home-made read model…), and does NOT mount its own reactivity (a re-run on a signal).
2. **The app NEVER reasons about NextGraph's current state**: no code and no comment of the kind "we do X because the ORM fan-out hangs / because a cold read returns 0". From the app's point of view, those problems do not exist.
1. **The pulled contract is the only reference.** [[contract_polyfill-surface]] is the provider's engagement, version-pinned in this repo. An agent working here reads that file and **never opens the provider's repo or its `node_modules` copy** — not to check a signature, not to settle a doubt.
2. **What the contract does not answer is a gap.** Raise it with the provider and leave the app's call site as it is. An app-side workaround is a doctrine violation *even when it works*, because it hard-codes a passing state into code meant to outlive it.
3. **No description of how the data layer works underneath**, in code, in comments or in this repo's doctrine. Nothing of the form "we do X because a read behaves like Y". From the app's point of view there is only the contract and what it promises.
4. **No shortcut, in the app or in its tests.** Deep imports into the package are refused by its `exports` map, and that refusal is correct — see [[rule_tests-validate-festipod-not-the-sdk]].
## The contract is the SDK as it SHOULD BE — written down here, in this repo
## The surface shrinks, and that is normal
The app is coded against the SDK **as it should be**, and that contract lives in Festipod's own doctrine: [[knowledge_sdk-surface]]. That is what an agent reads to know what it may rely on. It never needs to open the polyfill's repo, and it never needs to know what NextGraph does or does not implement today.
The contract's own change policy states that this surface **changes, and shrinks**, and that it must be re-pulled at every upgrade. A removal is therefore never a regression to absorb defensively — it is work the app deletes.
**Ignore NextGraph's implementation state — entirely.** Not "mostly", not "except when it bites". The app's code and comments must contain **nothing** of the form "we do X because NextGraph does Y today". From the app's point of view, that state does not exist.
## What the app reads through
## The polyfill's mission: COMPENSATE THE GAP
Reactive reads go through `useShapeQuery` (a `useSyncExternalStore` binding over `watchShape`) plus the Fp adapters in `src/shared/data/`. The app mounts no reactivity of its own and keeps no bespoke read model.
`@ng-eventually/client` is a **polyfill**, and its mission is exactly that of any polyfill: **close the gap between the target SDK and what the underlying platform currently provides**.
The **virtual wallet** (several identities on one physical wallet) is the largest piece of that gap, and historically the reason the polyfill was created — but it is **one piece, not the whole mission**. Emulating capabilities, the union read-model, `open-repo`, readiness mirroring, reconnection: all of it is gap-compensation, all of it is legitimately the polyfill's job, and **none of it surfaces in the app**.
**The operative consequence.** When something does not work, the question is never *"how do we work around NextGraph in the app?"* — it is *"what does the polyfill have to compensate?"*. An app-side workaround is a doctrine violation even when it works, because it hard-codes a temporary state of NextGraph into code that must outlive it.
## Status (deviation resolved)
**Resolved**: `FestipodDataContext` now reads through `useShapeQuery` (a `useSyncExternalStore` binding over the polyfill's `watchShape`) + Fp adapters (`src/shared/data/shapeAdapters.ts`). **Removed**: `readEntities.ts`, the bespoke reactivity (`subscribeDocs`+`bumpRead`+`readTick`), the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and the comments reasoning about the ORM hang. The auto-seed is gated on `isSuccess` (no more 3s timer). The app consumes nothing but the SDK surface.
**Target (design reminder)**: the polyfill exposes a `useShape` that is **reactive and scoped to the virtual wallet**, whose **shape follows TanStack `useQuery`** — `{ data, isPending/isLoading, isSuccess, isError, … }` — **in anticipation of the PLANNED update of `useShape` by NextGraph** (which is going to adopt that behaviour). So this is not an invention: it is a future NextGraph API, emulated ahead of time, that will align once NextGraph ships it. It **natively distinguishes** `isPending` (sync in progress) from `isSuccess` + empty `data` (synchronized, genuinely empty) — exactly what is needed. Internally, the hook encapsulates readUnion over `subscribeDoc` plus the identity scoping (invisible to the app). The app **removes** its bespoke machinery (`readEntities`/`subscribeDocs`/`bumpRead`) and reads through that hook.
The auto-seed bug (the 3s timer) is a **symptom**: with `isSuccess`, the auto-seed decides "empty" only once the sync is confirmed, instead of guessing a delay. See [[rule_no-broker-polling]] and [[knowledge_nextgraph-stack]].
What the app **does** rely on is the distinction the observable carries: `isPending` (sync in progress) is not the same as `isSuccess` with empty `data` (synced and genuinely empty). Code that needs "is it really empty?" — the auto-seed gate, the `ready` flag — uses that distinction and nothing finer.
@@ -1,33 +0,0 @@
---
type: rule
summary: Any important knowledge established about how NextGraph ACTUALLY works (a core/broker/verifier mechanism, a primitive's semantics, a shape property) → record it AT THE MOMENT of discovery in the polyfill's reference docs `../../nextgraph/ng-eventually-js/docs/`, never in the Festipod repo; distinguish VERIFIED from INFERRED, and never deduce the TARGET shape from the source's CURRENT state
---
# Rule: record NextGraph knowledge the moment you establish it
When an investigation establishes an **important fact about how NextGraph actually works** — a primitive's mechanism, a structure's semantics, a shape property ("the overlay is *store*-scoped, never document-scoped"), an access guard, what an operation does or does not require — **write it down straight away** in the polyfill's reference documentation:
`../../nextgraph/ng-eventually-js/docs/` (from this repo's root) — typically the reference note for the subject (caps/NURI model, current state, SDK reference).
**Never in the Festipod repo.** `AGENTS.md` forbids it explicitly: Festipod doctrine describes *how Festipod uses the SDK*, not the state of NextGraph. See [[rule_app-uses-sdk-surface-only]].
## At the moment of discovery — not at the end
"I will write it up at the end of the session" does not work: the context is compacted before that, and the fact is lost. This knowledge is **very expensive** to establish (several agent investigations through the Rust source, often contradicting each other before they converge) and **impossible to verify from memory** — a second session will pay full price again for the same answer, or worse, will settle for a wrong intuition.
## The central pitfall: current state ≠ target shape
**Never read `nextgraph-rs`'s current state to DEDUCE the target shape from it.** The source contains **unfinished scaffolding** that looks like model: you can find membership and permission types in it that are **inert at runtime** (never called outside unit tests, structures built empty). Deducing a "membership" primitive from that and shaping it into the polyfill means carving in a shape that will never exist — exactly the failure mode the polyfill exists to prevent.
The source is there to **verify an existing mechanism**, never to **infer an intention**. Intentions are to be asked of NextGraph's designer.
## Shape of the note
- **Distinguish VERIFIED** (a path read end to end, or better: observed at runtime) from **INFERRED** (deduced, not traced). A load-bearing fact left unmarked silently turns into a certainty.
- **Point at symbols**, not line numbers (which are volatile) — and date the note.
- Write down the fact's **consequence** too, not just the fact: that is what will be re-read.
- A fact that **contradicts** an existing note → fix the note, do not pile on.
## Sibling rule
This one covers **knowledge** — what *is*; [[rule_nextgraph-inbox]] covers what must be **reported upstream or waited for** — the malfunctions and the gaps (→ `../../nextgraph/orm-tests/INBOX/`). One and the same investigation often produces both: file each half in its own place. See [[knowledge_nextgraph-stack]].
@@ -1,48 +1,49 @@
---
type: rule
summary: Festipod persists EVERY entity as ITS OWN document (through the SDK), placed in its scope (public/protected/private) — never several entities written into a store-level document. The document is the unit of sharing and of rights: the SDK's isolation is PER-DOCUMENT, so one document per entity is what makes it possible.
summary: Festipod persists EVERY entity as ITS OWN document (through the SDK), placed in its scope — never several entities in a store-level document. The document is the unit of sharing and of rights: access is granted PER DOCUMENT, so one document per entity is what makes it possible.
---
# Rule: one document per entity (never at store level)
When Festipod creates an entity (event, meeting point, profile, participation, notification), it writes it as **its own document**, through the data SDK's "create a document" call ([[knowledge_nextgraph-stack]]), stating its **scope** (`public` / `protected` / `private`). The entity is then read from and written to **that** document.
When Festipod creates an entity (event, meeting point, profile, participation, notification), it writes it as **its own document**, through the surface's "create a document" call ([[knowledge_nextgraph-stack]]), stating its **scope** (`public` / `protected` / `private`). The entity is then read from and written to **that** document.
**Never** write several entities into a shared "store-level" document (e.g. putting everything into a single root document). That is an anti-pattern that breaks isolation.
## Why
The **document is the SDK's unit of sharing and of rights**: isolation (who can read what) is enforced **per document**. `private` → the owner; `protected` → the owner + their connections; `public` → everyone. That discrimination is possible **only if each entity has its own document**: putting several entities (or worse, several owners) into a single document makes sharing all-or-nothing and defeats scope-based isolation.
The **document is the unit of sharing and of rights**: the contract states that **access is granted per document**. `private` → the owner; `protected` → the owner + their connections; `public` → everyone. That discrimination is possible **only if each entity has its own document**: putting several entities (or worse, several owners) into a single document makes sharing all-or-nothing and defeats scope-based isolation.
Isolation itself is **entirely handled by the SDK** ([[knowledge_trust-model]] in the `app-security` concept) — the app carries no access logic; it only declares its identity (at login) and its connections (an act of sharing), then trusts whatever the SDK returns. The "one document per entity" granularity is the write-side counterpart of that trust.
Isolation itself is **entirely the surface's business** ([[knowledge_trust-model]] in the `app-security` concept) — the app carries no access logic; it declares **no identity at all**, only which of its own documents it shares with whom, then trusts whatever it gets back. The "one document per entity" granularity is the write-side counterpart of that trust.
## How to apply it
- At creation time: ask the SDK for **a document for the entity, in its scope** (`createEntityDoc(scope)`); write the entity into it. Do not reuse a document from another scope, nor a store-level document.
- For reads: go through the SDK's **reactive shape surface** (see below) — the app names a SHEX shape and a **logical scope**, and the SDK resolves that scope to the documents to read (the discovery index for public events; its own scope documents for its own entities), opens/synchronizes them and pushes changes. No NURI resolution, no document listing and no query written on the app side.
- At creation time: ask the SDK for **a document for the entity, in its scope** — `createEntityDoc(scope)`. Placement is named by **scope alone** — the session belongs to one user, so there is no identity to pass, and a creation that cannot be recorded **throws** rather than handing back a reference that would read empty forever. Write the entity into it. Do not reuse a document from another scope, nor a store-level document.
- **A document only HAS an inbox if its owner opened one** (`openDocumentInbox(doc)`). Festipod opens one on the documents meant to **receive** deposits — its **events** — not on every entity. A deposit then **names the document**: `inbox.postToDocument(doc, …)`, never an address the app resolved itself.
- For reads: go through the **reactive shape surface** (see below) — the app names a SHEX shape and a **logical scope**, and the surface resolves that scope to the documents to read, synchronizes them and pushes changes. No NURI resolution, no document listing and no query written on the app side.
- The *entity → scope* mapping (event/meeting point → public, network profile/participation → protected, settings → private) is a product fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]).
## Reads: the SDK's reactive shape surface (`watchShape` / `useShapeQuery`)
**Reads go through the SDK surface only** ([[rule_app-uses-sdk-surface-only]]). The app names a shape and a scope, and gets a live, `useQuery`-shaped result back:
1. `watchShape(shapeType, scope)` (SDK) returns an **observable** — `{ data, isPending, isSuccess, isError }` — which resolves the scope against the current identity's wallet (its own scope documents, plus the discovery index for `public`), waits for the sync barrier, and **pushes** on every change. `data` is always an array; a synced-but-empty scope reads `{ data: [], isPending: false, isSuccess: true }`, which is what distinguishes "still syncing" from "genuinely empty".
1. `watchShape(shapeType, scope)` returns an **observable** — `{ data, isPending, isSuccess, isError }` — which resolves the scope itself and **pushes** on every change. `data` is always an array; a synced-but-empty scope reads `{ data: [], isPending: false, isSuccess: true }`, which is what distinguishes "still syncing" from "genuinely empty".
2. `useShapeQuery(shapeType, scope)` (`src/shared/data/useShapeQuery.ts`) is the app's **only** React binding over it (`useSyncExternalStore`), memoized per (shape, scope) so the underlying document subscriptions are not churned on every render.
3. `FestipodDataContext` mounts exactly three of them — events (`public`), profiles (`protected`), participations (`protected`) — and maps the SDK's `UnionSubject` property bags onto the app's `Fp*` domain types through `src/shared/data/shapeAdapters.ts`.
3. `FestipodDataContext` mounts exactly three of them — events (`public`), profiles (`protected`), participations (`protected`) — and maps the returned `UnionSubject` property bags onto the app's `Fp*` domain types through `src/shared/data/shapeAdapters.ts`.
**The app resolves, lists, registers and re-queries nothing.** There is no app-side document set, no manual re-read signal and no per-document subscription: reactivity is the SDK's own push. The single app-side layer laid over the read is a **pure optimistic overlay** (`pendingAdd*` / `pendingRemoveIds` in `FestipodDataContext`), auto-reconciled the moment the reactive set catches up — it hides the broker's push latency, it is not a read path.
**The app resolves, lists, registers and re-queries nothing.** There is no app-side document set, no manual re-read signal and no per-document subscription: reactivity is the surface's own push. The single app-side layer laid over the read is a **pure optimistic overlay** (`pendingAdd*` / `pendingRemoveIds` in `FestipodDataContext`), auto-reconciled the moment the reactive set catches up — it hides push latency, it is not a read path.
> **Removed (verified 2026-07-28).** An earlier version of this rule described reads as a bespoke union model: an on-demand document set (`publicDocs`/`protectedDocs` fed by `listMyEntityDocs` + `registerDoc`), a one-shot `readEntities` → `readModel.readUnion`, and a manual re-query signal (`bumpRead`/`readTick`). **None of those read symbols exist in `src/` any more** — `src/shared/data/readEntities.ts` is gone, and the surviving mentions are historical code comments. Do not code against them ([[rule_app-uses-sdk-surface-only]]).
## Direct writes (the round-trip pitfall)
**Writing** an entity happens **directly into its own document** (through the SDK's SPARQL call — `src/shared/data/entityWrites.ts`, `writeEntity`), **not** by adding to a reactive set. Reason: a reactive set is only *writable* if the target document is **already** within its subscription scope; but registering the freshly created document is React state that only takes effect on the **next** render → you cannot create-then-add in a single synchronous pass (seed loop, first creation). Against the real broker, an `add` on an empty scope raises "Set is readonly because scope is empty" (the fake-ng unit tests do not catch it).
**Writing** an entity happens **directly into its own document** (through the surface's SPARQL call — `src/shared/data/entityWrites.ts`, `writeEntity`), **not** by adding to a reactive set. Reason: a reactive set is only *writable* if the target document is **already** within its subscription scope; but registering the freshly created document is React state that only takes effect on the **next** render → you cannot create-then-add in a single synchronous pass (seed loop, first creation). The observable symptom when you try: an `add` on an empty scope raises "Set is readonly because scope is empty".
So: **write = direct SPARQL into the entity's document** (immediate, per-document); **read = the SDK's reactive shape surface** (above).
**Graph convention (write into the anchored default graph).** A write passes the document's NURI as the **anchor** of `docs.sparqlUpdate` and writes the SPARQL body **without** an explicit `GRAPH <…>` clause; the SDK's shape read queries that same anchored default graph. This is the **canonical, always-safe** form — to be kept for `writeEntity`, `updateEntityField` and `registration.ts`.
**Graph convention (write into the anchored default graph).** A write passes the document's NURI as the **anchor** of `docs.sparqlUpdate` and writes the SPARQL body **without** an explicit `GRAPH <…>` clause; the shape read queries that same anchored default graph. This is the **canonical** form — to be kept for `writeEntity`, `updateEntityField` and `registration.ts`. It is a choice of **simplicity and uniformity**, not a round-trip necessity: an explicit `GRAPH` wrapper anchored to the same document does round-trip, so a "0 entities" symptom is never evidence of a graph mismatch — look at the test wallet first (`bdd-testing/caveat_wallet-bloat-hang`).
> **Correction (2026-07-06).** An earlier comment (and an earlier version of this paragraph) claimed that an explicit `GRAPH <docNuri>` body writes into a *distinct named graph* that an anchored read would not see → the entity would "disappear". **That is false on the current broker** (`@ng-org/web 0.1.2-alpha.13`): the lib's real e2e harness (`packages/client/e2e/`) verifies that an `INSERT DATA { GRAPH <plainNuri> {…} }` **anchored** to the doc round-trips (read back both from the default graph and from `GRAPH <plainNuri>`). The "0 entities" symptom we had attributed to that "pitfall" in fact came from the **bloated-wallet hang** (see `bdd-testing/caveat_wallet-bloat-hang`), not from a graph mismatch. So the "no `GRAPH` wrapper" rule remains a choice of **simplicity/safety**, not a round-trip necessity. (The *why* on the SDK side lives in `@ng-eventually/client`, not here.)
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`: one `DELETE … INSERT … WHERE` update, not a DELETE followed by a separate INSERT — the latter left a window where a reader could see the field briefly absent) 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:…`.
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 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]].
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty principal (the entity would be discarded on read). The current user's principal is **stable and derived from the username** (`urn:festipod:user:<normalized-username>`), available **immediately** after login (no dependency on reading the protected profile, which may lag) and **invariant** (it does not flip from a fallback to the profile IRI mid-session, which would desynchronize a participation written under one value from a check made under the other). It is the same principal that the SDK identity (`setCurrentUser`) and the owner cap derive from the username; bilateral connections (`declareConnections`) are declared with those same username keys (not profile IRIs) so that "protected = my connections" discriminates.
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).
@@ -1,42 +0,0 @@
---
type: rule
summary: The shared NextGraph inbox `../../nextgraph/orm-tests/INBOX/` takes TWO families of notes — malfunctions (a primitive misbehaves) AND gaps (a primitive we need, not yet implemented, which we emulate in the polyfill in the meantime). It doubles as a tracker of NextGraph's progress: when a gap is filled upstream, its note says what to REMOVE from the polyfill.
---
# Rule: the NextGraph inbox takes malfunctions AND gaps
The shared NextGraph inbox is `../../nextgraph/orm-tests/INBOX/` (from this repo's root) — in the sibling repo `nextgraph/orm-tests`, which hosts the ORM integration tests against a real broker (`tests/standalone/` for repros).
It is **not** just a bug tracker. It has **two inputs** and **one feedback loop**.
## Input 1 — malfunctions
A NextGraph primitive exists but **misbehaves**: a socket that dies (`SerializationError`), no automatic reconnection, a `doc_subscribe` that does not deliver or delivers late, a slow repo cold-open, a write that is not durable broker-side, a reachable panic.
## Input 2 — the gaps we need
A primitive **is not implemented yet** (or is only inert scaffolding) while our model depends on it. File it too, with the three pieces of information that make it valuable:
- **what we need** and why — the model that depends on it;
- **what the polyfill does in the meantime** — the emulation that fills the hole;
- **what will have to be removed** from the polyfill the day it lands upstream.
It is that third point that turns the note into a **cleanup ticket**. Without it, the emulation outlives its reason for existing and the polyfill starts drifting away from the target — exactly what it exists to prevent.
## What does NOT qualify
An **app** bug (a badly wired React effect, an effect's gating) or a **polyfill wiring** issue (wrong NURI, subscription not re-armed). Those are fixed **on our side**. The distinction is crucial: first prove that the primitive is at fault — ideally with a test — not our integration. See [[rule_app-uses-sdk-surface-only]].
## The loop: the inbox tracks NextGraph's progress
The notes do not only travel upstream, they are also **re-read**: taken together, they say where NextGraph stands relative to what Festipod needs. When a note is resolved upstream, the polyfill update follows — often by **removing** emulation that has become useless, not by adding code.
## Note format
Name: `YYYY-MM-DD-<slug>.md`. Contents: nature (**malfunction** or **gap**), symptom or need, **verbatim evidence** (logs, measurements, source pointers marked "to re-verify"), a repro when it is a malfunction (ideally a standalone in `orm-tests/tests/standalone/`), expected vs observed, and — for a gap — the **polyfill workaround** and **what will have to be removed**. Severity + status.
The inbox receives the **report that is actionable for the NextGraph maintainers**; a longer post-mortem can live on the polyfill side.
## Sibling rule
This one covers what must be **reported upstream or waited for**; [[rule_capture-nextgraph-findings]] covers established **knowledge** about how things actually work (→ the polyfill's reference docs). One and the same investigation often produces both: file each half in its own place. See [[knowledge_nextgraph-stack]].
@@ -2,7 +2,7 @@
type: _overview
summary: Festipod's product model — the meeting point grafted onto a public event as the unit of value, its actors, its business concepts, and the confidentiality scopes (public/protected/private) per entity
triggers:
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication, découverte, périmètre, scope, public, protected, privé]
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, participation, participer, communauté, connexion, festival, déduplication, découverte, périmètre, scope, public, protected, privé]
paths: ["src/modules/*/features/**"]
---
@@ -14,16 +14,19 @@ 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
The product model of **who sees what** — personal data reserved to one's network, public events/meeting points, identified-or-anonymous sign-up notification — is a business fact: see [[knowledge_data-scopes-and-discovery]]. The detailed authorization matrix (actor × verb) and its incubation live in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
The product model of **who sees what** — personal data reserved to one's network, public events/meeting points, an anonymous sign-up notification to the host — is a business fact: see [[knowledge_data-scopes-and-discovery]]. The detailed authorization matrix (actor × verb) and its incubation live in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
## Links
- [[knowledge_business-model]] — the event / meeting point inversion
- [[knowledge_actors-and-concepts]] — reference for actors and business concepts
- [[knowledge_data-scopes-and-discovery]] — public/protected/private scopes per entity + discovery
- [[behavior_participation-listing-is-contacts-only]] — the carved-in-stone rule: listing someone's events is contacts-only
- [[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
- [[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
@@ -0,0 +1,34 @@
---
type: behavior
summary: The link between a person and the events they participate in is personal data — listing someone's events is reserved to their contacts; the person themself always can, anyone else never
---
# Participation listing is contacts-only
> **La règle (canonical statement, French):** *« On ne doit pouvoir lister les événements auxquels participe une personne que si on est en contact avec elle. »*
A **product invariant**, carved in stone (user decision 2026-10-23). It governs any feature, screen, query or data-layer mechanism that surfaces *which events a given person participates in* — whoever asks, whichever channel.
## What it protects
The set of events someone attends reveals where they go, when, and with whom — it is **personal data**, in the same class as the profile itself. The event and the meeting point are public; the *link person ↔ participation* is not. An ordinary user must learn nothing from it; only a **connection** (bilateral contact) may.
## The three outcomes
| Who asks | Answer |
|---|---|
| The person themself | always listable — their own acts are theirs |
| A contact (bilateral connection) | listable — being a contact IS the grant |
| Anyone else | **never** — no listing, no count of *their* events, no cross-event correlation |
## Deliberately mechanism-free
The rule states the **what**, never the **how** — and that is intended. Which resource carries the link, how contacts read it, what the data layer exposes: all free, and all **constrained**. Any implementation choice — an attendees collection, an outbox read, an index, a grant — is acceptable **only while** the rule holds for every client, not just the Festipod app. A mechanism that lets a foreign client enumerate one person's events (correlatable IDs, scannable public collections) violates the rule even if the app's own UI behaves.
## Related
- [[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`)
@@ -0,0 +1,12 @@
---
type: decision
summary: The meeting-point creation form carries a title (optional) and a place (required) only; free-text "when", duration and invitees were removed because the Pod vocabulary has no slot for them, and creation is announced only once it happened
---
# Decision (2026-10-21): the meeting-point form asks only what can be stored
- Fields: **title** (optional, the intent) and **place** (required, stored as `location`). Nothing is merged into another field any more.
- **When**, **duration** and **invitees** were removed from the form: ActivityStreams has no term for a free-text moment or a free-text duration (`startTime` and `duration` are structured values), and the app has no invitee model. A field that vanishes after submit is worse than no field. `time` stays unmapped on ActivityPods; screens show it only when a record carries one, and the invented "~60 min" is gone.
- Reopening this needs a slot offered by the provider (or a structured time/duration input), not a new free-text field.
- `addMeetingPoint` returns a promise: the refine provider propagates the write, the disconnected demo rejects. The screen toasts success only after resolution and an error otherwise.
- Titles may be absent: cards show the place alone, and the title line only when there is one.
@@ -0,0 +1,20 @@
---
type: decision
summary: Sharing my connection means creating an invite capability (VerifiableCredential issued by the Pod) and sharing the pod provider's /invite/<capability> page — the recipient's contact request carries a VerifiablePresentation of the capability and is auto-accepted; no QR, no profile-url page
---
# Decision (2026-10-22): connection sharing is the invite capability
## Decision
- **No QR code.** Neither the reference apps nor the platform need one (kept from the 2026-10-21 arbitration).
- **The shared link is the platform's invite page, not a profile page.** Festipod creates an **invite capability** — a `VerifiableCredential` issued by the user's own Pod at `POST {webId}/vc/v0.3/credentials/issue` (name "Invite Link"; `apods:hasActivityGrant` = the contact-request shape; `apods:hasAuthorization` = Read on the profile document) — and shares `{frontendUrl}/invite/{encodeURIComponent(capabilityUri)}`. Implementation: `src/shared/utils/inviteLink.ts`, `src/shared/hooks/useInviteLink.ts`.
- **Why this and not the `/network/@user@host` page**: a bare profile link fails exactly for the person it is shared with — the not-yet-contact, who gets an empty public view and a manual-accept round trip on the Pod provider. The invite page instead reads the inviter's profile **through the capability** (rich name/photo) and its Connect button posts the contact request **with a VerifiablePresentation of the capability**, which the inviter's Pod verifies and **auto-accepts** (`inviteLinkContactRequest` in the pod provider backend). The capability IS the proof of invitation.
- The `frontendUrl` (host of the invite page) is discovered via nodeinfo of the viewer's pod host, as before.
- The earlier `decision_2026-10-21_profile-link-not-qr` is **superseded by this one** (link target changed from the network page to the invite page).
## Consequences
- The ActivityPods pod-provider frontend fix of 2026-10-22 (public view of `/network` no longer fetching the private profile) is no longer on Festipod's contact path; it remains a standalone correctness fix there, uncommitted.
- Invite capabilities are created on demand; several valid ones may coexist (each grants the same profile read). Revocation is the Pod provider's job.
- Not yet verified end to end on a real pod pair — same caveat as the contacts decision.
@@ -0,0 +1,59 @@
---
type: decision
summary: How sign-ups stay unlinkable on ActivityPods — a private Join handled by the backend on emission; attendee lists of member URNs hashed with a secret kept in the profile, so only contacts recognise anyone; a participations collection per person readable by their contacts group; the creator notified anonymously
---
# Decision (2026-10-29): the participation privacy mechanism
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
- **Event → participants.** From an event (always public) anyone can count the participations, but nobody learns *who* participates — no name, no WebID — unless a contact.
- **Person → events.** Nobody can list the events a person participates in, unless a contact. Nothing published on a person's behalf identifies an event.
The **creator of a meeting point has no particular read right**: for these two links they are "anyone else" unless a contact of the participant. The **Festipod backend** is not a person: it acts (records, notifies) and shows nothing to anyone.
"Contact" is the audience the platform itself shares the profile with: the person's `/contacts` WAC group. The pod puts someone in it only through the person's own acts — the target of a contact request they send, the requester of a request they accept, whoever presents an invite link they issued (accepted automatically, so a forwarded link admits its holder) — never through an activity a stranger sends.
| Who | From an event | From a person |
|---|---|---|
| The person themself | sees themself | lists their events |
| A contact (current or future) | recognises which of their contacts participate | lists the person's events, past ones included |
| Anyone else, the creator included | a count of participations, opaque member URNs | nothing |
## The mechanism
1. **Sign-up.** The participant posts an `as:Join` (withdrawal: `as:Leave`) to their own outbox, naming the **meeting point directly**, **not public and not addressed to anyone**. The outbox is filtered by read rights: a non-public activity is readable only by its recipients and its author (verified 2026-10-29 on the local pod — absent from the anonymous listing, direct GET refused 403), so the target stays hidden from strangers with no intermediate object.
2. **The backend acts on emission** — `onEmit` of the participant's outbox, the welcometomyplace registration pattern (`PodActivitiesHandlerMixin`); the app reads the outbox through `apods:ReadOutbox`. The creator's inbox receives no activity of the participant.
3. **The member URN.** `urn:festipod:userref:<lowercase-hex sha256("<participant WebID> <event URI> <secret>")>`, where the event URI is the meeting point's anchor (`as:context`). The backend computes it from the participant's secret and records it in the meeting point's `apods:attendees` collection (in the creator's pod). Properties: the same for one person across all meeting points of one event (the per-event count is the number of **distinct** URNs, publicly computable); unrelated from one event to another; computable only by whoever can read the secret — the person, their contacts, the app. A client never supplies a URN, and the backend's ledger assigns one URN per (meeting point, participant), persisted before any write: every later act on that pair — after a failure or a rewrite of the secret — uses it, so one person never holds two entries in one list. Every collection write is verified by reading the collection back: a refused write or a failed read leaves the act unrecorded and unnotified, so the next act retries it.
4. **The secret** lives in the participant's `as:Profile`, under the Festipod predicate `http://festipod.org/participationSecret` (32 random bytes, hex), so it has exactly the profile's audience: the app and the person's contacts. The participant's browser generates it at the first sign-up when absent and writes it through the ordinary profile update, before posting the Join. A Join whose author's profile carries no usable secret is refused by the backend (nothing recorded). Verified live 2026-10-29: the pod stores this predicate written with the app's token and returns it under its full IRI, although `fp` is not registered at prefix.cc.
5. **The participations collection** — the person-side record of where they participate, for themself and for their contacts. An `as:Collection` in the participant's pod, attached to their profile under `http://festipod.org/participations`, created by the backend the way welcometomyplace attaches `apods:attendees` to an event (`PodCollectionsHandlerMixin`), and readable by the participant's `/contacts` WAC group (`pod-permissions.add` with `acl:agentGroup`, the group found through `pod-wac-groups.getUriFromCollectionUri` on `apods:contacts`). The group is evaluated at read time: a future contact sees past participations. The pod creates such a collection **world-readable** (observed 2026-10-12 on the attendee lists): the backend takes that public read away, with the same revocation it applies to the list of a meeting point switched private — a participations collection readable by strangers would list the person's events to anyone. Its items are meeting point URIs, added on a Join and removed on a Leave by the backend. The backend creates the collection at the person's first sign-up when the profile links none (one creation at a time per person); a profile with no collection has no participation to list.
6. **Recognition on an event.** The viewer reads each contact's profile once per session (it is read for names anyway) and keeps the secrets in memory; for an event, it puts the URNs of all its meeting points' lists in a set, hashes each contact once and tests membership — O(contacts + participations) per event, no network beyond the lists. Measured 2026-10-29 (Bun, Web Crypto): 500 contacts / 2 000 participations in 3.4 ms, 5 000 / 20 000 in 28 ms.
7. **Notification of the creator** on every sign-up and withdrawal, sent by the backend with `pod-notifications.send` (the welcometomyplace call), **always anonymous** ("a participant…"); the backend never passes the participant's profile. Naming a participant to a creator who is their contact would need a trustworthy contacts signal the app can read: the `/contacts` group is not readable by the app (403, verified live 2026-10-29), and the `apods:contacts` list can be forged (below). `pod-notifications.send` posts from the app's own outbox, which requires the backend to run with WebACL on a secure main dataset, as the three reference apps do.
## Rejected alternatives
- **Addressing the Join to the creator** (welcometomyplace's choice, where events are private): the creator's inbox, and the participant's outbox as a recipient, show them the participant's WebID.
- **Addressing the Join to the contacts**: an activity's read rights are granted once, at emission, to each recipient individually (semapps `setRightsHandler`; `getRecipients` expands only the sender's followers) — future contacts would never see past participations.
- **One read grant per item of the `apods:contacts` list**, kept in sync with contact changes: the list is forgeable — on a received `Accept` of a contact request the pod adds its sender without checking that a request was ever sent (pod provider `contacts/request.ts`, `acceptContactRequest.onReceive`); it first caches the sender's profile, so a sender whose profile the recipient cannot read is not added, but a sender who first sends the person a contact request (which lets the person read the sender's profile) is added (both verified live 2026-10-29) — a stranger would then gain the person's participations, where the group grants them nothing (verified live the same day: the forged contact is refused the profile and the participations collection, and is notified anonymously).
- **Granting the contacts group read on each Join**: the app holds read only on activities (`apods:ReadOutbox` maps to a default read on the activities container), no control; the browser's token carries the app's rights.
- **A private object named by the Join** (an "event reference"): useless once the Join is non-public, and creating its container and ACL from the browser is not a reference-app path.
- **A secret readable by its owner only**: contacts could not recognise anyone from the event side, and resolving identities would mean reading every contact's participations on every event view.
- **The backend resolving identities for viewers**: it would turn the backend into the gate of identity on the main screen.
- **Participations stored in the profile itself**: the profile is read for every contact at session start and would grow with each person's history.
## Accepted residuals
- The target of a contact request is in the requester's `/contacts` group as soon as it is sent, before accepting: they see the requester's profile and participations (user-accepted 2026-10-29). Showing such pending requests in the contacts screen is to be discussed.
- **Platform defect, reported upstream (2026-10-29):** `removeContact` removes from the `apods:contacts` list only, never from the `/contacts` group nor from the profile's read authorisation (pod provider `contacts/manager.ts`) — a removed contact keeps seeing the profile and the participations until the platform fixes it.
- **Platform defect, reported upstream (2026-10-29):** the `apods:contacts` list accepts a forged `Accept` (above) — the reason notifications stay anonymous; the contacts screen, which displays that list, shows such a stranger as a contact.
- A person who rewrites their secret between two meeting points of one event is counted twice in that event — no worse than a second account.
- The outbox root, read anonymously, carries in its pagination link the URI (never the content) of the newest activity: it reveals that an activity exists.
- An act writes twice (the list in the creator's pod, the collection in the participant's pod), not atomically: a failed write leaves the two apart until the person's next act on that meeting point.
- Removing the app leaves the collection and its grant in place.
- If the pod keeps refusing to link a new collection to the profile, each retried Join leaves one more empty, unlinked collection in the person's pod (the act itself stays unrecorded and retryable).
## Not built: public users
A future per-person setting will make some users **public**: their Joins become public, their list entries hold their WebID instead of a URN, their participations collection becomes readable by everyone. Switching a person between the two modes converts their existing entries. Nothing in the mechanism above precludes it.
@@ -0,0 +1,34 @@
---
type: decision
summary: Join/Leave are addressed to the Festipod app actor alone and handled on reception in the app's inbox, no longer picked up unaddressed by an outbox webhook; any compatible sender may send, authorship rests on the signature and the pod, the meeting point is read in its creator's pod
---
# Decision (2026-11-03): participation activities are delivered to the app
User-validated 2026-11-03, on the advice of the ActivityPods developer: an activity meant for an actor who acts on it is addressed to that actor and reaches it through its inbox — the ActivityPub protocol rather than a side channel. This replaces steps 1 and 2 (the unaddressed sign-up, "the backend acts on emission") of [[decision_2026-10-29_participation-privacy-mechanism]]; every other step of that mechanism (member URN, secret, participations collection, recognition, anonymous notification) and all its residuals stand.
## The delivery
- The participant posts the `as:Join` (withdrawal: `as:Leave`) to their own outbox, naming the meeting point, with `to` = the Festipod app actor (`<backend>/as/actor/app`, which is also the Solid-OIDC client id) and no other addressee. Their pod delivers it to the app's inbox.
- The backend handles it with `onReceive` of `@semapps/activitypub`'s `ActivitiesHandlerMixin` (`registration.service.js`), the path `@activitypods/app` itself uses for `Create interop:ApplicationRegistration`. No reference app receives user activities in the app's inbox: this is a named exception to the project rule `festipod-only-reference-app-patterns`, justified by the platform developer's advice and by the framework's own use of that path.
- The outbox webhook (`apods:ReadOutbox`) stays: the catalog, the contacts and the attendee lists still follow the user's own resources there, which have no actor to address.
## Who may send: anyone, nobody for someone else
- **No "app installed" gate** (user decision): interoperability is the aim of the ActivityPods architecture, and a Join sent by another compatible app is legitimate. The processing filters by what it needs: a sender whose profile secret the backend cannot read is refused, nothing written.
- **Authorship.** The inbox accepts a delivery only when it is signed by the activity's `actor` (semapps 1.2.7 `inbox.post` refuses any other actor, `INVALID_ACTOR`; read in code 2026-11-03). The handler further requires the activity to lie in that actor's pod and its stored copy to name them, and acts on the stored copy (`verifiedDelivery` in `emitted-activity.js`). A pod refuses writes under another actor's path, so nobody can join or leave for someone else.
- **The meeting point is read in its creator's pod.** Read through the participant's pod, a resource of another pod goes through the proxy the participant's own actor document declares: for a participant on another server, an answer that server chooses — another meeting point's attendee list, a forged name (reproduced by the adversarial review 2026-11-03 against mocked platform calls). The handler reads the meeting point again through its creator's access and uses that copy alone (`meetingPointOf`).
## Confidentiality: unchanged
The Join stays readable by its author and its only addressee, the app; the creator's inbox receives nothing. The rights matrix lines on the Join's read rights pass unchanged (148/148 hard lines, run 2026-11-03 with the change as committed, creator re-read included). The backend learns the participant's identity as before.
## Costs accepted
- The inbox POST awaits the processing (semapps `processInbox` waits for its job), so each Join/Leave delivery holds the participant's pod's request for the whole handling: 2.6–6.2 s over the 9 deliveries of a full validation run (measured 2026-11-03 on the local stack, creator re-read included, from the queue's job timestamps), against 0.7–1.5 s for the app's other inbox jobs. The app's inbox queue runs 5 jobs at a time, shared with app registrations. Whether the pod provider serializes its outgoing deliveries is not verified.
- The app's inbox stores a copy of every Join/Leave in the backend's dataset — the person ↔ meeting point link the attendance ledger already holds. An anonymous GET of the app's inbox answers 404 (observed 2026-11-03).
- Known exposure, not introduced here and not settled: the backend issues app-signed GETs to URIs a sender chooses — the meeting point it names, its own documents — as the framework's app-registration handler already does with the registration URI a delivery names. Festipod filters no private or internal address; whether the framework does is not verified.
## Open
How to welcome a participant from another app, whose secret the backend cannot read — refused today. Leads: count them under a member URN only the backend can compute (counted, recognised by nobody); agree with the ActivityPods developer on a shared vocabulary for the secret; another policy. A product decision, not taken.
@@ -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"**: 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. 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.
@@ -1,11 +1,11 @@
---
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 reads a global event index
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it; other users' events are found through a shared index (the Festipod backend's catalog on ActivityPods), not by reading the public scope
---
# Data scopes and discovery
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* (documents, capabilities, index) is handled by the `@ng-eventually/client` data SDK — the app only states **the business intent**.
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* belongs to the data layer behind the active backend (concept `data-layer`) — the app only states **the business intent**.
## Three scopes per piece of data
@@ -16,28 +16,40 @@ 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 | **protected** | the participant + their connections |
| 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 |
Guiding principle: **the "public" side (meeting point, event) and the "personal" side (profile, participations, connections) coexist within a single user.** Personal information is reserved to the **network** (bilateral connections), never visible to an ordinary user.
The attendee list of a public meeting point — its `apods:attendees` collection — is world-readable, and that is the **model, not a deviation**: participations are public objects. What is protected is the **identity link**: the list holds opaque member URNs that only a contact of the participant can recognise, and a person's events are listable by their contacts only; the meeting point's creator has no particular read right. The mechanism is [[decision_2026-10-29_participation-privacy-mechanism]]. When the meeting point is switched private, its attendee list stops being readable anonymously with it.
Guiding principle: **the "public" side (meeting point, event, the participation as an object) and the "personal" side (profile, the identity link of a participation, connections) coexist within a single user.** Personal information is reserved to the **network** (bilateral connections), never visible to an ordinary user.
- **Meeting points / events are universally public.** Any user can read them and subscribe; creating a meeting point makes you its host, declaring an event makes you its declarer (no prerequisite).
- **The host is the sole holder of write rights** on their meeting point; the declarer has no particular right over the meeting points grafted onto their event.
- **Bilateral connection**: `DemandeDeConnexion` (unilateral, transient) → `Connexion` (bilateral, persistent) — the latter opens access to the other person's *protected* data.
Festipod **places each entity in the store of its scope**; isolation between scopes is **handled by the data SDK**, not by application code (see concept `app-security`).
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data layer** (the SDK on NextGraph; the pod's access control and the Festipod backend's public-read grants on ActivityPods), not by screen code (see concept `app-security`).
## Event discovery
A user discovers the events they did not create through a **global index**: the SDK reads that index, which yields the references (NURIs) of the event documents, then synchronizes and queries locally. **Primary** discovery goes through that index; a **secondary**, relational axis is layered on top (the connections' *protected* participations: "my friends are attending…").
A user's own reads only ever return **their own** records — on either backend, no call unions every user's public data (concept `data-layer`). Discovering events one did not create — the product's premise — therefore goes through a **shared index**: the **primary** discovery axis. A **secondary**, relational axis stays layered on top: the connections' *protected* participations ("my friends are attending…").
> **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, **anonymous otherwise**. This "identified if known, anonymous otherwise" is a property of the data model — the app relies on it, the mechanism is provided by the SDK.
The intent is the same on both backends — every public event is findable by everyone — but the index is not:
- **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; 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
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,9 +15,13 @@ 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 is wired step by step, and the count a bystander sees lags the flow by one connection.** Each step is honest: `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, and the confirmation the user sees follows the write. Driven end to end in a real browser, the signer's own confirmation is instant and correct, but the `participantCount` a bystander sees stays at 0 through the session and the first reconnect, only catching up on the second — a known layer limitation, not data loss (concept `data-layer`, [[caveat_participant-count-one-connection-lag]]). Nothing short of exercising the whole thing end to end shows this kind of gap (concept `bdd-testing`, [[cookbook_live-probe]]). Treat the bullet above as *screens reachable*, not as an instantly-consistent journey. **Public discovery does not work yet**: a user sees another user's public event only once a shared index exists and is curated (concept `data-layer`, [[decision_2026-08-17_discovery-through-a-shared-index]]; concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]) — no code consumes it today.
> **Product reservation — persistence is not guaranteed end to end.** An event that was created can **disappear** after a period of inactivity followed by a reconnection under the same identity (same wallet). This is an **open defect**, not a property of the product model: impact and pointer on the `data-layer` side → [[caveat_write-durability-across-disconnect]]. Consequence for the domain: "my events / my sign-ups" behave as *implemented* but **not yet as durable** — do not build any product promise (reminders, history, commitment) on top of them while this caveat is open. The `src/modules/event/features/reconnexion-*.feature` scenarios of the `event` module are the non-regression guard for that promise (execution status: concept `bdd-testing`).
> **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.
> **Product reservation — a user cannot be shown two identities on one device.** Signing in is one act with no choice attached: the user does not name, pick or switch an identity, and there is no in-app sign-out from one identity into another (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). One session = one person, for the life of the page. Consequence for the domain: **do not design a flow that asks "who are you signing in as"**, nor an account-switcher, nor a demo that plays two people side by side on one device — none of them is expressible. Two people means two devices (or two browser contexts). The **isolation between two identities** is still a real requirement, but it is currently unproven at the `@data` layer for the same reason (concept `bdd-testing`).
## Identified evolutions (not implemented)
+2 -2
View File
@@ -3,7 +3,7 @@ type: _overview
summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands
triggers:
keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json]
paths: ["build.ts", "package.json", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", "backend/Dockerfile", "docker-compose*.yml", "backend/services/base-path.js", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
---
# Tech stack
@@ -18,4 +18,4 @@ The project's stack and tooling. Guiding principle: **Bun-first** — Bun replac
- [[knowledge_bun-apis]] — native Bun APIs (serve, sqlite, redis, sql, file, shell)
- [[knowledge_build-pipeline]] — build.ts, bundler, server, harness built separately, Storybook
- [[knowledge_stack-and-commands]] — stack components + the real scripts (plus quirks)
- [[knowledge_deployment]] — Dockerfile, prod served from src/, no CI, `portless` in dev
- [[knowledge_deployment]] — the Coolify test stack (one base URL, backend under /api), both images, first-boot traps, prod served from src/
@@ -1,7 +1,7 @@
---
type: caveat
summary: Firefox 151+ blocks (Local Network Access) the hosted broker embedding the local dev app in its iframe → blank iframe, zero app logs, no error at all. This is NOT a code bug. Browser-side fix — about:config network.lna.enabled=false.
last_checked: 2026-07-13
last_checked: 2026-08-10
---
# Firefox LNA blocks the broker's app iframe in local dev
@@ -16,7 +16,7 @@ In local dev, the app runs INSIDE the hosted broker's iframe (`nextgraph.eu`/`ne
`Local Network Access detected: ... accessing target "…festipod.localhost…" (127.0.0.1) … prompt action: auto_deny`.
Two corollaries that mislead:
- **The top level loads just fine**: navigating directly to `https://festipod.localhost:1355` (the AccessGateScreen barrier) is NOT subject to LNA. Only **iframe embedding** by the broker is. So "the cert is already accepted / the app starts up" before the iframe does not mean the iframe will go through.
- **The top level loads just fine**: navigating directly to `https://festipod.localhost:1355` is NOT subject to LNA. Only **iframe embedding** by the broker is. So "the cert is already accepted / the app starts up" before the iframe does not mean the iframe will go through.
- **HTTPS changes nothing**: LNA targets the **local destination address**, not the protocol. Switching to `portless proxy start --https` (app on `https://festipod.localhost`) does not unblock it.
## Fix (browser, not code)
@@ -0,0 +1,33 @@
---
type: caveat
summary: A running `pnpm dev` never picks up a refreshed data-layer package — VERIFIED, not even across a real rebuild (new bundle hash, still the stale dependency). Only a restart serves the fresh copy; suspect a stale server before suspecting the code.
last_checked: 2026-08-16
---
# Pitfall: refreshing the data-layer package never reaches a running dev server, not even across a rebuild
`pnpm run overlay:polyfill` (or `overlay:indexing`, for the other provider) overlays the local checkout into `node_modules/<package>/` as real files, and keeps them current. **That is all it does.** A `bun run dev` already running goes on serving the package it loaded at startup, however many times the overlay is rewritten underneath it, and however many rebuilds happen in between.
**VERIFIED, controlled sandbox test with this project's own bun.** A dependency resolving to copy A, overlaid with copy B: the running server still serves A at +3 s and +13 s after the overlay. An edit to **application source** then triggers a genuine rebuild — a new bundle hash confirms it — and the rebuilt bundle **still serves A**. Only a restart serves B. So the mechanism is not "the watcher never fires because `node_modules` is excluded" — a rebuild the watcher DOES trigger still carries the stale dependency forward; the server's resolution of that import is pinned at process start, and a rebuild does not re-resolve it.
**So: restart `pnpm dev` after every refresh of the package — a rebuild is not a substitute, even a real one.** There is no signal that you needed to; a stale server looks exactly like a current one.
## Why this is worth a leaf
VERIFIED 2026-08-16, and it cost about an hour. A defect had been fixed on the provider's side, the overlay was refreshed, and an automated probe on a freshly launched server confirmed the fix — 3 runs out of 3, clean. The same sequence performed by hand in a browser reproduced the defect immediately. The two observations looked irreconcilable, and the search went to the wallet, to prior state, to timing.
The dev server had been running for **six days**. It predated the package rename and the whole migration, and it was serving code from before the fix. The browser was running a different application from the one under test.
Two things made it hard to see. The failure mode is **silence** — nothing warns that the served code is old. And `scripts/overlay-local-checkout.ts` explicitly promised the opposite, that `bun --hot` would reload the copied file live; that claim is now corrected in the script, but a reader who trusted it would rule out the true cause first, which is exactly what happened.
## The reflex to build
When a fix does not appear to take effect, or when a hand-run and an automated run disagree, **check how long the server has been up before anything else**. It is one command, and it eliminates the cheapest hypothesis first:
```bash
ps -o lstart= -p $(pgrep -f 'bun --hot src/index.ts' | head -1)
```
Do not reach for "touch a source file to force a rebuild" as a lighter alternative to restarting — it does trigger a real rebuild, and the rebuild still serves the stale dependency. The same reasoning applies to anything else served out of `node_modules` — the trap is the location, not this package.
Related: [[cookbook_live-probe]] (bdd-testing) — a probe answers only for the code the server actually holds, so a stale server invalidates the probe's conclusion, not the product's behaviour.
@@ -1,13 +1,24 @@
---
type: knowledge
summary: Dev runs on bun --hot, prod builds through build.ts (Bun bundler + Tailwind plugin) into dist/, path alias @/* → ./src/*
summary: Three run paths — dev AND production both serve from src/ (bun --hot / bun run start), while bun run build produces a dist/ that nothing serves; NODE_ENV therefore never means "I am a bundle"; path alias @/* → ./src/*
last_checked: 2026-08-16
---
# Build pipeline
- **Dev**: `bun --hot src/index.ts` (through `bun run dev`) — HMR, port 3000.
- **Prod**: `bun run build` → `build.ts` (Bun bundler + Tailwind plugin) → `dist/`.
- **Path alias**: `@/* → ./src/*` (declared in `tsconfig.json`).
## Three paths, and only two of them ever run
| Path | Command | What is served |
|---|---|---|
| **Dev** | `pnpm dev` → `bun --hot src/index.ts` | **`src/`** — HMR, port `PORT` (default 4005) |
| **Production** | `bun run start` → `NODE_ENV=production bun src/index.ts` | **`src/` as well** — Bun transpiles on the fly |
| Bundle | `bun run build` → `build.ts` (Bun bundler + Tailwind plugin) → `dist/` | **nothing** |
> ⚠️ **`dist/` has no consumer, and `NODE_ENV=production` does not mean "built".** The container copies the sources and runs `bun run start`, serving from `src/` exactly as dev does ([[knowledge_deployment]]) — **nothing ever serves `dist/`**, here or anywhere else. So any code that branches on `NODE_ENV` to answer *"am I a bundle?"* is wrong in the one place it matters: in production the answer is **no**. That inference shipped once, on the runtime-config fetch below, and the deployed app could sign nobody in. Ask the artifact you care about, never the environment.
- **Path alias**: `@/* → ./src/*` (declared in `tsconfig.json`, resolved relative to that file — `paths` has needed no `baseUrl` since TS 4.4).
> ⚠️ **Never put `baseUrl` back in `tsconfig.json`.** TypeScript 6 reports it as an **error that aborts the whole compilation**, and the failure is silent where it hurts: `tsc --noEmit` then exits **0 having checked nothing**, so the typecheck gate goes green over any amount of broken code. A green typecheck is only meaningful if `tsc` actually ran — treat an instant, output-free `tsc` as a red flag, not a fast pass.
The server serves `src/index.html`, which loads `src/app/frontend.tsx` (see `app-architecture` §app-shell). The bundler transpiles the TSX and bundles the CSS without any external tool — no Vite/webpack/esbuild (see [[rule_bun-first]]).
@@ -18,14 +29,18 @@ The server serves `src/index.html`, which loads `src/app/frontend.tsx` (see `app
## Build-time globals vs runtime config (the shared wallet pitfall)
`build.ts` injects **compile-time globals** through `define` (e.g. `__FESTIPOD_SHARED_WALLET_PASSWORD__` from `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`, and `__FESTIPOD_AUTO_SEED__` from `FESTIPOD_AUTO_SEED` — the dev auto-seed, OFF when absent). **Pitfall**: the `src/index.ts` server (used by `bun run dev` AND `bun run start`) bundles `index.html` through Bun's HTML import, which **applies no `define`** — neither `bun --define` nor `process.env` propagates there (verified). So an environment variable passed to `bun run dev` never reaches the frontend bundle along that path.
`build.ts` injects **compile-time globals** through `define`: `__FESTIPOD_SHARED_WALLET_PASSWORD__` from `FESTIPOD_SHARED_WALLET_PASSWORD`, and `__FESTIPOD_AUTO_SEED__` from `FESTIPOD_AUTO_SEED` — the dev auto-seed, OFF when absent. **Pitfall**: the `src/index.ts` server (used by `bun run dev` AND `bun run start`) bundles `index.html` through Bun's HTML import, which **applies no `define`** — neither `bun --define` nor `process.env` propagates there (verified). So an environment variable passed to `bun run dev` never reaches the frontend bundle along that path.
For those paths served from `src/`, the configuration therefore goes through the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated. In a `build.ts` bundle the value is already inlined by `define`, so the fetch is skipped (`NODE_ENV === 'production'`). Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear):
Everything served from `src/` — **dev and production alike** — therefore takes its configuration from the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated.
**The fetch is skipped on one condition only: the global is already set** (which is what a `build.ts` bundle's `define` does, and nothing else does). The entry reads it through bracket access, so `define` — which rewrites the dotted form — leaves that read alone. The question is *"was the value inlined?"*, asked of the global itself; it was once asked as *"is `NODE_ENV` production?"*, which in this project means the opposite of what it looks like (see above) — the deployed app then skipped the only step that could give it a wallet, `ensureIdentity()` threw for want of one, and `/festipod-config.json` sat there served and unasked (`app-security` → [[caveat_shared-wallet-global-before-gate-import]]).
Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear):
```
FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \
FESTIPOD_SHARED_WALLET_FILE=./festipod-e2e-tests.ngw \
bun run dev
pnpm dev
```
## The test harness is built separately
@@ -1,32 +1,80 @@
---
type: knowledge
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000, env PORT/NODE_ENV; no CI/CD committed; dev goes through the portless wrapper
last_checked: 2026-07-14
summary: Deployment — the Coolify test stack (app + backend + Redis, shared Fuseki), one base URL with the backend under /api (Strip Prefixes OFF), both images' build traps, first-boot traps, the data-layer git pin, the shared wallet as env config
last_checked: 2026-10-19
---
# Deployment & infra
## Dockerfile
## The Coolify test stack
`docker-compose.coolify.yml` is what Coolify (build pack *Docker Compose*) builds and runs from the git repository, on the test server only for now: `frontend` (the root `Dockerfile`, below), `backend` (`backend/Dockerfile`, Moleculer + `@activitypods/app`) and a per-stack Redis. Fuseki is the one shared with the other ActivityPods apps of the Coolify instance (`shared-infra`): only the backend joins the external `coolify` network to reach it, with a pair of datasets of its own, named as on the sibling apps: explicit `MAIN_DATASET` / `SETTINGS_DATASET` for the main stack (`festipod-test` / `settings-festipod-test`), computed from `DATASET_PREFIX` and the backend's service name when left empty (a preview: `festipod-backend-pr-<n>`). Coolify stores the compose default of those two variables, itself an expression, as their value — it reads as unresolved text in its UI but resolves at deploy time; setting explicit names avoids the confusion. The variables Coolify must carry are listed at the top of the compose file; `SERVICE_FQDN_*` / `SERVICE_NAME_*` are generated by Coolify at deploy time.
**One base URL per environment** (user decision 2026-10-19, so a pull-request preview lives on a single host, like the sibling apps): the app at `https://festipod.dev.reconnexion.coop/`, the backend on the same host under `/api` — its Domains field is `https://<host>/api,https://<host>/.well-known`. Coolify's **Strip Prefixes must be OFF** (it is on the sibling apps): the backend mounts its routes under `/api` itself, read from `SEMAPPS_HOME_URL=https://<host>/api/`, and the front end is pointed at `VITE_BACKEND_URL=https://<host>/api`. With the prefix stripped every backend request is refused (404).
**The backend is base-path aware** (`backend/services/base-path.js`), because SemApps is: it mounts every route it adds under the base URL's path (LDP resources, the app actor and its inbox, public keys, access needs, webhooks, `/nodeinfo/2.1`) but keeps the `/.well-known` ones (`context.jsonld`, `nodeinfo`, `solid`, `void`) at the origin root. Festipod's own routes (`/app`, `/as/actor/app`, `/catalog`) are mounted under the base path the same way, the public-surface allow-list matches paths relative to it (the two `/.well-known` endpoints against the full path), and the Solid-OIDC `client_id` is the requested URI rebuilt from the base URL as written — so it equals, byte for byte, the actor URI the backend mints and the `${backendUrl}/as/actor/app` the front end derives. At the root (`http://localhost:3005`, local dev) every decision is identical to the pre-base-path code. To verify: `bun test backend/services` (with and without `SEMAPPS_HOME_URL=https://<host>/api/`).
**First-boot traps.**
- The backend registers its app actor in its dataset with the domain it has on the FIRST boot: set the domains in Coolify before the first deployment.
- The backend runs with WebACL on, as the reference apps do, so its main dataset must be SECURE: the backend creates it so when it does not exist, and refuses to start ("the main dataset is not secure") on an existing one created otherwise. Starting again from nothing (domains changed, or a dataset created before WebACL was on): delete the two datasets in Fuseki AND the secure dataset's files Fuseki's delete leaves on disk (`databases/<main>`, `<main>Acl`, `<main>Mirror`, `configuration/<main>.ttl`), empty the stack's Redis (its cache keeps serving the old app actor, keys and access needs), redeploy — then every user installs the app again. The exact steps are in the compose file's header. Two more traps, both met on the test server 2026-10-30:
- **A deleted dataset cannot be re-created under the same name in the same Fuseki process**: Jena keeps the closed storage of that location, and every query on the new dataset fails with `ClosedException: already closed` (500), so the backend crashes at boot. Restart Fuseki before re-creating it — on the test server it is the shared one, so every test app loses its triplestore meanwhile — or give the stack new dataset names (`MAIN_DATASET` / `SETTINGS_DATASET`, e.g. `festipod-test-2`).
- A "Model permissions violation: http://semapps.org/securedWebacl" in Fuseki's log at the backend's boot is EXPECTED: semapps probes the WebACL graph anonymously to check the dataset is secure and wants that refusal. The backend must reach Fuseki as the Fuseki user named exactly `admin` (`JENA_USER`), the only one the semapps permissions module grants a secure dataset to (semapps `ShiroEvaluator`, read in source).
- On a cold first boot, `GET <base>app` answered from an in-memory copy of the actor taken before the access-need group got linked, so it lacked `interop:hasAccessNeedGroup` until the backend restarted (the actor itself, `<base>as/actor/app`, was right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`. Since 2026-11-02 the backend refreshes that copy from the store at the end of its boot (`backend/services/app.service.js`). That is verified locally without a Redis cache, not yet on Coolify, whose stack has one: after a first deployment, check that `GET <base>app` links a group before running the matrix.
**Verified 2026-10-19, locally, not yet on Coolify**: both images built from a clean export of the tree, run against the local pod provider behind a same-origin proxy that routes `/api` and `/.well-known` to the backend unstripped with the Host header kept (as Traefik does) — the login flow, the rights matrix (98/98 hard lines) and the revocation probe all pass; the same images at the origin root pass them too.
## Backend image
`node:22-slim`, `pnpm install --frozen-lockfile --prod`, `pnpm start` (tsx over the Moleculer runner). Two things it needs that a local install hides:
- **pnpm 10 skips dependency build scripts unless approved**: `backend/package.json` approves `bcrypt`, `sharp` and `sodium-native` (`pnpm.onlyBuiltDependencies`). Without it the image builds and the container exits at start (`sharp-linux-x64.node` missing; bcrypt would fail next). A local `node_modules` may carry the binaries from an earlier manual build, which is how this stayed invisible.
- **sharp must not depend on GitHub at build time**: its installer downloads prebuilt libvips and binaries from GitHub, and that download timed out on every in-build attempt (2026-10-19) and failed the build. The image installs `libvips-dev` (so sharp detects a system libvips and compiles itself) and sets `npm_config_nodedir=/usr/local` (node-gyp uses the image's own headers). bcrypt still tries GitHub first and falls back to compiling, slower but working offline. Cost: a larger image (a multi-stage build keeping only `libvips42` at runtime would drop several hundred MB — not done).
## App image (root Dockerfile)
A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]):
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/client` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/polyfill` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
- `ENV NODE_ENV=production`, `USER bun`, `EXPOSE 3000/tcp`, `ENTRYPOINT ["bun","run","start"]`.
**`bun` peer pitfall**: `bun-plugin-tailwind` declares `bun` as a peerDependency → pnpm materializes the npm `bun` package and **creates a `node_modules/.bin/bun` shim** that shadows the `bun` from the PATH under `bun run`/`pnpm run`. Its postinstall is ignored by default → broken shim → `bun run start` fails. Fixed by approving the build: `pnpm.onlyBuiltDependencies: ["bun"]` in `package.json` (the postinstall then downloads the real binary). Without that, the whole pnpm migration breaks startup.
**`bun` peer pitfall — historical, and no longer reproducing.** `bun-plugin-tailwind` declares `bun` as a peerDependency, and pnpm used to materialize the npm `bun` package with a `node_modules/.bin/bun` shim that shadowed the real binary under `bun run`; its postinstall being skipped left a broken shim and `bun run start` failed. `pnpm.onlyBuiltDependencies: ["bun"]` in `package.json` was added for that. **VERIFIED 2026-08-17 in a built image: the shim does not appear at all** — `bun` is absent from `node_modules/.bin`, and `which bun` resolves to the base image's `/usr/local/bin/bun`. So the approval is currently inert in this dependency shape. Keep it (it costs nothing and the shape can come back), but do not trust the mechanism as described without re-checking the built image — this paragraph described a live hazard and now describes a dormant one.
**Quirk**: `start` = `NODE_ENV=production bun src/index.ts` → the container **runs the TypeScript source directly** (Bun transpiles on the fly), it **does not use `dist/`**. `bun run build` (→ `dist/`) is therefore **not** on the default production path. Serving the build would require changing the entrypoint.
**Asset paths are written `/../chunk-*.js`.** Verified in the built image's entry HTML. Browsers normalise that to `/chunk-*.js` at the root and it serves correctly, and the existing deployment already passes it through its proxy — so it works. It is still an odd literal: a proxy or CDN that rejects or rewrites `..` segments differently would break asset loading, and the symptom would be a blank page with 404s on chunks rather than anything naming the cause.
## CI/CD
**`tailwindcss` is a devDependency the server needs at serve time, not only at build time.** `bunfig.toml`'s `[serve.static] plugins = ["bun-plugin-tailwind"]` applies to `Bun.serve`'s HTML-import serving — the path both `bun run dev` and `bun run start` use ([[knowledge_build-pipeline]]) — not only to `bun run build.ts`. The install stage must therefore keep installing devDependencies: no `--prod`, and `NODE_ENV` stays unset until the `release` stage, after `pnpm install --frozen-lockfile` has already run. Moving `ENV NODE_ENV=production` earlier, or adding `--prod` to the install, would drop `tailwindcss` and break every serve, dev included.
**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies.
**Production runs the sources, and this is the normal path, not a quirk**: `start` = `NODE_ENV=production bun src/index.ts` → the container **runs the TypeScript directly** (Bun transpiles on the fly). `bun run build` (→ `dist/`) is on **no** path at all — nothing serves that directory, in this container or anywhere else; serving it would mean changing the entrypoint. Consequence for the code: in this deployment `NODE_ENV=production` says *how* the sources run, never *that they were bundled* — [[knowledge_build-pipeline]].
## The data-layer git dependency must stay pinned, and the pin must be checkable
`package.json` resolves `@ng-eventually/polyfill` from `git+https://…/ng-eventually.git#<ref>&path:/packages/polyfill` — the `path:` selector is what lets a subdirectory of the provider's repo be installed as the package. Two things follow, ahead of any real deployment:
- **`<ref>` must name a tag or a commit, never a branch.** A branch moves: the image was built against whatever commit the branch pointed to at build time, and the branch head can advance afterwards without the image changing — so "the same deployment" silently starts drifting from what it was actually built against. The tag-naming convention itself is the provider's call and is not settled yet; the requirement is only that the ref be immutable.
- **The same `<ref>` should also be the `ref:` of the `polyfill-surface` entry in `.project/contracts.yaml`.** That manifest pins the version of [[contract_polyfill-surface]] the app is coded against; when it names the same ref as `package.json`'s specifier, the contract the app was written for and the package actually installed name the same state, and a difference between the two becomes visible instead of silent. Both now name the **same commit**, which is the state a deployment can ship on. A tag is expected to replace that commit once the provider settles a naming convention — a one-line change in each of the two files, with the invariant unchanged: whatever the ref is, the two must agree.
**`pnpm install --frozen-lockfile` (the Dockerfile's install step) never regenerates — it only verifies.** `pnpm-lock.yaml` must already reproduce `package.json` exactly, so any change to the git specifier (ref, path, or package name) needs `pnpm install` run and the regenerated lockfile committed *before* the image can build; skipping that step fails the build outright, not silently. This has bitten once: the lockfile still named the old package and path after the dependency was renamed, so `--frozen-lockfile` refused and the image could not build until it was regenerated.
## The NextGraph-era deployment
**A deployed origin IS embeddable in the hosted broker's iframe — VERIFIED 2026-08-17 in production**, on the first deployment carrying the injected wallet and the external data layer: a user signed in and saw their own data, which is only reachable through that iframe. The question had been open because nothing in this repo exercises it; it is settled for this origin, and it is settled by the deployment rather than by a test — **no scenario covers it**, so a change of origin, of proxy, or of the broker's embedding policy would be found by a person, not by the suite. [[caveat_firefox-lna-blocks-broker-iframe]] remains the one recorded failure mode, and it is a local-dev-origin one (`127.0.0.1` blocked by Firefox LNA).
## Environment variables
- `PORT` (default 3000), `NODE_ENV` (enables/disables HMR and the dev auto-seed — see concept `data-layer`).
- No `.env*` is committed (`.env` is gitignored). No secret management in the repo.
- `PORT` (default 4005), `NODE_ENV` (enables/disables HMR and the dev auto-seed — see concept `data-layer`).
- No `.env*` is committed (`.env` is gitignored).
### The shared wallet: config, not a secret, not a mount
[[contract_polyfill-surface]] requires the app to serve a wallet file (`.ngw`) and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`. `*.ngw` is gitignored and no deployment mounts one, so `src/index.ts` serves it from environment variables, read fresh on every request:
- `FESTIPOD_SHARED_WALLET_PASSWORD` — the password, always read this way (dev, tests, and deployments alike).
- `FESTIPOD_SHARED_WALLET_FILE` — a filesystem path to the `.ngw` file. The form local dev and the test harness use: the file sits on the machine's disk.
- `FESTIPOD_SHARED_WALLET_FILE_BASE64` — the file's bytes, base64-encoded. The form a deployment uses instead, since nothing mounts a `.ngw` into the container.
**Precedence is one-directional and does not fall through.** `FESTIPOD_SHARED_WALLET_FILE` wins whenever it is set, *even if the path turns out unreadable* — an unreadable path answers 404, it does **not** fall back to the base64 form. A deployment must set exactly one of the two; leaving a leftover `FESTIPOD_SHARED_WALLET_FILE` pointing nowhere in a deployment environment silently 404s instead of serving the base64 value that was actually intended. A malformed base64 value answers 500 naming the variable — never a 404, which would be indistinguishable from "not configured at all".
**Neither the password nor the wallet file is a secret**, and that is deliberate, not an oversight: the contract has the app hand both to every user who opens it — that is how a first-time device without its own wallet onboards. Provisioning them as protected/mounted storage would guard something the app already gives away by design; they travel as plain configuration instead, and a new host needs only its environment variables, nothing to mount.
## Dev
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — it goes through the **`portless`** wrapper (an external port-management tool), not a bare `bun --hot`. HMR is active outside production.
`pnpm dev` = **`bun --hot src/index.ts`**, served on the fixed port `PORT` (root `.env`, default 4005) like the sibling ActivityPods apps — no port-management wrapper. HMR is active outside production. The local stack (pod provider, backend, ports) is in [[cookbook_local-activitypods-platform]].
**Reactive local link to the polyfill**: in production the `@ng-eventually/client` dependency comes from Gitea (git+https, pinned by `pnpm-lock.yaml`). To edit the polyfill locally and see the changes live, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`, strategy S2) replaces `node_modules/@ng-eventually/client` with a **real copy** of the local source (`…/ng-eventually-js/packages/client`) — **without** its own `node_modules/@ng-org` — and resyncs `src/` on every edit. That is what guarantees **a single `@ng-org/web` instance** (a single verifier): a symlink to the monorepo checkout would carry its own `@ng-org` → a 2nd instance → broken SDK. To go back to the committed state: `pnpm install`.
**Reactive local overlay for the SDK**: in production the `@ng-eventually/polyfill` dependency comes from Gitea (git+https, pinned by `pnpm-lock.yaml`), and `@ng-helpers/indexing` likewise. When a provider's package has to be exercised from a local checkout, `pnpm run overlay:polyfill` or `pnpm run overlay:indexing` (script `scripts/overlay-local-checkout.ts`, one provider per run) replaces `node_modules/<package>` with a **real copy** of that checkout (location overridable with `NG_EVENTUALLY_LOCAL` / `NG_HELPERS_LOCAL`) — **without** its own `node_modules/*` — and resyncs on every edit. Copying rather than symlinking is what keeps a **single instance** of every package the provider shares with Festipod installed (`@ng-org/*`, and for `indexing`, `@ng-eventually/polyfill` itself): a symlink would drag in a second one and the SDK would stop working. To go back to the committed state: `pnpm install`.
@@ -1,6 +1,6 @@
---
type: knowledge
summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, build:ng for the local fork, link:polyfill for the reactive local link)
summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, overlay:polyfill/overlay:indexing for the reactive local overlay)
---
# Stack & commands
@@ -10,7 +10,7 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React,
| Layer | Technology |
|---|---|
| Runtime / bundler / test | **Bun** (see [[rule_bun-first]]) |
| **Dependency installation** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **only** installation moves to pnpm; runtime/build/test stay on bun. Reason: `@ng-eventually/client` is resolved from Gitea over **git+https** (pnpm handles `git+…#main&path:/packages/client` cleanly, along with deduplication of the `@ng-org` peers). Do not switch installation back to bun/npm. |
| **Dependency installation** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **only** installation moves to pnpm; runtime/build/test stay on bun. Reason: `@ng-eventually/polyfill` is resolved from Gitea over **git+https** (pnpm handles `git+…#main&path:/packages/polyfill` cleanly, along with deduplication of the `@ng-org` peers). Do not switch installation back to bun/npm. |
| UI | **React** (mobile-first, max width 768px — styling covered by concept `app-architecture`) |
| Data | **NextGraph** P2P local-first (concept `data-layer`) |
| CSS build | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — present in the build, but the screens style themselves with `app-*`/inline, no Tailwind utilities (see concept `app-architecture`) |
@@ -21,9 +21,9 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React,
| Script | Command / role |
|---|---|
| `dev` | `portless festipod bun --hot src/index.ts` — dev with HMR through the `portless` wrapper (see [[knowledge_deployment]]) |
| `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`) |
| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
| `dev` | `bun --hot src/index.ts` — dev with HMR on the fixed port `PORT` (root `.env`, default 4005); launched with `pnpm dev`, run by Bun |
| `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`), same `PORT` default (4005) |
| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/`, **which nothing serves**: production runs `start`, from `src/` ([[knowledge_build-pipeline]]) |
| `test:cucumber` | chains `cucumber:run` → `cucumber:report` → `features:parse` → `steps:extract` |
| `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js` — **through Node+tsx, not Bun** (Playwright/happy-dom plugin compatibility), and through the package's **actual JS entry**, not the `.bin/` shim (see Pitfalls) |
| `test:data` | same, with `--tags @data` |
@@ -33,11 +33,11 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React,
| `steps:extract` | `bun scripts/extract-step-definitions.ts` |
| `build:orm` | `rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm` |
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)builds the NextGraph packages from a local source (optional tool) |
| `link:polyfill` | `bun scripts/link-polyfill.ts` — **reactive** local link to the `@ng-eventually/client` polyfill (strategy S2: copy-overlay + watcher), preserving the single `@ng-org` instance. Details in [[knowledge_deployment]]. |
| `overlay:polyfill` / `overlay:indexing` | `bun scripts/overlay-local-checkout.ts <provider>` — **reactive** local overlay of a provider's checkout (`@ng-eventually/polyfill` or `@ng-helpers/indexing`; copy-overlay + watcher, `--once` for a single pass). Details in [[knowledge_deployment]]. |
| `storybook` / `build-storybook` | Storybook dev (6006) / static build |
## Pitfalls
- **`cucumber:run`/`test:data` run under Node+tsx**, not Bun — the test plugins do not load under a native Bun import. Do not "bunify" these scripts.
- **Never point a script at `node_modules/.bin/*`.** Installation goes through pnpm ([[rule_bun-first]] §exception), which puts **shell shims** there rather than JS entries: `node --import tsx/esm node_modules/.bin/cucumber-js` fails. Invoke the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`). This holds for any npm script that would launch a dependency's binary under `node`.
- **`build:orm` was broken until 2026-07-28**: it targeted `./src/shapes/`, which does not exist (the shapes live under `src/shared/shapes/`), so the command exited with an error. **Fixed in `package.json`** — it now runs. Beware of a side effect: the generator has moved on since the committed bindings were produced, so a run reformats them and drops the `: Schema` annotation. That regeneration is a **tool-version bump, not a content fix** — treat it as its own validated change, do not let it ride along.
- **`build:orm` was broken until 2026-07-28**: it targeted `./src/shapes/`, which does not exist (the shapes live under `src/shared/shapes/`), so the command exited with an error. **Fixed in `package.json`** — it now runs, and reproducibly: regenerating from an *unchanged* `.shex` reproduces the committed bindings byte-for-byte. Verify that before trusting an ORM diff as your own — run the generator once on the shape untouched, then again after your edit, so the diff shown is the edit alone (concept `data-layer` → [[knowledge_nextgraph-stack]]).
@@ -1,6 +1,6 @@
---
type: rule
summary: By default use Bun and its native APIs, never the Node equivalents — bun instead of node/ts-node, bun test/build, bunx, and no express/ws/pg/dotenv. EXCEPTION — package installation goes through pnpm (in both repos), not bun install
summary: By default use Bun and its native APIs, never the Node equivalents — bun instead of node/ts-node, bun test/build, bunx, and no express/ws/pg/dotenv. EXCEPTION — package installation goes through pnpm, not bun install
---
# Rule: Bun-first
@@ -28,9 +28,9 @@ API details: [[knowledge_bun-apis]].
## Exception: package installation goes through pnpm
**Dependencies are installed with `pnpm install` — not `bun install` — in BOTH repos** (Festipod *and* the `@ng-eventually/client` polyfill). Everything else stays on Bun: **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Only the installation step changes package manager.
**Dependencies are installed with `pnpm install`, not `bun install`.** Everything else stays on Bun: **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Only the installation step changes package manager.
**Why.** In production the polyfill is installed from a Gitea repository as a **subdirectory** git dependency: `git+https://…/ng-eventually.git#main&path:/packages/client`. pnpm (≥ 10.26) resolves that `#<ref>&path:/…` format and guarantees a **single** instance of `@ng-org/*` (a single verifier); `bun install` does not handle this workflow cleanly. The reference lockfile is therefore `pnpm-lock.yaml`, and the reactive local link to the polyfill goes through `pnpm run link:polyfill` (see [[knowledge_deployment]]).
**Why.** The data SDK is installed from a Gitea repository as a **subdirectory** git dependency: `git+https://…/ng-eventually.git#main&path:/packages/polyfill`. pnpm (≥ 10.26) resolves that `#<ref>&path:/…` format and guarantees a **single** instance of `@ng-org/*`; `bun install` does not handle this workflow cleanly. The reference lockfile is therefore `pnpm-lock.yaml`, and the reactive local overlay goes through `pnpm run overlay:polyfill` (`overlay:indexing` for the other provider; see [[knowledge_deployment]]).
**Practical consequence.** npm scripts that relied on `node_modules/.bin/*` may break (pnpm puts shell shims there, not JS entries) — call the package's actual JS entry (e.g. `node_modules/@cucumber/cucumber/bin/cucumber.js`) rather than the `.bin/` shim.
+74
View File
@@ -0,0 +1,74 @@
# Inter-repo contracts. Festipod publishes no interface of its own — what it lists under
# `publish:` is its own consumer-side DECLARATION, the channel a provider pulls to learn
# what this app calls, what it needs and what blocks it. It consumes three — the SDK
# surface `@ng-eventually/polyfill` engages toward the applications built on it, the
# indexing layer `ng-helpers` engages toward the applications that need to make things
# findable, and the Refine data providers `@activitypods/refine-providers`, which engage
# nothing at all.
#
# The pulled copy under `into:` IS the specification Festipod codes against. An agent
# working here reads that copy and never opens the provider's own source: a gap is raised
# upstream (see `data-layer/rule_app-uses-sdk-surface-only`), never peeked around.
#
# Each interface gets its own FOLDER inside the concept that owns it, holding the pulled
# engagement and — once Festipod actually consumes the interface — the `usage_festipod.md`
# declaration beside it. All three land in `data-layer`: it is the concept that owns
# how Festipod uses an external data surface, including the machinery behind discovery
# (`functional-domain` owns the product intent of discovery and explicitly delegates its
# technical how to the data SDK).
#
# `pullFrom:` names the canonical identity of the provider (its git remote URL + the
# repo-relative path of the leaf), so the manifest travels with the branch. Per-developer
# access to a local checkout lives in `.project/contracts.local.yaml`, which is never
# committed.
publish:
# Both declarations are named `usage_festipod.md`, so the keys are qualified by their
# interface — publish keys must stay unique, and the bare subject no longer says which.
# Listing a declaration here is what lets its provider re-find it with `list --from`
# after it moves; it sends nothing on its own.
polyfill-surface--festipod: concepts/data-layer/polyfill-surface/usage_festipod.md
activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md
consume:
- contract: polyfill-surface
into: concepts/data-layer/polyfill-surface/
type: git
# BLOCKED on the provider: it has moved this leaf into its own interface folder
# (`.../app-contract/polyfill-surface/contract_polyfill-surface.md`) and has NOT pushed
# that move. The path below is the only one that resolves at a pushed commit, and it is
# the path the local copy's stamp came from — so it stays until the move is pushed.
# Until then `pull` and `check` both fail on this entry (the file no longer exists at
# this path in a working copy that has the move). Adopt the new path and re-pull the
# moment the provider pushes; the pulled copy's basename does not change.
pullFrom: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git/.project/concepts/app-contract/contract_polyfill-surface.md
# The contract is published from the branch that carries it while that branch is still
# in flight; it moves to `main` once the provider lands it there. Flip this line then,
# and re-pull — the stamp records which commit the local copy actually came from.
ref: a8d53010c227462cc9317e9be499c2100ca8d533
- contract: indexing-layer
into: concepts/data-layer/indexing-layer/
type: git
pullFrom: https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git/.project/concepts/indexing/indexing-layer/contract_indexing-layer.md
# Pinned on the TAG, never on a branch: a branch moves under us and the pin would stop
# naming a state anyone can go back to. Re-pin to the next tag at each upgrade.
ref: v1.0.1
- contract: activitypods-providers
into: concepts/data-layer/activitypods-providers/
# `@activitypods/refine-providers` publishes NO engagement: no contract document, no
# stated change policy, not even a git tag. So this entry is `type: reconstruct` — the
# one bounded crossing this protocol allows, made once at adoption time: the leaf under
# `into:` is OUR observation of that package, authored here and pinned to the commit it
# was read at. It is not a promise anyone made us, and every upgrade has to re-observe
# it. Once reconstructed, that leaf IS the specification this app codes against.
type: reconstruct
# Canonical identity: the package's git remote plus the repo-relative folder the
# observation was made from. Per-developer access to a checkout of it lives in
# `.project/contracts.local.yaml`, which is never committed.
reconstructFrom: https://github.com/activitypods/refine-providers.git/src
# The branch the observation was read from. A tag would be the better pin, but this
# repository publishes none — the resolver turns this into the commit actually read,
# which is what `built_version` records.
ref: master
@@ -0,0 +1,195 @@
---
subject: Refine as Festipod's provider-swap abstraction layer
created: 2026-10-08
status: open
---
# Refine as the provider-swap abstraction layer
## Where the work lives
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`. On top of the T04 tree close and its adversarial-review round sits the T05 tree (public data, discovery, the end-to-end rights matrix), commits `e21c408` → `88f23b7` plus the commit recording its close — `## T05` and `## Current gate state` below name the states by date and tip.
It is based on **`ng-eventually`** at `c7cda38` — the most advanced branch, and the reason this branch exists. An earlier attempt, **`festipod-refine`** (tip `352d1f1`, worktree `.claude/worktrees/festipod-64`), was built on `main`, which turned out to be far behind; the Refine work was transferred here and that branch is now only an archive. It holds the **only** worked implementation of a `@ng-org/refine-providers` backend (`src/shared/refine/providers/nextgraph.ts`, in its history up to the commit that withdrew it) — the reference for the shape of the next backend.
The main checkout `/home/sylvain/projects/festipod/festipod` is itself on `ng-eventually`. Do not modify it. Note that the concept hook reads it, so it announces leaves that may not exist in whichever worktree you are in.
## The goal
Put all of Festipod's persistence behind Refine so that several **provider sets** can serve the same app. **ActivityPods is the default and the sole priority** — directive of 2026-10-09 (recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`): everything that can goes through `@activitypods/refine-providers`, and a **backend for actions on behalf of a user** is to be added. A Refine provider over NextGraph plus its polyfill remains the declared second target, but its development is **suspended, temporarily** — no work goes into it while ActivityPods is wired.
## Directives of 2026-10-09 (resumption) — and first reconnaissance
Recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`; the essentials and what the reconnaissance (three read-only passes over the user-designated reference repos) established:
- **The on-behalf mechanism is mapped.** An app backend IS an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer). It signs pod requests with HTTP signatures as the app actor; the pod provider checks the app's registration and grants, then impersonates the user (`pod-provider/backend/middlewares/app-control.js`). Services call `pod-outbox.post`, `pod-resources.*`, `pod-collections.*`, `pod-wac-groups.*`, `pod-permissions.*` with an `actorUri`. Frontend calls use the Solid-OIDC dual-identity token instead (`webId` = app client_id, `impersonatedUser` = user). Reference implementations: `welcometomyplace/backend/services/registration.service.js`, `attendees-matcher.service.js`; both example apps run the same stack (`@activitypods/app` 2.3 + `@semapps/*` 1.2, Moleculer backend + Vite/React/Refine frontend, Docker-deployed).
- **The refine-providers package is NOT in the platform monorepo** — it lives at `/home/sylvain/projects/activitypods/refine-providers` (a `/home/sylvain/projects/nextgraph/activitypodes-refine-providers` checkout also exists; the pinned reconstruction remains the work-time specification).
- **Shapes**: 15 shape trees published at `https://shapes.activitypods.org` from `/home/sylvain/projects/activitypods/shapes` (repo evolvable by us). The 2026-10-09 directives **amended the first mapping proposal — minimise new shapes, interoperability first**: a MeetingPoint is a plain `as/Event` carrying an `as:context` link to its parent event (event within an event; the `as/Event` shape is open — no `sh:closed` — so nothing breaks and no new shape is needed). For Participation the existing vocabulary was surveyed: see the registration precedent in the next bullet.
- **Registration/Participation — production precedent found.** welcometomyplace persists no registration resource: join = `as:Join` activity in the user's outbox, handled by the backend service which adds the actor to the event's `apods:attendees` collection (`PodCollectionsHandlerMixin`, `attachPredicate`); leave = `as:Leave`; reads via `pod-collections.getItems`. The one open design question this raises — collection creator-side (this pattern) vs participant-owned participation documents (the 2026-10-03 structure) — is recorded in the brief, to arbitrate before implementation.
- **No unattended per-user backend token exists** on the platform: server-side per-user actions ride the app-actor signature + grants; the platform-idiomatic channel for user-facing reactions is the app outbox → user inbox (notifications style).
## What exists, and what deliberately does not
In `src/shared/refine/`:
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects, tested over the case space.
- `providers/types.ts` — the contract a backend must satisfy (`RefineBackend`, `RecordBinding`), each field derived from a measured encounter rather than anticipated.
- `providers/activitypods.ts` (+ test) — the ActivityPods backend: the three providers of `@activitypods/refine-providers` and the bindings of the four entities (events, meeting points, participations, user profiles); events and meeting points are created addressed `to: as:Public`.
- `providers/index.ts` — the registry: ActivityPods is the one registered backend, hence the default; the NextGraph+polyfill backend would be the second when it resumes.
- `RefineProvider.tsx` — mounts `<Refine>` over the backend the URL named.
**Wired since 2026-10-09 (T04).** `FestipodDataContext` runs over Refine with the ActivityPods backend (`src/shared/context/refineData.tsx`). Beside Refine, by design: joins and leaves posted to the user's outbox (`src/shared/data/outbox.ts`); discovery through the Festipod backend catalog and anonymous reads of other users' records and attendee collections (`src/shared/data/catalog.ts`, `publicRead.ts`, `attendees.ts`); sign-in, consent and the app-status probe (`src/app/auth/`). The Festipod backend (`backend/`, Moleculer + `@activitypods/app`) acts on users' pods as the app actor: attendee collections, public-read grants, the catalog, and the allow-listed public surface.
In `.project/`:
- `concepts/data-layer/activitypods-providers/contract_activitypods-providers.md` — a **reconstruction**, not a pull: that package publishes no engagement, so this is our own observation, pinned (`built_version: 66616484b9…`). It is the specification to code against.
- `concepts/data-layer/activitypods-providers/usage_festipod.md` — our declaration toward them. Its Frictions/Needs are the channel to the provider.
- `concepts/data-layer/brief_2026-10-03_structure-for-two-rights-models.md` — the data-shape design, below.
- `concepts/data-layer/brief_2026-10-09_activitypods-first-backend.md` — the 2026-10-09 directives (ActivityPods priority, on-behalf backend, NextGraph suspended) and the reference material; the brief this hand-off's resumption added.
- `handoff/refine-provider-abstraction.md` — this document.
- `contracts.yaml` — `publish:` (both declarations, keys qualified by interface) and three `consume:` entries (polyfill-surface, indexing-layer, activitypods-providers).
- `contracts.local.yaml` — gitignored, per-worktree. Must exist for `contracts.py check` to resolve the ActivityPods entry.
## Decisions, and why
**One app variant per provider set, selected by a URL path prefix.** The default at the root, another behind a prefix naming it; switching is a plain link and costs a full page reload. The reload is the mechanism, not a cost: it discards the previous backend's Refine cache and session with no code to undo them, and losing access to the left backend's data is accepted. An earlier plan for in-session hot switching was dropped. The deep reason: **the sign-in experience is not abstractable** — the NextGraph SDK mounts its own full-screen barrier and owns the whole wallet experience (`src/app/AuthGate.tsx` renders no access screen), while a Solid backend hands the app an OIDC flow it must render itself (Pod-provider picker, callback route). One confiscates the screen, the other delegates a flow.
The prefix **is** the backend's name, so a backend must never be named after a top-level route (`events`, `profile`, `home`, `settings`, `users`).
**The router does not yet strip the prefix, deliberately.** `parsePath`/`navigate` in `src/app/router.tsx` are a shared primitive every screen depends on; the suites that would prove a change there are not run in this worktree; and while one backend is registered no URL carries a prefix, so nothing is broken. That half lands with the second backend, when it becomes testable.
**The data shape: references, not embedded content.** Three levels — Event (own document, public, written by its creator, carrying an attached list of *references*); Participation (own document, owned by **the participant**, carrying `event`, `active`, `participant`); Profile (own document, protected). Three facts force it: the participation must be writable by its participant and nobody else, and NextGraph grants write per document; per-reader variation is impossible inside one document where encryption is per document, so it is obtained by **indirection** (the reference is always present, the ability to *follow* it varies); and `rule_document-per-entity` already says this for that reason.
The non-visibility mechanism differs and both land on the same application state — *a reference one may or may not resolve*. On NextGraph the target is **undecipherable** for want of a key (nothing is absent). On ActivityPods it is refused by WAC, or omitted by server-side projection. So the domain type models the NextGraph shape, where everything is present: `active` and `participant` are **not** optional; only resolution fails. (Proposing them optional was an error — it lifted an ActivityPods adaptation into the common model.)
**No stored counter, ever.** The participant count is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document.
**NextGraph is the common denominator.** Its model is the strict subset: no WAC groups, no revocation, sharing wholesale per document, but writes work offline. Structure for it and ActivityPods serves the structure, more cheaply even. The converse fails: anything needing a server projection or a revocation is untenable on NextGraph.
**The Refine boundary rule.** Refine's `DataProvider` is flat CRUD (`getList`, `getMany?`, `getOne`, `create`, `createMany?`, `update`, `updateMany?`, `deleteOne`, `deleteMany?`, `getApiUrl`, `custom?`) — **no relation or join concept at all**, and no `getManyReference`. Everything that makes Festipod social (append to someone else's collection, grant/revoke read, discovery, offline queueing) is outside its vocabulary. Since cache, live subscription and optimistic update are bought **per resource**, the rule is: *anything the app displays and that changes must be a Refine resource; anything one-shot and invisible may live in the seam.* A write passed beside Refine does not invalidate its cache, so the list would not refresh.
Refine does support **several data providers at once** (`Resource.dataProviderName`, `useList({dataProviderName})`). Deliberately unused for switching, but it is the tool for reading one entity from another backend (discovery from a shared index) or for migration.
**The seam should shrink.** `types.ts` was written from measurements against one raw provider that could not be changed. Since we can contribute to the providers, several of its fields are symptoms rather than requirements: `updateReplaces` + `toPatch` should vanish (a provider exposes one semantic), and `recordKey` vs `domainId` should collapse to `id` (ActivityPods already does). `toDomain`/`toValues` shrink to vocabulary mapping; `visibility` and `resource` stay — visibility is a product declaration, not a transport fact. **The measure of success is how small `RecordBinding` gets.** Capability differences belong inside the provider, never in the app.
## What is known about `@activitypods/refine-providers`
The reconstruction is the specification. Its source was read **once, deliberately, at adoption time** (that is what a reconstruction is); it is not to be read again at work time — a gap goes into the declaration's Frictions/Needs. Checkouts, if ever needed again: the package at `/home/sylvain/projects/nextgraph/activitypodes-refine-providers`, the platform at `/home/sylvain/projects/activitypods/activitypods`.
- A record's `id` is the LDP resource URI, and it is the **only** identifier — `getOne`/`update`/`deleteOne` use it directly as the URL. The seam's two identifiers collapse into one here.
- `update` fetches the current resource, strips `id`/`@context`, and PUTs `{...current, ...variables}` — so **a partial write is safe**, the merge being **shallow**.
- `getList` resolves the resource's container from the **session's own WebID** (public Solid TypeIndex, best-effort private), so it returns only what the current user holds. `getOne`/`getMany` fetch a URI directly and so **do** cross Pods — they just need the URI.
- No server-side filtering, sorting or pagination: the whole container is fetched, then filtered in memory.
- **`getMany` silently drops failed fetches** (`allSettled` then filter) — it erases exactly the "unreadable reference" state the model needs.
- Module-level caches (shape trees, type registrations per WebID, subscription endpoints) are **never invalidated**.
- `authProvider` installs a `setInterval` and a `visibilitychange` listener at construction with **no teardown**; the only opt-out is `appStatusCheckInterval: false`.
- Live updates are real push (`notify:WebSocketChannel2023`), but a **list** subscription reports membership only (`Add`/`Remove`), never a member's field change.
- `types` takes precedence over `shapeTreeUri` when both are set — the **opposite** of what the configuration type documents.
- `custom` is not implemented, so there is **no escape hatch through Refine** for non-CRUD operations.
- Collections are POSTable LDP resources (`/as/collection`) with `semapps:dereferenceItems` — *"all items will be dereferenced (if permissions allow)"*. That is how `event.participations` can come back as full objects for a reader who is allowed, and as URIs otherwise, in one read. Items are added/removed by SparqlPatch on `as:items`.
- An app declares `accessNeeds` (`shapeTreeUri` + `accessMode`) plus special needs: `apods:CreateCollection`, `apods:CreateWacGroup`, `apods:PostOutbox`, `apods:QuerySparqlEndpoint`, `apods:UpdateWebId`, inbox/outbox reads.
- **Backend-less apps are supported**: `onlineBackend` is false only when the app description document at the `clientId` URI is not fetchable. A static file suffices.
- A Festipod **class** with its own shape tree needs `fp:` registered at prefix.cc (the pod registers the ontology of every class it creates a data registration for, and refuses an unknown prefix); shape trees must sit at a public URL, one per entity, and **cannot reference other shapes** (no sharing cascade). A Festipod **predicate** on an existing resource does not: verified 2026-10-29 on a profile with the owner's token (app-token path to confirm live).
**Three contributions this points to** (we may contribute to these providers): `getMany` must signal the unreadable instead of swallowing it; collection support as a resource with `dereferenceItems`; and a teardown for the auth provider's timer.
## Participation privacy and deduplication — settled
Settled 2026-10-29: `.project/concepts/functional-domain/decision_2026-10-29_participation-privacy-mechanism.md` — a private Join handled by the backend on emission, attendee lists of per-(person, event) member URNs (distinct-person counts, no identity), recognition by contacts only, a contacts-readable participations collection per person.
## Problems hit, and how they turned out
- **Two false claims were written into durable records and corrected.** First, that the participant count needs an inbox and that a CRUD-only backend therefore could not carry the model — wrong: every participation is readable by everyone, only the link to a person is protected, so counting is ordinary CRUD. Second, a declared "need" for a WebID filter on `getList` — Festipod does not need it: `getOne`/`getMany` already cross Pods, the participations arrive with the event, and discovery is an **index** problem (already decided on this base: `decision_2026-08-17_discovery-through-a-shared-index`), not a provider one. Both corrections are in the files.
- **`__FESTIPOD_BACKEND__` was a lie in a comment** claiming `build.ts` defined it. Nothing did, and nothing could: this project serves from `src/` in production too (`bun run start`), so `build.ts`'s `define` block never reaches what runs (`tech-stack`, `knowledge_build-pipeline`). The runtime mechanism this base already uses instead is a config route — `/festipod-config.json` served by `src/index.ts`, read by `src/app/frontend.tsx` *before* it imports `App`. Replaced by the URL read.
- **Three separate times the answer was already on `ng-eventually`** (the runtime config route, the delegation of wallet access to the SDK, the dedup implementation). That is what triggered rebasing onto it.
- **The migration was delegated to a subagent** and verified independently afterwards: the five transferred files are byte-identical, the manifest merge preserved both pre-existing entries, and the gates were re-run by hand. Its one worry — that a missing local `node_modules` had made the baseline incomparable — was **unfounded** (pnpm had installed the full tree locally; 22 entries against the main checkout's 21). Its debt block was better than the specification it was given: it caught the `__FESTIPOD_BACKEND__` consequence and two branch-relative phrasings.
- **Moving base falsified three claims in the transferred code**, all corrected: the inbox the count comment points at is local here; the worked provider implementation is not in this branch's ancestry; and the `MeetingPoint` SHEX shape **does** exist here, so its binding stopped being optional (a backend that cannot carry one of the four entities cannot serve the app).
## Current gate state (2026-10-13, T05 closed, tip = the commit recording this state)
The gate, every command run from the worktree root, the live ones against the local stack (pod provider :3000, pod frontend :5000, Festipod backend :3003, the app served on :4000 — the only registered OIDC redirect origin; bring-up: `data-layer`, `cookbook_local-activitypods-platform`):
- `bunx tsc --noEmit` — exit 0, no output
- `bun test` — 395 pass / 0 fail (33 files)
- the UI layer, `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json` — 125 scenarios (71 skipped, 54 passed), 0 failed (the 71 are `Scénario non implémenté` placeholders, skipped by design); restore `reports/` afterwards with `git checkout -- reports/`
- the smoke, same command with `--config cucumber.smoke.json` — 5 scenarios (5 passed), 22 steps
- `bun run test:e2e-flow` — the banked login flow, fresh user (picker → pod login → consent → install verified → event → meeting point) — `"ok": true` (install asserted: the user's `as/event` container answers 200)
- `bun run test:e2e-rights` — the rights matrix: three fresh users plus anonymous, positive AND negative lines with live controls (backend public surface, public reads, refused writes, profile privacy, catalog and public intent, discovery, join/leave and counters, isolation, deletion, visibility toggles); exit 0 only when every hard line holds — 98 hard lines, 98 PASS / 0 FAIL, plus KNOWN-DEVIATION 1, KNOWN-DEFECT 1, MANUAL-CHECK 3, INFO 7 — identical on three consecutive runs with fresh users (2026-10-13)
- `bun run test:e2e-revocation` — revoking the app on the pod logs the user out to the picker; signing in again goes through consent — exit 0 (positive, negative and recovered phases `ok: true`)
- `bun run validate` — runs the UI layer, the smoke, and the two e2e probes above as stack-gated layers: the stack is probed first; down → both SKIPPED, loudly, never counted as passed; up → a non-zero exit fails the layer. The smoke layer never skips (an unreachable stack fails it), so a fully-down stack still fails `validate` — all four layers passed (2026-10-13); with `APP_URL` pointed at a dead port, the UI layer and the smoke pass and the two probes are reported SKIPPED, the unreachable URL named
The three e2e scripts share one sign-in drive, `driveLogin` in `scripts/e2e-rights-matrix-support.ts`: a state machine over observed hops (the browser must leave the app for the pod, Accept is clicked once stable and must land, the shell counts only with the bottom navigation shown and no picker); the login-flow probe and the matrix also assert the install after it.
Earlier state (2026-10-10, the T04 adversarial-review round), verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean.
The round: three adversarial passes (auth & security, data seam & backend, gates & ops); every confirmed finding was fixed in the commit recording this state. Fix themes — auth: `redirect` param validated (same-origin leading-slash only), callback re-entry guarded against StrictMode double-exchange, stale `?code=` URLs degrade to the picker instead of the fatal screen, `register_app` without a session falls back to the picker (was a permanent blank screen), ActivityPods logout wired through the provider, deep links survive login, clientId trailing slash normalized. Data fidelity: the meeting point's host is read from the Pod-stamped `dc:creator` (the `organizer` term is absent from the platform context and never round-tripped, so the Join lost its addressee), `location`/`city` are written as inline named nodes (`as:location` is `@type: @id`; a free-text string resolved into a garbage IRI), record ids are URL-encoded (absolute IRIs broke the router's `/events/:id` parse), participant resolution is keyed by actor URI (profiles were stored under their document URI and never matched), one delayed re-invalidation reconciles the count after join/leave, a Join/Leave without addressee throws instead of posting to no one, anonymous placeholder rows render on the event detail screen. Backend & ops: the attendees collection gets a public `acl:Read` grant in `after.createAndAttach` (the count is readable by everyone; anonymity lives in the profiles' own WAC), Join/Leave handlers retry while the collection is still attaching, `tsx` promoted to dependencies so the `--prod` image starts, compose `env_file` made optional with fuseki/redis services added and the frontend published on :4000 (the SEMAPPS_FRONT_URL default — fixes the shipped redirect_uri mismatch; :5000 belongs to the pod provider frontend and is deliberately avoided), `test:data` removed (zero matching scenarios since the suspension — a vacuous green), `strict: true` on both cucumber configs.
`contracts.py check` also reports, pre-existing: `polyfill-surface: provider moved: a33fb8a214 → a8d53010c2` — the pulled copy is behind the pinned ref.
## T05 — public data, discovery, rights matrix (closed 2026-10-13)
What was missing for Festipod's premise — discover other users' public events and sign up to their meeting points — and what closed it, by commit:
- **Public read (events, meeting points, attendee lists)** — `a0d2cb8`: a record is world-readable only when Festipod marks it public, i.e. creates it addressed `to: as:Public` (events and meeting points both); `bto`/`bcc`/`audience` never count. The grant on the attendee collection follows. An event POSTed with `apods:attendees` pre-attached no longer crashes the backend.
- **Discovery in the app (catalog read path)** — `e21c408`: Découvrir lists the user's own events plus the backend catalog's, de-duplicated, own first; meeting points filtered out.
- **Opening another user's event** — `fbb1f6f`, then `6bcf403`: detail, meeting points, per-point counts (event = sum), join; foreign reads are anonymous direct reads by URI (the session token only goes to the session's own origin); a join or leave stays on screen until a read issued after it confirms it.
- **Catalog persistence** — `f32e166`: the index survives a backend restart (backend `settings` dataset, default graph); an owner's deletion de-indexes.
- **Catalog hardening** — `f14cc37`: only LISTED-public records are listed (`to: as:Public`; `cc`-only = unlisted: readable, never listed); only the owner's own outbox can change an entry.
- **Revocation detection** — `4fc856f`, `d4fcb7e`: an app-status probe on load, focus and visibility, at most once a minute; `installed: false` → logout + picker notice (never `registerApp`); `upgradeNeeded: true` → consent.
- **Backend trust surface** — `88f23b7`: an allow-list in front of every backend route (9 public endpoints, everything else 404 for every caller — `/sparql` and `/key` included); every decision fed by a pod notification is taken on the activity and object re-read from the pod, serialized per object; public read and catalog listing follow the owner's visibility changes both ways, attendee collections included.
- **Rights matrix** — `d38f8a7`, then extended with the public-surface and visibility groups at the close: `bun run test:e2e-rights` (see `## Current gate state`).
**User decisions recorded during T05:**
- **Attendee lists stay world-readable for now** (2026-10-12) — an explicit deviation from the domain doctrine, where participation is in the protected scope (`functional-domain`, `knowledge_data-scopes-and-discovery`): anonymous readers of a public meeting point's attendee collection see its members' WebIDs, which embed usernames. The matrix tracks it as a KNOWN-DEVIATION line (printed, non-fatal, flags loudly the day it changes). Moving participation to the protected scope is a later task.
- **A revoked app logs the user out** (2026-10-12): picker with a notice, never sent back to consent.
- **Tests never break the dev environment** (user rule 2026-10-30, concept `bdd-testing`): attacks, forged or spoofed activities included, are allowed between the throwaway accounts a run creates and deletes.
- **Trusted local network** (2026-10-13): no key rotation after the exposure below; it is recorded as a risk. Until `88f23b7` the Festipod backend served, anonymously and on all interfaces (reached from the LAN), an open `/sparql` over its `settings` dataset — the app actor's private key, the pod provider's password hashes and emails (the dataset is shared with the pod provider locally), webhook URLs, catalog snapshots — and the private key again at `/key` through the LDP catch-all. Fuseki still listens on all interfaces with `admin/admin`.
**MANUAL checks** — kept out of the automated matrix because they need a crafted request; each is covered by backend unit tests and owes one manual run by a human: (1) a Delete posted to a user's OWN outbox that embeds a Tombstone of ANOTHER user's event must not de-index it (`catalog.service` ownership, `emitted-activity` re-read); (2) a forged pod notification (webhook) must not publish a private event, grant or revoke a read, or list/de-list anything; (3) a crafted delivery to `POST /as/actor/app/inbox` or `/.webhooks/<id>`, and any write outside them, must change nothing (`public-surface`, `emitted-activity`).
**Platform defects to report upstream** (pre-existing, platform-side, never patched here):
- Anonymous `PATCH` on an attendee collection is accepted (204) although its only public rule is `acl:Read` — an `as:items` insert LANDS (attendee forgery; arbitrary predicates are dropped). Tracked by the matrix's KNOWN-DEFECT line.
- `pod-permissions.remove` removes nothing for a public read: it compares the full `foaf:Agent` IRI to the compacted form the pod stores, and would PUT an empty rule set the pod refuses (Festipod revokes through its own ACL rewrite instead).
- The collections mixin crashes the backend on a record POSTed with `apods:attendees` already set (`getGroupSlugFromCollectionUri(undefined)` → `new URL(undefined)`).
- The webhook listener does not await its handlers: any handler throw, or a webhook arriving during backend startup, kills the backend process.
- The delete matcher compares `formerType` `as:Event` to the compacted `Event`, so `onDelete` never fires for events (it would call `onUpdate`).
- The refine-providers live provider's `subscribe` throws an uncaught `Forbidden` when the pod refuses its push channel (`POST /.notifications/WebSocketChannel2023` → 403), one per refusal.
**Known limitations** (T05.i, accepted): (a) an attendance add or remove already sent when the 30 s serialization limit hits still lands, unrecorded — if a newer act of the same participant was applied meanwhile, the collection shows the older act's effect until her next act; (b) a public-read or catalog write that lands late leaves a wrong state until its single re-decision runs, and that re-decision is not itself followed up; (c) a share the owner adds between Festipod's ACL read and its PATCH is recorded as Festipod's (the pod has no conditional write); (d) a timeout between the ledger record and the PATCH leaves a record without a grant; (e) grants made before the ledger existed are never revoked.
**Open follow-ups:**
- A person who joined two meeting points of one event is counted twice in the event's total (sum semantics).
- Webfinger and the app actor's ActivityPub collections now answer 404 (not on the allow-list): add them only if a pod or remote server turns out to need them.
- No backfill: pre-ledger grants (development data) are never revoked, events made private before `88f23b7` keep a public attendee collection until their next update, and a record created while the backend was not receiving notifications is never listed.
- Also noted by the children: an owner's card ignores other users' meeting points until opened; other users' joins show only after a reload; does leaving revoke the organizer's read on the participant's profile (the join grants it); provider calls have no timeout; a hanging `provider.logout` leaves the connected view until reload.
## Open points
- Adding `## Needs` to the polyfill declaration is where the per-person discriminator belongs. It is **outward-facing** (the provider reads it on its next pull), so it awaits the user's go.
- `acl:Append` behaviour, as above.
- The router's prefix stripping, with the second backend.
- The three provider contributions.
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor was seen without its `interop:hasAccessNeedGroup` link (cause not established, possibly the stale `GET /app` snapshot fixed 2026-11-02) — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
- Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path.
- Driving `getAppStatus()`: DONE (`4fc856f`, `d4fcb7e`). Festipod relies on its `installed` member, which the reconstruction does not pin — recorded in the activitypods-providers usage declaration (constraint 9, need 3), together with its reliance on `create` sending `to` unchanged (constraint 10).
- The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision).
- The attendees collection of a public meeting point is world-readable — an explicit deviation from the protected participation scope, decided 2026-10-12 (see `## T05`); moving participation to the protected scope is a later task.
- The dedup defi (same event + same place + same time) is confirmed absent across the seam; the 2026-10-03 brief marks it an open challenge — a product call, not a regression.
- Discovery through the backend catalog: DONE — read path `e21c408`, persistence `f32e166`, hardening `f14cc37` (see `## T05`).
- `src/modules/home/**` screens still build raw `/events/${id}` paths; `parsePath` tolerates them, but they should adopt `eventPath`/`userPath` next time they are touched.
- The dockerized path is validated by `docker compose config` only — the backend image has never been built or started by the gates; one manual operator run is owed.
## Standing constraints
- A pulled or reconstructed contract **is** the specification. Never read a provider's source at work time; a gap goes into the declaration's Frictions/Needs and is signalled out of band (content lives in the document). Never describe in this repo how the data layer is implemented.
- **No attribution to Claude** anywhere in produced artifacts — commits, PRs, docs, comments. This overrides harness defaults.
- Project documentation in **English**, never hard-wrapped (one line per paragraph or list item). Conversation with the user in **French**.
- **No effort or timeline estimates**, anywhere.
- Package installation goes through **pnpm**, never `bun install`; Bun-first otherwise.
- Project facts are documented through `/concept document`, not written freehand into `.project/`.
- Commit freely on a branch; push at validated states without being asked (Gitea SSH needs the sandbox disabled).
- Never bare `git stash` — the stack is shared across worktrees and sessions.
- `pre-push` blocks while any `_debt.md` exists.
+7
View File
@@ -0,0 +1,7 @@
# Rules (local)
Each rule below shows its one-line summary. **Read the full file** when a rule looks relevant — it carries the *why* and *how to apply* needed to use the rule with judgment.
## activitypods
- **festipod-only-reference-app-patterns** — N'écrire sur le pod ActivityPods que par des patterns des apps de référence du dossier ~/projects/activitypods/, sinon exception justifiée. → `.project/rules/activitypods/festipod-only-reference-app-patterns.md`
@@ -0,0 +1,15 @@
---
name: festipod-only-reference-app-patterns
summary: N'écrire sur le pod ActivityPods que par des patterns des apps de référence du dossier ~/projects/activitypods/, sinon exception justifiée.
group: activitypods
scope: local
created: 2026-10-28
---
# Ne faire sur le pod que ce que font les apps de référence
**Rule:** Dans Festipod, n'utiliser pour interagir avec la plateforme ActivityPods que des patterns déjà présents dans les applications de référence du dossier `~/projects/activitypods/`, sauf si aucune autre solution n'existe — auquel cas l'exception doit être nommée et justifiée explicitement avant de coder.
**Why:** La validation live du 2026-10-28 a montré que tout chemin de plateforme non exercé par une app de référence est un terrain miné : le provisioning holder-side navigateur (création de conteneur dans `as/collection` + PATCH `/_acl` avec un token de session Solid-OIDC `azp`) a révélé deux bugs fournisseurs (500 après création effective du conteneur ; hang sans écriture dans `webacl.resource.addRights`), alors que les chemins utilisés par les apps de référence fonctionnent en production. Aucune app de référence ne provisionne d'infrastructure depuis le frontend : toute écriture pod hors données (conteneur, ACL, groupe, collection) se fait **backend-side, signée app-actor** via `signature.proxy.query`.
**How to apply:** Références canoniques (2026-10-28, les mieux maintenues, ajoutées au dossier) : **`welcometomyplace`, `mutual-aid.app`, `portejunes`** — en priorité welcometomyplace, seule à exercer les patterns sociaux avancés. Secondaires : `mastopod` (access needs simples), `refine-providers/examples/antd-app` (frontend), et les services du app-framework (`activitypods/app-framework/app`). Avant toute écriture pod, vérifier le pattern dans ces sources ; s'il est absent des trois canoniques, ne pas coder : chercher une composition de patterns existants, et à défaut demander la décision utilisateur en nommant l'exception. Patterns welcometomyplace que Festipod réutilise : traiter une activité sur `onEmit` de l'outbox de son émetteur (catalogue, contacts, listes de participants) ; exception nommée (2026-11-03, sur avis du développeur d'ActivityPods, `decision_2026-11-03_participation-delivered-to-the-app`) : un Join/Leave de participation est adressé à l'acteur de l'app et traité à sa réception dans l'inbox de l'app (`onReceive` de l'`ActivitiesHandlerMixin` de semapps, le chemin du framework pour l'installation d'une app, `registration.service.js`) ; collection lisible par un groupe = `pod-permissions.add({agentPredicate: 'acl:agentGroup', mode: 'acl:Read'})` sur une collection `pod-collections` (`attendees.service.js`), le groupe `/contacts` d'un utilisateur trouvé par `pod-wac-groups.getUriFromCollectionUri({collectionUri: apods:contacts})` — jamais un droit par élément de la liste `apods:contacts`, falsifiable par un `Accept` forgé ; notifier = `pod-notifications.send`, qui exige un backend en WebACL activé sur un dataset sécurisé, comme les trois apps de référence. Ne JAMAIS reprendre leur `addMember` de l'organisateur dans le groupe `/contacts` du participant : c'est un choix produit de welcometomyplace (événements privés), une fuite d'identité sous la règle de Festipod (`behavior_participation-listing-is-contacts-only`). Couvre l'usage de la plateforme, pas les choix produit propres à Festipod.
+3 -2
View File
@@ -1,4 +1,5 @@
# To discuss
- [ ] revoir l'implémentation des ReadCap et WriteCap, aligner avec NextGraph et vérifier que le polyfill enforce bien la logique de droits d'accès en attendant que cela soit implémenté
- [ ] clarifier la terminologie identité NextGraph (wallet = liste de clés ; user = données + username ; profils = identités contenues dans le user) et réconcilier avec decision_2026-07-06 (« identifiant = wallet »), decision_2026-07-20 (« username dans le profil ») et le modèle principal/identity du polyfill
- [ ] clarifier le vocabulaire d'identité employé par Festipod (wallet, utilisateur, profil, username) et réconcilier decision_2026-07-06 (« identifiant = wallet ») et decision_2026-07-20 (« username dans le profil ») avec ce que le contrat publie aujourd'hui : `ensureIdentity()` ne prend aucun identifiant, et `username` ne désigne plus que `UserProfile.username`
- [ ] déclarer (usage_festipod) et faire confirmer par le fournisseur l'usage de `fetchJson` et `resolveContainerUri` (`@activitypods/refine-providers/utils`) pour l'upload d'images : utilisés par `useUploadImage` mais absents du contrat épinglé ; et le moyen de créer/lier un `as:Profile` quand le Pod n'en expose aucun (aujourd'hui : message seulement)
- [ ] afficher « en attente » dans l'écran des contacts les personnes à qui l'utilisateur a envoyé une demande non encore acceptée (elles voient déjà son profil et ses participations : le groupe /contacts les inclut dès l'envoi) — source possible : les `Offer` sans réponse de son outbox, ou une collection des demandes envoyées à demander en amont
+12 -8
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
@@ -12,17 +12,17 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu
src/screens/index.ts # Registre d'écrans (utilisé par Storybook)
```
- **Un module n'importe QUE depuis `shared/` — jamais d'un autre module.** C'est l'invariant qui rend l'archi réelle.
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (port 3000).
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `pnpm dev` (port fixe, `PORT` du `.env` racine, défaut 4005 ; même défaut pour `pnpm start`).
## Frontière SDK NextGraph
## Frontière SDK
Le SDK de données de Festipod est **`@ng-eventually/client`** — traité comme un **SDK NextGraph fini et mature** (documents par entité placés par scope public/protected/private, capabilities, inboxes). Il est injecté une seule fois via `ngSession.configure(...)`.
Le SDK de données de Festipod est **`@ng-eventually/polyfill`**, injecté une seule fois via `ngSession.configure(...)`.
**`@ng-eventually/client` est un POLYFILL**, et ce mot dit toute sa mission : **compenser l'écart** entre le SDK **tel qu'il devrait être** et ce que NextGraph fournit **aujourd'hui**. Le wallet virtuel (plusieurs identités sur un wallet physique) en est la plus grosse pièce, pas la totalité.
**L'engagement que le fournisseur publie est tiré dans ce repo et épinglé** : `data-layer`, fiche `contract_polyfill-surface`. **C'est la seule référence.** On n'ouvre jamais les sources du fournisseur ni sa copie dans `node_modules`, pas même pour vérifier une signature. Ce que le contrat ne dit pas, ce repo ne le sait pas : **un manque est remonté au fournisseur**, jamais contourné ici ni documenté ici. Vaut aussi pour les tests, qui valident **Festipod** et jamais le SDK (`bdd-testing`, `rule_tests-validate-festipod-not-the-sdk`).
**L'app ignore ENTIÈREMENT l'état d'implémentation de NextGraph.** Elle est codée contre le SDK cible, dont le contrat est écrit **dans ce repo** (concept `data-layer`, fiche `knowledge_sdk-surface`). Aucun code ni commentaire du type « on fait X parce que NextGraph fait Y aujourd'hui ». Quand quelque chose ne marche pas, la question n'est jamais « comment contourner dans l'app » mais **« qu'est-ce que le polyfill doit compenser »**.
Le contrat se re-tire à chaque montée de version : `python3 ~/projects/skills/concept/contracts.py pull` (dérive : `… check`). Sa surface **rétrécit** — un symbole retiré est du code que l'app supprime, pas une régression à amortir.
**Ne jamais documenter dans ce repo l'état courant de NextGraph** (contraintes du SDK sous-jacent, contournements, internes broker/verifier) : cela vit dans le repo `@ng-eventually/client`. La doctrine Festipod décrit uniquement *le contrat SDK cible* + *comment Festipod l'utilise* + le domaine + l'architecture + le contrat BDD.
**Ne jamais décrire dans ce repo comment la couche de données est implémentée.** La doctrine Festipod décrit uniquement *le contrat* + *comment Festipod l'utilise* + le domaine + l'architecture + le contrat BDD.
## Doctrine du projet — concepts (livrée automatiquement)
@@ -33,8 +33,12 @@ La connaissance détaillée vit dans `.project/concepts/` (système *concept*) :
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, périmètres public/protected/private par entité, découverte, défi déduplication |
| `app-architecture` | Modules, invariant d'imports, app shell, routing path-based, écrans |
| `tech-stack` | Bun-first, APIs Bun, build pipeline, commandes |
| `data-layer` | Persistance via le SDK `@ng-eventually/client` : entités-documents par scope, shapes SHEX/ORM, modes connected/demo, pièges |
| `data-layer` | Persistance via le SDK `@ng-eventually/polyfill` : entités-documents par scope, shapes SHEX/ORM, modes connected/demo, pièges |
| `bdd-testing` | Cucumber multi-couches FR, contrat `@ui`/`@data`/`@e2e`, harness broker, cookbook |
| `app-security` | Isolation déléguée au SDK (pas de contrôle d'accès dans les écrans), auth wallet, matrice d'autorisations cible |
Pour **documenter** un fait projet : `/concept document <sujet>` (ne pas écrire en libre dans `.project/`).
## Rules
@.project/rules/INDEX.md
+1 -1
View File
@@ -3,7 +3,7 @@ FROM oven/bun:1-alpine AS base
WORKDIR /app
# Install dependencies with pnpm.
# - git: the @ng-eventually/client polyfill is a git+https (public Gitea) dependency → no auth.
# - git: @ng-eventually/polyfill is a git+https (public Gitea) dependency → no auth.
# - nodejs + npm: pnpm is a Node CLI; we pin the exact pnpm version via `npm i -g`
# (Alpine's nodejs package does not bundle corepack).
# The `bun` npm peer (pulled by bun-plugin-tailwind) is approved to build in package.json
+22
View File
@@ -0,0 +1,22 @@
.PHONY: start stop config upgrade attach-activitypods
# The local pod provider and its infrastructure (fuseki, redis, mailcatcher, arena).
# Festipod's own backend and app run outside Docker: `cd backend && pnpm dev`, `pnpm dev`.
DOCKER_COMPOSE_DEV=docker compose -f docker-compose-dev.yml
start:
$(DOCKER_COMPOSE_DEV) up -d
stop:
$(DOCKER_COMPOSE_DEV) kill
$(DOCKER_COMPOSE_DEV) rm -fv
config:
$(DOCKER_COMPOSE_DEV) config
upgrade:
$(DOCKER_COMPOSE_DEV) pull
$(DOCKER_COMPOSE_DEV) up -d
attach-activitypods:
$(DOCKER_COMPOSE_DEV) exec activitypods-backend pm2 attach 0
+10 -3
View File
@@ -21,7 +21,7 @@ Tous les utilisateurs sont authentifiés — il n'y a pas d'accès anonyme à l'
### Concepts métier
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. C'est ce à quoi on s'inscrit (on ne s'inscrit pas à un événement). Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. On s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement. Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
- **Événement** — l'ancrage. Un événement public réel (festival, conférence, salon, exposition…) référencé dans Festipod pour servir de support à des points de rencontre. C'est simplement un *prétexte* (titre, dates, lieu, thèmes) ; le déclarant n'est pas l'organisateur officiel de l'événement, juste celui qui l'a inscrit dans Festipod.
- **Communauté d'intérêt** — un groupement thématique d'utilisateurs. Sert principalement à découvrir des événements (via abonnement) et à délimiter les périmètres de référencement.
- **Liste curated** — une liste d'événements éditorialisée (par un utilisateur ou une communauté), distincte de « les événements que j'ai déclarés » ou « les événements de la communauté ». Permet d'organiser/recommander.
@@ -60,11 +60,18 @@ Identifiées comme nécessaires (notamment pour la scalabilité et la découvert
## Quick Start
Comme les autres apps ActivityPods : le Pod provider local et l'infrastructure tournent dans Docker, le backend et l'app hors Docker. Ports par défaut : Pod provider 3000 (frontend 5000), backend Festipod 3005, app 4005 — à surcharger dans `backend/.env` et `.env`.
```bash
bun install
bun run dev # Dev server avec HMR (port 3000)
cp .env.example .env && cp backend/.env.example backend/.env
make start # Pod provider local + Fuseki + Redis + mailcatcher + Arena
pnpm install && (cd backend && pnpm install)
cd backend && pnpm dev # backend (port SEMAPPS_PORT, 3005 par défaut)
pnpm dev # app avec HMR, dans un autre terminal (port PORT, 4005 par défaut)
```
Détails, pièges et remise à zéro : concept `data-layer`, `cookbook_local-activitypods-platform`.
## Commandes utiles
```bash
+21
View File
@@ -0,0 +1,21 @@
SEMAPPS_APP_NAME=Festipod
SEMAPPS_APP_DESCRIPTION=Points de rencontre sur les événements publics
SEMAPPS_APP_LANG=en,fr
SEMAPPS_HOME_URL=http://localhost:3005/
SEMAPPS_FRONT_URL=http://localhost:4005/
SEMAPPS_PORT=3005
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
# Triple store
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
SEMAPPS_JENA_USER=admin
SEMAPPS_JENA_PASSWORD=admin
SEMAPPS_MAIN_DATASET=festipod
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings-festipod
# Redis (format: redis://localhost:6379/0) — no cache locally; queue db 15 (the sibling apps use 12, 13, 14)
# The port is the one `make start` publishes: REDIS_PORT of the root .env when set, else 6379.
SEMAPPS_REDIS_CACHE_URL=
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6379/15
+34
View File
@@ -0,0 +1,34 @@
FROM node:22-slim
# Install pnpm and build tools required for native modules (sharp, bcrypt).
# libvips-dev: with a system libvips present, sharp compiles itself against it instead of
# downloading its prebuilt binaries from GitHub, a download that timed out on every in-build
# attempt (2026-10-19) and failed the build.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
make \
g++ \
pkg-config \
libvips-dev \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable && corepack prepare pnpm@10.26.0 --activate
# node-gyp compiles against the headers this image already ships rather than downloading them.
ENV npm_config_nodedir=/usr/local
WORKDIR /app
# Install dependencies first to leverage Docker layer caching. The native modules' build
# scripts only run because package.json approves them (pnpm.onlyBuiltDependencies).
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
# Copy application code.
COPY config ./config
COPY moleculer.config.js ./
COPY services ./services
EXPOSE 3003
CMD ["pnpm", "start"]
+20
View File
@@ -0,0 +1,20 @@
// The backend's own env files, wherever the process starts: the root `bun test` runs the
// backend's tests too, from the repository root.
require('dotenv-flow').config({ path: require('path').join(__dirname, '..') });
module.exports = {
APP_NAME: process.env.SEMAPPS_APP_NAME,
APP_DESCRIPTION: process.env.SEMAPPS_APP_DESCRIPTION,
APP_LANG: process.env.SEMAPPS_APP_LANG,
HOME_URL: process.env.SEMAPPS_HOME_URL,
FRONT_URL: process.env.SEMAPPS_FRONT_URL,
SHAPE_REPOSITORY_URL: process.env.SEMAPPS_SHAPE_REPOSITORY_URL,
PORT: process.env.SEMAPPS_PORT,
SPARQL_ENDPOINT: process.env.SEMAPPS_SPARQL_ENDPOINT,
MAIN_DATASET: process.env.SEMAPPS_MAIN_DATASET,
JENA_USER: process.env.SEMAPPS_JENA_USER,
JENA_PASSWORD: process.env.SEMAPPS_JENA_PASSWORD,
REDIS_CACHE_URL: process.env.SEMAPPS_REDIS_CACHE_URL,
QUEUE_SERVICE_URL: process.env.SEMAPPS_QUEUE_SERVICE_URL,
AUTH_ACCOUNTS_DATASET_NAME: process.env.SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME
};
+66
View File
@@ -0,0 +1,66 @@
const { WebAclMiddleware, CacherMiddleware } = require('@semapps/webacl');
const CONFIG = require('./config/config');
Error.stackTraceLimit = Infinity;
const cacherConfig = CONFIG.REDIS_CACHE_URL
? {
type: 'Redis',
options: {
prefix: 'action',
ttl: 2592000,
redis: CONFIG.REDIS_CACHE_URL
}
}
: undefined;
const SkipOrphanBlankNodesCleanupMiddleware = () => ({
name: 'SkipOrphanBlankNodesCleanupMiddleware',
localAction: (next, action) => {
if (action.name === 'triplestore.deleteOrphanBlankNodes') {
return async () => {};
}
return next;
}
});
// ActivityPods app services mark internal calls with isSystemCall, but the SDK
// triplestore layer needs an explicit webId='system' to bypass pod-side WAC.
const SystemCallWebIdMiddleware = () => ({
name: 'SystemCallWebIdMiddleware',
localAction: (next, action) => {
return async ctx => {
if (ctx.meta?.isSystemCall && !ctx.params?.webId) {
ctx.params.webId = 'system';
}
return next(ctx);
};
}
});
module.exports = {
middlewares: [
SkipOrphanBlankNodesCleanupMiddleware(),
SystemCallWebIdMiddleware(),
// As in the reference apps: the cacher before the WebACL middleware, which checks the read
// rights of every `ldp.resource.get` and gives each resource created in the main dataset the
// rights its container prescribes (the app actor and its public key readable by anyone, say).
CacherMiddleware(cacherConfig),
WebAclMiddleware({ baseUrl: CONFIG.HOME_URL })
],
logger: {
type: 'Console',
options: {
formatter: 'short',
level: 'info'
}
},
retryPolicy: {
enabled: true,
retries: 0,
delay: 100,
maxDelay: 30000,
factor: 2,
check: err => err && !!err.retryable
}
};
+45
View File
@@ -0,0 +1,45 @@
{
"name": "festipod-backend",
"private": true,
"scripts": {
"dev": "tsx node_modules/moleculer/bin/moleculer-runner.js --hot --repl services/*.service.js services/**/*.service.js",
"start": "tsx node_modules/moleculer/bin/moleculer-runner.js services/*.service.js services/**/*.service.js"
},
"dependencies": {
"@activitypods/app": "2.3.2",
"@rdfjs/data-model": "2.1.1",
"@semapps/activitypub": "1.2.7",
"@semapps/auth": "1.2.7",
"@semapps/core": "1.2.7",
"@semapps/crypto": "1.2.7",
"@semapps/importer": "1.2.7",
"@semapps/ldp": "1.2.7",
"@semapps/mime-types": "1.2.7",
"@semapps/nodeinfo": "1.2.7",
"@semapps/ontologies": "1.2.7",
"@semapps/solid": "1.2.7",
"@semapps/sparql-endpoint": "1.2.7",
"@semapps/triplestore": "1.2.7",
"@semapps/webacl": "1.2.7",
"dotenv-flow": "^3.2.0",
"ioredis": "^4.17.3",
"moleculer": "^0.14.19",
"moleculer-bull": "0.2.8",
"moleculer-cron": "0.0.4",
"moleculer-repl": "^0.6.6",
"sharp": "0.31.3",
"tsx": "^4.20.3",
"url-join": "^4.0.1"
},
"engines": {
"node": ">=22.12"
},
"pnpm": {
"onlyBuiltDependencies": [
"bcrypt",
"sharp",
"sodium-native"
]
},
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
}
+7629
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
// Whether a resource lies in an actor's OWN pod — the ownership test in front of any mutation the
// backend applies because of an activity an actor emitted (the catalog's index/update/de-index).
//
// THE TRUST ANCHOR is the pod provider's namespace. An ActivityPods actor's URI is its pod root
// (`http://localhost:3000/alice`) and every resource of that pod lives under it
// (`http://localhost:3000/alice/data/…`); the pod lets no one create or modify a resource under
// another actor's path (observed 2026-10-12: an outbox `Create` or `Update` carrying another pod's
// resource is refused, 403 "Remote resource(s) cannot be created/modified"). The platform's own
// "is this resource on the actor's pod" test is the same path-prefix rule (`isLocal` in its
// fetch-pod-or-proxy mixin).
//
// NOT the actor document's `pim:storage`: it does declare the storage (`…/alice/data`), but that
// document is writable by its owner — a user can add another user's storage to her own
// `pim:storage` (verified 2026-10-12: PATCH → 204, the profile then lists both) — so it proves
// nothing about who owns a resource.
//
// `actorUri` must come from the server side — the actor whose outbox a webhook fired on, or the
// signer the app's inbox verified for a delivered activity — never from a field nothing vouches for,
// such as a webhook body's `actor`, which the emitting client writes. One use takes an unvouched
// value as a HINT only: registration's `meetingPointOf` reads a meeting point in the pod its first
// copy names, and believes that pod only once the copy it serves names the same creator.
const WEB_PROTOCOLS = new Set(['http:', 'https:']);
/**
* The URL when `value` is an http(s) URL written in canonical form, else undefined. Requiring the
* canonical form means no `..` / `%2e%2e` segment, host-case, default-port or other variant can
* read as one path to this test and as another to whoever later resolves it.
*/
function canonicalUrl(value) {
if (typeof value !== 'string') return undefined;
let url;
try {
url = new URL(value);
} catch {
return undefined;
}
return WEB_PROTOCOLS.has(url.protocol) && url.href === value ? url : undefined;
}
/**
* isInActorPod(uri, actorUri) → boolean. True only when `uri` is strictly under the actor's pod
* root: same origin, and a path that continues the actor's path with a `/` and at least one more
* character (so `/alice2/…` is not under `/alice`). False for anything else, including a missing or
* non-canonical argument, the actor URI itself (with or without a trailing slash), and an actor at
* the origin root (it would own the whole host).
*/
function isInActorPod(uri, actorUri) {
const resource = canonicalUrl(uri);
const actor = canonicalUrl(actorUri);
if (!resource || !actor) return false;
const root = actor.pathname.replace(/\/+$/, '');
if (root.length === 0) return false;
const prefix = `${root}/`;
return resource.origin === actor.origin && resource.pathname.startsWith(prefix) && resource.pathname.length > prefix.length;
}
module.exports = { isInActorPod };
+56
View File
@@ -0,0 +1,56 @@
const { test, expect } = require('bun:test');
const { isInActorPod } = require('./actor-pod');
const ALICE = 'http://localhost:3000/alice';
const BOB = 'http://localhost:3000/bob';
test("a resource of the actor's own pod is in it", () => {
expect(isInActorPod(`${ALICE}/data/0b5e7c1e-event`, ALICE)).toBe(true);
expect(isInActorPod(`${ALICE}/data/as/event/x`, ALICE)).toBe(true);
expect(isInActorPod(`${ALICE}/data/x#fragment`, ALICE)).toBe(true);
// An actor URI written with a trailing slash owns the same namespace.
expect(isInActorPod(`${ALICE}/data/x`, `${ALICE}/`)).toBe(true);
});
test("another actor's resource is not — including a name that merely starts the same", () => {
expect(isInActorPod(`${BOB}/data/x`, ALICE)).toBe(false);
expect(isInActorPod('http://localhost:3000/alice2/data/x', ALICE)).toBe(false);
expect(isInActorPod('http://localhost:3000/alicedata/x', ALICE)).toBe(false);
});
test('another origin is not, even with the same path', () => {
expect(isInActorPod('http://localhost:3001/alice/data/x', ALICE)).toBe(false);
expect(isInActorPod('https://localhost:3000/alice/data/x', ALICE)).toBe(false);
expect(isInActorPod('http://evil.example/alice/data/x', ALICE)).toBe(false);
});
test('a path that only LOOKS under the actor is refused (non-canonical forms)', () => {
for (const uri of [
`${ALICE}/../bob/data/x`,
`${ALICE}/./data/x`,
`${ALICE}/%2e%2e/bob/data/x`,
`${ALICE}/data/../../bob/data/x`,
'HTTP://localhost:3000/alice/data/x',
'http://LOCALHOST:3000/alice/data/x',
'http://localhost:3000//alice/data/x/../../../bob',
` ${ALICE}/data/x`,
`${ALICE}/data/x `
]) {
expect(isInActorPod(uri, ALICE)).toBe(false);
}
});
test('the actor URI itself, its parent, and missing or malformed input are refused', () => {
expect(isInActorPod(ALICE, ALICE)).toBe(false);
expect(isInActorPod(`${ALICE}/`, ALICE)).toBe(false);
expect(isInActorPod('http://localhost:3000/', ALICE)).toBe(false);
expect(isInActorPod(undefined, ALICE)).toBe(false);
expect(isInActorPod(`${ALICE}/data/x`, undefined)).toBe(false);
expect(isInActorPod({ id: `${ALICE}/data/x` }, ALICE)).toBe(false);
expect(isInActorPod('not a url', ALICE)).toBe(false);
expect(isInActorPod('urn:x:alice/data', 'urn:x:alice')).toBe(false);
});
test('an actor at the origin root owns nothing (it would own the whole host)', () => {
expect(isInActorPod('http://localhost:3000/bob/data/x', 'http://localhost:3000/')).toBe(false);
});
+188
View File
@@ -0,0 +1,188 @@
const urlJoin = require('url-join');
const { AppService } = require('@activitypods/app');
const CONFIG = require('../config/config');
const { requestUri } = require('./base-path');
// `GET /app` has two personalities, discriminated by the request's `JsonLdContext` header.
//
// 1. Solid-OIDC Client ID Document. The pod provider's OIDC adapter (RedisAdapter.find)
// resolves an unknown `client_id` that starts with `http` by fetching it, sending
// `Accept: application/ld+json` and `JsonLdContext: https://www.w3.org/ns/solid/oidc-context.jsonld`.
// The pod provider runs the login with `client_id = <HOME_URL>/app`, so this same URL must,
// for that fetch, return a document whose `@context` literally includes the Solid-OIDC
// context, whose `client_id` equals the fetched URI exactly, and which carries `redirect_uris`.
// Anything else fails `invalid_client_metadata / client_id is mandatory property`.
//
// 2. SAI app description (the actor). The pod provider's auth-agent fetches this URL
// WITHOUT the `JsonLdContext` header (`ldp.remote.getNetwork`) to install/describe the app,
// and expects the app actor document served today (`type: [Application, interop:Application]`).
//
// The `JsonLdContext` header is the only discriminator: same path, two representations.
module.exports = {
mixins: [AppService],
settings: {
baseUrl: CONFIG.HOME_URL,
app: {
name: CONFIG.APP_NAME,
description: CONFIG.APP_DESCRIPTION,
thumbnail: urlJoin(CONFIG.FRONT_URL, 'logo.svg'),
frontUrl: CONFIG.FRONT_URL,
supportedLocales: CONFIG.APP_LANG
},
oidc: {
clientUri: CONFIG.FRONT_URL,
redirectUris: urlJoin(CONFIG.FRONT_URL, 'login'),
postLogoutRedirectUris: urlJoin(CONFIG.FRONT_URL, 'login?logout=true'),
tosUri: null
},
accessNeeds: {
required: [
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
accessMode: ['acl:Read', 'acl:Write', 'acl:Control'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
accessMode: ['acl:Read', 'acl:Write'],
preferredScope: 'interop:AllFromRegistry'
},
{
// The images users upload (event covers, avatars): the Pod's `file` container.
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/File'),
accessMode: ['acl:Read', 'acl:Write'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/vcard/Group'),
accessMode: 'acl:Read',
preferredScope: 'interop:AllFromRegistry'
},
'apods:ReadInbox',
'apods:ReadOutbox',
'apods:PostOutbox',
'apods:QuerySparqlEndpoint',
'apods:CreateCollection',
'apods:UpdateWebId'
],
optional: []
},
queueServiceUrl: CONFIG.QUEUE_SERVICE_URL
},
actions: {
// Overrides the mixin's `get` (which returns `this.appActor` for every request) to
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is the mixin's
// snapshot of the actor, refreshed by this service's own started() (see there).
get: {
async handler(ctx) {
// moleculer-web exposes the raw request only when the route alias sets
// passReqResToParams (see services/core/core.service.js). Internal calls
// (ctx.call('app.get')) have no `$req`, so they always get the actor.
const req = ctx.params && ctx.params.$req;
const headers = (req && req.headers) || {};
const jsonLdContextKey = Object.keys(headers).find(key => key.toLowerCase() === 'jsonldcontext');
const isOidcClientIdDocument =
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
// Moleculer registers this action only once every started() handler of the service has
// finished, by which time the mixin has set `this.appActor`: reading the actor from the
// triplestore when it is unset is a guard, not a boot path.
let actor = this.appActor;
if (!actor) {
try {
actor = await ctx.call('ldp.resource.get', {
resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
accept: 'application/ld+json',
webId: 'system'
});
} catch (e) {
actor = undefined;
}
}
if (isOidcClientIdDocument) {
// The client_id must equal the fetched URI exactly. Both `/app` and `/as/actor/app`
// serve this document (the configured client_id is the actor uri; `/app` remains
// a valid alias), so echo the request path rather than hardcoding one.
// `req.url` is rewritten to the alias remainder by moleculer-web; the raw path, base
// path included, survives in `originalUrl`.
const requestPath = String((req && (req.originalUrl || req.url)) || '').split('?')[0];
return {
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
client_id: requestPath ? requestUri(CONFIG.HOME_URL, requestPath) : urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
client_name: CONFIG.APP_NAME,
client_uri: CONFIG.FRONT_URL
};
}
return actor;
}
}
},
async started() {
// Heal the actor ↔ access-need-group link. `@activitypods/app` attaches the group to the
// actor in an after.post hook of the group's creation. On a fresh dataset (2026-10-26)
// consents registered zero grants and a one-shot version of this heal found no group; the
// cause was not established — the mixin's started() creates the actor, then the group,
// before this handler runs. Both the pod frontend (consent screen)
// and the pod backend (requirements discovery) read the needs from that link — an empty
// consent registers the app with zero grants and the register flow loops — so the link
// is checked in the store and attached when missing, polling for the group rather than
// checking once. Idempotent: only patches when the link is missing.
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
const readActor = () =>
this.broker.call('ldp.resource.get', {
resourceUri: actorUri,
accept: 'application/ld+json',
webId: 'system'
});
const linksGroup = actor => {
const link = actor && actor['interop:hasAccessNeedGroup'];
return Array.isArray(link) ? link.length > 0 : Boolean(link);
};
const findGroup = async () => {
const groups = await this.broker.call('access-needs-groups.list', {
filters: {
'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired'
},
accept: 'application/ld+json'
});
return groups && groups['ldp:contains'] && groups['ldp:contains'][0];
};
// A started() handler must not block the broker's boot, but the link must
// exist before the first consent/register flow; ~1 min is far past the
// observed gap (seconds) while the dataset is empty.
const deadline = Date.now() + 60000;
for (;;) {
try {
if (linksGroup(await readActor())) break;
const group = await findGroup();
if (group) {
await this.broker.call('actors.attachAccessNeedGroup', {
accessNeedGroupUri: group.id || group['@id']
});
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
break;
}
} catch (e) {
this.logger.warn(`Access-need-group heal attempt failed: ${e.message}`);
}
if (Date.now() >= deadline) {
this.logger.error('No access-need group appeared within 60s; the actor stays unlinked — consents will register zero grants');
break;
}
await new Promise(r => setTimeout(r, 2000));
}
// `get` serves `this.appActor`, the snapshot the mixin's started() takes BEFORE it creates
// or replaces the group: on a dataset's first boot that snapshot lacks the link, and on a
// boot that changes the access needs it names the group just deleted. `GET /app` kept
// that until the next restart (observed 2026-11-02), so refresh it from the store.
try {
const actor = await readActor();
if (linksGroup(actor)) this.appActor = actor;
} catch (e) {
this.logger.warn(`Could not refresh the app actor snapshot: ${e.message}`);
}
}
};

Some files were not shown because too many files have changed in this diff Show More