Compare commits

..

39 Commits

Author SHA1 Message Date
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
Sylvain Duchesne 05ee576d7d refactor(comments): retirer les raisonnements sur l'état de NextGraph du code app
Application de la règle qu'on vient de durcir : l'app IGNORE entièrement l'état
d'implémentation de NextGraph. Le CODE était légitime — `inbox.readSynced` est
une surface SDK exportée ; ce sont les JUSTIFICATIONS qui fautaient, en
expliquant les choix par des internes du cœur.

Réécrit en termes de CONTRAT :
- registration.ts / FestipodDataContext : « barrier-gated read, le repo d'inbox
  n'est pas encore ouvert dans le verifier, un read ancré renverrait 0 » devient
  « `read` rend ce qui est connu localement maintenant, `readSynced` rend une
  fois les dépôts synchronisés visibles ; ce site a besoin du second parce qu'il
  lit depuis une session froide ».
- ngBootstrap : « le verifier sérialise les créations » devient « `docCreate` est
  un aller-retour qui ne recouvre pas le suivant, donc le coût du seed croît
  LINÉAIREMENT avec le nombre de documents ». Le ~2s mesuré est conservé, mais
  explicitement comme une observation, pas comme un contrat.
- entityWrites : description de lecture périmée (ORM fan-out, ngSet couplé au
  scope) remplacée par la vue réactive.

La distinction read/readSynced vit désormais là où elle est légitime :
knowledge_sdk-surface, avec le critère de choix (`read` dans une session qui
observe déjà l'inbox, `readSynced` dès que la justesse dépend d'une session
froide voyant le dépôt d'une autre identité). knowledge_context-internals cesse
d'expliquer le fix par `ensureRepoOpen`/premier `State` et pointe le contrat.

Laissé tel quel : `src/shared/support/hooks.ts` et les steps e2e — le harness de
test connaît légitimement la plomberie ; la règle vise l'app. Et le « no
cross-account fan-out » de FestipodDataContext, qui décrit le périmètre de l'app
et non NextGraph.

tsc : 0 erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-28 17:17:47 +02:00
Sylvain Duchesne 7459d49e83 docs+fix: recadrer le polyfill comme compensateur d'écart, corriger la doctrine périmée
RECADRAGE — la doctrine était trop étroite. rule_app-uses-sdk-surface-only
disait « le polyfill existe pour le WALLET VIRTUEL » : juste sur le fond, mais à
la lettre l'émulation des caps qu'on vient de livrer n'entrait pas dans son
mandat. Nouvelle formulation, portée aussi dans AGENTS.md :

  @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 en est la plus grosse pièce, pas la
  totalité.

Avec la conséquence opérationnelle : 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 ». Un contournement côté app est une violation même
quand il fonctionne — il grave un état temporaire de NextGraph dans du code qui
doit lui survivre. Et l'ignorance de l'état d'implémentation est durcie :
ENTIÈREMENT, pas « sauf quand ça mord ».

NOUVEAU — data-layer/knowledge_sdk-surface : le contrat SDK cible, écrit dans CE
repo pour qu'un agent n'ait jamais à ouvrir le repo du polyfill. Couvre lectures
réactives, écritures, placement par scope, inbox, discovery, capabilities
(capFor/shareCap/publishRepoLink, livrées avec P1a), identité, sûreté SPARQL —
et les surfaces exportées mais interdites à l'app.

DOCTRINE PÉRIMÉE corrigée, après vérification dans le code :
- rule_document-per-entity décrivait la lecture via readEntities/readUnion/
  registerDoc/bumpRead : ZÉRO site d'appel, readEntities.ts supprimé. Réécrite
  sur watchShape/useShapeQuery. Le fond (un document par entité) est intact.
- brief_2026-07-06 §P3 réaffirmait une phrase que son propre encadré déclare
  fausse : rétractée explicitement.
- knowledge_data-modes citait useShapeWithDefaults(), qui n'existe nulle part.
- ConnectScreen : les fiches avaient raison mais étaient vagues — l'écran existe,
  est routé et monté, et est bien absent du registre. Précisé.

FIX CODE — build:orm était CASSÉ : il pointait ./src/shapes/, qui n'existe pas
(les shapes vivent sous src/shared/shapes/), et sortait en erreur. Donc la
commande que la doctrine prescrit après tout changement de .shex ne marchait
pas. Corrigé et vérifié : exit 0. La fiche avait raison, c'est le code qui était
faux — le point 4 approuvé, simplement situé dans l'autre fichier.

Régénération NON embarquée : lancer build:orm reformate les bindings et retire
l'annotation `: Schema`. C'est une montée de version d'outil, pas une correction
de contenu — elle mérite son propre changement validé, pas un passage clandestin.
Noté dans la fiche.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-28 16:49:27 +02:00
Sylvain Duchesne b6a6b14fad docs(concept): passer les 59 fiches de doctrine en anglais
Convention du projet pour la documentation projet. Traduction fidèle, sans
changement de fond : mêmes fiches, mêmes sections, mêmes liens. Le lint est
identique à la baseline (59 leaves, 0 nouveau lien cassé, wikilinks bit-à-bit
inchangés) et aucun `.feature` n'a été touché.

Le `summary:` du frontmatter est traduit lui aussi — c'est ce que le hook affiche
dans l'index, il porte autant que le corps.

RESTENT EN FRANÇAIS, délibérément :
- les fichiers .feature (convention explicite du projet : Etant donné/Quand/Alors)
  et le bloc Gherkin cité dans brief_2026-07-06 ;
- les libellés d'interface cités en prose (« Entrer », « ✓ Je participe »,
  « Voir tous les participants », « participant inconnu »…) : ce sont des chaînes
  réelles de l'app, pas de la prose ;
- les noms de scénarios BDD ;
- les `triggers.keywords` des _overview : jetons de matching du hook, et la
  conversation reste en français — les traduire aurait cassé la livraison.

EFFET SECONDAIRE UTILE : relire intégralement a fait remonter des contradictions
et des péremptions que personne ne voyait section par section. Notées, non
corrigées (hors périmètre de la traduction) :
- rule_document-per-entity décrit la lecture via readEntities/readUnion/
  registerDoc/bumpRead, que rule_app-uses-sdk-surface-only déclare SUPPRIMÉS au
  profit de watchShape/useShapeQuery. Une règle qui décrit des APIs retirées est
  activement trompeuse — à traiter en priorité.
- brief_2026-07-06 §P3 réaffirme « prouvé par l'e2e D.2, sans reload » juste
  après l'encadré qui déclare cette phrase fausse et sur-cadrée.
- knowledge_data-modes cite useShapeWithDefaults() là où useShapeQuery est
  documenté ailleurs.
- knowledge_stack-and-commands : build:orm pointe ./src/shapes/* alors que les
  shapes vivent sous src/shared/shapes/.
- knowledge_screens / knowledge_routing : ConnectScreen décrit comme absent du
  registre mais présent en route.
- brief_2026-05-18 : « identifié si connecté » était ambigu en français (session
  ouverte vs est une connexion) ; l'anglais a forcé à trancher — rendu par
  « if a connection », à confirmer côté produit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-28 16:27:24 +02:00
Sylvain Duchesne 3ec3b37a65 docs(concept): poser le principe de lecture publique non récursive
Remplace le point « validation d'existence impossible » — qui traînait une
préoccupation de forgerie hors périmètre — par le principe qui fait réellement
tenir le modèle :

  Un élément du store public est public : qui a l'URL lit le contenu.
  Mais PAS récursivement — un contenu public peut référencer du contenu privé.

C'est exactement notre cas. Le créateur lit la Participation (publique) et ne
peut pas suivre la référence vers le profil (protected). Lecture par le créateur
ET anonymat vis-à-vis de lui, sans aucun mécanisme supplémentaire.

Le compteur est Set.size. Rien d'autre à en dire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 17:05:39 +02:00
Sylvain Duchesne e2adfacb0b docs(concept): la validation d'existence est IMPOSSIBLE, pas optionnelle
Le brief inscriptions disait que le créateur pouvait vérifier qu'un did pointe
sur un objet réel sans détenir la clé, et rangeait ça en durcissement optionnel.
Faux : le contrôle d'accès en lecture laisse bien passer, mais l'ADRESSAGE
présuppose le cap — aucune commande d'existence au niveau SDK, et une référence
cap-less n'a ni les identifiants de blocs ni l'overlay nécessaires.

Conséquence assumée, écrite noir sur blanc : le créateur ajoute la référence SUR
PAROLE, donc le compteur est déclaratif et forgeable. Hors périmètre sécurité,
mais cette étape ne doit pas être présentée comme une validation.

Le modèle lui-même n'en dépendait pas — il était déjà noté comme non requis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 16:08:15 +02:00
Sylvain Duchesne 96e28a702f docs(concept): solder la doc-debt des 6 concepts
Dette accumulée depuis le 13/07 (27 marqueurs). Au-delà du vidage, trois
corrections de doctrine réellement fausse — c'est ce que le reconcile devait
attraper :

- app-security : `sharedWallet.ts` capture le mot de passe à l'ÉVALUATION du
  module. Tant qu'un repli existait, un global posé trop tard ne faisait que
  dégrader ; depuis que le wallet partagé est l'unique mode, il rend la barrière
  INUTILISABLE (écran d'erreur, aucun champ). Conséquence non anticipée de la
  décision shared-wallet-only → nouveau caveat.
- bdd-testing : la doctrine rendait des tests faux-verts. `ctx.newPage()` sur le
  profil persistant relit l'IndexedDB local et ne prouve JAMAIS la durabilité
  broker ; seul un contexte partagé neuf tranche. Un agent suivant la doctrine
  écrivait un test qui passe sans rien vérifier → nouveau caveat.
- app-architecture : `knowledge_routing` décrivait encore une route `/login`
  disparue, et `knowledge_screen-pattern` citait `LoginScreen` qui n'existe
  plus. Nouveau caveat sur les deux espaces d'id vus depuis un écran.

Aussi : data-layer/knowledge_context-internals décrit la jointure
participation→profil et corrige un mécanisme de changement d'identité périmé ;
tech-stack raccroche la table des scripts au vrai point d'entrée cucumber ;
functional-domain note qu'« implémenté » ≠ « durable ».

Trois marqueurs soldés comme sans objet : ils visaient
`reconnexion-socket-mort.{feature,steps.ts}`, absents de l'arbre ET de tout
l'historique — expérience abandonnée avant tout commit. Ce qu'elle devait
établir est capturé ailleurs (caveat de durabilité, post-mortem polyfill, fiche
INBOX socket-death).

Liens morts vers une décision disparue avec le concept `nextgraph-platform`
réparés. Reste au lint : le brief 07-06 (superseded) porte des file:line et des
références aux internes NextGraph — laissé intact, il décrit l'Option-B encore
implémentée et se dissoudra à la graduation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 14:43:09 +02:00
Sylvain Duchesne a8401bd143 docs(concept): inscriptions — Participation lisible + drapeau active, purge par le créateur
Affinement PO du 2026-07-27. La Participation devient LISIBLE par tous et se
réduit à trois choses : référence à l'événement, booléen `active`, did cap-less
vers le profil protected du participant. Pas de description pour l'instant.

Ce que ça débloque : une suppression n'est pas détectable sans la clé (vérifié),
ce qui imposait un nudge forgeable pour la désinscription. Un objet lisible avec
un drapeau change la nature du problème — l'annulation n'est plus à DÉTECTER,
elle est à LIRE. Le blocage disparaît au lieu d'être contourné.

Le principe qui tient l'ensemble : la vérité est dans l'objet que le participant
contrôle, tout message n'est qu'un indice. Un faux « purge X » conduit le
créateur à lire X, la voir active, et ne rien faire. La forgerie devient
structurellement inoffensive — d'où l'absence de besoin de signer les dépôts
d'inbox, ce qui tombe bien : NextGraph ne l'offre pas (inbox non authentifiée,
vérification de signature non implémentée et exigeant de déchiffrer).

Le pointeur d'identité vise le profil protected existant, pas un second document
par participation : les connexions en détiennent déjà le cap. Ajouter une
connexion ne réécrit donc rien — on scelle une fois, durablement. Un champ
chiffré dans la Participation aurait exigé de re-sceller à N destinataires et de
réécrire à chaque nouvelle connexion (et n'est pas un primitif NextGraph : la
granularité de chiffrement est le document, en tout-ou-rien).

Arbitrages assumés : pas de filtrage à la lecture (Set.size est une borne haute,
exacte après purge — obsolescence acceptée pour garder la lecture en O(1)) ;
la purge incombe au créateur ; pas de description.

Point ouvert noté : Participation passe en scope public alors que la doctrine
produit la place en protected. Ce leaf décrit l'implémenté — à mettre à jour à
la graduation du brief, pas avant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 13:49:18 +02:00
Sylvain Duchesne ab077d8080 docs(concept): réserve durable sur le pseudonyme permanent de l'overlay
app-security/caveat_stable-overlay-pseudonym (nouveau) — toute référence
cap-less vers un document protected expose le `✌️` du store, identique partout
et pour toujours. BLAKE3 non inversible le rend OPAQUE, d'où la tentation de le
croire INOFFENSIF : ce sont deux choses différentes. C'est la constance qui
expose, pas la lisibilité. Un seul recoupement, une seule fois, et tout
l'historique bascule — y compris ce qui a été publié des années plus tôt.

Aucune porte de sortie, vérifié sur quatre axes : pas de rotation d'overlay,
store id généré une fois pour toutes, aucune migration de contenu, aucune forme
de référence n'évitant d'exposer l'overlay. Le renouvellement de capabilities
ne toucherait que l'inner ; l'outer y survit.

Placé en app-security et non dans le brief inscriptions : un brief se dissout à
sa graduation, la réserve doit lui survivre. Le brief n'en garde qu'un résumé
et pointe dessus. Déclencheurs élargis (anonymat, pseudonyme, traçage,
corrélation, overlay, cap-less) pour qu'elle remonte quand on s'apprête à
concevoir de l'« anonyme ».

Consigne pratique qui en découle : ne jamais présenter une action comme
« anonyme » si elle fait circuler une référence cap-less — c'est pseudonyme,
et le pseudonyme est permanent.

Dédup par `✌️` validée par le PO ; le brief le note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 12:31:55 +02:00
Sylvain Duchesne 42dbfd0c34 docs(concept): modèle d'inscription recalé + l'inbox NextGraph suit aussi les manques
Le brief inscriptions est réécrit sur le modèle reposé par le PO : tout est
clés et URLs, sans notion d'appartenance. Le participant crée une Participation
chiffrée, dépose son did (URI sans ReadCap) dans l'inbox de l'événement ; le
créateur traite l'inbox automatiquement, déduplique sans pouvoir lire, et range
la référence dans un Set porté par l'événement ; compteur = Set.size ; seules
les connexions détiennent la clé et reconnaissent la personne.

La dédup s'appuie sur un fait vérifié dans nextgraph-rs : l'overlay (segment
`✌️` d'un NURI) est STORE-scopé, jamais document-scopé. Deux Participations
d'une même personne portent donc le même `✌️`. Contrepartie actée dans le
brief : ce `✌️` est un pseudonyme stable et permanent — c'est le MÊME bit
d'information qui permet de dédupliquer sans lire et de tracer d'un événement
à l'autre ; on ne peut pas garder l'un sans l'autre.

Retiré du brief : le trilemme et la piste de dédup par vérification de
signature. Ils reposaient sur une notion de membership importée de l'état
courant du source Rust, où elle est un échafaudage inerte — erreur de méthode
désormais consignée en règle.

Règles :
- rule_capture-nextgraph-findings (nouvelle) — toute connaissance établie sur
  le fonctionnement réel de NextGraph se consigne AU MOMENT de la découverte
  dans la doc du polyfill ; distinguer VÉRIFIÉ d'INFÉRÉ ; ne jamais déduire la
  forme cible de l'état courant du source.
- rule_file-nextgraph-bugs → rule_nextgraph-inbox — l'inbox reçoit désormais
  DEUX familles : les dysfonctionnements ET les manques dont on a besoin. Une
  fiche de manque dit ce que le polyfill émule en attendant et ce qu'il faudra
  en RETIRER quand ça atterrit en amont : l'inbox devient un suivi de
  l'avancement de NextGraph, pas un simple bug-tracker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 12:07:06 +02:00
Sylvain Duchesne 5b536ff981 docs(concept): brief inscriptions par Set + règle de report des bugs NextGraph
- data-layer/brief_2026-07-20_attendance-set-model : réaligner les inscriptions
  sur la vision initiale — objet participation auto-possédé (la vérité) + Set
  curé de références cap-less sur l'événement + cap scellé aux seules
  connexions ⇒ compteur = `Set.size`, présence anonyme par défaut, personne ne
  désinscrit autrui. Inclut la revue adverse (trilemme anonyme/dédup/inviolable)
  et les verdicts du spike P0 vérifiés dans `nextgraph-rs` :
  fetch d'existence sans clé = OUI, détection de suppression sans clé = NON
  (⇒ la désinscription passe par un nudge), confidentialité = OUI.
  Statut : direction cible, PAS un pivot immédiat.
- data-layer/rule_file-nextgraph-bugs : tout dysfonctionnement NextGraph
  identifié donne lieu à une fiche dans `../../nextgraph/orm-tests/INBOX/`.
- to-discuss : alignement ReadCap/WriteCap, terminologie identité NextGraph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 11:30:12 +02:00
Sylvain Duchesne c5e627c5fc fix(participants): joindre participation→profil à travers les deux espaces d'id
Une Participation stocke son user comme principal stable
`urn:festipod:user:<clé>`, alors qu'un UserProfile a pour `id` son NURI
`did🆖`. La jointure brute `partUserIds.includes(u.id)` ne matchait donc
jamais en mode connecté → chaque participant s'affichait « inconnu ».

- `resolveParticipantUser` : match direct (espace seed demo) puis, à défaut,
  match sur `normalizeIdentifier(username)` après retrait du préfixe principal.
- `USER_PRINCIPAL_PREFIX` : source unique du préfixe, partagée par l'écriture
  (`currentUserId`) et la lecture, pour qu'elles ne divergent pas.
- EventDetailScreen : filtrer soi-même sur `currentUser?.id` (id de profil,
  même espace que `p.id`) et non sur `currentUserId` (principal).

Aussi : épingle `packageManager` pnpm (l'install passe par pnpm, cf.
rule_bun-first) — le runtime/test/build restent Bun.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 11:30:00 +02:00
Sylvain Duchesne 7e65a83d42 fix(test): cucumber via l'entrée JS réelle (@cucumber/cucumber/bin) — pnpm casse le shim .bin
pnpm installe node_modules/.bin/cucumber-js comme shim shell (pas du JS) → 'node --import tsx/esm node_modules/.bin/cucumber-js' échoue. Pointer sur l'entrée JS réelle du paquet. Répare test:data et cucumber:run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:17:36 +02:00
Sylvain Duchesne 3a49376f17 test(reconnexion): repro à froid sans état local (@wip) + persistance/pause (@wip)
reconnexion-froide-sans-local = test décisif broker-vs-local (verdict LOCAL-ONLY), @wip. persistance-e2e + pause @wip. rename identifiant dans reconnexion/isolation/harness-ng.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:16:04 +02:00
Sylvain Duchesne e326bebd42 feat(auth): wallet partagé = seul mode + purge identifiant-wallet↔username
AccessGateScreen 3-branches (erreur config si pas de wallet partagé) ; renommage username→identifier de l'identité du wallet (registration, ngSession, hooks, steps auth) sans toucher UserProfile.username ; .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:16:04 +02:00
Sylvain Duchesne 46ed894621 feat(data): logs [app][data] identité-first + participantCount avant→après
Préfixe identité-first ; label participation ; valeur compteur avant/après écriture owner + lecture affichage. Additif.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:16:04 +02:00
165 changed files with 6683 additions and 14243 deletions
+50
View File
@@ -0,0 +1,50 @@
# Festipod — variables d'environnement (exemple)
#
# Copier en `.env` et renseigner les valeurs.
# En dev (`bun run 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).
#
# 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 => 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 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
# NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide.
NODE_ENV=
# ── Outillage dev (facultatif) ─────────────────────────────────────────────
# 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=
+3
View File
@@ -44,3 +44,6 @@ storybook-static
dist-staging/
*.ngw
.tasks/
# Per-developer contract access map (canonical provider → local checkout) — never committed.
.project/contracts.local.yaml
@@ -1,9 +0,0 @@
# Doc-debt — app-architecture
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/context/AccountContext.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+14 -12
View File
@@ -1,6 +1,6 @@
---
type: _overview
summary: Architecture feature-based de l'app — modules par domaine, invariant d'imports, app shell à providers, routing path-based, écrans et registre
summary: Feature-based architecture of the app — modules per domain, import invariant, provider-stacked app shell, path-based routing, screens and registry
triggers:
keywords: [module, modules, screen, écran, routing, route, navigate, useNavigate, useParams, registry, registre, app shell, shared, import]
paths: ["src/app/**", "src/screens/**", "src/modules/*/screens/**", "src/shared/components/**", "src/shared/context/**"]
@@ -8,17 +8,19 @@ triggers:
# App architecture
Comment le code de l'app est **structuré** et **assemblé**. Architecture *feature-based* : le code est organisé par **domaine métier** (module), pas par couche technique.
How the app's code is **structured** and **assembled**. *Feature-based* architecture: the code is organized by **business domain** (module), not by technical layer.
**À lire en premier :** [[rule_module-imports]] — l'invariant central qui garde les modules découplés.
**Read this first:** [[rule_module-imports]] — the central invariant that keeps modules decoupled.
## Liens
## Links
- [[knowledge_module-structure]] — arborescence modules + couche `shared/`
- [[knowledge_app-shell]] — `src/app/`, pile de providers, points d'entrée
- [[knowledge_routing]] — routing path-based (History API), table de routes, hooks
- [[knowledge_screens]] — inventaire des écrans, registre, lib de composants
- [[knowledge_screen-pattern]] — anatomie canonique d'un écran (sans props, layout flex, showToast)
- [[knowledge_styling-system]] — `src/index.css`, classes `app-*`, vars, pièges (Tailwind non-utilisé, `user-content` inerte)
- [[cookbook_add-screen]] — procédure pour câbler un nouvel écran (registre + router + shell)
- `tech-stack` — build, bundler Bun, commandes
- [[knowledge_module-structure]] — module tree + `shared/` layer
- [[knowledge_app-shell]] — `src/app/`, provider stack, entry points
- [[knowledge_routing]] — path-based routing (History API), route table, hooks
- [[knowledge_screens]] — screen inventory, registry, component library
- [[knowledge_screen-pattern]] — canonical anatomy of a screen (no props, flex layout, showToast)
- [[caveat_identity-ids-in-screens]] — `currentUserId` is 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).
@@ -0,0 +1,36 @@
---
type: caveat
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: "not answered yet" looks exactly like an answer
Two things a screen receives can be *unresolved*, and in both cases the unresolved form reads like an ordinary value. Nothing throws.
## `currentUserId` is empty until my profile resolves
`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.
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.
- 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.
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*.
## An ownership answer can be UNKNOWN
`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,20 +1,20 @@
---
type: cookbook
summary: Procédure pour ajouter un écran — créer le composant dans le module, l'enregistrer dans src/screens/index.ts, ajouter la route dans router.tsx, le monter dans App.tsx, et un alias screenNameMap si testé en BDD
summary: Procedure for adding a screen — create the component in the module, register it in src/screens/index.ts, add the route in router.tsx, mount it in App.tsx, plus a screenNameMap alias if it is covered by BDD
---
# Cookbook : ajouter un écran
# Cookbook: adding a screen
Un écran doit être câblé à **plusieurs endroits** — en oublier un produit des bugs silencieux (cf. le cas `ConnectScreen`, [[knowledge_screens]]).
A screen has to be wired up in **several places** — forgetting one produces silent bugs (see the `ConnectScreen` case, [[knowledge_screens]]).
1. **Créer le composant** : `src/modules/{module}/screens/MyScreen.tsx`, en suivant [[knowledge_screen-pattern]] (fonction sans props, `useFestipodData`/`useNavigate`/`useParams`, layout flex, style via [[knowledge_styling-system]]). Respecter [[rule_module-imports]] (importer seulement depuis `shared/`).
1. **Create the component**: `src/modules/{module}/screens/MyScreen.tsx`, following [[knowledge_screen-pattern]] (function with no props, `useFestipodData`/`useNavigate`/`useParams`, flex layout, styling via [[knowledge_styling-system]]). Respect [[rule_module-imports]] (import only from `shared/`).
2. **Enregistrer dans le registre** : `src/screens/index.ts` — ajouter l'import + l'entrée (`id`, `name` FR, `path`, `component`). **Étape la plus oubliée** : un écran absent du registre est invisible à Storybook et aux consommateurs du registre, même s'il fonctionne en route.
2. **Add it to the registry**: `src/screens/index.ts` — add the import plus the entry (`id`, French `name`, `path`, `component`). **The most frequently forgotten step**: a screen missing from the registry is invisible to Storybook and to the registry's consumers, even though it works as a route.
3. **Ajouter la route** : `src/app/router.tsx`étendre le type `Route`, ajouter le cas dans `parsePath()` (et la conversion inverse si présente).
3. **Add the route**: `src/app/router.tsx`extend the `Route` type, add the case in `parsePath()` (and the reverse conversion if present).
4. **Monter dans le shell** : `src/app/App.tsx` — ajouter le cas dans le switch qui mappe `route.page` → composant.
4. **Mount it in the shell**: `src/app/App.tsx` — add the case in the switch that maps `route.page` → component.
5. **(Si testé en BDD)** : ajouter un alias dans `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`) si le nom français du `.feature` ne se résout pas trivialement vers l'`id`. Voir concept `bdd-testing`.
5. **(If covered by BDD)**: add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`) if the French name used in the `.feature` does not resolve trivially to the `id`. See concept `bdd-testing`.
> Vérifier la cohérence : l'`id` doit être identique entre le registre, le router et `screenNameMap`. Un écart silencieux = écran injoignable ou non rendu.
> Check consistency: the `id` must be identical across the registry, the router and `screenNameMap`. A silent mismatch = an unreachable or unrendered screen.
@@ -1,33 +1,44 @@
---
type: knowledge
summary: src/app/ est le shell réel de l'app — App.tsx empile les providers (Theme > NextGraph > FestipodData > Router) et bascule l'écran selon la route
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
`src/app/` est le **shell de l'app réelle** (mobile web app), pas un outil de prototypage.
`src/app/` is the **shell of the real app** (mobile web app), not a prototyping tool.
> Note de migration : d'anciennes notes décrivaient `src/app/` comme un « prototyping tool » en routing par hash (`#/`, `#/demo/...`). C'est **périmé** depuis la restructuration en vraie app. La vérité courante : routing path-based via History API (voir [[knowledge_routing]]).
> Migration note: older notes described `src/app/` as a "prototyping tool" using hash routing (`#/`, `#/demo/...`). That is **obsolete** since the restructuring into a real app. The current truth: path-based routing via the History API (see [[knowledge_routing]]).
## Pile de providers
## Provider stack
`App.tsx` empile les providers puis bascule l'écran selon la route courante :
`App.tsx` stacks the providers, then switches screens according to the current route:
```
ThemeProvider
└ NextGraphProvider (cycle de connexion NextGraph — concept data-layer)
└ FestipodDataProvider (données, mode connected/demo — concept data-layer)
└ RouterProvider (route courante + navigate)
└ 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
```
Le composant racine lit `useRouter()` pour résoudre `route.page`écran à rendre.
`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.
## Points d'entrée
### Ordering invariants (what breaks if you move a layer)
| Fichier | Rôle |
- **`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
| File | Role |
|---|---|
| `src/index.ts` | `Bun.serve()`serveur HTTP, sert `index.html` + rapport cucumber |
| `src/index.html` | Entrée HTML, charge `src/app/frontend.tsx` |
| `src/app/frontend.tsx` | Racine React, rend `<App />` |
| `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 — 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]]) |
Le build et le bundler (Bun + Tailwind, alias `@/* → ./src/*`) sont documentés dans le concept `tech-stack`.
The build and the bundler (Bun + Tailwind, alias `@/* → ./src/*`) are documented in the `tech-stack` concept.
@@ -1,41 +1,41 @@
---
type: knowledge
summary: Arborescence feature-based — modules métier (event, user, home, auth, workshop, meeting, notification) et couche shared/ importable par tous
summary: Feature-based tree — business modules (event, user, home, auth, workshop, meeting, notification) and a shared/ layer importable by all of them
---
# Structure des modules
# Module structure
Le code est organisé par **domaine métier**, pas par couche technique.
The code is organized by **business domain**, not by technical layer.
```
src/modules/
event/ # Événements : CRUD, discovery, participants, points de rencontre
user/ # Profils, connexions (« amis »), partage
event/ # Events: CRUD, discovery, participants, meeting points
user/ # Profiles, connections ("friends"), sharing
home/ # Dashboard, settings
auth/ # Login, welcome/onboarding
workshop/ # Specs atelier (features seulement, pas d'écrans)
meeting/ # Specs point de rencontre (features seulement)
notification/ # Specs notification (features seulement)
workshop/ # Workshop specs (features only, no screens)
meeting/ # Meeting point specs (features only)
notification/ # Notification specs (features only)
```
Chaque module peut contenir :
- `screens/`composants d'écran React
- `features/` fichiers Gherkin `.feature` (specs BDD, voir concept `bdd-testing`)
- `steps/{ui,data,e2e}/` — step definitions Cucumber par couche
Each module may contain:
- `screens/`React screen components
- `features/` — Gherkin `.feature` files (BDD specs, see concept `bdd-testing`)
- `steps/{ui,data,e2e}/` Cucumber step definitions, one folder per layer
## Couche `shared/`
## The `shared/` layer
`src/shared/` contient tout le réutilisable inter-modules :
`src/shared/` holds everything reusable across modules:
| Répertoire | Contenu |
| Directory | Contents |
|---|---|
| `components/` | Lib de composants UI (voir [[knowledge_screens]]) |
| `context/` | `ThemeContext`, `NextGraphContext`, `FestipodDataContext` (voir concept `data-layer`) |
| `data/` | User stories, `features.ts` (auto-généré), `seedData.ts`, `types.ts` |
| `hooks/` | `useShapeWithDefaults` (NextGraph) |
| `shapes/` | SHEX + bindings ORM (voir concept `data-layer`) |
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts` |
| `steps/`, `support/` | Step definitions et hooks Cucumber partagés (concept `bdd-testing`) |
| `components/` | UI component library (see [[knowledge_screens]]) |
| `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/` | 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`, `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.) |
La règle dependance entre modules et `shared/` est dans [[rule_module-imports]].
The dependency rule between modules and `shared/` lives in [[rule_module-imports]].
@@ -1,18 +1,17 @@
---
type: knowledge
summary: Routing path-based via History API (router maison dans src/app/router.tsx) — table de routes, hooks useNavigate/useParams, pas de prop drilling
summary: Path-based routing via the History API (hand-rolled router in src/app/router.tsx) — route table, useNavigate/useParams hooks, no prop drilling
---
# Routing
Routing **path-based** via l'History API — router maison dans `src/app/router.tsx` (`window.history.pushState` + `popstate`, `parsePath(pathname)`). Pas de routing par hash.
**Path-based** routing via the History API — hand-rolled router in `src/app/router.tsx` (`window.history.pushState` + `popstate`, `parsePath(pathname)`). No hash routing.
## Table de routes
## Route table
| Path | Écran |
| Path | Screen |
|---|---|
| `/` | WelcomeScreen |
| `/login` | LoginScreen |
| `/home` | HomeScreen |
| `/events` | EventsScreen |
| `/events/new` | CreateEventScreen |
@@ -25,12 +24,12 @@ Routing **path-based** via l'History API — router maison dans `src/app/router.
| `/profile/edit` | UpdateProfileScreen |
| `/profile/friends` | FriendsListScreen |
| `/profile/share` | ShareProfileScreen |
| `/profile/connect` | (connexion) |
| `/profile/connect` | ConnectScreen (`src/modules/user/screens/ConnectScreen.tsx`) — routed and mounted in `App.tsx`, but **absent from the screen registry**, see [[knowledge_screens]] |
| `/users/:id` | UserProfileScreen |
| `/settings` | SettingsScreen |
> Cette table reflète `parsePath()` dans `router.tsx` — y revenir si elle évolue, c'est la source de vérité.
> This table mirrors `parsePath()` in `router.tsx` — go back to it whenever it changes, that is the source of truth. There is **no** authentication route: the access barrier is not routed, it is rendered *above* the route switch by `AuthGate` (see [[knowledge_app-shell]]).
## Hooks
Les écrans utilisent `useNavigate()` et `useParams()` du router — **pas de prop drilling**. Le shell intercepte la navigation pour basculer l'écran affiché (voir [[knowledge_app-shell]]).
Screens use the router's `useNavigate()` and `useParams()` **no prop drilling**. The shell intercepts navigation to switch the displayed screen (see [[knowledge_app-shell]]).
@@ -1,32 +1,34 @@
---
type: knowledge
summary: Anatomie canonique d'un écran — fonction nommée sans props, lit tout via useFestipodData/useNavigate/useParams, layout flex colonne (Header / contenu scrollable / BottomNav pour les écrans hub), feedback via showToast, libellés français en dur
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
---
# Pattern canonique d'un écran
# Canonical screen pattern
Tous les écrans suivent la même forme. La connaître évite de réinventer ou de diverger.
Every screen follows the same shape. Knowing it avoids reinventing or diverging.
## Forme
## Shape
```tsx
export function MyScreen() { // fonction nommée, JAMAIS de props
export function MyScreen() { // named function, NEVER any props
const navigate = useNavigate();
const { eventId, userId } = useParams();
const { getEvent, currentUser, } = useFestipodData();
const [local, setLocal] = useState(); // état local d'écran (étapes, sélections)
const [local, setLocal] = useState(); // screen-local state (steps, selections)
const handleAction = () => {
// …muter via 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 (
<div style={{ display:'flex', flexDirection:'column', height:'100%' }}>
<Header title="…" /* left/right optionnels */ />
<div style={{ flex:1, overflow:'auto' }}>{/* contenu scrollable */}</div>
<BottomNav active="…" /> {/* seulement sur les écrans hub */}
<Header title="…" /* left/right optional */ />
<div style={{ flex:1, overflow:'auto' }}>{/* scrollable content */}</div>
<BottomNav active="…" /> {/* only on hub screens */}
</div>
);
}
@@ -34,10 +36,13 @@ export function MyScreen() { // fonction nommée, JAMAIS de props
## Invariants
- **Zéro prop** : l'écran ne reçoit rien ; tout vient du contexte/hooks (`useFestipodData`, `useNavigate`, `useParams`). Exceptions légitimes : `LoginScreen`/`WelcomeScreen` n'utilisent pas `useFestipodData` (auth/intro).
- **Layout** : flex colonne pleine hauteur ; `Header` en haut, contenu en `flex:1; overflow:auto`, `BottomNav` en bas **uniquement pour les écrans hub** (Home, Events, Profile, Friends). Les écrans de flux (création, édition, détail) n'ont pas de `BottomNav`.
- **Feedback** : `showToast(message, 'success'|'info'|'error')` (mécanisme `ToastContainer` exporté par `sketchy/`).
- **Libellés** : **français, en dur** — aucun i18n, aucune clé de traduction dans le projet.
- Style : voir [[knowledge_styling-system]]. Navigation/registre : [[knowledge_routing]], [[knowledge_screens]].
- **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.
- **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.
- Styling: see [[knowledge_styling-system]]. Navigation/registry: [[knowledge_routing]], [[knowledge_screens]].
Pour **créer** un écran (les 3+ endroits à câbler), voir [[cookbook_add-screen]].
To **create** a screen (the 3+ places to wire up), see [[cookbook_add-screen]].
@@ -1,40 +1,41 @@
---
type: knowledge
summary: Inventaire des écrans par module, registre central src/screens/index.ts, et lib de composants sous shared/components/sketchy/ — dont le NOM est conservé mais qui rend un thème moderne (pas 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
---
# Écrans et composants
# Screens and components
## Lib de composants : `sketchy/` = thème moderne
## Component library: `sketchy/` = modern theme
⚠️ **Piège de nommage.** La lib de composants vit sous `src/shared/components/sketchy/` (chemin conservé, importé par ~17 écrans), **mais elle ne rend plus un style « hand-drawn »** : elle a été portée vers un thème **moderne** (DM Sans / orange, classes `app-*`). Le *chemin d'import* est bon, la *description visuelle « sketchy »* est périmée. Ne pas réintroduire d'esthétique dessinée en se fiant au nom du dossier.
⚠️ **Naming pitfall.** The component library lives under `src/shared/components/sketchy/` (path kept, imported by ~17 screens), **but it no longer renders a "hand-drawn" style**: it was ported to a **modern** theme (DM Sans / orange, `app-*` classes). The *import path* is right, the *"sketchy" visual description* is obsolete. Do not reintroduce hand-drawn aesthetics on the strength of the folder name.
Composants typiques : `Header`, `BottomNav`, `Button`, `Card`, `Input`, `Badge`, `Avatar`/`AvatarStack`, `Text`/`Title`, `Toggle`, `ListItem`, `Divider`, `Placeholder`, `BrokerBanner`, `NgStatus`.
Typical components: `Header`, `BottomNav`, `Button`, `Card`, `Input`, `Badge`, `Avatar`/`AvatarStack`, `Text`/`Title`, `Toggle`, `ListItem`, `Divider`, `Placeholder`, `BrokerBanner`, `NgStatus`.
## Registre d'écrans
## Screen registry
`src/screens/index.ts` importe tous les écrans de tous les modules et expose :
`src/screens/index.ts` imports every screen from every module and exposes:
```typescript
export const screenGroups // groupés par domaine (home, events, user, general)
export const screens // liste à plat
export const screenGroups // grouped by domain (home, events, user, general)
export const screens // flat list
export function getScreen(id): Screen | undefined
```
Utilisé notamment par Storybook (voir concept `tech-stack`) pour parcourir les écrans.
Used notably by Storybook (see concept `tech-stack`) to browse the screens.
## Inventaire
## Inventory
Écrans par module (IDs = clés du registre) :
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/** : `AccessGateScreen` — la **barrière d'accès** (login NextGraph + saisie de l'identifiant), rendue par `src/app/AuthGate.tsx`, **hors registre/routing** (ce n'est pas un écran routé). Les anciens `LoginScreen` puis `ConnexionScreen` ont été retirés (cf. concept `app-security`, [[knowledge_authentication]]).
- **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 `/`) 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]]).
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `WelcomeScreen` et la barrière `AccessGateScreen`.
> The path → screen mapping lives in [[knowledge_routing]]. Most screens consume `useFestipodData()` (concept `data-layer`); the exception is `WelcomeScreen`.
## Piège : registre incomplet
## Pitfall: incomplete registry
Le registre doit lister **tous** les écrans. Cas observé : `ConnectScreen` (`src/modules/user/screens/`, routé `/profile/connect`, monté dans `App.tsx`) est **absent de `src/screens/index.ts`** → invisible à Storybook et aux consommateurs du registre, bien qu'il fonctionne en route. Toujours vérifier que l'écran est enregistré (cf. [[cookbook_add-screen]]).
The registry must list **every** screen. Observed case, **still true (verified 2026-07-28)**: `ConnectScreen` (`src/modules/user/screens/ConnectScreen.tsx`) exists, is routed at `/profile/connect` (`router.tsx`) and is mounted in `App.tsx` (`case 'connect'`), yet it is **missing from `src/screens/index.ts`** → invisible to Storybook and to the registry's consumers, even though it works as a route. Always check that the screen is registered (see [[cookbook_add-screen]]).
@@ -1,32 +1,32 @@
---
type: knowledge
summary: src/index.css est la source de vérité du style — variables --app-* (couleurs, rayons, police DM Sans) et classes app-* rendues par les composants ; les écrans combinent ces classes avec des styles inline ; Tailwind est dans le build mais les écrans n'utilisent pas d'utilitaires Tailwind ; la classe user-content est inerte
summary: src/index.css is the styling source of truth — --app-* variables (colors, radii, DM Sans font) and app-* classes rendered by the components; screens combine those classes with inline styles; Tailwind is in the build but screens use no Tailwind utilities; the user-content class is inert
last_checked: 2026-06-15
---
# Système de style
# Styling system
**Source de vérité : `src/index.css`** (thème « Modern clean — DM Sans »). C'est là que vivent les variables CSS et les classes `app-*`. Pas de fichiers CSS par module.
**Source of truth: `src/index.css`** ("Modern clean — DM Sans" theme). That is where the CSS variables and the `app-*` classes live. No per-module CSS files.
## Variables (`:root`)
- Couleurs : `--app-black #1a1a1a`, `--app-gray #888`, `--app-bg/--app-white #fff`, accent orange `--app-accent #E8590C` (+ `-light #FFF7ED`, `-border`, `-dark #C05621`), vert `--app-green #22543D` (+ `-light`, `-border`, `-text`).
- Rayons : `--app-radius 16px`, `--app-radius-sm 12px`, `--app-radius-xs 8px`.
- Police : `--font-app: 'DM Sans', …`.
- Colors: `--app-black #1a1a1a`, `--app-gray #888`, `--app-bg/--app-white #fff`, orange accent `--app-accent #E8590C` (+ `-light #FFF7ED`, `-border`, `-dark #C05621`), green `--app-green #22543D` (+ `-light`, `-border`, `-text`).
- Radii: `--app-radius 16px`, `--app-radius-sm 12px`, `--app-radius-xs 8px`.
- Font: `--font-app: 'DM Sans', …`.
## Classes `app-*`
## `app-*` classes
Définies dans `index.css`, rendues par les composants de `shared/components/sketchy/` : `app-btn` (+ `-primary`/`-green`), `app-input`, `app-card`, `app-title`/`app-subtitle`/`app-text`, `app-badge`, `app-toggle`, `app-checkbox`, `app-header`, `app-navbar`, `app-list-item`, `app-avatar`, `app-placeholder`, `app-divider`, `app-tab`.
Defined in `index.css`, rendered by the components in `shared/components/sketchy/`: `app-btn` (+ `-primary`/`-green`), `app-input`, `app-card`, `app-title`/`app-subtitle`/`app-text`, `app-badge`, `app-toggle`, `app-checkbox`, `app-header`, `app-navbar`, `app-list-item`, `app-avatar`, `app-placeholder`, `app-divider`, `app-tab`.
## Conventions d'écriture d'un écran
## Conventions for writing a screen
- Utiliser les **composants `sketchy/`** (qui portent les classes `app-*`) pour boutons/inputs/cartes/typo.
- Pour le **layout** (flex, gaps, paddings, couleurs ponctuelles), les écrans utilisent des **styles inline** (`style={{…}}`) — c'est le pattern normal, pas une déviation.
- Icônes : **emojis**/symboles Unicode (📅 📍 📝 🎪…), pas d'imports d'icônes en général.
- Largeur : `.app-container` borne à **`max-width: 768px`, `height: 100dvh`** (mobile-first/tablette portrait). Aucune media query — pas de responsive desktop.
- Use the **`sketchy/` components** (which carry the `app-*` classes) for buttons/inputs/cards/typography.
- For **layout** (flex, gaps, paddings, one-off colors), screens use **inline styles** (`style={{…}}`) — that is the normal pattern, not a deviation.
- Icons: **emojis**/Unicode symbols (📅 📍 📝 🎪…), generally no icon imports.
- Width: `.app-container` caps at **`max-width: 768px`, `height: 100dvh`** (mobile-first/portrait tablet). No media queries — no desktop responsiveness.
## Pièges
## Pitfalls
- **Tailwind est dans le build** (plugin `bun-plugin-tailwind`, dépendance `tailwindcss`), mais **les écrans n'utilisent pas de classes utilitaires Tailwind** — le style réel passe par `app-*` + inline. Ne pas « tailwindiser » un écran en pensant suivre la convention.
- **`user-content` est une classe INERTE** : utilisée sur de nombreux titres/noms dans les écrans, **sans aucune définition CSS**. C'est un marqueur legacy sans effet — ne pas s'appuyer dessus pour styler, ne pas croire qu'elle fait quelque chose.
- Pas de **dark mode** : le toggle « darkMode » de `SettingsScreen` n'est branché à rien.
- **Tailwind is in the build** (`bun-plugin-tailwind` plugin, `tailwindcss` dependency), but **screens use no Tailwind utility classes** — the actual styling goes through `app-*` + inline. Do not "tailwindify" a screen thinking you are following the convention.
- **`user-content` is an INERT class**: used on many titles/names across the screens, **with no CSS definition whatsoever**. It is a legacy marker with no effect — do not rely on it for styling, do not assume it does anything.
- No **dark mode**: the "darkMode" toggle in `SettingsScreen` is not wired to anything.
@@ -1,24 +1,24 @@
---
type: rule
summary: Un module n'importe QUE depuis shared/ (et le registre d'écrans) — jamais depuis un autre module ; c'est l'invariant qui garde l'architecture feature-based
summary: A module imports ONLY from shared/ (and the screen registry) — never from another module; this is the invariant that keeps the feature-based architecture real
---
# Règle : un module n'importe jamais d'un autre module
# Rule: a module never imports from another module
**Les modules importent uniquement depuis `shared/` — jamais entre eux.**
**Modules import only from `shared/` — never from each other.**
```
src/modules/event/screens/EventDetailScreen.tsx
✅ import depuis 'shared/components/...'
✅ import depuis 'shared/context/FestipodDataContext'
✅ import depuis 'src/screens' (types du registre)
❌ import depuis 'modules/user/screens/...'
✅ import from 'shared/components/...'
✅ import from 'shared/context/FestipodDataContext'
✅ import from 'src/screens' (registry types)
❌ import from 'modules/user/screens/...'
```
## Pourquoi
## Why
C'est ce qui rend l'architecture *feature-based* réelle et pas cosmétique : chaque domaine reste un bloc autonome, déplaçable/supprimable sans casser les autres. Tout besoin partagé **remonte dans `shared/`** ; toute dépendance inter-domaines passe par un contrat de `shared/` (souvent `FestipodDataContext` ou le registre d'écrans), jamais par un import direct.
This is what makes the *feature-based* architecture real rather than cosmetic: each domain stays a self-contained block, movable or removable without breaking the others. Any shared need **moves up into `shared/`**; any cross-domain dependency goes through a `shared/` contract (usually `FestipodDataContext` or the screen registry), never through a direct import.
## Vérifier
## How to check
`grep -rE "from '\.\./\.\./(event|user|home|auth|workshop|meeting|notification)/" src/modules/` ne doit rien remonter d'un module vers un *autre* module. Un import qui croise deux noms de modules différents est une violation.
`grep -rE "from '\.\./\.\./(event|user|home|auth|workshop|meeting|notification)/" src/modules/` must not surface anything pointing from one module to *another* module. An import that crosses two different module names is a violation.
-9
View File
@@ -1,9 +0,0 @@
# Doc-debt — app-security
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+20 -10
View File
@@ -1,21 +1,31 @@
---
type: _overview
summary: curité & confidentialité de Festipodl'isolation entre périmètres est assurée par le SDK de données, l'app lui fait confiance et ne porte aucune logique d'autorisation dans les écrans ; authentification par wallet ; matrice d'autorisations cible en incubation
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, 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"]
---
# App security
Le modèle de **sécurité, confidentialité et autorisations** de Festipod.
Festipod's **security, privacy and authorization** model.
- **Modèle appliqué** — l'**isolation entre périmètres** (public / protected / private) est **assurée par le SDK de données** (`@ng-eventually/client`), qui n'expose à chaque utilisateur que ce à quoi il a droit. L'app **fait confiance** au SDK : aucun écran ne porte de logique d'autorisation. Voir [[knowledge_trust-model]].
- **Matrice d'autorisations cible** — le détail *qui peut faire quoi* par acteur × verbe (données personnelles = réseau, anonymat via inbox de notification) : [[brief_2026-05-18_authorization-matrix]]. **Incubation.** Graduera en `rule_`/`behavior_` à mesure que le produit se cale.
- **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.
## Liens
## Pitfalls
- [[knowledge_trust-model]] — l'app délègue l'isolation au SDK, pas de contrôle d'accès dans les écrans
- [[knowledge_authentication]] — auth par wallet, tous authentifiés, pas d'accès anonyme
- [[brief_2026-05-18_authorization-matrix]] — matrice d'autorisations cible (incubation)
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — quel scope pour quelle entité (fait produit)
- [[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, 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,126 +1,126 @@
---
type: brief
summary: Matrice d'autorisations cible par type de donnée (PdR, inscription, événement, profil, connexion) exprimée en scopes public/protected/private + dialog ; décisions cadre acquises (tous authentifiés, PdR publics, données personnelles = réseau, notification par inbox identifiée-ou-anonyme) ; questions ouvertes sur modèle d'écriture événement et identité de l'hôte
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
---
# Matrice d'autorisations et inventaire des requêtes
# Authorization matrix and query inventory
**Status:** Incubating — modèle cible, non figé en règles.
**Status:** Incubating — target model, not frozen into rules.
## Context
Le modèle **cible** de qui-peut-quoi. La confidentialité de Festipod se dérive de : (1) une matrice d'autorisations par acteur × verbe ; (2) l'inventaire des requêtes par écran ; (3) les **périmètres** (scopes) qui en découlent — données partageant à la fois autorisation *et* schéma d'accès. Le placement concret entité → scope est un fait produit : concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. L'isolation est **assurée par le SDK de données** ([[knowledge_trust-model]]).
The **target** model of who-may-do-what. Festipod's privacy derives from: (1) an authorization matrix per actor × verb; (2) the inventory of queries per screen; (3) the **scopes** that follow from them — data sharing both an authorization *and* an access pattern. The concrete entity → scope placement is a product fact: concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. Isolation is **enforced by the data SDK** ([[knowledge_trust-model]]).
## Cadre
## Framing
### Acteurs (tous authentifiés)
### Actors (all authenticated)
`Alice` (point de vue, propriétaire de la donnée en focus) · `Bob` (second protagoniste, relations bilatérales) · `D` (déclarant d'événement) · `H` (hôte d'un PdR) · `I` (inscrit) · `C` (connexion) · `U` (utilisateur lambda sans relation).
`Alice` (viewpoint, owner of the data in focus) · `Bob` (second protagonist, bilateral relationships) · `D` (event declarer) · `H` (host of a meeting point) · `I` (registrant) · `C` (connection) · `U` (plain user with no relationship).
### Verbes
### Verbs
`créer` · `lire` (one-shot) · `s'abonner` (lecture réactive) · `modifier` · `supprimer`. Conventions : `✓` autorisé · `✗` interdit · `cond` sous condition · `—` sans objet.
`create` · `read` (one-shot) · `subscribe` (reactive read) · `update` · `delete`. Conventions: `✓` allowed · `✗` forbidden · `cond` conditional · `—` not applicable.
## Décisions cadre (acquises)
## Framing decisions (settled)
- **Tous authentifiés.** Pas d'accès anonyme.
- **Points de rencontre publics universels.** Tout utilisateur peut lire et s'abonner.
- **Création de PdR ouverte à tous.** Pas de prérequis.
- **Hôte = détenteur des droits d'écriture** sur un PdR (1 hôte, le créateur ; le fait d'être hôte est public).
- **Informations personnelles = réservées au réseau.** Visibles seulement au titulaire et à ses connexions : participations, intégralité du profil, liste de connexions, et tout état déclaratif dont la divulgation serait une fuite. Statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans le même utilisateur.
- **Connexion bilatérale.** Existe après acceptation des deux côtés. Deux objets : `DemandeDeConnexion` (unilatérale, transitoire) et `Connexion` (bilatérale, persistante).
- **Notification d'inscription via l'inbox du PdR.** L'acte « s'inscrire » est composite : (a) écriture d'un objet `Inscription` dans le périmètre *protected* de l'inscrit, (b) dépôt d'un lien dans l'**inbox** du document PdR. L'expéditeur est **identifié si connexion de l'hôte, anonyme sinon** — propriété du modèle de données.
- **Adhésion à une communauté / suivi : hors périmètre actuel.**
- **Everyone is authenticated.** No anonymous access.
- **Meeting points are universally public.** Any user may read and subscribe.
- **Creating a meeting point is open to all.** No prerequisite.
- **Host = holder of the write rights** on a meeting point (1 host, the creator; being the host is public).
- **Personal information = reserved to the network.** Visible only to the holder and their connections: participations, the profile in full, the connection list, and any declarative state whose disclosure would be a leak. "Public" status (meeting point, event) and "personal" status (profile, participations, connections) coexist within the same user.
- **Bilateral connection.** It exists once both sides have accepted. Two objects: `DemandeDeConnexion` (one-sided, transient) and `Connexion` (bilateral, persistent).
- **Registration notification through the meeting point's inbox.** The act of registering is composite: (a) writing an `Inscription` object into the registrant's *protected* scope, (b) dropping a link into the **inbox** of the meeting point document. The sender is **identified if they are one of the host's connections, anonymous otherwise** a property of the data model.
- **Joining a community / following: out of current scope.**
## Matrice par type de donnée
## Matrix per data type
### Point de rencontre
### Meeting point
| Verbe | Alice (= Hôte) | I (autre inscrit) | D (déclarant parent) | U (lambda) |
| Verb | Alice (= Host) | I (other registrant) | D (parent declarer) | U (plain user) |
|---|---|---|---|---|
| créer | ✓ (rend hôte) | — | ✗ | ✓ (rend hôte) |
| lire | ✓ | ✓ | ✓ | ✓ |
| s'abonner | ✓ | ✓ | ✓ | ✓ |
| modifier | ✓ | ✗ | ✗ | ✗ |
| supprimer | ✓ | ✗ | ✗ | ✗ |
| create | ✓ (becomes host) | — | ✗ | ✓ (becomes host) |
| read | ✓ | ✓ | ✓ | ✓ |
| subscribe | ✓ | ✓ | ✓ | ✓ |
| update | ✓ | ✗ | ✗ | ✗ |
| delete | ✓ | ✗ | ✗ | ✗ |
Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage UI, pas un droit, tout étant public). Le `D` n'a aucun droit particulier sur les PdR greffés sur son événement.
Notes: no `C` differentiation (connections are a UI display filter, not a right, since everything is public). `D` has no special right over the meeting points grafted onto their event.
### Inscription à un point de rencontre
### Registration to a meeting point
`Inscription` lie un utilisateur et un PdR. **Donnée personnelle** (inscrit + ses connexions). Acte composite (a)+(b) ci-dessus.
`Inscription` links a user and a meeting point. **Personal data** (registrant + their connections). Composite act (a)+(b) above.
| Verbe | Alice (inscrite) | C (connexion) | H (hôte) | I (autre inscrit) | U |
| Verb | Alice (registrant) | C (connection) | H (host) | I (other registrant) | U |
|---|---|---|---|---|---|
| créer (acte composite) | ✓ | — | ✗ | ✗ | ✓ (rend inscrite) |
| lire le contenu | ✓ | ✓ | cond : ✓ si H ∈ connexions(Alice) ; sinon lien opaque | cond : ✓ si I ∈ connexions(Alice) | ✗ |
| s'abonner | ✓ | ✓ | cond (idem) | cond (idem) | ✗ |
| lire l'inbox du PdR (entrées brutes) | — | — | ✓ | ✗ | ✗ |
| modifier | ? **à trancher** (selon champs) | ✗ | ✗ | ✗ | ✗ |
| supprimer | ✓ (se désinscrire ; retirer le lien de l'inbox si possible) | ✗ | cond : modération inbox seule (ne supprime pas l'objet) | ✗ | ✗ |
| create (composite act) | ✓ | — | ✗ | ✗ | ✓ (becomes registrant) |
| read the content | ✓ | ✓ | cond: ✓ if H ∈ connections(Alice); opaque link otherwise | cond: ✓ if I ∈ connections(Alice) | ✗ |
| subscribe | ✓ | ✓ | cond (same) | cond (same) | ✗ |
| read the meeting point's inbox (raw entries) | — | — | ✓ | ✗ | ✗ |
| update | ? **to be decided** (depends on the fields) | ✗ | ✗ | ✗ | ✗ |
| delete | ✓ (unregister; remove the link from the inbox if possible) | ✗ | cond: inbox moderation only (does not delete the object) | ✗ | ✗ |
**Visibilité hôte : résolue** (identifiée si connecté, anonyme sinon). **Questions ouvertes :** champs modifiables d'une inscription (booléen seul ou +commentaire/statut/accompagnants ?) ; **suppression côté inbox**un déposant peut-il retirer son lien d'un doc qu'il ne contrôle pas ?
**Host visibility: resolved** (identified if a connection, anonymous otherwise). **Open questions:** which fields of a registration are editable (a bare boolean, or + comment/status/companions?); **inbox-side deletion**can a depositor withdraw their link from a document they do not control?
### Événement
### Event
| Verbe | Alice (= D) | H (hôte d'un PdR greffé) | U |
| Verb | Alice (= D) | H (host of a grafted meeting point) | U |
|---|---|---|---|
| créer | ✓ (rend déclarant) | — | ✓ (rend déclarant) |
| lire / s'abonner | ✓ | ✓ | ✓ |
| modifier | ? **à trancher** | ? **à trancher** | ? **à trancher** |
| supprimer | ? **à trancher** | ✗ | ✗ |
| create | ✓ (becomes declarer) | — | ✓ (becomes declarer) |
| read / subscribe | ✓ | ✓ | ✓ |
| update | ✓ (owner, sole writer) | ✗ | ✗ |
| delete | ? **to be decided** | ✗ | ✗ |
**Questions ouvertes :** qui peut **modifier** un événement déclaré — déclarant seul (propriétaire) ? tout utilisateur (wiki) ? personne (immuable) ? Central pour la déduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Qui peut **supprimer**, et que deviennent les PdR greffés (orphelins/cascade/marqué supprimé) ?
**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)?
### Profil utilisateur
### User profile
**Rien dans le profil n'est public.** Deux périmètres : **profil réseau** (Alice + connexions : nom, avatar, bio, ville, intérêts) ; **profil privé** (Alice seule : settings, email, préférences).
**Nothing in the profile is public.** Two scopes: the **network profile** (Alice + connections: name, avatar, bio, city, interests); the **private profile** (Alice alone: settings, email, preferences).
| Verbe | Alice | C | U |
| Verb | Alice | C | U |
|---|---|---|---|
| créer | ✓ (à l'inscription) | — | — |
| lire — réseau | ✓ | ✓ | ✗ |
| lire — privé | ✓ | ✗ | ✗ |
| s'abonner | ✓ | ✓ (réseau) | ✗ |
| modifier | ✓ | ✗ | ✗ |
| supprimer (compte) | ✓ | ✗ | ✗ |
| create | ✓ (at sign-up) | — | — |
| read — network | ✓ | ✓ | ✗ |
| read — private | ✓ | ✗ | ✗ |
| subscribe | ✓ | ✓ (network) | ✗ |
| update | ✓ | ✗ | ✗ |
| delete (account) | ✓ | ✗ | ✗ |
**Tension à résoudre :** un PdR est lisible par tous, mais son hôte ne devrait pas être identifiable par un lambda. Trois positions : (i) **pseudonyme par identité seule** (nom/avatar résolus seulement aux connexions) ; (ii) **identité dénormalisée dans l'offre** (l'hôte choisit une « carte de visite » par PdR, vivant dans l'objet PdR, profil fermé) ; (iii) **anonymat de l'hôte** (identité révélée seulement aux connexions). À trancher. Autres : composition champ-par-champ de chaque périmètre ; statut du `username` (public/réseau/supprimé ?).
**Tension to resolve:** a meeting point is readable by everyone, yet its host should not be identifiable by a plain user. Three positions: (i) **pseudonym through identity alone** (name/avatar resolved only for connections); (ii) **identity denormalized into the offer** (the host picks a "business card" per meeting point, living inside the meeting point object, profile closed); (iii) **host anonymity** (identity revealed only to connections). To be decided. Others: field-by-field composition of each scope; status of the `username` (public/network/dropped?).
### Connexion (lien d'amitié)
### Connection (friendship link)
Bilatérale. `DemandeDeConnexion` (unilatérale, en attente) → `Connexion` (bilatérale, à l'acceptation ; ouvre l'accès aux données personnelles). La liste de connexions d'Alice est **personnelle** (Alice + ses connexions).
Bilateral. `DemandeDeConnexion` (one-sided, pending) → `Connexion` (bilateral, upon acceptance; opens access to personal data). Alice's connection list is **personal** (Alice + her connections).
| Verbe | Alice (initiatrice) | Bob (autre côté) | C | U |
| Verb | Alice (initiator) | Bob (other side) | C | U |
|---|---|---|---|---|
| créer la demande | ✓ | — | — | — |
| accepter | — | ✓ | — | ✗ |
| lire la liste d'Alice | ✓ | ✓ | ✓ | ✗ |
| s'abonner | ✓ | ✓ | ✓ | ✗ |
| supprimer (rompre A↔B) | ✓ | ✓ | ✗ | ✗ |
| create the request | ✓ | — | — | — |
| accept | — | ✓ | — | ✗ |
| read Alice's list | ✓ | ✓ | ✓ | ✗ |
| subscribe | ✓ | ✓ | ✓ | ✗ |
| delete (break A↔B) | ✓ | ✓ | ✗ | ✗ |
**Questions ouvertes :** granularité côté Bob (voit-il toute la liste d'Alice ou juste A↔B ? — conséquence du principe : toute la liste) ; découvrabilité « amis d'amis » (Alice voit-elle Bob↔Carole ? — non, sauf si Carole ∈ connexions(Alice)).
**Open questions:** granularity on Bob's side (does he see Alice's whole list, or only A↔B? — the principle implies: the whole list); "friends of friends" discoverability (does Alice see Bob↔Carole? — no, unless Carole ∈ connections(Alice)).
## Périmètres dérivés
## Derived scopes
Heuristique : même périmètre si (a) me cellule d'autorisation en écriture *et* (b) accédées ensemble. Trois **scopes** émergent, plus le cas bilatéral :
Heuristic: same scope if (a) same write-authorization cell *and* (b) accessed together. Three **scopes** emerge, plus the bilateral case:
| Périmètre | Écriture | Lecture | Données |
| Scope | Write | Read | Data |
|---|---|---|---|
| **public** | Alice seule | Tous | PdR hébergés par Alice ; événements déclarés *(sous réserve du modèle d'écriture)* |
| **protected** (réseau) | Alice seule | Alice + connexions | Profil réseau ; participations ; index des connexions |
| **private** | Alice seule | Alice seule | Profil privé (settings, email, préférences) |
| **dialog** (A↔B) | Alice et Bob | Alice et Bob | La `Connexion` bilatérale (+ matière à messagerie future) |
| **public** | Alice alone | Everyone | Meeting points hosted by Alice; declared events *(subject to the write model)* |
| **protected** (network) | Alice alone | Alice + connections | Network profile; participations; connection index |
| **private** | Alice alone | Alice alone | Private profile (settings, email, preferences) |
| **dialog** (A↔B) | Alice and Bob | Alice and Bob | The bilateral `Connexion` (+ groundwork for future messaging) |
La **`Connexion` bilatérale** a *deux* écrivains → périmètre **dialog** dédié à la paire ; l'**index « toutes les connexions d'Alice »** vit en *protected* (liste les références des connexions). L'**inbox du PdR** est un attribut du document public, pas un périmètre séparé.
The bilateral **`Connexion`** has *two* writers → a **dialog** scope dedicated to the pair; the **"all of Alice's connections" index** lives in *protected* (it lists the connection references). The **meeting point's inbox** is an attribute of the public document, not a separate scope.
## Inventaire des requêtes par écran
## Query inventory per screen
*À remplir une fois la matrice stabilisée.* Schéma prévu : `| Écran | Lectures one-shot | Abonnements | Écritures | Acteur déclencheur |`. Écrans à analyser : voir la table de routes (concept `app-architecture`).
*To be filled in once the matrix has stabilized.* Planned schema: `| Screen | One-shot reads | Subscriptions | Writes | Triggering actor |`. Screens to analyze: see the route table (concept `app-architecture`).
## See Also
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — placement entité → scope + découverte
- [[knowledge_trust-model]] — l'isolation est assurée par le SDK
- `README.md §Modèle fonctionnel` — source des acteurs
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — entity → scope placement + discovery
- [[knowledge_trust-model]] — isolation is enforced by the SDK
- `README.md §Modèle fonctionnel` — source of the actors
@@ -0,0 +1,22 @@
---
type: caveat
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 wallet-password global BEFORE the module is first imported
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.
**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` 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-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,45 +0,0 @@
---
type: decision
summary: L'identifiant de l'espace virtuel se saisit à la barrière d'accès (AccessGateScreen), dans le même acte que l'ouverture du wallet ; l'écran de « login perçu » séparé (ConnexionScreen, « choisissez un nom d'utilisateur ») est retiré ; l'identifiant est un id technique normalisé en minuscules, pas un username Festipod
---
# Décision (2026-07-06) : identifiant saisi à la barrière d'accès
## Contexte
Le flux stopgap de [[decision_2026-06-15_shared-wallet-login-flow]] enchaînait **deux
écrans** : (1) `AccessGateScreen`, la barrière d'accès (vrai login NextGraph, ouverture du
wallet partagé) ; (2) `ConnexionScreen`, un « login perçu » où l'utilisateur choisissait un
**nom d'utilisateur**. Cette identité applicative était en réalité la clé du **wallet virtuel**
(clé du compte shim / cap owner), pas un username produit — le cadrage « nom d'utilisateur »
était donc trompeur (logique `setUsername` confuse).
## Décision
L'utilisateur saisit son **identifiant** directement dans `AccessGateScreen`, **dans le même
acte** qui ouvre le wallet (« Entrer » enregistre l'identifiant puis déclenche `connect()`).
`ConnexionScreen` est **supprimé**. L'identifiant :
- est un **id technique** qui nomme l'espace virtuel (un pseudo en pratique, **pas** un
username Festipod) ;
- est **normalisé** à la saisie (trim, `@` retiré, **minuscules**) et persisté avant la
redirection broker (donc il survit au round-trip) ;
- **est** l'id d'identité remis au SDK (`setCurrentUser`), et la clé des caps et du compte
shim — plus de handle à casse mixte à réconcilier.
`AuthGate` affiche donc la barrière tant que le wallet n'est pas ouvert **ou** que l'identifiant
n'est pas posé, puis l'app directement — sans écran intermédiaire.
## Alternatives écartées
- **Garder les deux écrans** : le second écran « nom d'utilisateur » perpétuait la confusion
entre identité-produit et identifiant-de-wallet, et ajoutait une étape sans valeur.
- **Dériver l'identifiant du wallet** (pas de saisie) : impossible ici — le wallet partagé est
unique ; l'identifiant est précisément ce qui distingue les espaces virtuels au sein de ce
wallet (émulation, cf. concept `data-layer` et le SDK `@ng-eventually/client`).
## Portée
Supersede la partie « écran 2 / login perçu » de [[decision_2026-06-15_shared-wallet-login-flow]]
(l'ouverture du wallet partagé via broker reste inchangée). État courant du flux :
[[knowledge_authentication]].
@@ -1,32 +0,0 @@
---
type: decision
summary: Le wallet partagé est le SEUL mode de fonctionnement (le polyfill @ng-eventually/client en dépend comme backend de données) ; le repli « sans wallet partagé » est retiré — mauvaise config → écran d'erreur franc, plus de formulaire nu. Réaffirme que l'identifiant de la barrière = id du wallet/espace, distinct du username du profil.
---
# Décision (2026-07-20) — le wallet partagé est l'unique mode ; identifiant ≠ username du profil
## Contexte
Régression observée : à l'ouverture, l'app tombait sur un **formulaire nu demandant un identifiant**, sans l'assistance de chargement du portefeuille. Cause : `FESTIPOD_SHARED_WALLET_PASSWORD` non défini dans l'environnement du serveur → `hasSharedWallet()` faux → `AccessGateScreen` basculait sur son mode replié. Or ce mode est une **impasse** : un appareil sans wallet ne peut pas se connecter une fois l'assistance d'import masquée. En parallèle, l'ancienne notion de « username » traînait encore pour désigner l'**identité du wallet**, ce qui la confondait avec le vrai username du profil.
## Décision
1. **Le wallet partagé est le seul mode supporté.** Festipod ne fonctionne pas sans lui — le polyfill `@ng-eventually/client` s'en sert comme backend de données (voir [[knowledge_authentication]], `rule_app-uses-sdk-surface-only`). `hasSharedWallet() === false` n'est donc **pas un mode fonctionnel** : c'est une **mauvaise configuration**`AccessGateScreen` affiche un **écran d'erreur franc** (« Portefeuille partagé non configuré, définir `FESTIPOD_SHARED_WALLET_PASSWORD` »), jamais le formulaire nu en impasse.
2. **L'identifiant de la barrière ≠ le username du profil.** L'identifiant saisi à `AccessGateScreen` est l'**id technique du wallet/espace** (normalisé en minuscules, porté par le param d'URL `?id=`), pas un username. Le **username** est un concept distinct qui vit dans `UserProfile` (`@handle`, prédicat `http://festipod.org/username`). Le code et les tests ne doivent plus étiqueter l'identité du wallet « username/user » (renommé en `identifier`). Réaffirme et prolonge [[decision_2026-07-06_identifier-at-access-barrier]].
## Conséquences
- `AccessGateScreen` : rendu 3-branches (erreur config / flux d'import assisté quand non connecté / champ identifiant seul quand déjà connecté).
- Renommage `username → identifier` de l'identité du wallet dans l'infra de test (`freshScenarioIdentifier`, `freshIdentifier`), `registration.ts`, `ngSession`, + commentaires ; **`UserProfile.username` intact** (profil, seed, affichage, SHEX).
- `.env.example` ajouté à la racine pour rendre la config explicite (dont `FESTIPOD_SHARED_WALLET_PASSWORD`, `FESTIPOD_SHARED_WALLET_FILE`).
## Alternative écartée
Garder le repli sans-wallet comme futur « flux wallet-propre » : écarté **pour l'instant** — aucun flux wallet-propre à court terme, et le repli silencieux créait une impasse trompeuse. À réintroduire **explicitement** le jour où un mode wallet-propre (chaque utilisateur avec son propre wallet NextGraph) existera, hors stopgap.
## Liens
- Stopgap wallet partagé : `decision_2026-06-15_shared-wallet-login-flow` (référencé par `AccessGateScreen`/`AccountContext`).
- [[decision_2026-07-06_identifier-at-access-barrier]] — l'identifiant à la barrière.
- [[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,22 +1,37 @@
---
type: knowledge
summary: L'identité d'un utilisateur = son wallet NextGraph ; tous les utilisateurs sont authentifiés (pas d'accès anonyme) ; l'auth est déléguée au SDK, l'app n'a pas de comptes/mots de passe applicatifs
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
---
# Authentification
# Authentication
**L'identité d'un utilisateur = son wallet NextGraph.** Il n'y a **pas d'accès anonyme** à l'app : tout utilisateur est authentifié (cf. concept `functional-domain`). Il n'y a **pas de système de comptes/mots de passe applicatif**l'authentification est **déléguée au SDK de données** (`@ng-eventually/client`) : ouvrir sa session, c'est ouvrir son 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.
## Flux
## The flow — one act, no screen of Festipod's own
- La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans de [[decision_2026-06-15_shared-wallet-login-flow]]).
- Cet **identifiant est un id technique** (un pseudo en pratique, **pas** un username Festipod) : il est **normalisé** (trim, `@` retiré, **minuscules**) puis persisté (`AccountContext``IdentityStore`), donc un rechargement — ou un autre appareil rouvrant le même wallet partagé — retombe sur le même espace. C'est cet id qui est donné au SDK (`setCurrentUser`) et sur lequel les caps et le compte shim sont clés.
- **Porté cross-frontière par un PARAM D'URL `?id=`** (source de vérité), PAS par localStorage. L'app tourne dans deux contextes — **top-level** (`127.0.0.1:3000` direct, `window.self === window.top`, où s'affiche la barrière) et **iframe** (embarquée sous `nextgraph.net` après le round-trip broker, `window.self !== window.top`). Le navigateur **partitionne le storage par site top-level** : le localStorage du top-level et celui de l'iframe sont **deux partitions distinctes** → localStorage NE PEUT PAS porter l'identité d'un contexte à l'autre (symptôme observé : deux valeurs divergentes selon le contexte). Le SDK redirige via `location.href = broker + encodeURIComponent(window.location.href)` (embarque l'URL app complète, query comprise, dans le `o=` rechargé en iframe), donc un **param d'URL traverse**. `AuthGate` écrit `?id=<identifiant>` (`history.replaceState`) **avant** `connect()` ; `AccountContext` résout l'identifiant par priorité **(1) `?id=` de l'URL** puis **(2) localStorage** (préremplissage/convenance same-partition uniquement). Clé localStorage : `festipod.account.identifier`.
- **Saisi UNE SEULE FOIS au premier accès + prérempli au retour.** Au rechargement top-level, la session NG n'est pas restaurée d'office (`NextGraphContext` repart en `disconnected`) : `AuthGate` réaffiche la barrière tant que `status !== 'connected'`, mais le champ d'`AccessGateScreen` est **prérempli** (prop `initialIdentifier`) — jamais un champ nu et vide. Régressions gardées par `src/modules/auth/features/{barriere-acces-identifiant,identifiant-resolution}.feature` (@ui) — d'autant plus utiles que le flux de barrière est **désactivé** dans les tests @e2e (`__FESTIPOD_ACCESS_GATE_DISABLED__`), donc invisible à cette couche.
- Une fois la session ouverte, l'utilisateur courant et son accès aux stores par scope sont fournis par `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.
## Le wallet de test
**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]].
Les tests `@data`/`@e2e` ouvrent un wallet réel (`festipod-tests`, profil persistant) — voir concept `bdd-testing`. Ce sont des **credentials de test en clair**, sans enjeu de sécurité, dédiés au staging.
**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]].
> Le modèle d'autorisations qui s'appuiera sur cette identité (connexions bilatérales, données personnelles = réseau, anonymat de l'hôte) est en incubation : [[brief_2026-05-18_authorization-matrix]].
**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. 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: L'isolation entre périmètres (public/protected/private) est assurée par le SDK de données ; l'app lui fait confiance et n'affiche que ce qu'il retourneaucun contrôle d'accès dans les écrans, toute la confidentialité repose sur le 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 returnsno 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
---
# Modèle de confiance
# Trust model
**Posture :** l'app lit les données via les subscriptions ORM du SDK `@ng-eventually/client` et les affiche **sans logique d'autorisation côté app** (`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`).
Principes :
Principles:
1. **L'isolation est déléguée au SDK.** Chaque entité vit dans le store de son **scope** (public / protected / private, cf. concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]) ; le SDK **n'expose à l'utilisateur courant que ce à quoi il a droit**. L'app suppose que ce qu'elle reçoit est déjà autorisé — la confidentialité repose sur le SDK, pas sur du code Festipod.
2. **Les écrans ne portent aucune règle d'accès.** Pas de vérification « cet utilisateur a-t-il le droit de voir cette donnée » dans les composants ni dans le contexte de données. La séparation public / réseau / privé est une propriété du **placement par scope**, pas d'un filtre applicatif.
3. **La relation entre utilisateurs (« connexions ») est une notion applicative, pas une primitive du SDK.** NextGraph n'a pas de primitive de connexion/amitié bilatérale ; côté SDK il n'existe qu'un **grant de lecture dirigé** vers une identité. L'app **possède** donc son graphe de relations (`src/shared/utils/connections.ts`) et le **traduit** en grants dirigés par document remis au SDK — elle ne délègue pas la notion de relation au SDK, seulement l'**application** de l'isolation qui en découle. Ce que l'app déclare au SDK reste minimal : **son identité** (l'identifiant, cf. [[knowledge_authentication]]) et **ces grants** ; elle ne porte toujours aucune logique d'accès dans les écrans.
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.
## Le point de vigilance
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.
Parce que l'app **affiche tout ce qu'elle reçoit**, la confidentialité tient entièrement à ce que le SDK n'expose que le légitime. C'est un choix assumé (l'app reste mince), mais il implique de **ne jamais réintroduire côté écran une donnée que le scope n'aurait pas dû laisser passer**.
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]]).
> À vérifier si on doute : `useNgData` dans `FestipodDataContext.tsx` ne contient aucune branche de filtrage par identité — c'est intentionnel, l'isolation vient d'en dessous.
## 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.
-20
View File
@@ -1,20 +0,0 @@
# Doc-debt — bdd-testing
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion-socket-mort.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/support/hooks.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/isolation.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+24 -18
View File
@@ -1,36 +1,42 @@
---
type: _overview
summary: BDD Cucumber/Gherkin en français sur 3 couches (@ui, @data, @e2e) — setup, contrat de couches (quoi tester où), harness broker réel, et le piège des vestiges source-grep
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"]
---
# BDD testing
Tests BDD **Cucumber/Gherkin en français** (`Etant donné`, `Quand`, `Alors`) sur **3 couches** de coût croissant.
BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `Alors`) across **3 layers** of increasing cost.
**À lire avant d'écrire un test :** [[rule_test-layer-contracts]] — chaque couche répond à une question distincte ; mélanger produit des tests fragiles. C'est la règle qui décide ** va une assertion.
**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*.
## Les 3 couches
## The 3 layers
```
/\ @e2e app réelle dans l'iframe broker — parcours critiques
/\ @e2e real app inside the broker iframe — critical journeys
/ \
/----\ @data mutations & persistance via broker NextGraph réel
/----\ @data mutations & persistence through the real NextGraph broker
/------\
/ @ui \ rendu d'écran in-process (happy-dom + seed) — le gros du volume
/ @ui \ in-process screen rendering (happy-dom + seed) — the bulk of the volume
/__________\
```
## Liens
## Links
- [[rule_test-layer-contracts]] — quoi tester à chaque couche (le contrat)
- [[knowledge_cucumber-setup]] — config, layout, scripts, fichiers auto-générés
- [[knowledge_ui-layer]] — couche `@ui` : render helper, fixtures, bons/anti patterns
- [[knowledge_data-layer-broker]] — couche `@data` : harness broker, cycle de vie wallet, bridge
- [[knowledge_e2e-layer]] — couche `@e2e` : app réelle dans l'iframe
- [[knowledge_multibrowser-harness]] — plusieurs navigateurs isolés × modèle de wallet (private/shared), injection storageState
- [[decision_2026-03-12_headless-wallet-creation]] — pourquoi le wallet de test est créé en UI headless
- [[caveat_source-grep-vestiges]] — vestiges de l'ère « analyse de source » dans `world.ts`
- [[cookbook_add-scenario]] — ajouter un scénario/step (couches, piège de sérialisation `evaluate`, `@wip`)
- [[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 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.
@@ -0,0 +1,51 @@
---
type: caveat
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)
**Reconnection** scenarios all ask the same question — *does the user get their own data back after closing and reopening?* — but **depending on the browser context chosen, they do not answer the same question**. That is the pitfall: the most natural setup (`ctx.newPage()`) yields a green that proves nothing about the broker.
## The two setups, and what each one proves
| 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 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).
## What makes the "no-local" verdict valid (do not break it)
Three conditions, all met in `reconnexion-froide-sans-local.steps.ts`:
1. **Separate process**`freshBrowser` is a non-persistent `chromium.launch`, distinct from the profile carrying the wallet (see [[knowledge_multibrowser-harness]] for the isolation proven all the way to the broker origin).
2. **Hermetic partition** — every Playwright `newContext()` has its own storage; no IndexedDB shared with the writing page.
3. **The only pre-injected state is `pool.sharedWalletState`**, captured **once at `BeforeAll`**, hence **before** the scenario writes anything → the snapshot **cannot** contain the data under test.
> **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 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 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.
**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 = 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.
> 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
- [[knowledge_data-layer-broker]] — the single-browser `@data` layer (persistent profile).
- [[knowledge_multibrowser-harness]] — `freshBrowser`, `spawnContext`, `pool.sharedWalletState`.
- [[knowledge_e2e-layer]] — the `@e2e` counterpart (real app) of the "close and reopen" setup.
- [[rule_no-broker-polling]] — the red line waiting steps must not cross.
@@ -1,21 +1,21 @@
---
type: caveat
summary: world.ts garde des vestiges de l'ère « analyse de source » (screenFileMap, screenFieldDetectors, screenExpectedContent, screenRequiredFields ; hasText/hasField/hasElement à fallback source) — à supprimer une fois la migration @ui vers le DOM rendu terminée
summary: world.ts still carries leftovers from the "source analysis" era (screenFileMap, screenFieldDetectors, screenExpectedContent, screenRequiredFields; hasText/hasField/hasElement with a source fallback) — to be deleted once the @ui migration to the rendered DOM is complete
last_checked: 2026-06-15
---
# Caveat : vestiges d'analyse de source dans `world.ts`
# Caveat: source-analysis leftovers in `world.ts`
La suite `@ui` **précède** le contrat de couches ([[rule_test-layer-contracts]]). Des restes de l'ère « grep sur le code source » subsistent et **ne doivent pas être étendus** :
The `@ui` suite **predates** the layer contract ([[rule_test-layer-contracts]]). Remnants of the "grep the source code" era are still around and **must not be extended**:
- `world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` — mappings de l'approche analyse-de-source.
- `hasText` / `hasField` / `hasElement`**préfèrent désormais le DOM rendu** mais **retombent sur la source** pour que les steps non migrés continuent de marcher pendant la transition.
- `world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` — mappings from the source-analysis approach.
- `hasText` / `hasField` / `hasElement`they now **prefer the rendered DOM** but **fall back to the source** so that unmigrated steps keep working during the transition.
## Plan de migration (en cours)
## Migration plan (in progress)
1. Réécrire les assertions grep-source → requêtes DOM via le render helper.
2. Supprimer les tests sur détails d'implémentation (`/showDuplicateWarning/`, `/importableEvents/`, regex sur JSX).
3. Déplacer les assertions comportementales vers `@e2e` quand pas déjà couvertes.
4. Retirer les checks de contenu `@e2e` redondants avec `@ui`.
1. Rewrite grep-the-source assertions as DOM queries through the render helper.
2. Delete tests that assert implementation details (`/showDuplicateWarning/`, `/importableEvents/`, regexes over JSX).
3. Move behavioural assertions to `@e2e` where they are not already covered.
4. Drop the `@e2e` content checks that duplicate `@ui`.
Une fois la migration terminée, les 4 maps vestiges peuvent disparaître au profit d'assertions sur le DOM rendu + seed. **Tant qu'elles existent, ne pas s'appuyer dessus pour de nouveaux tests.**
Once the migration is done, the 4 leftover maps can go away in favour of assertions on the rendered DOM plus seed data. **As long as they exist, do not rely on them for new tests.**
@@ -1,35 +1,37 @@
---
type: caveat
summary: Le wallet de test partagé (.playwright-profile) accumule des données à chaque run ; passé un seuil, les sparql_query ancrées au private store hangent (>15s) et toute la suite @data échoue au setup — repartir d'un profil frais restaure des lectures ~1s
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
---
# Piège : le wallet de test se gonfle et fait *hang* les lectures @data
# Pitfall: the test wallet saturates — within a run, and across runs
Le profil Chromium persistant `.playwright-profile` (racine du working tree) porte le **wallet
partagé** ouvert par toute la suite `@data`/`@e2e`. Ce wallet **accumule des données à chaque
run** : comptes shim (un par scénario, via l'identifiant frais `freshScenarioUsername`), docs
d'entités seedés, dépôts d'inbox historiques… Le private store est le **point d'ancrage du shim**
(résolution de compte) et est interrogé par **toute** lecture/écriture (`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.
**Symptôme.** Passé un certain volume (observé ~99 Mo de profil), une `sparql_query` **ancrée au
private store** ne revient plus sous 15 s — elle *hang*. Comme la résolution de compte est sur le
chemin de **chaque** read/write, **toute la suite @data échoue au setup** (0 événement chargé,
timeouts), sans erreur explicite. Diagnostic vérifié : sur un wallet frais la même requête revient
en **~1,5 s** et le seed complète normalement.
Two distinct phenomena, and the first is the one that bites today.
**Contournement.** Mettre le profil gonflé de côté et laisser le hook d'auth (beforeAll) en
recréer un frais :
## 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).
L'identifiant frais par scénario (`freshScenarioUsername`) borne le *registre* des comptes mais
**pas** la croissance physique du private store partagé — d'où la récurrence. Une hygiène durable
(purge périodique / wallet jetable par run) reste à mettre en place ; en attendant, si les
`resolveAccount failed`/timeouts réapparaissent, repartir d'un profil frais.
**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.
> Le *pourquoi* côté broker (comment une requête ancrée touche le repo du private store) appartient
> au SDK `@ng-eventually/client`, pas ici — ce caveat ne décrit que la conséquence côté tests.
**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.
@@ -1,29 +1,36 @@
---
type: cookbook
summary: Procédure pour ajouter un scénario/step BDD — .feature français taggé, steps par couche, piège de sérialisation de appFrame.evaluate (passer les args, pas de closure), ajouter les helpers aux DEUX harness, tag @wip pour le non-implémenté
summary: How to add a BDD scenario/step — a tagged French .feature, steps per layer, the appFrame.evaluate serialization pitfall (pass arguments, no closures), add helpers to BOTH harnesses, @wip tag for what is not implemented yet
---
# Cookbook : ajouter un scénario / un step
# Cookbook: adding a scenario / a step
1. **Écrire le `.feature`** : `src/modules/{module}/features/us-N-slug.feature`, `# language: fr`, tag de tête `@CATEGORIE @priority-N`, et un tag de couche par scénario (`@ui` / `@data` / `@e2e`). Mots-clés FR : `Fonctionnalité`, `Contexte` (Background), `Scénario`, `Étant donné`/`Quand`/`Alors`. Tagger `@wip` un scénario dont les steps ne sont pas encore écrits.
1. **Write the `.feature`**: `src/modules/{module}/features/us-N-slug.feature`, `# language: fr`, a header tag `@CATEGORIE @priority-N`, and one layer tag per scenario (`@ui` / `@data` / `@e2e`). French keywords: `Fonctionnalité`, `Contexte` (Background), `Scénario`, `Étant donné`/`Quand`/`Alors`. Tag `@wip` any scenario whose steps are not written yet.
2. **Choisir la couche** (cf. [[rule_test-layer-contracts]]) : assertion de rendu `@ui` ; mutation/persistance → `@data` ; parcours complet`@e2e`.
2. **Pick the layer** (see [[rule_test-layer-contracts]]): rendering assertion → `@ui`; mutation/persistence → `@data`; full journey`@e2e`.
3. **Écrire les steps** dans `src/modules/{module}/steps/{ui,data,e2e}/*.steps.ts` (ou `src/shared/steps/ui/` si cross-domaine). Signature : `async function (this: FestipodWorld, …)`. Importer `FestipodWorld` depuis `../../../../shared/support/world` (ajuster le chemin relatif).
3. **Write the steps** in `src/modules/{module}/steps/{ui,data,e2e}/*.steps.ts` (or `src/shared/steps/ui/` if cross-domain). Signature: `async function (this: FestipodWorld, …)`. Import `FestipodWorld` from `../../../../shared/support/world` (adjust the relative path).
4. **Accès aux données selon la couche** :
- `@ui` : `this.renderedDoc` / `this.getDomText()` / `this.hasText(...)` après `navigateTo(...)` (voir [[knowledge_ui-layer]]).
- `@data`/`@e2e` : `await this.appFrame!.evaluate(fn, ...args)` sur le bridge `window.__testData` (voir [[knowledge_data-layer-broker]]).
4. **Data access, per layer**:
- `@ui`: `this.renderedDoc` / `this.getDomText()` / `this.hasText(...)` after `navigateTo(...)` (see [[knowledge_ui-layer]]).
- `@data`/`@e2e`: `await this.appFrame!.evaluate(fn, ...args)` on the `window.__testData` bridge (see [[knowledge_data-layer-broker]]).
5. **⚠️ Piège de sérialisation `appFrame.evaluate`** : la fonction passée s'exécute **dans l'iframe**, les variables du step **ne sont pas capturées** (closures perdues). **Passer toute valeur en argument** :
5. **⚠️ `appFrame.evaluate` serialization pitfall**: the function you pass runs **inside the iframe**, and the step's variables **are not captured** (closures are lost). **Pass every value as an argument**:
```ts
// ❌ const title = eventTitle; await appFrame.evaluate(() => td.getEventByTitle(title)) // title undefined
// ❌ const title = eventTitle; await appFrame.evaluate(() => td.getEventByTitle(title)) // title is undefined
// ✅ await appFrame.evaluate((t) => td.getEventByTitle(t), eventTitle)
```
Toujours `await` (oublier → assertion avant résolution).
Always `await` (forgetting it means asserting before the promise resolves).
6. **Si tu ajoutes une opération de données** : exposer le helper sur `window.__testData` dans **les deux** harness (`src/shared/test-harness/harness.tsx` ET `harness-ng.tsx`) — sinon le fallback mock diverge du broker réel.
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. **Câbler un écran testé** : si le nom français de l'écran ne se résout pas vers son `id`, ajouter un alias dans `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. **Lancer** : `bun run test:cucumber` (tout) ou `bun run test:data` (@data). Rapport : `reports/cucumber-report.html`. Le `@data`/`@e2e` exige le wallet de test (`bun run test:auth-setup` au premier coup si besoin, sinon création auto — cf. [[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,6 +1,6 @@
---
type: decision
summary: Décision 2026-03-12 — créer le wallet de test en automatisant l'UI broker headless (Playwright) plutôt que par API NG, car ça teste le vrai flux d'auth et évite de reverse-engineer l'API d'inscription
summary: Decision 2026-03-12 — create the test wallet by automating the broker UI headlessly (Playwright) rather than through the NG API, because it exercises the real auth flow and avoids reverse-engineering the sign-up API
---
# Automated Headless Wallet Creation for CI
@@ -10,28 +10,28 @@ summary: Décision 2026-03-12 — créer le wallet de test en automatisant l'UI
## Context
Les tests `@data` exigent un wallet NextGraph dans un profil Chromium persistant. Avant, le premier run exigeait une interaction manuelle (navigateur visible, création de wallet à la main) → bloquait le CI.
`@data` tests require a NextGraph wallet inside a persistent Chromium profile. Previously, the first run required manual interaction (visible browser, wallet created by hand) → CI was blocked.
## Options Considered
### Option A: création programmatique du wallet via SDK NG
Appeler `ng.wallet_create()` depuis Node/Bun, sans UI.
- **Pour** : plus rapide, pas de navigateur.
- **Contre** : `@ng-org/web` est browser-only (WASM + postMessage) ; il faudrait reverse-engineer l'API d'inscription d'`account.nextgraph.eu` ; ne teste pas le vrai flux d'auth.
### Option A: create the wallet programmatically through the NG SDK
Call `ng.wallet_create()` from Node/Bun, without any UI.
- **Pros**: faster, no browser needed.
- **Cons**: `@ng-org/web` is browser-only (WASM + postMessage); we would have to reverse-engineer the sign-up API of `account.nextgraph.eu`; it does not exercise the real auth flow.
### Option B: automatiser le flux UI headless
Piloter via Playwright la même UI de création de wallet, en headless.
- **Pour** : teste le vrai flux auth/login de bout en bout ; pas de reverse-engineering ; même profil persistant réutilisé ; CI-ready sans étape manuelle.
- **Contre** : dépend de `nextgraph.eu`/`account.nextgraph.eu` joignables ; fragile aux changements d'UI NextGraph ; +~27s au premier run.
### Option B: automate the UI flow headlessly
Drive the very same wallet-creation UI with Playwright, in headless mode.
- **Pros**: exercises the real auth/login flow end to end; no reverse-engineering; the same persistent profile is reused; CI-ready with no manual step.
- **Cons**: depends on `nextgraph.eu`/`account.nextgraph.eu` being reachable; brittle to NextGraph UI changes; adds ~27s to the first run.
## Decision
**Option B** — automatiser l'UI broker. Le flux de création (navigate → Create Wallet → ToS → username/password → submit) est lui-même un test légitime de la feature d'auth. La dépendance aux services externes est acceptable puisque les tests dépendent déjà du broker joignable.
**Option B** — automate the broker UI. The creation flow (navigate → Create Wallet → ToS → username/password → submit) is itself a legitimate test of the auth feature. Depending on external services is acceptable since the tests already depend on the broker being reachable.
## Consequences
**Positif :** tests pleinement CI-ready (zéro interaction) ; flux auth testé en passant ; `bun run test:data` part d'un état propre.
**Négatif :** exige un accès internet (nextgraph.eu, account.nextgraph.eu) ; fragile aux changements d'UI NextGraph (textes de boutons, IDs de formulaire).
**Risque :** rate-limiting d'`account.nextgraph.eu` si le CI recrée souvent des wallets.
**Positive:** fully CI-ready tests (zero interaction); the auth flow gets tested along the way; `bun run test:data` starts from a clean state.
**Negative:** requires internet access (nextgraph.eu, account.nextgraph.eu); brittle to NextGraph UI changes (button texts, form IDs).
**Risk:** rate-limiting from `account.nextgraph.eu` if CI recreates wallets often.
> Mécanique de cycle de vie détaillée : [[knowledge_data-layer-broker]].
> Detailed lifecycle mechanics: [[knowledge_data-layer-broker]].
@@ -1,46 +1,46 @@
---
type: knowledge
summary: Config Cucumber (cucumber.json, langue fr, loader tsx), layout des features/steps colocalisés par module, steps partagés dans shared/steps/, et les scripts qui génèrent features.ts/testResults.ts/stepDefinitions.ts
summary: Cucumber config (cucumber.json, fr language, tsx loader), layout of features/steps colocated per module, shared steps under shared/steps/, and the scripts that generate features.ts/testResults.ts/stepDefinitions.ts
---
# Setup Cucumber
# Cucumber setup
26 fichiers `.feature` (US-1 à US-26), tous en **français**, taggés `@CATEGORIE @priority-N` (catégories EVENT, WORKSHOP, USER, MEETING, NOTIF).
26 `.feature` files (US-1 to US-26), all in **French**, tagged `@CATEGORIE @priority-N` (categories EVENT, WORKSHOP, USER, MEETING, NOTIF).
## Layout
Features et steps **colocalisés avec leur module** :
Features and steps are **colocated with their module**:
```
src/modules/event/features/us-13-creer-evenement.feature
src/modules/event/steps/{ui,data,e2e}/
```
Steps **partagés** (cross-domaine) dans `src/shared/steps/ui/` :
- `navigation.steps.ts` — navigation, auth, clics/sélections, assertions section/bouton/champ
- `form.steps.ts` — validation de champs, champs requis, import/duplicate
- `screen.steps.ts`contenu d'écran (participants, events, profils, QR)
**Shared** (cross-domain) steps live in `src/shared/steps/ui/`:
- `navigation.steps.ts` — navigation, auth, clicks/selections, section/button/field assertions
- `form.steps.ts` field validation, required fields, import/duplicate
- `screen.steps.ts`screen content (participants, events, profiles, QR)
Les noms français des écrans (`"accueil"`, `"détail événement"`, `"mon profil"`…) mappent vers les IDs d'écran via `screenNameMap`.
The French screen names (`"accueil"`, `"détail événement"`, `"mon profil"`…) map to screen IDs through `screenNameMap`.
Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scénario dont les steps ne sont pas encore implémentés **ou dont le comportement applicatif n'est pas encore fiable** (usage : marquer un attendu réel qui échoue à cause d'un bug produit, pas un test obsolète — ex. historique : la désinscription qui ne se reflétait pas dans l'UI, `@wip` **levé** depuis sa résolution T02.c, cf [[caveat_participation-deletion]]). **`@wip` est EXCLU du run par défaut** (`cucumber.json: "tags": "not @wip"`) : ces scénarios documentent un attendu sans casser la suite ; retirer le `@wip` quand c'est fiable. Un `Contexte` (Background) fréquent — « Étant donné que je suis connecté » — ne fait que poser un flag `isAuthenticated`, pas d'auth réelle en `@ui`.
Scenario tags: `@ui` / `@data` / `@e2e` (layer) plus **`@wip`** for a scenario whose steps are not implemented yet **or whose application behaviour is not reliable yet** (intended use: flagging a genuine expectation that fails because of a product bug, not marking an obsolete test — historical example: sign-up cancellation not being reflected in the UI, `@wip` **lifted** once it was fixed in T02.c, see [[caveat_participation-deletion]]). **`@wip` is EXCLUDED from the default run** (`cucumber.json: "tags": "not @wip"`): those scenarios document an expectation without breaking the suite; drop the `@wip` once they are reliable. A common `Contexte` (Background) — « Étant donné que je suis connecté » — merely sets an `isAuthenticated` flag, there is no real auth in `@ui`.
## Config
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx** (`node --import tsx/esm node_modules/.bin/cucumber-js`), pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`.
`cucumber.json`: `import` of `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**`; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (excludes WIP scenarios); `language: fr`. **Runner = Node + tsx**, not Bun — the plugins (Playwright, happy-dom) do not load under a native Bun import. Do not "bunify" `cucumber:run`/`test:data`. ⚠️ The runner must point at the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`), **never** at `node_modules/.bin/cucumber-js`: depending on the installer, `.bin/` holds a **shell shim** (not JS) that `node --import tsx/esm` cannot execute.
## Le harness de test est buildé à la demande
## The test harness is built on demand
Les harness `@data`/`@e2e` (`src/shared/test-harness/harness.tsx`, `harness-ng.tsx`) **ne sont pas** buildés par `build.ts`. Le `BeforeAll` de `hooks.ts` les compile **à la demande** (`bun build``dist/test-harness*.js`). Le wallet de test peut être créé d'avance via `bun run test:auth-setup` (`scripts/setup-test-auth.ts`), sinon il est créé automatiquement au premier run (cf. [[decision_2026-03-12_headless-wallet-creation]]).
The `@data`/`@e2e` harnesses (`src/shared/test-harness/harness.tsx`, `harness-ng.tsx`) are **not** built by `build.ts`. The `BeforeAll` hook in `hooks.ts` compiles them **on demand** (`bun build``dist/test-harness*.js`). The test wallet can be created ahead of time with `bun run test:auth-setup` (`scripts/setup-test-auth.ts`); otherwise it is created automatically on the first run (see [[decision_2026-03-12_headless-wallet-creation]]).
## Fichiers auto-générés
## Auto-generated files
Des scripts `scripts/` parsent features/steps en data TS consommée par l'outil de parcours :
Scripts under `scripts/` parse features and steps into TS data consumed by the browsing tool:
| Script | Entrée | Sortie |
| Script | Input | Output |
|---|---|---|
| `parse-features.ts` | `*/features/*.feature` | `src/shared/data/features.ts` |
| `parse-test-results.ts` | `reports/cucumber-report.json` | `src/shared/data/testResults.ts` |
| `extract-step-definitions.ts` | `shared/steps/ui/*.ts` | `src/shared/data/stepDefinitions.ts` |
Lancer : `bun run test:cucumber` (tout), `bun run test:data` (@data). Après ajout de steps : `bun run steps:extract`.
Run: `bun run test:cucumber` (everything), `bun run test:data` (@data). After adding steps: `bun run steps:extract`.
@@ -1,66 +1,42 @@
---
type: knowledge
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock
last_checked: 2026-07-05
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
---
# Couche `@data` (broker réel)
# The `@data` layer (real broker)
`@data` teste le **vrai pipeline NextGraph** via un broker, pas des données mockées.
`@data` exercises the **real NextGraph pipeline** through a broker, not mocked data.
## Architecture
```
Cucumber → Playwright (Chromium, profil persistant)
→ broker wallet login (automatisé)
→ broker charge le harness en iframe (http://127.0.0.1:{port})
Cucumber → Playwright (Chromium, persistent profile)
→ broker wallet login (automated)
→ broker loads the harness in an iframe (http://127.0.0.1:{port})
→ harness-ng.tsx (init → useShape → ORM → broker)
bridge window.__testData
→ window.__testData bridge
```
**Dual mode** : broker réel (`harness-ng.tsx`, faut) ou fallback mock (`harness.tsx`, DeepSignalSets standalone si le build NG échoue).
**Dual mode**: real broker (`harness-ng.tsx`, the default) or mock fallback (`harness.tsx`, standalone DeepSignalSets if the NG build fails).
## Cycle de vie du wallet (automatisé, CI-ready)
## Wallet lifecycle (automated, CI-ready)
- **Premier run** : pas de marker `.wallet-ready` → Chromium headless crée le wallet (`nextgraph.eu` → Create Wallet → ToS sur `account.nextgraph.eu` → username/password → submit), **puis se logge** — ce login initial est requis pour amorcer la session (sauvé en localStorage) ; sans lui, les écritures ne passeraient pas. Marker écrit.
- **Runs suivants** : marker trouvé → login automatisé (click Login → wallet → password → submit) → harness en iframe → `window.__testData.ready`.
- Credentials wallet : `festipod-tests` / `festipod-tests`.
- **First run**: no `.wallet-ready` marker → headless Chromium creates the wallet (`nextgraph.eu` → Create Wallet → ToS on `account.nextgraph.eu` → username/password → submit), **then logs in** — that initial login is required to bootstrap the session (saved in localStorage); without it, writes would not go through. Marker written.
- **Subsequent runs**: marker found → automated login (click Login → wallet → password → submit) → harness in the iframe → `window.__testData.ready`.
- Wallet credentials: `festipod-tests` / `festipod-tests`.
> Le choix « automatiser l'UI headless plutôt que créer le wallet par API » est tranché dans [[decision_2026-03-12_headless-wallet-creation]].
> The choice of "automating the headless UI rather than creating the wallet through an API" is settled in [[decision_2026-03-12_headless-wallet-creation]].
## Détails techniques
## Technical details
- **Flags Chromium** (`--disable-web-security`, `--allow-insecure-localhost`, désactivation de Private Network Access) : nécessaires car le broker public charge un harness `http://127.0.0.1` en iframe.
- **Profil persistant** `.playwright-profile/` (gitignored, wallet en localStorage) — exige le vrai binaire Chrome, pas `chrome-headless-shell`.
- **Serveur HTTP** lancé en `BeforeAll` (port auto), sert le HTML + `/harness.js` (fichiers séparés — le script inline casse à cause de caractères spéciaux du bundle).
- **Bridge = le vrai chemin app (per-entité).** Depuis le passage à *un document par entité*
(concept `data-layer`, [[rule_document-per-entity]]), le bridge `window.__testData`
(`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/
`getEventParticipants`, `loadTestData`) **délègue au contexte de données de l'app**
(`appData` via `FestipodDataProvider`) — c'est le chemin per-entité réel des écrans, pas une
lecture au niveau du store-racine. Le harness monte donc l'**`AccountProvider`** et se logge
par défaut (`@mariedupont`) pour établir l'identité courante (sans quoi le filtre ReadCap ne
laisserait passer que le public). Il lit `appData` via une **ref vivante** (un snapshot capturé
devient périmé après un re-rendu de seed).
- Chemins probes de bas niveau conservés (scope store-racine `protectedNuri`) pour les
scénarios ReadCap/isolation qui *gouvernent* ce document : `rawJoin`/`rawParticipations`,
`governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`.
- **Identité avant écriture.** Une `Participation` a un `fp:user` obligatoire ; comme la lecture
du profil peut retarder derrière les events publics, les steps attendent
`ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en
lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation
soit relue.
- **Caveat wallet persistant + isolation par scénario (T03.j)** : le wallet partagé **accumule**
le registre de comptes émulé et les docs per-entité à chaque scénario/run. Le fan-out de lecture
(`listEntityDocs` = `allAccounts()` → 1 SELECT/compte) parcourt tous les docs de tous les comptes
→ ralentit et fait *timeouter* les steps quand le wallet est pollué. Ce registre vit **côté
broker** : supprimer `.playwright-profile/` ne le nettoie PAS (re-sync depuis le broker) et force
une re-auth lente — mauvais levier. À la place, le `Before` @data appelle
`window.__testData.resetDataState()` : **UN** SPARQL DELETE sur le graphe ancre (private-store)
qui efface tous les records `urn:ng-eventually:shim:Account``allAccounts()` s'effondre à vide →
le fan-out se **borne** à ce que le scénario courant reprovisionne (comptes recréés paresseusement
par `ensureAccount`). O(1) sur UN graphe — **pas** un delete en fan-out (qui saturait le navigateur,
cf. T03.i `authClearParticipation` retiré). Borné à ≤10s (`Promise.race`) pour ne pas disputer le
budget 60s du `Before` (login broker déjà lent). Infra de test uniquement — ne touche ni la lib ni
le modèle produit ni le chemin de lecture applicatif. Le seed connecté reste **allégé** (peu de
docs) car chaque `docCreate` est un aller-retour broker sériel ~2s.
- **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. 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,67 +1,76 @@
---
type: knowledge
summary: Couche @e2e — Playwright boote l'app RÉELLE (pas un harness) dans l'iframe broker, interagit via appFrame.evaluate()/locator(), réutilise setupBrokerPage() de @data ; teste navigation/redirects/clics, pas de fallback mock
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
---
# Couche `@e2e` (app réelle)
# The `@e2e` layer (real app)
`@e2e` teste l'**UI de l'app réelle** tournant dans l'iframe broker — contrairement à `@data` qui charge un harness de test.
`@e2e` tests the **UI of the real app** running inside the broker iframe — unlike `@data`, which loads a test harness.
## Architecture
```
Cucumber → Playwright (Chromium, profil persistant)
Cucumber → Playwright (Chromium, persistent profile)
→ https://nextgraph.net/redir/#/?o=http://127.0.0.1:{appPort}
login broker (automatisé, même mécanique que @data)
→ broker charge la VRAIE APP en iframe
→ app rend avec NextGraphProvider auto-connectant
→ steps via appFrame.evaluate() + locators Playwright
→ broker login (automated, same machinery as @data)
→ broker loads the REAL APP in an iframe
→ app renders with a self-connecting NextGraphProvider
→ steps through appFrame.evaluate() + Playwright locators
```
**Serveur app** : lancé en `BeforeAll` (`spawn('bun', ['src/index.ts'], { env: { PORT } })`, poll jusqu'à réponse HTTP, tué en `AfterAll`). Réutilise le helper `setupBrokerPage()` de `@data` (redirect, login, découverte de l'iframe).
**App server**: started in `BeforeAll` (`spawn('bun', ['src/index.ts'], { env: { PORT } })`, polled until it answers over HTTP, killed in `AfterAll`). It reuses the `setupBrokerPage()` helper from `@data` (redirect, login, iframe discovery).
## Step definitions
Dans les modules (ex. `src/modules/auth/steps/e2e/connexion.steps.ts`) :
- `this.appFrame!.evaluate()` — JS dans l'iframe app (navigation hash/path, checks de contenu)
- `this.appFrame!.locator()`éléments DOM
- `this.appFrame!.waitForFunction()` — poll d'état attendu
- `SCREEN_MARKERS` — map ID d'écran → texte unique de vérification
Inside the modules (e.g. `src/modules/auth/steps/e2e/connexion.steps.ts`):
- `this.appFrame!.evaluate()` — JS inside the app iframe (hash/path navigation, content checks)
- `this.appFrame!.locator()`DOM elements
- `this.appFrame!.waitForFunction()` — poll for an expected state
- `SCREEN_MARKERS` — map from screen ID to a unique verification text
Navigation : `window.history.pushState` + dispatch `popstate` (routing path-based, cf. `app-architecture`).
Navigation: `window.history.pushState` plus a `popstate` dispatch (path-based routing, see `app-architecture`).
## Différences avec `@data`
## Differences from `@data`
| Aspect | `@data` | `@e2e` |
|---|---|---|
| Chargé en iframe | harness (`harness-ng.tsx`) | app réelle (`src/index.ts`) |
| Signal ready | `window.__testData.ready` | `root.innerHTML.length > 100` |
| Interaction | bridge `evaluate()` | `evaluate()` + locators |
| Fallback mock | oui | **non** (broker réel requis) |
| Teste | opérations données | comportement UI (nav, redirects, clics) |
| Loaded in the iframe | harness (`harness-ng.tsx`) | real app (`src/index.ts`) |
| Ready signal | `window.__testData.ready` | `root.innerHTML.length > 100` |
| Interaction | `evaluate()` bridge | `evaluate()` + locators |
| Mock fallback | yes | **no** (real broker required) |
| Tests | data operations | UI behaviour (nav, redirects, clicks) |
> **Ne pas re-vérifier en `@e2e` ce que `@ui` couvre déjà** — `@e2e` doit casser quand la *collaboration* entre couches casse, pas quand une icône change (cf. [[rule_test-layer-contracts]]).
> **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]]).
## Smoke `@smoke` — garde la classe « page blanche une fois connecté »
## Scenario identity, and why no scenario drives the SDK's barrier
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) garde une
CLASSE de régression : un crash de rendu qui ne survient QUE une fois l'app connectée
et montée sur des données réelles (symptôme : seul le bandeau de l'iframe broker
s'affiche, `#root` reste vide). Le smoke réutilise le boot du hook `Before` @e2e,
navigue vers l'accueil connecté et asserte DEUX choses :
1. **HomeScreen a réellement monté** — présence de marqueurs forts (`.app-navbar` +
bouton `[aria-label="Relayer un événement"]`), absents d'un spinner / du bandeau
broker. Un `throw` dans un composant/provider monté après connexion démonte l'arbre
(aucun `ErrorBoundary`) → ces marqueurs disparaissent → rouge.
2. **Zéro erreur runtime**`this.pageErrors` (voir ci-dessous) doit être vide.
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]].
Le hook `Before` @e2e **collecte** désormais dans le World les `pageerror` +
`console.error` de la page app (champ `pageErrors`, réinitialisé par scénario) — c'est
ce qui rend l'assertion « pas d'erreur » possible. Le run par défaut de `bun run
validate` exécute `@smoke and not @wip` (pas tout `@e2e`, pour rester rapide).
**Preuve de détection** : un `throw` en tête de `HomeScreen` fait virer le smoke au
rouge ; sans lui, vert.
**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]]).
## Fichiers clés
> **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.
`src/shared/support/hooks.ts` (lifecycle Playwright + collecte `pageErrors`), `world.ts` (champs `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
## 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, 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.
The scenario is **`@wip`** (diagnostic instrument, excluded from the default run).
## `@smoke` — guarding the "blank page once connected" class
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) guards a CLASS of regression: a rendering crash that happens ONLY once the app is connected and mounted on real data (symptom: only the broker iframe banner shows, `#root` stays empty). The smoke test reuses the boot from the @e2e `Before` hook, navigates to the connected home screen and asserts TWO things:
1. **HomeScreen really mounted** — presence of strong markers (`.app-navbar` plus the `[aria-label="Relayer un événement"]` button), absent from a spinner or from the broker banner. A `throw` inside a component/provider mounted after connection unmounts the tree (there is no `ErrorBoundary`) → those markers disappear → red.
2. **Zero runtime errors**`this.pageErrors` (see below) must be empty.
The @e2e `Before` hook now **collects** the app page's `pageerror` and `console.error` events into the World (field `pageErrors`, reset per scenario) — that is what makes the "no error" assertion possible. The default run of `bun run validate` executes `@smoke and not @wip` (not all of `@e2e`, to stay fast). **Detection proof**: a `throw` at the top of `HomeScreen` turns the smoke test red; without it, green.
## Key files
`src/shared/support/hooks.ts` (Playwright lifecycle + `pageErrors` collection), `world.ts` (fields `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (headed debugging), `.playwright-profile{,-debug}/` (gitignored).
@@ -1,70 +1,63 @@
---
type: knowledge
summary: Harness multi-navigateur sur DEUX axes orthogonaux — nombre de navigateurs (machinerie, contextes frais isolés via un freshBrowser non-persistant) ET modèle de wallet (own/@private-wallet vs shared/@shared-wallet) ; shared provisionné par injection storageState (test) ; e2e @humain qui valide le mécanisme produit RÉEL via la vraie app staging (fichier .ngw téléchargé depuis l'écran → import nextgraph.eu « Import a Wallet File » → Entrer → connecté) ; convention @wip exclue via 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
---
# Harness multi-navigateur (private-wallet vs shared-wallet)
# Multi-browser harness (shared wallet)
Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés** dans un même scénario, sous **deux axes orthogonaux**. Permet de tester à la fois le modèle « chacun son wallet » (`@private-wallet`) et le modèle « wallet partagé entre navigateurs » (`@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]]).
## Les deux axes (orthogonaux)
| Axe | Ce qu'il décide | Exprimé par |
| Concern | What it decides | Expressed by |
|---|---|---|
| **Nombre de navigateurs** (machinerie) | 1..N contextes nommés isolés | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
| **Modèle de wallet** | identité NG distincte vs partagée | **phrasing du step + tag** (voir ci-dessous) |
| **Number of browsers** (machinery) | 1..N isolated named contexts | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
| **Wallet model** | which wallet a context carries | the `WalletModel` argument (`'own'` \| `'shared'`) |
Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-wallet` (même wallet) : on fait du multibrowser **en private** (chacun son wallet) **et en shared** (wallet partagé), et on compare les deux setups avec les **mêmes** steps de comportement.
## Wallet model — one is exercised, one is dormant
## Modèle de wallet : phrasing + tags
- `É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).
- `Étant donné un navigateur "A" avec son propre wallet` → modèle **own**, tag `@private-wallet`.
- `Étant donné un navigateur "A" avec le wallet partagé` → modèle **shared**, tag `@shared-wallet`.
- Tag umbrella `@multibrowser` (feature entière).
## Architecture (where things live)
## Architecture (où vit quoi)
- **`src/shared/support/browserPool.ts`** — shared state + factory. Outside the **persistent** Chromium context that carries the shared wallet (legacy single-browser `@data`/`@e2e`, **unchanged**, see [[knowledge_data-layer-broker]]), the harness launches a **non-persistent** browser `freshBrowser` (`chromium.launch`) which mints fresh isolated contexts on demand (`spawnContext(wallet)`). The module is imported by `hooks.ts` (lifecycle) and `world.ts` (per-scenario use) — no import cycle.
- **`world.ts`** — API: `openBrowser(name, wallet)`, `browser(name)`, `loadAppInBrowser(name, 'app'|'harness')`, `closeBrowsers()`; registry `browsers: Map<name, NamedBrowser>`. Named browsers are closed in `After`, `freshBrowser` in `AfterAll`.
- **`hooks.ts`** — a scenario tagged `@multibrowser` **does not get** the legacy single page; the steps open the browsers themselves. Requires the real broker mode (`freshBrowser` is unavailable in the mock fallback).
- **`src/shared/support/browserPool.ts`** — état partagé + fabrique. Hors du contexte Chromium **persistant** porteur du wallet partagé (legacy mono-navigateur `@data`/`@e2e`, **inchangé**, cf. [[knowledge_data-layer-broker]]), le harness lance un navigateur **non-persistant** `freshBrowser` (`chromium.launch`) qui mint des contextes frais et isolés à la demande (`spawnContext(wallet)`). Module importé par `hooks.ts` (cycle de vie) et `world.ts` (usage par scénario) — pas de cycle d'import.
- **`world.ts`** — API : `openBrowser(name, wallet)`, `browser(name)`, `loadAppInBrowser(name, 'app'|'harness')`, `closeBrowsers()` ; registre `browsers: Map<name, NamedBrowser>`. Navigateurs nommés fermés en `After`, `freshBrowser` en `AfterAll`.
- **`hooks.ts`** — un scénario taggé `@multibrowser` **ne reçoit pas** la page unique legacy ; les steps ouvrent les navigateurs. Exige le mode broker réel (`freshBrowser` indispo en fallback mock).
## Wallet provisioning
## Provisioning du wallet
- **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**.
- **own** : `newContext()` vide → identité NG distincte / pas de wallet.
- **shared** : `newContext({ storageState })`, où `storageState` est **capturé une fois** au `BeforeAll` depuis le profil persistant (warm-up via `setupBrokerPage` puis `browserContext.storageState()`), exposé par `pool.sharedWalletState`. **Vérifié empiriquement (2026-06-16)** : les origines `nextgraph.eu` + `nextgraph.net` round-trippent dans les contextes frais, et deux navigateurs **shared** atteignent tous deux l'app **connectée** à NextGraph (`window.__testData.ready`) **sans login manuel**.
> 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`).
> Ce provisioning est **de test** — distinct du mécanisme **produit** (import assisté par FICHIER). Le scénario shared-wallet par storageState **court-circuite l'import** ; pour valider le mécanisme RÉEL, voir l'e2e `@humain` ci-dessous.
## No scenario left drives the real app through a fresh context
## Parcours humain — e2e du mécanisme produit (vert)
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]].
Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout en bout, via la vraie app**, pas l'injection de test. Un navigateur vierge ouvre l'app staging → l'`AccessGateScreen` propose le **fichier** + le **mot de passe** → on télécharge le fichier **depuis l'écran**, on vérifie que le mot de passe affiché **égale** celui du wallet → import sur `nextgraph.eu` « Import a Wallet File » → retour → on **saisit un identifiant** puis clic « Entrer » (nommer l'espace et ouvrir le wallet = un seul acte, cf. concept `app-security` [[decision_2026-07-06_identifier-at-access-barrier]]) → app connectée, arrivée directe sur l'accueil (plus d'écran « nom d'utilisateur » séparé).
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.
- **Wallet e2e** : un fichier `.ngw` (`festipod-e2e-tests`, mot de passe = identifiant) placé **à la racine du worktree** ; `findE2eWalletFile()` le localise (`*.ngw`). Gitignoré → chaque environnement doit l'ajouter (sinon erreur claire).
- `pool.ensureStagingApp()` (`hooks.ts`) — build **isolé** `bun run build.ts --outdir=dist-staging` (barrière d'accès **ON par défaut** ; mot de passe gravé + **fichier copié** en `/shared-wallet.ngw`, cf. `build.ts`), servi statiquement. Mémoïsé, lazy (seul `@humain` le paie).
- **Bypass de la barrière pour `@e2e`** : le harness fait `browserContext.addInitScript` sur le **contexte persistant** pour poser `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (s'applique à l'iframe app avant ses scripts) → `@e2e` voit l'app directement, pas la barrière. Les contextes frais (`@humain`) n'y touchent pas → barrière ON. L'ancien `LoginScreen` `/login` a été retiré.
- `pool.importWalletViaFile(page, filePath, password)``nextgraph.eu/#/wallet/login``setInputFiles('input[type=file]')` (attendre que la SPA rende, sinon `EncryptionError`) → champ password → unlock.
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)` — moitié « login broker » extraite de `setupBrokerPage`. **Attente robuste** : après le redirect (multi-hop), attend l'iframe app OU le lien « Click here to login with your wallet », puis déverrouille avec le mot de passe. La session broker n'étant **pas** persistée entre lancements, ce login wallet est requis à chaque run (warm-up + `@e2e` + `@humain`).
## Isolation of the contexts (a property of the harness, not a tested behaviour)
> **C'est l'e2e qui garantit que ça marche pour un humain réel** : Festipod fournit le BON fichier + mot de passe, et ce fichier importé donne un wallet fonctionnel sur un device vierge. Le scénario `@shared-wallet` (storageState) reste un raccourci de provisioning de test, il ne valide pas l'import.
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. That holds on the **local** origin (`127.0.0.1`) and on the **broker origin** `nextgraph.net` **where the wallet actually lives**.
## Isolation (garantie à 3 niveaux, prouvée par les scénarios)
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.
1. `freshBrowser` est un **process séparé** du profil persistant porteur du wallet → un navigateur **own** démarre **sans wallet**.
2. Chaque `newContext()` est une **partition de stockage hermétique** (garantie Playwright).
3. Isolation prouvée non seulement sur l'origine **locale** (`127.0.0.1`) mais aussi sur l'**origine broker** `nextgraph.net` **où vit réellement le wallet** (sonde localStorage écrite dans A absente de B).
## Files
## Fichiers
- Feature: `src/modules/workshop/features/multibrowser-harness.feature`.
- Steps: `src/modules/workshop/steps/data/multibrowser.steps.ts`.
- A `/blank` route added to the harness server (`hooks.ts`): a minimal page **with no NG stack**, for the localStorage isolation checks.
- Feature : `src/modules/workshop/features/multibrowser-harness.feature`.
- Steps : `src/modules/workshop/steps/data/multibrowser.steps.ts`.
- Route `/blank` ajoutée au serveur harness (`hooks.ts`) : page minimale **sans stack NG**, pour les checks d'isolation localStorage.
## The `@wip` convention (now enforced)
## Convention `@wip` (désormais appliquée)
`cucumber.json` (profile `default`) carries `"tags": "not @wip"`. `cookbook_add-scenario` prescribed `@wip` for anything unimplemented, but it was **excluded nowhere**; now `not @wip` is **AND**ed with the CLI filters (e.g. `--tags @data``(not @wip) and @data`, verified).
`cucumber.json` (profile `default`) porte `"tags": "not @wip"`. Le `cookbook_add-scenario` prescrivait `@wip` pour le non-implémenté mais ce n'était **exclu nulle part** ; maintenant `not @wip` s'**AND** avec les filtres CLI (ex. `--tags @data``(not @wip) and @data`, vérifié).
## Links
## Liens
- [[knowledge_data-layer-broker]] — la couche `@data` mono-navigateur (profil persistant) que cette capability étend.
- [[cookbook_add-scenario]] — convention `@wip`, pièges de steps.
- [[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,33 +1,41 @@
---
type: knowledge
summary: Couche @ui — renderHelper.tsx rend tout écran dans LocalDataProvider + happy-dom, world.renderCurrentScreen() l'invoque à chaque navigateTo, assertions sur le DOM rendu avec les fixtures de seed déterministes
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
---
# Couche `@ui`
# The `@ui` layer
`@ui` rend un écran avec `LocalDataProvider` (seed) + `RouterProvider` via happy-dom, puis assert sur le **DOM rendu**.
`@ui` renders a screen with `LocalDataProvider` (seed) plus `RouterProvider` through happy-dom, then asserts on the **rendered DOM**.
- Helper : `src/shared/test-harness/renderHelper.tsx` (installe les globals happy-dom, enveloppe l'écran). Invoqué depuis `world.ts:renderCurrentScreen()` à chaque `navigateTo(...)`.
- Fixtures déterministes (`src/shared/data/seedData.ts`, voir concept `data-layer`) : `Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` existe, 5 events, etc.
- 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).
## Bons patterns d'assertion
## Good assertion patterns
```ts
// Texte visible
// Visible text
expect(this.getDomText()).to.include('Marie Dupont');
// Présence d'élément par classe/rôle
// Element present, by class/role
expect(this.renderedDoc!.querySelector('.app-avatar')).to.not.be.null;
// Rendu conditionnel (rempli vs vide)
// Conditional rendering (filled vs empty)
expect(this.renderedDoc!.querySelectorAll('.app-card').length).to.be.greaterThan(0);
// Champ requis rendu avec label + astérisque
// Required field rendered with a label and an asterisk
const labels = Array.from(this.renderedDoc!.querySelectorAll('p')).map(p => p.textContent ?? '');
expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
```
## Champs & helpers de `FestipodWorld` (`src/shared/support/world.ts`)
## `FestipodWorld` fields & helpers (`src/shared/support/world.ts`)
- `renderedDoc: Document | null`le DOM happy-dom rendu (peuplé par `renderCurrentScreen()`, appelé à chaque `navigateTo(...)`).
- `currentScreenId: string | null`l'écran courant.
- Helpers d'assertion : `getDomText()` (texte du DOM), `hasText(t)`, `hasField(name)`, `hasElement(selector)`ils **préfèrent le DOM rendu** mais **retombent sur la source** des écrans pour les steps non migrés (vestige, voir [[caveat_source-grep-vestiges]]).
- `renderedDoc: Document | null`the rendered happy-dom DOM (populated by `renderCurrentScreen()`, called on every `navigateTo(...)`).
- `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]]).
> Les classes `app-*` confirment le thème moderne (cf. `app-architecture`). Les anti-patterns (regex sur source, détails d'implémentation) sont proscrits par [[rule_test-layer-contracts]]. Pour écrire un nouveau scénario, voir [[cookbook_add-scenario]].
## ⚠️ No `@ui` module renders an access screen — there is none left to render
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.
**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,52 +1,27 @@
---
type: rule
summary: Ne JAMAIS poller le broker (re-lire en boucle « c'est là ? »). NextGraph est par abonnement — la donnée arrive par PUSH, et le 1er `State` d'un `doc_subscribe` est la barrière de sync déterministe (après lui : présence garantie / absence définitive). Tests ET app attendent le push / l'état réactif settlé, jamais une boucle de re-lecture broker.
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
---
# Ne jamais poller le broker — attendre l'abonnement
# Never poll the broker — wait for the subscription
NextGraph est **par abonnement (réactif)**. Une lecture n'est PAS « interroge en
boucle jusqu'à ce que ça apparaisse » ; c'est « abonne-toi, réagis au push ». Le
**1er `State`** d'un `doc_subscribe` marque la fin de la synchronisation initiale
(barrière synchrone) : après lui, la **présence** d'une donnée est **garantie** et
l'**absence** est **définitive**. Contrat vérifié empiriquement côté SDK
(`@ng-eventually/client`, test e2e « 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.
## L'anti-pattern à bannir
## The anti-pattern to ban
```
for (i = 0; i < N; i++) { if (await authParticipationCount(...) === X) break; sleep(500); }
```
Toute boucle qui **re-interroge le broker** (`authParticipationCount`,
`listMyEntityDocs`, `sparql_query` répétés) pour « attendre » une donnée est
proscrite : elle masque le vrai mécanisme, fragilise le test (timeout deviné), et
contredit frontalement le modèle NextGraph. C'est la remarque qui a fait supprimer
l'ancien caveat qui, à tort, érigeait le polling en pratique.
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.
## Ce qu'il faut faire
## What to do instead
Attendre le **push réactif**. En pratique (app ET test) : l'état réactif
(`AD().*` alimenté par `subscribeDoc` dans le contexte de données) se met à jour
**au push**. On attend que CET état reflète l'attendu — on **observe l'état réactif
settlé**, on ne ré-émet PAS de lecture broker. Le mécanisme de données est
l'abonnement ; l'attente ne fait qu'**observer le résultat réactif**.
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 : l'écran est déjà réactif (`subscribeDoc`re-render au push) — pas de poll
applicatif, pas de spinner piloté par timeout deviné (si un état d'attente est
voulu, il vient de la barrière d'abonnement native, pas d'un signal ajouté).
- Test : **un helper qui attend le push/la barrière de façon fiable est bienvenu**
(fiabilise sans fragiliser). Ce qui est banni, c'est la **boucle de re-lecture**,
pas l'attente d'un signal.
- **Fallback pragmatique** : si attendre strictement le push/signal s'avère fragile
d'une manière ou d'une autre, un **intervalle court** (`setInterval` / re-check
rapproché) qui **observe l'état réactif DÉJÀ mis à jour** (l'état local alimenté
par l'abonnement — PAS une re-lecture broker) est acceptable : c'est au plus près
de ce que vit l'utilisateur, qui **attend** simplement que l'écran (réactif) se
mette à jour. La ligne rouge est invariante : **ne jamais re-interroger le broker
en boucle** ; observer l'état réactif settlé, oui.
- 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.
Voir aussi [[caveat_wallet-bloat-hang]] (autre source de flakiness @data,
orthogonale). Le mécanisme non-polling côté lib (`open-repo` : subscribe + attendre
le 1er State + lire) vit dans le repo `@ng-eventually/client`, pas ici.
See also [[caveat_wallet-bloat-hang]] (another source of `@data` flakiness, orthogonal to this one).
@@ -1,29 +1,29 @@
---
type: rule
summary: Chaque couche BDD répond à une question distincte@ui = rendu (DOM + seed), @data = mutations/persistance broker, @e2e = collaboration des couches sur un parcours ; descendre chaque assertion à la couche la plus basse qui peut y répondre
summary: Each BDD layer answers a distinct question@ui = rendering (DOM + seed), @data = broker mutations/persistence, @e2e = collaboration of the layers over a journey; push every assertion down to the lowest layer that can answer it
---
# Règle : contrat des couches de test
# Rule: the test layer contract
Chaque couche répond à **une question distincte**. Mélanger les préoccupations produit des tests fragiles qui cassent au refactor sans attraper de vraie régression. **Descendre toute assertion à la couche la plus basse qui peut y répondre.**
Each layer answers **one distinct question**. Mixing concerns produces brittle tests that break on refactors without catching any real regression. **Push every assertion down to the lowest layer that can answer it.**
- **`@ui`couche affichage.** Rend un écran avec `LocalDataProvider` (seed) + happy-dom et assert sur le DOM. Vérifie que *données connues → l'écran montre le texte et les éléments attendus*. **Ne teste pas** la navigation, les mutations, ni la persistance.
- **`@ui`display layer.** Renders a screen with `LocalDataProvider` (seed) plus happy-dom and asserts on the DOM. Checks that *given known data, the screen shows the expected text and elements*. **Does not test** navigation, mutations or persistence.
- **`@data`couche données.** Pilote des mutations ORM via le **broker NextGraph réel** (harness headless, pas d'UI app). Vérifie que *les opérations sur shapes sont persistées et observables dans le wallet*. Pas de DOM ici — utiliser le bridge `window.__testData`.
- **`@data`data layer.** Drives ORM mutations through the **real NextGraph broker** (headless harness, no app UI). Checks that *shape operations are persisted and observable in the wallet*. No DOM here — use the `window.__testData` bridge.
- **`@e2e` couche intégration.** Boote l'app réelle dans l'iframe broker (Playwright/Chromium). Vérifie que *les couches collaborent pour livrer un parcours* (créer → listermodifier → recharger → toujours là). **Rare** : 1 scénario par chemin critique ; **ne jamais dupliquer** un check de contenu `@ui`.
- **`@e2e` — integration layer.** Boots the real app inside the broker iframe (Playwright/Chromium). Checks that *the layers collaborate to deliver a journey* (create → list → edit → reload → still there). **Rare**: 1 scenario per critical path; **never duplicate** a `@ui` content check.
## Pourquoi le coût impose la pyramide
## Why cost dictates the pyramid
`@ui` tourne in-process (instantané) ; `@data` boote un broker (~50s) ; `@e2e` boote broker + app + navigateur (~2min). Une affirmation de rendu appartient à `@ui`, pas à `@e2e`.
`@ui` runs in-process (instant); `@data` boots a broker (~50s); `@e2e` boots broker + app + browser (~2min). A rendering claim belongs to `@ui`, not to `@e2e`.
## Anti-patterns `@ui` à proscrire
## `@ui` anti-patterns to ban
```ts
// ❌ regex sur la source : couple le test à la structure du code
// ❌ regex over the source: couples the test to the structure of the code
expect(/<Title[^>]*>Marie Dupont<\/Title>/.test(source)).to.be.true;
// ❌ détails d'implémentation
// ❌ implementation details
expect(/showDuplicateWarning/.test(source)).to.be.true;
```
Préférer des assertions sur le **DOM rendu** + données de seed (voir [[knowledge_ui-layer]]). Les helpers/maps d'analyse de source sont des vestiges en voie de suppression : [[caveat_source-grep-vestiges]].
Prefer assertions on the **rendered DOM** plus seed data (see [[knowledge_ui-layer]]). The source-analysis helpers and maps are leftovers on their way out: [[caveat_source-grep-vestiges]].
@@ -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.
-8
View File
@@ -1,8 +0,0 @@
# Doc-debt — data-layer
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/ngSession.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+25 -18
View File
@@ -1,33 +1,40 @@
---
type: _overview
summary: Comment Festipod persiste ses données via le SDK @ng-eventually/client — entités stockées comme documents par scope, écriture SPARQL directe + lecture par modèle union, stack SHEX, modes connected/demo, 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", union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, 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]
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"]
---
# Data layer
Comment Festipod **persiste ses données** via NextGraph (P2P, local-first, chiffré de bout en bout). Le SDK de données est **`@ng-eventually/client`** : on le traite comme un SDK NextGraph fini — chaque entité est un **document** placé dans le store de son **scope** (public / protected / private). L'**écriture** est un SPARQL direct dans le document de l'entité ; la **lecture** est le **modèle union** (résoudre les documents par besoin → ouvrir/sync → **une** requête `sparql_query` sans ancre sur l'union → re-query sur signal), et non un abonnement ORM réactif en fan-out (qui *hang*). Voir [[rule_document-per-entity]]. Le mapping *quelle entitéquel scope* est un fait **produit** (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]) ; ce concept décrit la **mécanique de persistance**.
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 entitywhich scope* is a **product** fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]); this concept describes **how Festipod uses the surface**.
> **Frontière SDK.** Le SDK de données de Festipod est `@ng-eventually/client` — initialisé/injecté **une seule fois** via `ngSession.configure(...)`. On l'écrit comme un SDK NextGraph **fini** : ne jamais documenter ici l'état courant de NextGraph (contraintes, contournements, internes broker) — cela vit dans le repo `@ng-eventually/client`. Voir [[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]].
## Modèle & données
## Interfaces (one folder per interface, engagement + our declaration)
- [[knowledge_nextgraph-stack]] — SDK `@ng-eventually/client`, shapes SHEX, ORM réactif, `build:orm`, injection via `ngSession`
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (état local seedé), choix du provider
- [[knowledge_entities]] — types `Fp*` et leurs shapes SHEX
- [[knowledge_seed-data]] — données de seed, `CURRENT_USER_ID`
- [[knowledge_context-internals]] — pièges de `FestipodDataContext` (currentUser, auto-seed dev, `participantCount` cache, no-op local)
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.
## Règles d'écriture
- `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, [[usage_festipod]] — 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.
- [[rule_document-per-entity]] — chaque entité = **son propre document** (par scope), jamais au niveau du store ; c'est ce qui rend l'isolation par-document du SDK possible
## Model & data
- [[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; 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
## Pièges (lire avant de toucher aux suppressions / aux champs d'event)
## Write rules
- [[caveat_participation-deletion]] — la désinscription doit être **autoritative** et ne pas réapparaître
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… non couverts par la shape Event → perdus en connecté
- [[caveat_write-durability-across-disconnect]] — une écriture juste avant une inactivité/chute de socket peut être **perdue** (non durable broker) ; compte survit. Incident ouvert → post-mortem dans le 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
> Confidentialité (isolation par scope, confiance dans le SDK) : concept `app-security`. Périmètres produit par entité + découverte : concept `functional-domain`.
## Pitfalls (read before touching deletions / the participant count)
- [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back
- [[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`.
@@ -1,186 +0,0 @@
---
type: brief
summary: Design d'implémentation — rendre les lectures RÉACTIVES cross-session via doc_subscribe (par-document, sans fan-out ORM qui hang) et remplacer le participantCount muté-en-place par le flux Option-B (l'inscrit dépose dans l'inbox de l'événement, le propriétaire matérialise et incrémente son propre doc) ; plan de test 2-browsers réel sans polling
---
# Reactive reads + participant-count correct (Option B)
Brief d'implémentation, ancré dans le code courant. Objectif : deux évolutions couplées de la couche données Festipod (mode connected / `@ng-eventually/client`).
1. **Lectures réactives cross-session** — remplacer le one-shot `readUnion` + `bumpRead` (re-query manuel, local-only) par une réactivité réelle poussée par le broker, **sans jamais poller** et **sans le fan-out ORM qui hang**.
2. **Compteur de participants correct (Option B)** — supprimer la violation d'isolation actuelle (l'inscrit écrit `participantCount` sur le doc de l'événement qui ne lui appartient pas) et la remplacer par le flux dépôt-inbox → matérialisation-propriétaire.
Ce brief décrit **quoi construire et dans quel ordre**. Aucune modification de code n'est faite ici.
Références transverses : [[knowledge_context-internals]], [[rule_document-per-entity]], [[caveat_participation-deletion]], `functional-domain/knowledge_data-scopes-and-discovery`, `app-security/knowledge_trust-model`, et le contrat SDK `@ng-eventually/client` (`docs/sdk-reference.md`, `docs/read-model.md`, `docs/nextgraph-current-state.md`).
---
## 0. État courant (le point de départ, fichier:fonction)
### Lecture (one-shot, re-query manuel)
`src/shared/context/FestipodDataContext.tsx``useNgData()` :
- Le jeu de docs à lire **par besoin** est deux `useState` : `publicDocs` / `protectedDocs` (l.232-233). Il est alimenté par (a) l'effet de listing (l.302-332) qui appelle `listMyEntityDocs(owner, 'public'|'protected')` (borné à mon compte) + `readDiscoveredEvents()` (l'index global), et (b) `registerDoc(scope, nuri)` (l.251-255) qui ajoute un doc fraîchement créé.
- La **lecture réelle** (l.347-364) : `readEntities(allReadDocs)``readModel.readUnion(docs)` (un `sparql_query` ancré par doc, en parallèle, tolérant par-doc). Elle **re-tourne** quand `allReadDocs` change **ou** quand `readTick` change.
- `readTick`/`bumpRead` (l.236-237) = **signal de re-query manuel**, bumpé après chaque mutation. **Il n'y a AUCUN signal venant du broker** : une écriture faite par une AUTRE session n'incrémente jamais `readTick` de cette session → **pas de réactivité cross-session**. C'est le trou que ce brief comble.
- `listTick`/`relist` (l.246-247) rejoue l'effet de listing après un seed.
### Écriture du compteur (la violation à retirer)
- `joinEvent` (l.597-668) : après avoir écrit sa propre `Participation` (doc protected, l.621-631), il fait `updateEntityField(eventId, eventId, 'participantCount', int(next))` sur **le doc de l'événement** (l.635-640) — or ce doc appartient au **propriétaire de l'événement**, pas à l'inscrit. C'est un write hors-scope. Il dépose *aussi* dans l'inbox via `depositRegistration` (l.652) — ce dépôt-là est le bon canal ; c'est l'écriture directe du `participantCount` qui est à supprimer.
- `leaveEvent` (l.670-712) : symétriquement, décrémente `participantCount` sur le doc de l'événement (l.705-710) après le DELETE autoritatif de la participation.
- `caveat_participation-deletion` : le DELETE de participation doit rester **autoritatif** (SPARQL DELETE-WHERE via `deleteParticipation`, `src/shared/data/registration.ts` l.260-334, vérifié `remaining === 0`) — ce brief ne change pas ce contrat.
- [[knowledge_context-internals]] documente déjà que `participantCount` est un **cache muté en place**, jamais recalculé, et « pas une source de vérité ». Option B en fait une valeur **dérivée et possédée par le propriétaire**.
### Affichage (déjà « compte + anonyme », à conserver)
`src/modules/event/screens/EventDetailScreen.tsx` :
- `joined = isParticipating(eventId)` (l.20).
- `participants = getEventParticipants(eventId)` (l.21) → dans le contexte, `getEventParticipants` (FestipodDataContext l.108-111) filtre les `participations` connues par `eventId` et joint les `users` **lisibles** (donc seulement mes connexions, cf. cap protected).
- `knownParticipants = participants.filter(p => p.id !== currentUserId)` (l.33).
- Le libellé **« Participants ({event.participantCount}) »** (l.146) affiche le **compte dérivé**, et `knownParticipants.length < event.participantCount` rend les **placeholders « voir tous les participants »** (l.163-170) — c'est exactement le modèle « compte + anonymes » voulu. **Cet affichage ne change pas** : Option B ne fait que rendre `participantCount` correct et réactif, et les `knownParticipants` restent gouvernés par le cap de lecture protected.
### Les watchers polling de la lib (à remplacer)
Confirmé par lecture de la lib (`packages/client/src/`) :
- `inbox.watch(target, onDeposits, {intervalMs=1000})` (`inbox.ts:195-223`) = **`setInterval` polling**, se déclenche uniquement sur changement de `deposits.length`.
- `discovery.watchIndex(onEntries, {intervalMs=1000})` (`discovery.ts:163-187`) = **`setInterval` polling** identique.
- `useShape` (`use-shape.ts:12`) EST poussé/réactif, mais **seulement sûr sur UN seul document déjà ouvert** — le fan-out `graphs:[…]` hang (§2).
- **Aucun wrapper `doc_subscribe` n'est exposé aujourd'hui** dans `docs.ts` (qui n'expose que `docCreate` / `sparqlUpdate` / `sparqlQuery`). Le primitif `ng.doc_subscribe` est atteignable *untyped* via le proxy `ng` (`ng-proxy.ts:54-56` passthrough), mais il n'y a **pas de couche typée****la lib doit en ajouter une** (§A).
---
## 1. Les primitives plateforme (nextgraph-rs, vérifié)
- `doc_subscribe(repo_o: String, session_id, callback)` (`sdk/js/lib-wasm/src/lib.rs:1907`) est **par-document** : un seul NURI de repo, un callback. Il monte une souscription sur **une branche** du doc (`verifier.rs:352` `create_branch_subscription`), pousse d'abord un `TabInfo` + `State` initial (`verifier.rs:470-477`), puis un flux de `Patch` à chaque commit.
- Le push : à chaque transaction vérifiée sur une branche B, le vérifieur appelle `push_app_response(&B, AppResponse::…)` (`verifier.rs:252`) sur le `Sender` enregistré dans `branch_subscriptions[B]` (`verifier.rs:115`). **Unité de souscription = une branche d'un doc.**
- Le **fan-out ORM** vit ailleurs : `orm_start_graph(scope.graphs[], …)` (un seul appel sur un tableau). Là, un **seul** repo non-synchronisé dans le tableau fait que `open_for_target → resolve_target` retourne `RepoNotFound` (`request_processor.rs:147-171`, et surtout la boucle `initialize.rs:125-128` où le `?` **avorte toute la souscription**). Le `readyPromise` ne se résout jamais → **hang ~75s** (`nextgraph-current-state.md` § *The ORM fan-out hang*, cité dans `read-model.md:93-98` et l'en-tête de `read-model.ts:24-31`). **Corollaire : `doc_subscribe` par-doc n'a PAS ce défaut** — il ne subit pas de fan-out, donc un doc absent ne casse que sa propre souscription.
- **Write membership-bound, pas d'append** (confirmé, `repo.rs:584` `verify_permission` : auteur non-membre → `PermissionDenied` ; `commit.rs` : une transaction exige `WriteAsync`/`WriteSync`, obtenus uniquement par grant du propriétaire ; **aucune variante `Append` dans `PermissionV0`**). ⇒ **Option A est impossible** : un inscrit ne peut pas écrire/incrémenter un compteur sur le doc public d'un autre. D'où Option B via l'inbox.
- **Inbox = primitif plateforme réel** (`server_broker.rs:826` `inbox_post` : aucun contrôle de membership sur l'émetteur ; message scellé à la clé de l'inbox, lisible seulement par les *readers* enregistrés). C'est exactement le canal « n'importe qui dépose, seul le propriétaire dépile ». Aujourd'hui la lib l'émule sur le wallet partagé (`inbox.ts` post/read RDF), le natif étant différé.
---
## A. Lectures réactives — le design
### Principe : `doc_subscribe` par-doc comme **signal de changement**, `readUnion` reste le lecteur
On **ne** rend **pas** `readUnion` réactif et on **n'introduit pas** de fan-out ORM. On garde le pattern documenté (`read-model.md:100-110`) :
> une souscription réactive légère (`doc_subscribe`, ou l'ORM sur un seul store déjà ouvert — jamais un fan-out par-entité) sur les docs synchronisés ; sur son signal de changement, re-jouer le jeu borné de `sparql_query` par-doc (`readUnion`).
Concrètement :
1. **La lib expose un wrapper typé `doc_subscribe`.** Il n'existe pas aujourd'hui. Ajouter dans `packages/client/src/docs.ts` (ou un nouveau `subscribe.ts`) une fonction, p.ex. :
```ts
// renvoie un unsubscribe ; onChange appelé au State initial puis à chaque Patch
export function subscribeDoc(nuri: Nuri, onChange: (r: AppResponse) => void): () => void
```
qui wrappe `ng.doc_subscribe(nuri, sessionId, cb)` et normalise l'AppResponse (initial + patches) + la fermeture du flux. C'est **par-document** (un NURI), donc immunisé au hang du fan-out.
- Exposer aussi un helper pour souscrire **un ensemble** de docs en montant **une souscription par doc** (map `nuri → unsubscribe`), avec **isolation par-doc des erreurs** : un `RepoNotFound` / doc non-synchronisé ne fait échouer QUE sa propre souscription (retry/skip), jamais les autres. C'est le point-clé qui évite de reproduire le fan-out. Le contrat SDK (`sdk-reference.md`) devra documenter ce wrapper.
2. **Le contexte data (FestipodDataContext) monte une souscription par-doc sur le jeu qu'il lit déjà.** Le jeu `allReadDocs` (union `publicDocs` `protectedDocs`) est déjà borné et par-besoin. Nouvel effet dans `useNgData()` :
```
useEffect(() => {
const unsubs = allReadDocs.map(nuri => subscribeDoc(nuri, () => bumpRead()));
return () => unsubs.forEach(u => u());
}, [allReadDocs]);
```
→ sur **tout** patch d'un des docs abonnés (écrit par CETTE session OU une autre), `bumpRead()` re-déclenche le `readUnion` existant (l.347-364). **`readTick`/`bumpRead` restent** — ils cessent d'être « manuel après ma mutation » pour devenir « poussé par le broker ». La forme du contexte (valeurs `events`/`users`/`participations` en `useState`) **ne change pas** ; les écrans continuent de lire via `useFestipodData()` sans modification.
3. **Entrée de NOUVEAUX docs dans le jeu abonné, sans fan-out hang :**
- **Nouvel événement découvert** : la découverte réactive remplace `discovery.watchIndex` (setInterval) par une **souscription `doc_subscribe` sur le doc d'index global** (l'inbox d'index, un seul doc — `resolveInboxAnchor`-style). À chaque patch de l'index → re-lire `readDiscoveredEvents()` → les nouveaux `doc` NURIs entrent dans `publicDocs` (via `setPublicDocs`), ce qui **agrandit `allReadDocs`**, ce qui **remonte la souscription par-doc** (nouveau `useEffect` ci-dessus) → le nouvel événement est lu ET désormais abonné. Pas de fan-out : chaque doc est abonné **individuellement**, quand il entre.
- **Nouveau dépôt d'inbox** (nouveau participant, notification hôte) : idem, remplacer `inbox.watch` (setInterval) par une **souscription `doc_subscribe` sur le doc-inbox** concerné (un seul doc). Un patch → re-matérialiser (§B).
- **Doc que je viens de créer** : `registerDoc` continue de l'ajouter à `publicDocs`/`protectedDocs` → il entre dans `allReadDocs` → il est abonné. (`bumpRead` immédiat garde la latence perçue nulle localement.)
4. **La lib remplace ses watchers polling** : `inbox.watch` et `discovery.watchIndex` deviennent des wrappers `doc_subscribe` sur le doc-inbox / doc-index respectif (un doc chacun — pas de fan-out). Signature publique conservée (callback + unsubscribe) pour ne pas casser les appelants ; l'implémentation passe de `setInterval(read)` à `subscribeDoc(anchor, () => read().then(onX))`.
### Ce qui NE change pas
- `readUnion` reste one-shot, par-doc, tolérant (un doc en échec → `[]`, jamais d'abort).
- Le mapping `readEntities` (`src/shared/data/readEntities.ts`) est inchangé.
- **Aucun `useShape({graphs:[…]})` par-entité n'est introduit** — le seul `useShape` restant est le `FanoutProbe` du harness de test (qui sert justement à *démontrer* le hang), pas un chemin applicatif.
---
## B. Compteur de participants — Option B (dépôt → matérialisation propriétaire)
### Les documents / inboxes impliqués
- **Doc de participation de l'inscrit** : protected, **possédé par l'inscrit** (déjà créé par `joinEvent`, `createEntityDoc(owner,'protected')` + `writeEntity(ENTITY_TYPE.participation, …)`). Lisible en clair par les **connexions** de l'inscrit uniquement (cap protected + `declareConnections`).
- **Inbox de l'événement** : résolue par `hostInboxNuri(eventId)` → `resolveInboxAnchor()` (aujourd'hui une anchor unique ; à migration, un doc-inbox par événement — `hostInboxNuri` réserve déjà le param `eventId`). C'est là que l'inscrit **dépose le lien de participation**.
- **Doc de l'événement** : public, **possédé par le propriétaire**. C'est **le propriétaire** qui y écrit `participantCount` — jamais l'inscrit.
- **(référence) enregistrée par le propriétaire** : une entrée reliant le compte incrémenté au dépôt (idempotence + audit) ; peut vivre dans le doc de l'événement (référence de dépôt déjà matérialisé) ou un doc protected du propriétaire.
### Le flux (qui écrit quoi)
1. **Inscrit — `joinEvent`** (modifié) :
- Écrit sa propre `Participation` (protected, à lui) — **inchangé**.
- **Dépose dans l'inbox de l'événement** un payload `{ kind:'new-participant', eventId, participationDoc, participantId, uid }` via `depositRegistration` (aujourd'hui `inbox.post(target, {from:null, payload})`, `registration.ts:110-125`). `from` reste anonyme au transport (le SDK lie `from` à l'identité et rejette un spoof — cf. `registration.ts:106-108`) ; l'identité domaine voyage dans le payload. **Le dépôt porte le NURI du doc de participation** (`participationDoc`) pour que le propriétaire, s'il est une connexion, puisse le lire en clair.
- **SUPPRIME l'écriture de `participantCount` sur le doc de l'événement** (l.635-640 actuelles). L'inscrit n'écrit plus jamais sur le doc d'un autre.
2. **Propriétaire — matérialisation (quand connecté)** : la session du propriétaire est abonnée (`doc_subscribe`, §A.3) au doc-inbox de son événement. Sur un nouveau dépôt `new-participant` :
- dédup via `uid` (idempotence : ne pas re-compter un dépôt déjà matérialisé — vérifier la (référence) enregistrée) ;
- **incrémente `participantCount` sur SON PROPRE doc d'événement** (`updateEntityField(eventDoc, eventDoc, 'participantCount', int(next))`) — **c'est le propriétaire qui écrit son propre doc**, pas un privilège de lecture ni un write hors-scope ;
- enregistre la **(référence)** du dépôt matérialisé (marqueur d'idempotence).
- Cette logique remplace/prolonge l'effet de **matérialisation des notifications** existant (FestipodDataContext l.443-479, `readRegistrationNotifications`) : aujourd'hui il ne fait que surfacer des notifications ; il devient aussi le point où le compteur est incrémenté. Le déclencheur passe du polling implicite à la souscription `doc_subscribe` sur l'inbox.
3. **Autres sessions voient le compte changer** : le doc de l'événement est **public**, donc **toute** session qui l'a dans son `allReadDocs` y est abonnée (§A). L'écriture du propriétaire produit un patch → `bumpRead()` → `readUnion` re-lit → `event.participantCount` mis à jour → `EventDetailScreen` re-rend « Participants (N) » **sans reload ni action**. C'est le chemin réactif complet, cross-session.
### Désinscription (symétrique, autoritative)
- `leaveEvent` : garde le **DELETE autoritatif** de la participation (`deleteParticipation`, vérifié `remaining === 0`) — [[caveat_participation-deletion]] intact (ne doit pas ressusciter).
- **Retire la décrémentation directe** de `participantCount` par l'inscrit (l.705-710). À la place, l'inscrit **dépose un `leave`** (`{ kind:'leave-participant', eventId, uid }`) dans l'inbox de l'événement ; le propriétaire matérialise → **décrémente son propre doc** (idempotent via `uid`, `max(0, n-1)`, et refuse de re-décrémenter un `uid` déjà traité pour ne pas « ressusciter » un compte faux).
- **Cas propriétaire hors-ligne = comportement éventuel ACCEPTÉ** : si le propriétaire n'est pas connecté, le dépôt reste dans l'inbox ; le compte n'est **pas** mis à jour tant qu'il ne se reconnecte pas et ne matérialise pas. **C'est un comportement accepté** (cohérence à terme, local-first). Les autres voient le compte se corriger quand le propriétaire revient. À énoncer tel quel dans le contrat produit.
### Identité (C)
- Un participant est montré **par son nom** uniquement si le viewer est une **connexion** du participant : le doc de participation + le profil du participant sont protected, donc lisibles en clair seulement via le cap accordé par `declareConnections` (`src/shared/utils/connections.ts` → `grantRead(protectedDocsOf(owner), neighbour)`). Sinon le doc reste illisible → le participant n'apparaît **pas** dans `getEventParticipants` (qui joint sur les `users`/`participations` lus) → il tombe dans les **placeholders « inconnu »** de `EventDetailScreen` (l.163-170), le compte dérivé restant visible via `participantCount`.
- **Aucune lecture privilégiée de l'hôte** : le propriétaire ne lit pas les participations ; il ne fait que **compter des dépôts** et écrire son propre compteur. Il ne voit un participant nommé que s'il en est une connexion — exactement comme n'importe quel viewer. C'est conforme à `functional-domain/knowledge_data-scopes-and-discovery` (« identifié si connu, anonyme sinon ») et à `app-security/knowledge_trust-model` (pas de contrôle d'accès applicatif, l'isolation est par-document déléguée au SDK).
---
## D. Plan de test (e2e réel, sans polling)
### D.1 — POLYFILL bas-niveau : `doc_subscribe` réagit vraiment
But : prouver que la primitive réactive fonctionne, indépendamment de Festipod.
- Emplacement : test unité/intégration de la lib (`packages/client`) — ou un `@data` Festipod si le harness broker est requis.
- Setup : deux « vues » du **même** doc (deux souscriptions, ou une souscription + une écriture par un autre chemin). Monter `subscribeDoc(nuri, onChange)`, écrire dans le doc via `sparqlUpdate`.
- **Assertion** : `onChange` est appelé (State initial) **puis** re-appelé après l'écriture, **sans polling** (aucun `setInterval` ; l'assertion attend un event, pas un timeout). Vérifier qu'une écriture sur un **autre** doc ne déclenche PAS `onChange` (isolation par-branche). Vérifier qu'un doc non-synchronisé qui échoue **n'avorte pas** les autres souscriptions (par-doc).
### D.2 — FESTIPOD app-level : 2 navigateurs réels, sans reload ni action de A
But : B s'inscrit → l'`EventDetailScreen` de A montre `participantCount` incrémenté **et** un « participant inconnu », **sans que A recharge ni n'agisse**.
- Étendre `src/modules/event/features/e2e-multibrowser.feature` (`@multibrowser @shared-wallet`) et `src/modules/event/steps/e2e/multibrowser-features.steps.ts`.
- Nouveau scénario (esquisse Gherkin FR) :
```
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"
```
- **Assertions exactes** :
1. `participantCount` **côté A** passe de 1 à 2 — assert via `frame.waitForFunction` sur l'état réactif du contexte (`__testData.events` → l'event → `participantCount === 2`) **puis** confirmé sur le DOM rendu (le libellé « Participants (2) » de `EventDetailScreen`), **sans appel de `loadAppInBrowser`/reload** entre le join de B et l'assertion de A.
2. **Placeholder inconnu** : `knownParticipants.length < participantCount` → assert présence du bloc « Voir tous les participants » (ou un compteur d'anonymes = `participantCount knownParticipants.length ≥ 1`), le participant B n'étant PAS une connexion de A → non nommé.
3. **Négatif no-polling** : le passage 1→2 arrive via souscription (event-driven) ; le test attend l'event, il ne doit pas dépendre d'un `waitForTimeout` fixe comme *source* de la mise à jour (un timeout de garde reste toléré pour laisser la sync broker, comme dans le scénario désinscription existant l.131).
- **Helpers harness nécessaires** (dans `harness-ng.tsx`, exposés sur `window.__testData`, et répliqués dans les DEUX harness — cf. `bdd-testing/cookbook_add-scenario`) :
- un getter du `participantCount` réactif pour un event (déjà accessible via `__testData.events`).
- un accès au **rendu** `EventDetailScreen` de A **sans navigation manuelle** : soit monter l'app réelle sur la route détail (chemin @e2e), soit exposer `knownParticipants` / le compte d'anonymes. Réutiliser `createEventReal` (l.232), `appJoinEvent` (l.245), `readInboxDeposits` (l.283), `authParticipationCount` (l.302).
- un hook « le propriétaire a matérialisé » : comme A est le propriétaire ET connecté, sa souscription inbox doit incrémenter son propre doc — le test observe le résultat (count 2) sans piloter la matérialisation à la main.
- **Symétrie désinscription** : étendre le scénario existant « la désinscription ne ressuscite pas » (l.36-48) d'une assertion réactive : après le leave de B, `participantCount` côté A **repasse à 1 sans reload**, et `authParticipationCount === 0` (déjà couvert).
---
## E. Risques / questions ouvertes
1. **Le hang du fan-out** (le risque n°1). Le design l'évite **par construction** : souscription **par-document** (`doc_subscribe`), jamais `orm_start_graph(graphs:[…])`. À garder comme invariant : tout nouveau doc entre via une souscription **individuelle** avec isolation d'erreur par-doc — un doc non-synchronisé ne doit jamais pouvoir avorter les autres souscriptions ni bloquer le `readUnion` (qui reste tolérant par-doc). Risque résiduel : le **volume** de souscriptions par-doc (une par doc lu) — à valider sur le broker réel ; sinon, plafonner/prioriser les docs abonnés (event courant + son inbox + mes docs) plutôt que l'union entière.
2. **Compte propriétaire hors-ligne = éventuel — DÉCIDÉ (2026-07-06).** Tant que le propriétaire n'est pas connecté, aucun dépôt n'est matérialisé → `participantCount` reste périmé pour les autres (la participation elle-même est persistée côté broker — rien n'est perdu, seul l'agrégat attend la reconnexion de l'hôte). Accepté pour la V1. **Plus tard, un SERVICE prendra le relai** quand le propriétaire est déconnecté (le paquet différé `@ng-eventually/service` — le « curateur » évoqué dans les docs inbox de la lib) : un acteur toujours disponible matérialisera l'inbox à la place de l'hôte. Pas de fallback « N+ en attente » en V1.
3. **`doc_subscribe` par-doc — FAIT (lib `c0498a6`).** La lib expose désormais `subscribeDoc`/`subscribeDocs` (isolation d'erreur par-doc, pas de fan-out ORM), `inbox.watch`/`discovery.watchIndex` sont passés en `doc_subscribe` (plus de polling), et le contrat est dans `sdk-reference.md`. Validé broker réel (le callback traverse le RPC iframe et fire sur changement). Reste : brancher la souscription dans le chemin de lecture app (P3).
> **Hooks réactifs du SDK** (précision) : l'adaptateur React de NextGraph expose `useShape` (shapes RDF réactives) et `useDiscrete` (docs CRDT discrets) — pas de `useQuery`. La lib ré-expose `useShape`. Pour la lecture UNION de N docs (le cas de Festipod), `useShape`/l'ORM en fan-out *hangue* ; le chemin réactif app passe donc par `subscribeDocs` (par-doc) + re-`readUnion`, éventuellement enveloppé en un hook de lecture réactive côté lib (à décider en P3).
Autres points à trancher :
> ⚠️ **RECADRÉ + CORRIGÉ (2026-07-13).** L'affirmation ci-dessous « Prouvé par l'e2e D.2 … sans reload » était **FAUSSE** (le « vert » venait d'un wallet bloaté). Mais surtout le **cadrage « réactif / sans reload / push cross-session » était un SUR-CADRAGE** : la spec réelle est **« le propriétaire traite son inbox de façon fiable à sa PROCHAINE CONNEXION »** (pas de notification live temps-réel entre deux utilisateurs connectés). Bug corrigé sous ce cadrage : le materializer lisait l'inbox **avant sa sync** (→ 0 mémoïsé). Fix = lecture inbox **gated sur barrière** (`inbox.readSynced` = `ensureRepoOpen` + `read`) + déclenchement à la connexion + source unique `event.participantCount`. Scénario `event/e2e-multibrowser.feature` **reframé « à la prochaine connexion » et dé-`@wip`, VERT sur profil frais** (une reconnexion/re-matérialisation de A est le mécanisme accepté). Détail : [[knowledge_context-internals]] §participantCount. Le plan de phasage ci-dessous doit être relu à cette lumière (le « sans reload » n'est plus l'exigence).
- **Ordre de phasage :** ~~(P1) lib : `subscribeDoc` + variante multi-doc + tests D.1~~ **FAIT (`c0498a6`)** ; ~~(P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe`~~ **FAIT (`c0498a6`)** ; ~~(P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive~~ **FAIT (branche `ng-eventually`, non commité)** — `useNgData` monte un effet `subscribeDocs(allReadDocs, …)` clé sur un join trié des NURIs (`readDocKey`, anti-boucle : un patch → `bumpRead` → re-`readUnion` ne change pas le set → pas de re-souscription ; le reset d'identité `prevOwnerRef` vide le set → `readDocKey=''` → cleanup unsubscribe, puis re-listing → re-souscription sur le set reconstruit) + un effet de découverte réactive `watchDiscoveredEvents()` (wrapper app sur `discovery.watchIndex`, déjà `doc_subscribe`) → `relist()`. `readUnion` reste le lecteur one-shot tolérant. **Prouvé par l'e2e D.2** (`e2e-multibrowser.feature`, scénario « Un participant apparaît réactivement… », @multibrowser @shared-wallet, 12 steps verts en isolation) : B s'inscrit → A voit `participantCount === 2` + un participant « inconnu » **sans reload ni action**, via `doc_subscribe` sur le doc public de l'événement (le join en P3 écrit encore ce compteur, cf. §B.5 — c'est ce qui valide P3 avant P4). ; (P4) app : Option B join (retirer le write compteur de l'inscrit, matérialisation propriétaire) ; (P5) app : Option B leave symétrique ; ~~(P6) e2e D.2~~ **FAIT avec P3** (le scénario réactif ci-dessus ; la symétrie désinscription réactive reste à ajouter avec P5). P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1P3 avant P4P6.
- **Idempotence de la matérialisation** : le `uid` par-dépôt (`RegistrationPayload.uid`, `registration.ts:56`) est le pivot ; la (référence) enregistrée par le propriétaire doit être consultée avant tout incrément/décrément pour ne jamais double-compter (rejeu de sync) ni « ressusciter » un compte.
- **Migration inbox natif** : aujourd'hui l'inbox est émulée sur le wallet partagé (`inbox.ts` post/read RDF). À la migration vers l'inbox broker natif (`inbox_post`/`inbox_pop_for_user`, scellé), le flux Option B **reste valide** (dépôt non-membre autorisé, lecture réservée aux *readers* = propriétaire), mais le wrapper `subscribeDoc` sur l'inbox devra viser le mécanisme natif de notification de dépôt. À vérifier au moment de la migration.
@@ -0,0 +1,97 @@
---
type: brief
summary: Target model for sign-ups — a Participation READABLE by everyone (event ref + `active` boolean + a key-less reference to the participant's profile), deposited into the event's inbox; the creator processes the inbox, dedups 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 can read the profile and recognize the person. Supersedes the 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 **reference to their *protected* profile that carries no key**. **Nothing else** — no description for now.
2. They deposit the **Participation's reference** 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 a name for the profile, not the key to read it.
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 the contract promises no authenticated sender.
### Why a flag rather than a deletion
Without the read key, a **deletion** cannot be told apart from "nothing was ever there". 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 the key to read it — that is the very definition of being connected. A third party sees an opaque reference.
The advantage over an encrypted field inside the Participation: **adding a connection rewrites nothing**. The profile is shared with a new connection once, durably (and irreversibly — the contract publishes no revocation). An encrypted field would require re-encrypting to N recipients and rewriting the Participation on every new connection.
## What this rests on
Two guarantees the contract publishes, and one thing it does not.
| What the model needs | Where it stands |
|---|---|
| A reference can **name without granting read access** | Published: *"A returned reference carries no key… A reference found inside a document yields a name, not a key."* |
| Sharing is **per document, durable and one-way** | Published: `inbox.share(doc, toUser)` — one act, no revocation, nothing per reader on a public document |
| **Anyone may deposit, only the owner reads** the inbox | Published: `inbox.postToDocument` / `inbox.read` |
| A **dedup key** letting the creator count distinct people without reading them | **NOT published.** See below — this is the open dependency. |
## The dedup: the requirement, and the gap
**The requirement, validated by the PO (2026-07-27)**: the creator must be able to tell two references from the *same* person apart from two references from *different* people, **without ever knowing who** — otherwise the count is not a count of people, and a participant could inflate it by creating several Participations.
**The contract publishes nothing that does this.** A reference "yields a name, not a key", and no call answers "do these two references belong to one person?". So the mechanism is **not Festipod's to specify**: it is a **gap to raise with the provider**, stated as a need — *a stable, per-person discriminator that can be compared without reading the referenced document*.
Design consequence, whatever the mechanism turns out to be: the Set is **keyed by that discriminator** — at most one reference per person. `Set.size` = the number of distinct people.
### The reservation that must outlive this brief
Any such discriminator is by construction a **pseudonym**: it does not say *who*, but it is comparable across contexts, so whoever collects references can link them. **Never present a Festipod action as "anonymous"** when it circulates one — the contract guarantees no anonymity, and making the Participation public widens the surface on which it is collected. Whether the pseudonym can be rotated, or scoped, is part of the gap above.
## 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
What exists today ([[knowledge_context-internals]] §participantCount) derives a `participantCount` **written by the owner** from inbox markers carrying the **plaintext `userId`**.
- **Drop the `userId`** from inbox deposits → only the **Participation's reference** remains.
- **Count distinct people** through the discriminator above, no longer `userId`s.
- **`event.participantCount` goes away**, replaced by `Set.size`.
- **Identity resolution** now goes through **reading the profile** (hence through being connected), 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) — and what happens to a broken connection: the contract publishes **no revocation**, so sharing a profile is permanent. 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 — a contract gap**: no published way to **dedup without reading** (see above). Until the contract answers it, coding this model would produce a count that **claims** to be a count of people without being one. Raise it with the provider; do not emulate it here.
- **Parked**: **identity terminology** (wallet / user / profile) — see `.project/to-discuss.md`.
## Status: model settled, implementation gated
The model is **settled** (PO, 2026-07-27). What remains gated is the **implementation**, waiting on the dependency above. **Do not remove the current owner-derived counter** in the meantime ([[knowledge_context-internals]]).
Links: [[caveat_participation-deletion]], [[rule_document-per-entity]], [[rule_app-uses-sdk-surface-only]], app-security ([[brief_2026-05-18_authorization-matrix]], [[knowledge_trust-model]]).
@@ -1,17 +0,0 @@
---
type: caveat
summary: Le type FpEventData et le seed portent startDate/endDate/startTime/endTime/themes, mais le SHEX Event ne les définit pas — ces champs sont silencieusement perdus en mode connected (NextGraph)
last_checked: 2026-06-15
---
# Caveat : champs d'événement non persistés en mode connected
Le type app `FpEventData` (`src/shared/data/types.ts`) et le seed (`seedData.ts`) portent des champs **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — mais la **shape SHEX `Event`** (`src/shared/shapes/shex/festipodShapes.shex`) ne les définit **pas**. La shape ne couvre que : `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials` (à vérifier dans le `.shex`).
## Conséquence
En **mode connected** (SDK), le mapping (`mapEvent` dans `FestipodDataContext.tsx`) ne lit/écrit que les champs de la shape. Les champs hors-shape sont **silencieusement perdus** : remplis par des defaults ou vides. Or des écrans **les affichent** (ex. `startTime`/`endTime` dans `EventDetailScreen`) — donc en mode démo (seed local) ils apparaissent, mais en connecté ils disparaissent. Décalage observable seulement à l'usage.
## Pour corriger (si on veut les persister)
Ajouter les champs à `festipodShapes.shex` puis `bun run build:orm`, et étendre `mapEvent`. Tant que ce n'est pas fait, **ne pas se fier aux champs date/heure/thèmes en mode connecté**.
@@ -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.
@@ -1,15 +1,15 @@
---
type: caveat
summary: La désinscription à un point de rencontre doit être AUTORITATIVE — une fois la Participation supprimée, elle ne doit plus réapparaître ; vérifier après un vrai rafraîchissement que l'inscription a bien disparu côté données
summary: Withdrawing from a meeting point must be AUTHORITATIVE — once the Participation is deleted, it must not come back; after a real refresh, check that the sign-up is really gone on the data side
last_checked: 2026-07-03
---
# Caveat : la désinscription doit être autoritative
# Caveat: withdrawal must be authoritative
Contrat métier : quand un utilisateur **se désinscrit** d'un point de rencontre (`leaveEvent` dans `src/shared/context/FestipodDataContext.tsx`), la `Participation` doit être **supprimée durablement**. Elle ne doit **pas ressusciter** après une resynchronisation.
Business contract: when a user **withdraws** from a meeting point (`leaveEvent` in `src/shared/context/FestipodDataContext.tsx`), the `Participation` must be **durably deleted**. It must **not come back to life** after a resynchronization.
## Le piège
## The pitfall
Refléter la suppression uniquement dans l'état réactif de l'UI ne suffit pas : l'inscription peut réapparaître si la suppression n'est pas **persistée** côté données. La désinscription doit donc être **autoritative** au niveau du document, pas seulement au niveau de l'affichage.
Reflecting the deletion only in the UI's reactive state is not enough: the sign-up can reappear if the deletion is not **persisted** on the data side. Withdrawal must therefore be **authoritative at the document level**, not merely at the display level.
**À vérifier après toute évolution de `leaveEvent`** : s'inscrire puis se désinscrire, faire un **vrai rafraîchissement**, et confirmer que la participation a bien disparu (le bouton ne doit pas rester « ✓ Je participe »). Couvert par le scénario `@e2e` « Se désinscrire d'un événement » (`src/modules/event/features/cycle-de-vie-evenement.feature`) et un `@data` « désinscription persistante » (`inscription-inbox.feature`).
**To check after any change to `leaveEvent`**: sign up, then withdraw, do a **real refresh**, and confirm that the participation is indeed gone (the button must not stay on « ✓ Je participe »). Covered by the `@e2e` scenario « Se désinscrire d'un événement » (`src/modules/event/features/cycle-de-vie-evenement.feature`) and a `@data` one, « désinscription persistante » (`inscription-inbox.feature`).
@@ -1,17 +0,0 @@
---
type: caveat
summary: Une entité écrite juste avant une inactivité/chute de socket peut être perdue silencieusement (jamais durable côté broker) ; le compte survit (pas de fork). Observé Firefox. Le SDK ne confirme pas la durabilité et ne se reconnecte pas seul.
last_checked: 2026-07-14
---
# Piège : une écriture juste avant une déconnexion n'est pas garantie durable
**Symptôme produit.** L'utilisateur crée une entité (un événement), ça semble réussir, puis une **période d'inactivité** survient ; au rechargement / à la reconnexion, l'entité a **disparu**. Le scope se relit **vide**. L'**identité/compte survit** — ce n'est PAS un fork, c'est une écriture non durable.
**Mécanisme (résumé, non tranché).** Le socket broker peut mourir spontanément pendant l'idle (`SOCKET IS CLOSED … SerializationError`). L'écriture était dans l'outbox local ; au retour, le replay échoue (`Err(TopicNotFound)`) et l'entité est abandonnée. **Observé Firefox uniquement** à ce jour. Un test @data à froid (2026-07-14) a par ailleurs montré qu'une session **fraîche** (aucun état local, même compte A) ne récupère **pas** le scope propre de A depuis le broker : le test de reconnexion @data qui « passait » relisait en fait l'IndexedDB **locale**. Reste à trancher : **perte à l'écriture** vs **échec de réhydratation à froid** (mécanismes distincts) — voir le post-mortem dans le polyfill.
**Pourquoi l'app ne le voit pas.** `NgStatus` est dérivé **une seule fois** de la session initiale → aveugle aux chutes en cours de session. Le canal `disconnections_subscribe` du SDK se déclenche sur la panne mais **n'est pas consommé** (ni polyfill ni app). Aucune API ne confirme qu'une écriture a atteint le broker.
**Ne pas documenter ici les internes NextGraph.** Frontière SDK (voir [[knowledge_nextgraph-stack]]) : cause racine, chaîne causale (socket, reconnexion en TODO) et pistes de correction vivent dans le repo `@ng-eventually/client``docs/incidents/2026-07-14-write-loss-on-disconnect.md`. Cette fiche ne garde que l'**impact consommateur** + le pointeur.
**Statut : ouvert, non traité (2026-07-14).** À revisiter quand le core/SDK adresse la reconnexion ou expose une confirmation de durabilité — ce caveat tombera alors. Voir aussi le débat lecture-à-froid vs perte réelle dans [[brief_2026-07-06_reactive-reads-and-attendance]] (le `BARRIER timed-out` de @data est une signature distincte, non confirmée comme ce bug).
@@ -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,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,81 +1,95 @@
---
type: knowledge
summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B (fiable à la connexion du propriétaire via lecture inbox gated sur barrière ; source unique = event.participantCount), instrumentation useShapeQuery (spinner+timing), mutations no-op en mode local malgré le toast
last_checked: 2026-07-07
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 & pièges de `FestipodDataContext`
# Internals & pitfalls of `FestipodDataContext`
Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à connaître avant de toucher au contexte de données.
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
## Résolution du `currentUser` (mode NG)
## Identity and profile are TWO things — never join them
En mode connected, le **principal** du currentUser (`currentUserId`) n'est **pas** `CURRENT_USER_ID` ('user-1', mode local) ni l'IRI du profil lu. Quand un identifiant est connecté, c'est un id **stable dérivé de l'identifiant** : `urn:festipod:user:<identifiant-normalisé>`, disponible immédiatement (sans dépendre de la lecture du profil protégé) et invariant sur la session — c'est la même clé que `setCurrentUser`, le cap owner et le compte shim (cf. [[rule_document-per-entity]], corollaire d'identité). Pièges restants :
- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => normalizeIdentifier(u.username) === identifiant)` avec **fallback** `@mariedupont` puis `users[0]` — un fallback silencieux si l'identifiant ne correspond à aucun profil (l'identifiant est un id d'espace, pas forcément le `username` d'un profil seedé).
- Sans identifiant connecté (dev/demo), `currentUserId` retombe sur l'IRI du profil lu (ou `''` si le wallet est vide → `Participation` avec `user: ''` invalide) : ne créer une participation qu'une fois le principal résolu.
**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]]).
## Lecture = `watchShape` (surface SDK), plus de machinerie bespoke
**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`.
**Depuis 2026-07-10** : `useNgData` lit via `useShapeQuery(shape, scope)` (binding
`useSyncExternalStore` sur `watchShape` du polyfill) — TROIS lectures useQuery-shaped
(events/public, users/protected, participations/protected) + adaptateurs Fp
(`shapeAdapters.ts`). Supprimés : `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`,
le listing manuel (`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture),
`relist`. `ready` = combinaison des `isSuccess`. Cf. [[rule_app-uses-sdk-surface-only]].
**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.
**Visibilité immédiate des mutations = overlay OPTIMISTE** (pas de `registerDoc`) :
`createEvent`/`joinEvent`/`leaveEvent` alimentent `pendingAddEvents`/
`pendingAddParticipations`/`pendingRemoveIds` ; l'état exposé = merge(réactif, adds)
moins removes, dédupé par id (id = NURI du doc). Réconciliation auto au push
(un add qui apparaît dans le réactif / un remove qui en disparaît est retiré) —
jamais de poll ([[rule_no-broker-polling]]). Vidé au changement d'identité.
## "My profile" is the profile document I OWN
## Auto-seed de dev
`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.
**Depuis 2026-07-13, l'auto-seed est OPT-IN et OFF par défaut** : il ne se déclenche que si la variable d'env `FESTIPOD_AUTO_SEED` est définie (`=1`), plus sur `NODE_ENV`. Variable absente → **aucun seed automatique**, même en dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts` ; livrée en dev via la route runtime `/festipod-config.json` + `define` compile-time dans `build.ts`, même mécanisme que le shared-wallet — cf. `tech-stack/knowledge_build-pipeline`). Le seed **explicite** (`loadTestData()`, tests @data) est inchangé. Motivation : le seed auto répété bloatait le wallet (lenteurs de lecture, cf. [[caveat_wallet-bloat-hang]]).
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.
Quand il est activé, l'auto-seed se déclenche si events ET users sont vides — **gardé sur `isSuccess`** (la readiness de `watchShape`),
PLUS sur un `setTimeout` de 3s : on ne décide « wallet vide » qu'une fois la sync
**confirmée** (`isSuccess`), sinon la lecture pas-encore-finie était prise pour un
wallet vide → re-seed à chaque reconnexion (bug corrigé). Pièges restants :
- **Un seul seed à la fois** : `loadTestData()` pose `hasTriedAutoSeed`, l'auto-seed le
re-teste → un chargement explicite supprime l'auto-seed en attente (sinon deux
`bootstrapWallet` concurrents écrivent en double).
- Le seed est **possédé par l'identité courante** (`bootstrapWallet(…, owner)`) : les
entités protégées seedées passent le cap de lecture par-document du propriétaire.
- **Pas de retry** : si le seed échoue, écran vide + `console.error`.
Four outcomes, and *somebody else's profile* is not one of them:
## `participantCount` — dérivé et possédé par le propriétaire (Option B)
| Owned profiles | Answer |
|---|---|
| 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 |
> ✅ **CORRIGÉ (2026-07-13).** L'exigence est **« fiable à la PROCHAINE CONNEXION du propriétaire »** (le créateur traite son inbox à sa connexion), PAS une notification live cross-utilisateur temps-réel. Le bug était : le owner-materializer matérialisait **trop tôt** (avant que le dépôt de l'inscrit soit synchronisé) → lisait `active=0` → écrivait 0 → **mémoïsait ce 0** → ne retraitait plus. Fix : (1) **lecture inbox gated sur barrière** — `inbox.readSynced` (= `ensureRepoOpen(doc)` attend le premier `State`, PUIS `read`, comme `discovery.readIndex`) au lieu de `inbox.read`, donc un dépôt déjà synchronisé EST vu à la connexion ; (2) le materializer se déclenche **directement à la connexion** (`[ready, ownedKey]`), plus seulement sur un push ; (3) `materializedCountRef` ne verrouille plus un 0 prématuré (son seul rôle = anti-boucle : n'écrire que si la valeur dérivée change) ; (4) **source unique du NOMBRE = `event.participantCount`** (le littéral `participantCount: 1` de `CreateEventScreen` est retiré → démarre à 0 ; l'affichage ne calcule plus de nombre local). Gardé VERT (profil frais) par `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (dé-`@wip`). Pas de polling ([[rule_no-broker-polling]]).
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.
**Depuis Option B (2026-07-07)** : `participantCount` n'est plus muté en place par l'inscrit. Le flux est dépôt-inbox → matérialisation-propriétaire :
- `joinEvent`/`leaveEvent` n'écrivent **plus** `participantCount` sur le doc de l'événement (ce serait une violation d'isolation — l'inscrit écrirait le doc d'un autre ; le write NextGraph est membership-bound, pas d'append). L'inscrit écrit seulement son **propre** doc de participation (protected) puis **dépose** un marqueur dans l'inbox de l'événement (`depositRegistration` sur join, `depositLeave` sur leave, `src/shared/data/registration.ts`).
- La session du **propriétaire** de l'événement matérialise : elle est abonnée (`inbox.watch`, `doc_subscribe`, sans polling) à l'inbox de ses events possédés (`ownedEventIds` = `listMyEntityDocs(owner,'public')` + les events fraîchement créés), et sur chaque dépôt **recalcule** `participantCount` sur **son propre** doc d'événement (`updateEntityField` sur son doc). C'est le seul écrivain du compteur.
- **Le compteur est DÉRIVÉ, pas incrémenté** : `materializeAttendance` (registration.ts) lit l'inbox et calcule l'**ensemble** des inscriptions actives distinctes (dépôts `new-participant` dédupés par `uid`, MOINS ceux annulés par un `leave-participant` — par `regUid` exact ou fallback `(eventId, userId)`). `participantCount = |ensemble actif|`**pas de base « hôte »** : le créateur ne participe pas automatiquement (pas de notion d'hôte, cf. concept `functional-domain`), donc le compteur démarre à **0** à la création et n'avance que sur des inscriptions réelles. `createEvent` **n'écrit plus** de participation à la création (elle écrivait une participation hôte + posait le compteur à 1) ; le créateur voit « J'y serai » et peut rejoindre/quitter son propre événement comme tout le monde. Comme c'est une **fonction pure de l'inbox**, un rejeu de sync broker converge — jamais de double-comptage ni de décrément fantôme (idempotence). L'écriture est gardée (n'écrit que si la valeur change), anti-boucle. Couvert par le scénario `@data` « Le créateur ne participe pas automatiquement à son événement » (us-13) : compteur 0 + `isParticipating(E)===false` à la création, puis join→true / leave→false.
- **Propriétaire hors-ligne = éventuel** : seule la session du propriétaire matérialise ; déconnecté, le compteur n'avance pas pour les autres (les participations/dépôts restent persistés — rien n'est perdu ; un futur service matérialisera à sa place).
- Le compteur reste néanmoins un **agrégat**, pas la liste des participants nommés : `getEventParticipants` (identité nommée) reste gouverné par le cap de lecture protected ([[caveat_participation-deletion]] pour la suppression autoritative, inchangée). Cf. le brief `brief_2026-07-06_reactive-reads-and-attendance` §B.
> **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.
### Invariant id-form : apparier sur la forme CANONIQUE de l'event-id
## A profile is created at sign-in when there is none
Le `@id` d'un événement **est** son NURI de document (`did:ng:o:<repo>[:v:<overlay>]`). Le matérialiseur du propriétaire apparie les **dépôts** de l'inbox aux événements possédés **par l'event-id** : `ownedEventIds` (ce que le matérialiseur itère), la **clé de dépôt** (`payload.eventId`, ce sous quoi l'inscrit dépose) et la **cible d'écriture** du compteur doivent désigner le même événement.
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.
**Constat mesuré (2026-07-07)** : sur l'arbre courant ces trois voies portent le **même** NURI (suffixe `:v:<overlay>` inclus) — create-time, `listMyEntityDocs` et le `@id` relu coïncident, parce que `readUnion` **épingle le subject au NURI d'entrée** (lib `read-model.ts`, `63ecfee`). L'appariement marche donc déjà, **y compris** pour un événement possédé atteint via `listMyEntityDocs` (validé par le scénario @data « …fait converger le compteur dérivé »). La canonicalisation ci-dessous est **défensive**, pas la correction d'un bug actif. (Le non-match qu'une investigation avait cru voir était l'artefact **seedé-mais-pas-possédé** : sur un wallet persistant, le seed appartenait à une identité `test-*` d'un run antérieur → la session courante l'atteint par découverte, pas par `ownedEventIds` — comportement correct.)
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`.
**Règle** : apparier l'event-id sur sa **forme canonique** — l'id de repo de base, en retirant tout suffixe `:v:<overlay>` (`canonicalEventId`, `src/shared/data/registration.ts`). Cette forme canonique est utilisée pour l'**appariement** dans `materializeAttendance` / `readRegistrationNotifications`, et pour **dédupliquer** `ownedEventIds` (`ownedKey`, FestipodDataContext) afin qu'un même événement atteint par deux voies ne soit pas matérialisé deux fois. **Attention** : seul l'**appariement** utilise la forme stripée ; le compteur est toujours **écrit** sur le vrai NURI possédé (un doc vivant, ouvrable) — un id stripé ne doit jamais servir de cible d'écriture / d'ancre. C'est un invariant **côté app** (pas un détail NextGraph) : quelle que soit la façon dont la lib fait varier l'overlay, l'app apparie sur la base commune.
## Nothing succeeds in silence
## Changement d'identité = session fraîche (isolation)
Mutations on the create/participate path **reject** rather than returning quietly, and the screen's confirmation **follows** the write:
Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** les docs de scope de l'identité courante (pour ne pas perdre un doc juste créé avant la re-liste). Or le stopgap wallet-partagé garde **un seul arbre React** au travers d'un faux-logout + re-login sous un **autre identifiant** (pas de rechargement — `AccountContext.login` ne fait que réécrire l'identifiant en localStorage, `AuthGate` ne remonte rien). Sans réinitialisation, **les docs PROTECTED de l'identité précédente (ses participations) survivent dans le jeu de lecture de la nouvelle identité et fuient** via la lecture union : le cap gate ne peut pas les filtrer quand le registre de caps (en mémoire) ne gouverne pas ce doc *cette* session (doc persisté d'un run antérieur, ou chargement frais où les caps sont vides). Symptôme observé : un utilisateur B voyait la participation de A (et l'événement de A apparaissait sur l'**accueil** de B, car l'accueil = `getUserEvents(currentUserId)`, cf. concept `app-architecture`).
- `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.
**Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([identifier])` (ref-gardé pour ne pas tirer au premier mount) vide `publicDocs`/`protectedDocs`, appelle `resetCaps()` + `resetRegistryCache()`, puis bump le read tick ; l'effet de listing reconstruit le jeu **borné à la nouvelle identité**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités.
## The legacy participation id space — resolved on READ only
**Mécanisme confirmé empiriquement (2026-07-07)** : le leak se reproduit UNIQUEMENT quand DEUX conditions coïncident — (a) le jeu de lecture porte encore le doc PROTECTED de A au travers du switch (pas de reset), ET (b) le registre de caps en mémoire ne gouverne pas ce doc (`resetCaps()` déjà tiré / caps vides pour un doc persisté d'une session antérieure au reload). Alors la participation de A traverse la lecture union de B (le filtre par-document n'a aucun cap à vérifier). Avec le reset ci-dessus tiré, `setProtectedDocs([])` retire le doc de A du jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps. **Régression gardée** par le scénario `@data` « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature) : A crée E + s'y inscrit, B (page fraîche sur le même wallet, identifiant distinct) n'a NI E sur son accueil (`getUserEvents(B)`), NI `isParticipating(E,B)`, ET ne lit AUCUNE participation portant le principal de A. Le symptôme historique « B voit “Je participe” » survenait surtout quand B **réutilisait un identifiant déjà employé par A** (même principal normalisé) sur un wallet **bloaté** (docs persistés d'un run antérieur, caps vides).
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.
## Instrumentation `useShapeQuery` — spinner global + timing
**`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.
`useShapeQuery` (binding `useSyncExternalStore` sur `watchShape`) instrumente **chaque cycle de requête** : au début d'un cycle il s'enregistre dans un store module-level `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, Set d'ids — idempotent, sûr sous StrictMode), et à la 1re transition `isPending → isSuccess|isError` (le « premier résultat », équivalent readPromise) il se résout ET logge le délai : `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (le délai des événements Event/public est donc visible nommément). Le `cycleId` est mémoïsé sur `[shapeKey, scope]` → un switch d'identité/scope recrée l'observable ET un nouveau cycle (re-`beginQuery`), et le cleanup résout au démontage (jamais bloqué). Le hook `usePendingQueries()` expose le nombre de requêtes en attente ; `HomeScreen` affiche un `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` dans `index.css`) à côté du titre « Festipod » tant que le compte > 0 → il ne s'arrête que quand **toutes** les requêtes en cours ont reçu leur premier résultat. Toute future `useShapeQuery` y contribue automatiquement. La mesure vit côté app (délai perçu React), **pas** dans le polyfill.
> **Horizon.** The target model drops the plaintext `userId` and resolves identity by reading the profile — [[brief_2026-07-20_attendance-set-model]], gated. The id-space fix is noted there as still valid: do not undo it in anticipation.
## Mutations no-op en mode local
## Reads = `watchShape`, writes = an optimistic overlay
En mode local/demo (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` sont des **no-ops** (`console.log`, l'état ne change pas) — mais les écrans affichent quand même un **toast de succès** (« Tu participes »). UX potentiellement trompeuse : l'utilisateur croit s'être inscrit alors que rien n'a changé. Voir [[knowledge_data-modes]] pour le choix du provider selon le statut.
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).
**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]]).
## `participantCount` — derived, and written only by the event's owner
The counter is **not** incremented by whoever joins: only a document's owner writes to it. The flow is deposit → owner-materialization.
- 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.
> 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]].
Which event a deposit belongs to is matched on the **canonical id-form** — see [[knowledge_write-rights-are-ownership]] §Matching, which governs every event-id comparison in this file.
## Logging convention — identity-first, and the counter before→after
Every DATA log goes through **`logPrefix`**: `[<currentUserId or principal>][app][data]`. A run often drives several sessions at once and their lines are read side by side, so a line must say *whose* it is. Adding a DATA log means reusing `logPrefix`, not a bare `console.log`.
Two measurement points are laid down **as a pair**: the owner's materializer logs `participantCount` before → after its write, and the display read logs the value as exposed to the render. Together they separate a **data** problem (never incremented) from a **display** problem (incremented but not re-read). Do not remove one without the other — alone they diagnose nothing.
## There is no identity switch, and nothing to reset
The app settles its identity once, before anything renders, and offers no way to change it (`app-security` → [[decision_2026-08-10_the-barrier-names-no-identity]]). One page hosts exactly one identity for its whole life, so there is no identity-change reset: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Do not reintroduce a reset for a transition that cannot happen. Cross-identity **isolation** is still a real requirement, but proving it needs two genuinely separate browser contexts (`bdd-testing` → [[rule_tests-validate-festipod-not-the-sdk]]).
## Mutations are no-ops in local mode
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a log, no state change) — yet the screens still show a success toast. Misleading UX, unchanged. See [[knowledge_data-modes]].
@@ -1,28 +1,30 @@
---
type: knowledge
summary: Deux modes (connected = SDK @ng-eventually/client, disconnected/demo = état local seedé) ; FestipodDataContext choisit le provider selon le statut de connexion, tous les écrans passent par 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()
---
# Modes de données & contextes
# Data modes & contexts
L'app a **deux modes**, tous deux consommés via le hook `useFestipodData()` :
The app has **two modes**, both consumed through the `useFestipodData()` hook:
1. **Connected** — shapes ORM du SDK `@ng-eventually/client` (P2P, chiffré, local-first)
2. **Disconnected / Demo**état React local seedé depuis `seedData.ts` (voir [[knowledge_seed-data]])
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`)
- Cycle de connexion : `disconnected``connecting``connected` | `error`.
- Fournit la session (l'utilisateur courant et son accès aux stores par scope).
- Connection cycle: `disconnected``connecting``connected` | `error`.
- That status is what the data provider below keys on; the app holds no session of its own.
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
- Enveloppe les shapes via `useShapeWithDefaults()`.
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
- **Provider selon le statut de connexion** :
- `disconnected``LocalDataProvider` avec seed (démo)
- `connecting``LocalDataProvider` **vide** (évite de flasher le seed avant le chargement du wallet)
- `connected``NgDataProvider` (données réelles du wallet)
- `error``LocalDataProvider` avec seed (fallback gracieux)
- Reads the shapes reactively through **`useShapeQuery()`** (`src/shared/data/useShapeQuery.ts`), the app's React binding over the SDK's `watchShape(shape, scope)` observable — three scoped reads: events (`public`), profiles and participations (`protected`), mapped to the `Fp*` domain types by `shapeAdapters.ts` (see [[rule_document-per-entity]] §Reads). There is no `useShapeWithDefaults` — that name is obsolete (verified 2026-07-28).
- Exposes `useFestipodData()` (consumed by every screen) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
- **Provider chosen by connection status**:
- `disconnected``LocalDataProvider` with the seed (demo)
- `connecting` an **empty** `LocalDataProvider` (avoids flashing the seed before the wallet loads)
- `connected``NgDataProvider` (real wallet data)
- `error``LocalDataProvider` with the seed (graceful fallback)
> Les mutations sont **réellement persistées** en mode connected (`joinEvent` écrit une Participation et notifie l'hôte du PdR, `leaveEvent` supprime de façon autoritative — cf. [[caveat_participation-deletion]]). En mode local/demo elles sont des no-ops (cf. [[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,34 @@
---
type: knowledge
summary: Types de données Fp* — Event, UserProfile, Participation, MeetingPoint et Notification sont persistés NextGraph (shapes SHEX + ORM) ; seul Friendship reste local-only (app-TS)
last_checked: 2026-07-03
summary: The Fp* app types and their SHEX shapes — Event (no host), UserProfile, Participation, MeetingPoint and Notification are persisted, Friendship stays local-only; the generated ORM names carry NO Fp prefix and are aliased at the import sites
last_checked: 2026-08-17
---
# Entités de données
# 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 | Persistance | Champs clés |
| Type | Persistence | Key fields |
|---|---|---|
| `FpEventData` | SDK (shape Event) | id, title, date, location, distance, themes |
| `FpUserData` | SDK (shape UserProfile) | id, name, username, bio, city, counts |
| `FpParticipationData` | SDK (shape Participation) | eventId + userId + confirmed |
| `FpMeetingPointData` | SDK (shape MeetingPoint) | eventId, location, time, host |
| `FpNotificationData` | SDK (shape Notification) | kind, target, source |
| `FpEventData` | SDK (Event shape) | title, date, startDate, endDate, startTime, endTime, location, distance, participantCount, coverImage |
| `FpUserData` | SDK (UserProfile shape) | name, initials, username, role, isPublic |
| `FpParticipationData` | SDK (Participation shape) | event + user + isConfirmed |
| `FpMeetingPointData` | SDK (MeetingPoint shape) | event, host, title, place, time |
| `FpNotificationData` | SDK (Notification shape) | recipient, type, ref, payload, timestamp, isRead |
| `FpFriendshipData` | **local-only** | userId + friendId |
`MeetingPoint` et `Notification` ont de vraies **shapes SHEX** (`src/shared/shapes/shex/festipodShapes.shex`) avec bindings ORM générés (`festipodShapes.shapeTypes.ts` : `FpMeetingPointShapeType`, `FpNotificationShapeType`) et **sont persistés**. `Notification` est notamment créée lors de l'inscription à un point de rencontre (`joinEvent`).
**An event has no host.** `hostName`/`hostInitials` are gone from the type and the shape alike — the event is only the anchor, and the host lives one level down on the meeting point (`FpMeetingPointData.hostId`, SHEX `fp:MeetingPoint.host`). See concept `functional-domain`, [[knowledge_actors-and-concepts]].
`Friendship` n'a **pas** de shape SHEX ni de persistance — il reste app-TS-only (cf. [[knowledge_nextgraph-stack]]).
**A Notification is no longer created when someone signs up.** The joiner deposits into the event's inbox and the **owner** builds the notification from what it reads there — see [[knowledge_context-internals]] §Nothing succeeds in silence.
> Piège : même pour `FpEvent` (persisté), plusieurs champs du type app ne sont **pas** dans la shape et sont perdus en connecté — voir [[caveat_event-fields-not-persisted]].
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only ([[knowledge_nextgraph-stack]]).
## The generated ORM names carry no `Fp` prefix
The generator emits `Event`, `UserProfile`, `Participation`, `MeetingPoint`, `Notification` (and `EventShapeType`, `UserProfileShapeType`, …) — **without** the `Fp` prefix earlier bindings had.
**It cannot be restored at the generator.** The emitted name derives from the shape IRI, and those IRIs are the **persisted RDF classes**: renaming them to regain a prefix would rename the data. So the app **aliases at its import sites** (`… as FpEvent`, `… as FpEventShapeType`) — three of them, in the data context and the two test harnesses. That keeps the downstream names unchanged and, just as importantly, stops the DOM's own `Event` and `Notification` from being shadowed.
Alias at the import; never rename in the generated files, which `bun run build:orm` overwrites ([[knowledge_nextgraph-stack]]).
> `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,34 +1,32 @@
---
type: knowledge
summary: Le SDK de données est @ng-eventually/client (traité comme un SDK NextGraph fini) — injecté une seule fois via ngSession.configure ; ORM réactif useShape sur shapes SHEX festipodShapes, bindings régénérés via build:orm ; ne jamais documenter l'état courant de NextGraph ici
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
---
# Stack de données (SDK `@ng-eventually/client`)
# Data stack (SHEX shapes over the `@ng-eventually/polyfill` surface)
Festipod persiste via **`@ng-eventually/client`** — le SDK NextGraph que l'app consomme. On le traite comme un **SDK fini et mature** : documents par entité placés par scope, capabilities, inboxes, ORM réactif.
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]].
```
@ng-eventually/client # LE SDK de données de l'app (ORM réactif useShape, docs, scopes, inbox)
```
## SDK boundary (the golden rule)
## Frontière SDK (règle d'or)
- 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**.
- L'app **ne dépend que de `@ng-eventually/client`** pour la donnée.
- Le SDK est **initialisé/injecté une seule fois** via `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — point d'injection unique. Le reste de l'app (data-plane, lifecycle, login, types) passe par la lib.
- **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`. Ici on décrit seulement **comment Festipod utilise ce SDK**.
## ORM & SHEX shapes
## ORM & shapes SHEX
The reactive ORM (`useShape`) is built on **SHEX shapes**: `src/shared/shapes/shex/festipodShapes.shex` defines:
L'ORM réactif (`useShape`) s'appuie sur des **shapes SHEX** : `src/shared/shapes/shex/festipodShapes.shex` définit :
- **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 (event, host, title, place, time)
- **Notification** — recipient, type, ref, payload, timestamp, isRead
- **Event** — titre, description, dates, lieu, thèmes, participants
- **UserProfile** — nom, username, bio, ville, visibilité
- **Participation** — lie event + user, statut de confirmation
- **MeetingPoint** — point de rencontre (lieu, horaire, hôte)
- **Notification** — notification (créée notamment à l'inscription à un PdR)
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.
Bindings ORM générés dans `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Régénérer** avec `bun run build:orm` après toute modif `.shex`.
> **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.
> **Lecture recommandée = le hook réactif du SDK.** La façon canonique de lire, c'est `useShape` : on s'abonne à une shape sur un scope, on obtient la valeur courante, et le composant se re-rend à chaque changement (local **ou** distant synchronisé) — abonnement/push, jamais de polling ; les lectures one-shot sont l'exception. La référence complète du SDK (contrat de lecture/réactivité + où l'émulation courante diverge encore) vit côté lib : `packages/client/docs/sdk-reference.md` dans `@ng-eventually/client`. Ne pas recopier les internes NextGraph ici.
> **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` n'a **pas** de shape SHEX ni de persistance — il reste app-TS-only (cf. [[knowledge_entities]]).
> `Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_entities]]).
@@ -1,17 +1,26 @@
---
type: knowledge
summary: seedData.ts fournit des fixtures déterministes (10 users, events, participations) avec CURRENT_USER_ID = 'user-1' (Marie Dupont) ; utilisé en mode démo et par les tests @ui
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` fournit des fixtures **déterministes** :
`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 = utilisateur courant**, `user-1`
- Plusieurs events (dates, lieux, thèmes)
- Participations, meeting points, friendships
- `CURRENT_USER_ID = 'user-1'`
## Where they are still used
Ces fixtures servent (a) le **mode démo** (`LocalDataProvider`, cf. [[knowledge_data-modes]]) et (b) les tests **`@ui`** qui rendent les écrans avec ces données prévisibles (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` existe, etc. — voir 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`) seede ces données dans le wallet en mode connected — déclenché uniquement par action explicite de l'utilisateur (« 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,53 +1,25 @@
---
type: rule
summary: L'app se comporte EXACTEMENT comme si NextGraph était fini et sans défaut — elle ne consomme QUE des surfaces SDK-shaped (`useShape`, `docs`, `inbox`…) et ne raisonne JAMAIS sur un problème courant de NextGraph (hang du fan-out ORM, cold-open, etc.). La raison d'être du polyfill est le WALLET VIRTUEL ; tout contournement interne (read-model union, subscribeDoc, open-repo…) vit DANS le polyfill, invisible à l'app.
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.
---
# L'app n'utilise que la surface SDK — jamais les internes du polyfill
# The app uses the published surface only
## La règle
## The rule
L'app Festipod traite `@ng-eventually/client` comme un **SDK NextGraph fini et sans
défaut**. Concrètement :
Festipod is a consumer of **one published contract** and is entirely ignorant of how it is honoured.
1. **Lecture réactive = `useShape`** (la surface SDK-shaped, fournie par le polyfill,
**scopée au wallet virtuel**). L'app ne lit PAS via des internes du polyfill
(`readModel.readUnion`, `subscribeDoc`, un read-model maison…), et ne monte PAS sa
propre réactivité (re-run sur signal).
2. **L'app ne raisonne JAMAIS sur l'état courant de NextGraph** : pas de code ni de
commentaire du type « on fait X parce que le fan-out ORM hang / parce que la lecture
à froid rend 0 ». Ces problèmes n'existent pas du point de vue de l'app.
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]].
## La raison d'être du polyfill = le WALLET VIRTUEL
## The surface shrinks, and that is normal
Le polyfill existe pour émuler le **wallet virtuel** (plusieurs identités sur un seul
wallet physique), que NextGraph ne fournit pas encore nativement. **Ce n'est PAS**
« parce que le fan-out ORM hang » — ça, c'est un simple **détail d'implémentation
interne** de la façon dont le polyfill livre un `useShape` fonctionnel. Tous les
contournements (read-model union à la place du fan-out ORM, `open-repo`, readiness
miroir de `readyPromise`, émulation de caps…) sont **internes au polyfill** et
n'apparaissent jamais dans l'app.
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.
## État (déviation résolue)
## What the app reads through
**Résolu** : `FestipodDataContext` lit désormais via `useShapeQuery` (binding
`useSyncExternalStore` sur `watchShape` du polyfill) + adaptateurs Fp
(`src/shared/data/shapeAdapters.ts`). Sont **supprimés** : `readEntities.ts`, la
réactivité bespoke (`subscribeDocs`+`bumpRead`+`readTick`), le listing manuel
(`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture), et les commentaires
raisonnant sur le hang ORM. L'auto-seed est gardé sur `isSuccess` (plus de
chronomètre 3 s). L'app ne consomme plus que la surface SDK.
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.
**Cible (rappel du design)** : le polyfill expose un `useShape` **réactif, scopé au wallet virtuel**, dont
la **forme suit TanStack `useQuery`** — `{ data, isPending/isLoading, isSuccess, isError,
… }` — **en anticipation de la mise à jour PRÉVUE de `useShape` par NextGraph** (qui va
adopter ce fonctionnement). Ce n'est donc pas une invention : c'est une API future de
NextGraph, émulée d'avance, qui s'aligne quand NextGraph la livre. Elle **distingue
nativement** `isPending` (sync en cours) de `isSuccess` + `data` vide (synchronisé,
réellement vide) — exactement le besoin. En interne, le hook encapsule readUnion sur
`subscribeDoc` + le scoping identité (invisible à l'app). L'app **supprime** sa
machinerie bespoke (`readEntities`/`subscribeDocs`/`bumpRead`) et lit via ce hook.
Le bug d'auto-seed (chronomètre 3 s) est un **symptôme** : avec `isSuccess`, l'auto-seed
décide « vide » seulement une fois la sync confirmée, au lieu de deviner un délai. Voir
[[rule_no-broker-polling]] et [[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,112 +1,49 @@
---
type: rule
summary: Festipod persiste CHAQUE entité comme SON PROPRE document (via le SDK), placé dans son scope (public/protected/private) — jamais plusieurs entités écrites dans un document de niveau store. Le document est l'unité de partage et de droits : l'isolation du SDK est PAR-DOCUMENT, donc un document par entité est ce qui la rend 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.
---
# Règle : un document par entité (jamais au niveau du store)
# Rule: one document per entity (never at store level)
Quand Festipod crée une entité (événement, point de rencontre, profil, participation,
notification), il l'écrit comme **son propre document**, via l'appel « créer un document » du
SDK de données ([[knowledge_nextgraph-stack]]), en indiquant son **scope**
(`public` / `protected` / `private`). L'entité est ensuite lue et écrite dans **ce** 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.
**Ne jamais** écrire plusieurs entités dans un document partagé « de niveau store » (p. ex.
tout mettre dans un seul document racine). C'est un anti-pattern qui casse l'isolation.
**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.
## Pourquoi
## Why
Le **document est l'unité de partage et de droits** du SDK : l'isolation (qui peut lire quoi)
est appliquée **par document**. `private` → le propriétaire ; `protected` → le propriétaire +
ses connexions ; `public` → tout le monde. Cette discrimination n'est possible **que si chaque
entité a son propre document** : mettre plusieurs entités (voire plusieurs propriétaires) dans
un même document rend le partage tout-ou-rien et défait l'isolation par périmètre.
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.
L'isolation elle-même est **entièrement assurée par le SDK** ([[knowledge_trust-model]] du
concept `app-security`) — l'app ne porte aucune logique d'accès ; elle déclare seulement son
identité (au login) et ses connexions (acte de partage), puis fait confiance à ce que le SDK
renvoie. La granularité « un document par entité » est la contrepartie côté écriture de cette
confiance.
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.
## Comment l'appliquer
## How to apply it
- À la création : demander au SDK **un document pour l'entité, dans son scope**
(`createEntityDoc(scope)`) ; y écrire l'entité. Ne pas réutiliser un document d'un autre
périmètre ni un document de niveau store.
- En lecture : passer par le SDK via le **modèle de lecture union** (voir plus bas) — l'app
résout un jeu de documents *par besoin* (index de découverte pour les événements publics ;
ses propres documents de scope pour ses entités) et le SDK ouvre/synchronise puis lit
l'union en **une seule** requête ; pas de résolution de NURI ni de choix union/ancré côté app.
- Le mapping *entité → scope* (événement/PdR → public, profil réseau/participation → protected,
settings → private) est un fait produit (concept `functional-domain`,
[[knowledge_data-scopes-and-discovery]]).
- 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]]).
## Lecture : modèle union (open/sync + une requête ancrée-libre + re-query)
## Reads: the SDK's reactive shape surface (`watchShape` / `useShapeQuery`)
La **lecture** ne passe **PAS** par un abonnement ORM réactif en fan-out sur un jeu de documents
par-entité (`useShape({ graphs: […] })`) : contre le vrai broker un document fraîchement créé /
non-synchronisé dans ce fan-out fait avorter tout l'abonnement (`RepoNotFound`) → l'abonnement
n'émet jamais son initial → **hang ~75 s**. À la place, la lecture est le **modèle union** du SDK
([[knowledge_nextgraph-stack]], SDK `docs/read-model.md`) :
**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. **résoudre par besoin** le jeu de NURIs à lire — événements publics via l'**index de découverte**
(la seule énumération cross-comptes sanctionnée) ; « mes entités » (profil, participations) via
**mes propres** documents de scope (`listMyEntityDocs(username, scope)`, borné à mon compte —
jamais de fan-out sur tous les comptes) ;
2. le SDK **ouvre/synchronise** ces documents puis exécute **UNE** requête `sparql_query`
**sans ancre** sur l'union locale (`GRAPH ?g { … }`) et rend les triplets groupés par sujet
(`src/shared/data/readEntities.ts``readModel.readUnion`) ;
3. il n'y a **pas** de requête union réactive → la **réactivité = re-query** sur un signal de
changement (un document créé/enregistré déclenche `bumpRead`).
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 returned `UnionSubject` property bags onto the app's `Fp*` domain types through `src/shared/data/shapeAdapters.ts`.
Côté app, `FestipodDataContext` collecte les NURIs par besoin puis appelle `readEntities` ;
un document fraîchement créé est aussi enregistré localement (`registerDoc`) pour apparaître
immédiatement, avant que la re-liste ne le rattrape.
**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.
## Écriture directe (piège d'aller-retour)
> **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]]).
L'**écriture** d'une entité se fait **directement dans son propre document** (via l'appel
SPARQL du SDK — `src/shared/data/entityWrites.ts`, `writeEntity`), **pas** via l'ajout à un
ensemble réactif. Raison : un ensemble réactif n'est *inscriptible* que si le document cible est
**déjà** dans son scope d'abonnement ; or enregistrer le document fraîchement créé est un état
React qui ne prend effet qu'au rendu **suivant** → on ne peut pas créer-puis-ajouter en une passe
synchrone (boucle de seed, première création). Contre le vrai broker, un `add` sur un scope vide
lève « Set is readonly because scope is empty » (les tests unitaires fake-ng ne l'attrapent pas).
## Direct writes (the round-trip pitfall)
Donc : **écriture = SPARQL direct dans le doc de l'entité** (immédiat, par-document) ;
**lecture = union + re-query** (ci-dessus).
**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".
**Convention de graphe (écrire dans le graphe par défaut ancré).** L'écriture passe le NURI du
document comme **ancre** de `docs.sparqlUpdate` et écrit le corps SPARQL **sans** clause
`GRAPH <…>` explicite ; la lecture union interroge le même graphe par défaut ancré
(`readEntities`/`readUnion`). C'est la forme **canonique et toujours sûre** — à conserver pour
`writeEntity`, `updateEntityField` et `registration.ts`.
So: **write = direct SPARQL into the entity's document** (immediate, per-document); **read = the SDK's reactive shape surface** (above).
> **Correction (2026-07-06).** Un commentaire antérieur (et une version de ce paragraphe)
> affirmaient qu'un corps `GRAPH <nuriDuDoc>` explicite écrit dans un graphe *nommé distinct* que
> la lecture ancrée ne verrait pas → l'entité « disparaîtrait ». **C'est faux sur le broker
> courant** (`@ng-org/web 0.1.2-alpha.13`) : le harness e2e réel de la lib
> (`packages/client/e2e/`) vérifie qu'un `INSERT DATA { GRAPH <plainNuri> {…} }` **ancré** au doc
> round-trippe (relu aussi bien en graphe par défaut qu'en `GRAPH <plainNuri>`). Le symptôme « 0
> entité » qu'on avait attribué à ce « piège » venait en réalité du **hang de wallet gonflé** (cf.
> `bdd-testing/caveat_wallet-bloat-hang`), pas d'un mismatch de graphe. La règle « sans wrapper
> `GRAPH` » reste donc un choix de **simplicité/sûreté**, pas une nécessité de round-trip. (Le
> *pourquoi* côté SDK vit dans `@ng-eventually/client`, pas ici.)
**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`).
Idem pour la **mutation d'un champ** existant (p. ex. `participantCount`) : muter une valeur
en mémoire ne tient pas — la re-query union relit la valeur **persistée** depuis le broker
(retour à l'ancienne valeur) → persister via SPARQL (`updateEntityField` : DELETE puis
INSERT du triplet) pour que le changement tienne et que la relecture concorde. Chaque champ est écrit avec le **bon terme RDF** selon la shape SHEX (xsd:integer /
float / boolean, ou IRI pour les références `Participation.event`/`.user`) — un champ obligatoire
manquant ou mal typé fait que la lecture **jette l'entité** (elle ne fait jamais
l'aller-retour). Le **sujet** de l'entité = le **NURI de son document** (une entité = un document),
ce qui donne un `@id` en `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`: 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:…`.
Corollaire d'identité : une `Participation` porte un `fp:user` **obligatoire** — ne jamais
l'écrire avec un principal vide (l'entité serait jetée en lecture). Le principal du user courant
est **stable et dérivé du username** (`urn:festipod:user:<username-normalisé>`), disponible
**immédiatement** après login (pas de dépendance à la lecture du profil protégé, qui peut
retarder) et **invariant** (il ne bascule pas d'un fallback vers l'IRI de profil en cours de
session, ce qui désynchroniserait une participation écrite sous une valeur d'une vérification
sous l'autre). C'est le même principal que l'identité SDK (`setCurrentUser`) et le cap owner
dérivent du username ; les connexions bilatérales (`declareConnections`) se déclarent avec ces
mêmes clés username (pas des IRIs de profil) pour que « protégé = mes connexions » discrimine.
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the NURI of **the profile document this session OWNS** — never the identity it signed in as, which is opaque and never written into an entity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before that document resolves must **reject** rather than write, which is what `joinEvent` and `leaveEvent` do — they throw, and the screen's confirmation follows the write. See [[knowledge_context-internals]].
Sharing keys off a different space: `inbox.share(doc, toUser)` names a **person**, so bilateral connections (`declareConnections`) are declared with **normalized profile handles**, not document NURIs — the data context maps each peer IRI to that key before declaring, and skips peers whose profile it cannot read (they cannot be named).
@@ -1,10 +0,0 @@
# Doc-debt — functional-domain
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -1,6 +1,6 @@
---
type: _overview
summary: Modèle produit Festipod — le point de rencontre greffé sur un événement public comme unité de valeur, ses acteurs, ses concepts métier, et les périmètres de confidentialité (public/protected/private) par entité
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é]
paths: ["src/modules/*/features/**"]
@@ -8,22 +8,22 @@ triggers:
# Functional domain
Le **domaine fonctionnel** de Festipod : ce que le produit promet et le vocabulaire métier qui le décrit. Source d'origine : `README.md §Modèle fonctionnel`.
Festipod's **functional domain**: what the product promises and the business vocabulary that describes it. Original source: `README.md §Modèle fonctionnel`.
**À lire en premier :** [[knowledge_business-model]] — sans lui, on confond l'événement (l'ancrage) et le point de rencontre (la valeur), et on modélise à l'envers.
**Read this first:** [[knowledge_business-model]] — without it, you conflate the event (the anchor) with the meeting point (the value), and you model things backwards.
## Idée pivot
## Pivotal idea
Festipod laisse les utilisateurs créer des **points de rencontre** qui se *greffent* sur des **événements publics** existants. L'événement (festival, conférence…) n'est qu'un *prétexte* et un point d'ancrage spatio-temporel ; la valeur produite, c'est le point de rencontre. **On s'inscrit à un point de rencontre, jamais à un événement.**
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.**
## Périmètre & confidentialité
## Scope & confidentiality
Le modèle produit de **qui voit quoi** — données personnelles réservées au réseau, événements/PdR publics, notification d'inscription identifiée-ou-anonyme — est un fait métier : voir [[knowledge_data-scopes-and-discovery]]. La matrice d'autorisations détaillée (acteur × verbe) et son incubation vivent dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]).
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]]).
## Liens
## Links
- [[knowledge_business-model]] — l'inversion événement / point de rencontre
- [[knowledge_actors-and-concepts]] — référence des acteurs et concepts métier
- [[knowledge_data-scopes-and-discovery]] — périmètres public/protected/private par entité + découverte
- [[knowledge_roadmap]] — fonctionnalités actuelles vs évolutions à venir
- [[brief_2026-06-15_event-deduplication]] — défi ouvert de déduplication des événements en P2P
- [[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
- [[knowledge_roadmap]] — current features vs upcoming evolutions
- [[brief_2026-06-15_event-deduplication]] — open challenge of deduplicating events in P2P
@@ -1,23 +1,23 @@
---
type: brief
summary: Défi ouverten infra P2P, deux utilisateurs peuvent déclarer le même événement public et fragmenter les points de rencontre greffés ; pistes non tranchées
summary: Open challengeon a P2P infrastructure, two users can declare the same public event and fragment the meeting points grafted onto it; leads not yet settled
---
# Déduplication des événements en infra décentralie
# Deduplicating events on a decentralized infrastructure
**Status:** Défi ouvert — non tranché
**Capturé:** 2026-06-15 (issu de `README.md §Défis ouverts`)
**Status:** Open challenge — not settled
**Captured:** 2026-06-15 (from `README.md §Défis ouverts`)
## Problème
## Problem
NextGraph étant P2P, rien n'empêche deux utilisateurs de **déclarer indépendamment le même événement public** (par ex. « Eurockéennes 2027 ») et de produire deux entrées distinctes. La dispersion qui en résulte **fragmente les points de rencontre greffés** et réduit leur visibilitéce qui va à l'encontre de la fonction première de l'app (cf. [[knowledge_business-model]]).
Since NextGraph is P2P, nothing prevents two users from **independently declaring the same public event** (e.g. "Eurockéennes 2027") and producing two distinct entries. The resulting dispersion **fragments the meeting points grafted onto it** and reduces their visibilitywhich runs against the app's primary purpose (see [[knowledge_business-model]]).
## Pistes envisagées (non tranchées)
## Leads considered (not settled)
- **Recherche avant création**proposer à l'utilisateur, lors de la déclaration, les événements déjà déclarés dans son réseau / ses communautés qui correspondent à sa saisie.
- **Identifiant externe canonique** — utiliser une URL officielle de l'événement, Wikidata, ou `schema.org/Event` pour reconnaître les doublons et les présenter comme un seul événement à l'affichage.
- **Curation** — laisser des curators (humains ou communautaires) fusionner / vetter les entrées canoniques.
- **Search before create**when declaring, offer the user the events already declared within their network / their communities that match what they are typing.
- **Canonical external identifier** — use an official event URL, Wikidata, or `schema.org/Event` to recognize duplicates and present them as a single event on display.
- **Curation** — let curators (human or community-run) merge / vet the canonical entries.
## Lien avec le modèle d'écriture
## Link with the write model
Ce défi est couplé à une question ouverte de [[brief_2026-05-18_authorization-matrix]] : **qui peut modifier un événement déclaré** (propriétaire / wiki / immuable). Un modèle *wiki* faciliterait la convergence ; un modèle *propriétaire* la complique. À arbitrer ensemble.
This challenge is coupled to an open question in [[brief_2026-05-18_authorization-matrix]]: **who may modify a declared event** (owner / wiki / immutable). A *wiki* model would make convergence easier; an *owner* model makes it harder. To be decided together.
@@ -1,31 +1,31 @@
---
type: knowledge
summary: Référence des acteurs (utilisateur, connexion, déclarant, hôte, inscrit, membre) et des concepts métier (point de rencontre, événement, communauté, liste curated, connexion)
summary: Reference for the actors (user, connection, declarer, host, participant, member) and the business concepts (meeting point, event, community, curated list, connection)
---
# Acteurs et concepts métier
# Actors and business concepts
Référence du vocabulaire. Tous les acteurs sont des spécialisations d'un **utilisateur** authentifié dans un contexte donné — pas des rôles de compte distincts.
Vocabulary reference. Every actor is a specialization of an authenticated **user** in a given context — not a separate account role.
## Acteurs
## Actors
| Acteur | Définition |
| Actor | Definition |
|---|---|
| **Utilisateur** | Toute personne ayant un compte (un wallet NextGraph). Racine de tous les autres. |
| **Connexion (« ami »)** | Un autre utilisateur avec qui je suis connecté. Sert à scoper les listes (« mes amis qui participent à… ») et la confiance. Bilatérale (acceptation des deux côtés). |
| **Déclarant d'un événement** | L'utilisateur qui a inséré l'événement dans Festipod. *N'est pas forcément l'organisateur réel* : juste celui qui le référence. **Il n'y a PAS de notion d'« hôte d'événement »** : l'événement est public, simplement signalé par son déclarant, qui **n'est PAS obligé de participer** — à la création aucune participation n'est écrite, le compteur démarre à 0, et le déclarant peut rejoindre/quitter comme tout le monde (décision produit ; côté données cf. data-layer/[[knowledge_context-internals]] §participantCount). L'« hôte » reste un acteur au niveau du **point de rencontre** (ligne suivante), pas de l'événement. |
| **Hôte d'un point de rencontre** | L'utilisateur qui a créé un point de rencontre rattaché à un événement. |
| **Inscrit à un point de rencontre** | Un utilisateur inscrit à un point de rencontre ; de fait il devient participant à l'événement parent. |
| **Membre d'une communauté d'intérêt** | Un utilisateur abonné à une communauté pour découvrir les événements qu'elle référence. |
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. |
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
## Concepts métier
## Business concepts
| Concept | Définition |
| Concept | Definition |
|---|---|
| **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. |
| **Événement** | L'ancrage. Un événement public réel référencé dans Festipod pour servir de support à des points de rencontre. Simple prétexte (titre, dates, lieu, thèmes). |
| **Communauté d'intérêt** | Un groupement thématique d'utilisateurs. Sert surtout à 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 ». Permet d'organiser/recommander. |
| **Connexion** | Lien de confiance bilatéral entre deux utilisateurs (équivalent « ami »). |
| **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. |
| **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. |
| **Connection** | A bilateral trust link between two users (the equivalent of "friend"). |
> Communauté, liste curated et abonnement sont en grande partie **prospectifs** (cf. [[knowledge_roadmap]]). La matrice d'autorisations détaillée par type de donnée vit dans [[brief_2026-05-18_authorization-matrix]].
> Communities, curated lists and subscriptions are largely **prospective** (see [[knowledge_roadmap]]). The detailed authorization matrix by data type lives in [[brief_2026-05-18_authorization-matrix]].
@@ -1,26 +1,26 @@
---
type: knowledge
summary: Le point de rencontre est l'unité de valeur, greffée sur un événement-prétexte ; on s'inscrit au point de rencontre, pas à l'événement
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
---
# Modèle métier : le point de rencontre greffé
# Business model: the grafted meeting point
> Festipod permet aux utilisateurs de créer des **points de rencontre** qui viennent se « greffer » sur des **événements publics existants**. L'objectif : favoriser les rencontres autour de ces événements.
> Festipod lets users create **meeting points** that come to be "grafted" onto **existing public events**. The goal: encourage people to meet around those events.
## L'inversion à comprendre
## The inversion to grasp
L'**événement public** (festival, conférence, salon, exposition…) n'est **qu'un prétexte** et un *point d'ancrage temporel et géographique*. La valeur produite par l'app, c'est le **point de rencontre** que les utilisateurs viennent y greffer pour se retrouver.
The **public event** (festival, conference, trade show, exhibition…) is **only a pretext** and a *temporal and geographical anchor point*. The value the app produces is the **meeting point** that users come and graft onto it.
Conséquences directes sur la modélisation :
Direct consequences for modelling:
- **On s'inscrit à un point de rencontre, pas à un événement.** Sans points de rencontre, un événement Festipod n'a aucun intérêt.
- Le **déclarant** d'un événement n'est *pas* (forcément) son organisateur réel — c'est juste quelqu'un qui a inséré la référence dans Festipod pour que d'autres puissent y attacher des points de rencontre.
- L'**hôte** d'un point de rencontre est celui qui l'a créé ; l'acte de créer rend hôte. De même l'acte de déclarer un événement rend déclarant.
- **You sign up to a meeting point, not to an event.** Without meeting points, a Festipod event is of no interest whatsoever.
- 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.
## Authentification
## Authentication
**Tous les utilisateurs sont authentifiés** (chacun possède un wallet NextGraph) — il n'y a pas d'accès anonyme à l'app. Les différents « acteurs » (déclarant, hôte, inscrit, connexion…) sont des *spécialisations d'un utilisateur dans un contexte donné*, pas des comptes distincts. Voir [[knowledge_actors-and-concepts]].
**Every user is authenticated** (each one owns a NextGraph wallet) — there is no anonymous access to the app. The various "actors" (declarer, host, participant, connection…) are *specializations of a user in a given context*, not separate accounts. See [[knowledge_actors-and-concepts]].
## Stack porteuse
## Underlying stack
App web mobile-first, Bun + React + **NextGraph** (P2P, local-first, chiffré de bout en bout). Le choix P2P a une conséquence métier forte : voir le défi de [[brief_2026-06-15_event-deduplication]].
A mobile-first web app, Bun + React + **NextGraph** (P2P, local-first, end-to-end encrypted). The P2P choice has a strong business consequence: see the challenge in [[brief_2026-06-15_event-deduplication]].
@@ -1,44 +1,53 @@
---
type: knowledge
summary: Modèle produit de confidentialité et de découvertechaque entité vit dans un SCOPE (public / protected / private) selon qui doit la voir ; événements & points de rencontre = public, profil réseau & participations = protected (réseau), settings = private ; connexions bilatérales = scope dialog ; la découverte lit un index global d'événements
summary: The product model of confidentiality and discoveryevery entity lives in the SCOPE matching who must read it; other users' events are found through a shared index, not by reading the public scope
---
# Périmètres de données et découverte
# Data scopes and discovery
Le modèle **produit** de qui voit quoi, et comment on trouve les événements. C'est du **domaine** : le *comment* technique (documents, capabilities, index) est assuré par le SDK de données `@ng-eventually/client` — l'app décrit seulement **l'intention métier**.
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* is the `@ng-eventually/polyfill` data SDK's business — the app only states **the business intent**.
## Trois périmètres (scopes) par donnée
## Three scopes per piece of data
Chaque entité est stockée dans le **scope** correspondant à qui doit pouvoir la lire :
Every entity is stored in the **scope** matching who must be able to read it:
| Entité | Scope | Qui lit |
| Entity | Scope | Who reads it |
|---|---|---|
| Événement (l'ancrage) | **public** | tout le monde |
| Point de rencontre (PdR) | **public** | tout le monde |
| Profil réseau (nom, avatar, bio, ville, intérêts) | **protected** | le titulaire + ses connexions |
| Participation / inscription à un PdR | **protected** | l'inscrit + ses connexions |
| Index des connexions | **protected** | le titulaire + ses connexions |
| Profil privé (settings, email, préférences) | **private** | le titulaire seul |
| Connexion A↔B (lien bilatéral, + messagerie future) | **dialog** | les deux utilisateurs |
| 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 |
| 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 |
Principe directeur : **le statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans un même utilisateur.** Les informations personnelles sont réservées au **réseau** (connexions bilatérales), jamais visibles d'un utilisateur lambda.
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.
- **PdR / événement = publics universels.** Tout utilisateur peut lire et s'abonner ; créer un PdR rend hôte, créer un événement rend déclarant (aucun prérequis).
- **Hôte = seul détenteur des droits d'écriture** sur son PdR ; le déclarant n'a aucun droit particulier sur les PdR greffés sur son événement.
- **Connexion bilatérale** : `DemandeDeConnexion` (unilatérale, transitoire) → `Connexion` (bilatérale, persistante) — cette dernière ouvre l'accès aux données *protected* de l'autre.
- **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 **place chaque entité dans le store de son scope** ; l'isolation entre scopes est **assurée par le SDK de données**, pas par du code applicatif (cf. concept `app-security`).
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data SDK**, not by application code (see concept `app-security`).
## Découverte des événements
## Event discovery
Un utilisateur découvre les événements qu'il n'a pas créés via un **index global** : le SDK lit cet index, qui donne les références (NURIs) des documents-événements, puis synchronise et interroge en local. La découverte **primaire** passe par cet index ; un **axe secondaire** relationnel s'y superpose (les participations *protected* des connexions : « mes amis participent à… »).
Reading the `public` scope only ever returns **this session's own** public documents — there is no call that unions every user's public store (concept `data-layer`, [[contract_polyfill-surface]]). A declared event was therefore reachable by its declarer alone, which made the whole cross-user sign-up flow — the product's premise — unreachable.
> **Notification d'inscription (intention produit).** S'inscrire à un PdR notifie son hôte : identifié si l'inscrit fait partie des connexions de l'hôte, **anonyme sinon**. Ce « identifié si connu, anonyme sinon » est une propriété du modèle de données — l'app y compte, le mécanisme est fourni par le SDK.
**A user discovers events they did not create through a shared index**: an ordinary public document, indistinguishable from any other, that the app reaches by a reference it hardcodes. Anyone may deposit a reference to their event into it; only the index's owner curates those deposits into visible entries, ordered by the event's start date. This is the **primary** discovery axis, settled as [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`) — **no code consumes the index yet.** A **secondary**, relational axis stays layered on top: the connections' *protected* participations ("my friends are attending…").
## Questions ouvertes (métier)
Four costs come with it, accepted rather than solved: an event becomes findable only once **someone curates** the index, on no fixed schedule — curation is an operator role, not a feature that runs itself; an event declared before its document could carry the field the index reads never becomes findable through it, permanently; a withdrawn or later-corrected event **stays listed** — nothing here removes an entry, so a reader of the index must tolerate a reference that no longer resolves, or resolves to something changed; and an event's position in the list is **frozen at the moment it was curated** — correcting its date afterwards does not move it. Full mechanics and the rejected alternative: [[decision_2026-08-17_discovery-through-a-shared-index]].
- **Modèle d'écriture de l'événement** : propriétaire (déclarant seul) / wiki (tous) / immuable ? Central pour la déduplication ([[brief_2026-06-15_event-deduplication]]).
- **Identité de l'hôte vis-à-vis d'un lambda** : un PdR est lisible par tous, mais faut-il que son hôte soit identifiable ? (pseudonyme par défaut, carte de visite par PdR, ou anonymat révélé aux seules connexions.)
- **Champs modifiables d'une inscription** ; **découvrabilité « amis d'amis »**.
> **Sign-up notification (product intent).** Signing up to a meeting point notifies its host: identified if the participant is one of the host's connections, **unnamed otherwise**. This "identified if known, unnamed otherwise" falls out of scope placement — the host can read the sign-up, but not the *protected* profile it points at unless they are connected. The app states the intent; it implements no filter of its own.
> La matrice d'autorisations détaillée par acteur × verbe vit dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]).
## 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)
- **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**.
> The detailed authorization matrix by actor × verb lives in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
@@ -1,25 +1,31 @@
---
type: knowledge
summary: Ce qui est implémenté aujourd'hui (cycle événement + point de rencontre, profils, connexions) vs les évolutions identifiées mais non faites (communautés, abonnements, listes curated, multi-user)
summary: What is implemented today (event + meeting point lifecycle, profiles, connections) vs the evolutions identified but not built (communities, subscriptions, curated lists, multi-user)
---
# Fonctionnalités actuelles vs évolutions à venir
# Current features vs upcoming evolutions
## Implémenté (écrans visibles via le router)
## Implemented (screens reachable through the router)
- Authentification via wallet NextGraph
- Cycle de vie d'événement (déclaration, consultation, mise à jour)
- Cycle de vie de point de rencontre (rattaché à un événement)
- Inscription / désinscription à un point de rencontre
- Liste des participants à un événement
- Profil utilisateur, mise à jour, partage de profil
- Liste d'amis (connexions), profil d'un autre utilisateur
- Authentication through a NextGraph wallet
- Event lifecycle (declaration, viewing, update)
- Meeting point lifecycle (attached to an event)
- Signing up to / withdrawing from a meeting point
- List of an event's participants
- User profile, profile update, profile sharing
- Friends list (connections), another user's profile
> L'inscription/désinscription au point de rencontre est **réellement branchée** côté données : `joinEvent` persiste une Participation, notifie l'hôte du PdR et crée une Notification ; `leaveEvent` supprime la Participation de façon autoritative (cf. concept `data-layer`, [[caveat_participation-deletion]] côté data-layer). La découverte publique — un utilisateur voit un événement public d'un autre — fonctionne aussi.
> **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.
## Évolutions identifiées (non implémentées)
> **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]]).
- **Abonnement à une communauté d'intérêt** pour découvrir ses événements (discovery distribué).
- **Abonnement à un utilisateur** pour suivre ses déclarations sans être ami.
- **Listes curated** — créer/partager des sélections éditorialisées.
- **Multi-utilisateurs collaboratif** : le partage effectif d'un point de rencontre vu par plusieurs utilisateurs, appuyé sur les périmètres public/protected/private (cf. [[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)
- **Subscribing to an interest community** in order to discover its events (distributed discovery).
- **Subscribing to a user** in order to follow their declarations without being a friend.
- **Curated lists** — creating/sharing editorialized selections.
- **Collaborative multi-user**: actually sharing a meeting point seen by several users, resting on the public/protected/private scopes (see [[knowledge_data-scopes-and-discovery]]).
+10 -10
View File
@@ -1,21 +1,21 @@
---
type: _overview
summary: Stack et outillage — Bun-first (runtime, bundler, APIs natives), build pipeline, et commandes du projet
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", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
---
# Tech stack
Stack et outillage du projet. Principe directeur : **Bun-first** — Bun remplace Node/npm/vite/webpack/jest et fournit les APIs serveur natives.
The project's stack and tooling. Guiding principle: **Bun-first** — Bun replaces Node/npm/vite/webpack/jest and provides the native server APIs.
**À lire en premier :** [[rule_bun-first]] — la convention qui décide quel outil utiliser.
**Read this first:** [[rule_bun-first]] — the convention that decides which tool to use.
## Liens
## Links
- [[rule_bun-first]] — utiliser Bun, pas Node/npm/vite/jest/express/ws/pg…
- [[knowledge_bun-apis]] — APIs natives Bun (serve, sqlite, redis, sql, file, shell)
- [[knowledge_build-pipeline]] — build.ts, bundler, serveur, harness buildé à part, Storybook
- [[knowledge_stack-and-commands]] — composants de la stack + scripts réels (+ quirks)
- [[knowledge_deployment]] — Dockerfile, prod depuis src/, pas de CI, `portless` en dev
- [[rule_bun-first]] — use Bun, not Node/npm/vite/jest/express/ws/pg…
- [[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
@@ -1,41 +1,26 @@
---
type: caveat
summary: Firefox 151+ bloque (Local Network Access) le broker hébergé qui embarque l'app de dev locale dans son iframe → iframe blanche, zéro log app, aucune erreur. Ce n'est PAS un bug de code. Fix navigateur — about:config network.lna.enabled=false.
last_checked: 2026-07-13
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-08-10
---
# Firefox LNA bloque l'iframe app du broker en dev local
# Firefox LNA blocks the broker's app iframe in local dev
## Symptôme
## Symptom
En dev local, l'app tourne DANS l'iframe du broker hébergé (`nextgraph.eu`/`nextgraph.net`
en HTTPS embarque `festipod.localhost``127.0.0.1`). Sur **Firefox 151+**, l'iframe reste
**blanche** : **aucun log `[FestipodData]`/`[NG]`** (l'app JS n'est jamais exécutée), et
**aucune erreur** rouge (le blocage est une décision de politique réseau, pas un throw). Facile
à prendre pour un crash de rendu Festipod — ce n'en est PAS un.
In local dev, the app runs INSIDE the hosted broker's iframe (`nextgraph.eu`/`nextgraph.net` over HTTPS embeds `festipod.localhost``127.0.0.1`). On **Firefox 151+**, the iframe stays **blank**: **no `[FestipodData]`/`[NG]` log at all** (the app's JS never runs), and **no red error** either (the block is a network policy decision, not a throw). Easy to mistake for a Festipod rendering crash — it is NOT one.
## Cause
**Local Network Access (LNA)** : Firefox 151+ (activé par défaut, cf. rollout 149→151) interdit
à un **site public** (le broker HTTPS) d'atteindre une **ressource du réseau local**
(`127.0.0.1`) — y compris l'embarquer en iframe. Le log révélateur (console) :
**Local Network Access (LNA)**: Firefox 151+ (on by default, see the 149→151 rollout) forbids a **public site** (the HTTPS broker) from reaching a **local network resource** (`127.0.0.1`) — including embedding it in an iframe. The telltale console log:
`Local Network Access detected: ... accessing target "…festipod.localhost…" (127.0.0.1) … prompt action: auto_deny`.
Deux corollaires qui trompent :
- **Le top-level charge très bien** : ta navigation directe vers `https://festipod.localhost:1355`
(la barrière AccessGateScreen) n'est PAS soumise à LNA. Seul l'**embarquement iframe** par le
broker l'est. Donc « le cert est déjà accepté / l'app se lance » avant l'iframe ≠ l'iframe passera.
- **HTTPS n'y change rien** : LNA vise l'**adresse locale cible**, pas le protocole. Passer
`portless proxy start --https` (app en `https://festipod.localhost`) ne débloque pas.
Two corollaries that mislead:
- **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 (navigateur, pas code)
## Fix (browser, not code)
`about:config`**`network.lna.enabled` = `false`** (drapeau maître : désactive tous les
contrôles LNA). Alternative ciblée : **`network.lna.skip-domains`** avec `nextgraph.eu`,
`nextgraph.net` (garde la protection ailleurs). Autres prefs LNA : `network.lna.blocking`,
`network.lna.block_trackers`.
`about:config`**`network.lna.enabled` = `false`** (master flag: disables every LNA check). Targeted alternative: **`network.lna.skip-domains`** with `nextgraph.eu`, `nextgraph.net` (keeps the protection everywhere else). Other LNA prefs: `network.lna.blocking`, `network.lna.block_trackers`.
Ne PAS chasser un bug de rendu Festipod tant qu'il n'y a **aucun log `[FestipodData]`** dans la
console : sans logs app, l'app n'a pas tourné → c'est l'environnement (LNA, cert non approuvé,
serveur dev éteint), pas le code. Le smoke `@e2e` ne peut PAS attraper ça : Playwright n'applique
pas LNA comme un vrai Firefox.
Do NOT go hunting for a Festipod rendering bug as long as there is **no `[FestipodData]` log** in the console: with no app logs, the app never ran → it is the environment (LNA, untrusted cert, dev server down), not the code. The `@e2e` smoke test CANNOT catch this: Playwright does not enforce LNA the way a real Firefox does.
@@ -0,0 +1,33 @@
---
type: caveat
summary: A running `bun run 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 `bun run 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,26 +1,41 @@
---
type: knowledge
summary: Dev en bun --hot, build prod via build.ts (bundler Bun + plugin Tailwind) vers dist/, 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` (via `bun run dev`) — HMR, port 3000.
- **Prod** : `bun run build``build.ts` (bundler Bun + plugin Tailwind) → `dist/`.
- **Alias de chemin** : `@/* → ./src/*` (déclaré dans `tsconfig.json`).
## Three paths, and only two of them ever run
Le serveur sert `src/index.html`, qui charge `src/app/frontend.tsx` (voir `app-architecture` §app-shell). Le bundler transpile le TSX et bundle le CSS sans outil externe — pas de Vite/webpack/esbuild (cf. [[rule_bun-first]]).
| Path | Command | What is served |
|---|---|---|
| **Dev** | `bun run dev``bun --hot src/index.ts` | **`src/`** — HMR, port 3000 |
| **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** |
## Détails de `build.ts` et du serveur
> ⚠️ **`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.
- `build.ts` scanne `src/**/*.html` comme entrypoints (aujourd'hui un seul : `src/index.html`), `target: 'browser'`, minify + sourcemap linked, plugin `bun-plugin-tailwind`. Ajouter un 2e `.html` créerait un 2e bundle.
- `src/index.ts` (`Bun.serve`) sert : `/reports/cucumber` (rapport HTML), des stubs `/api/hello*`, `/festipod-config.json` + `/shared-wallet.ngw` (config runtime, voir ci-dessous), et un **catch-all `/*` → `src/index.html`** (routing SPA, doit rester en dernier). HMR si `NODE_ENV !== 'production'`, port via `PORT`.
- **Path alias**: `@/* → ./src/*` (declared in `tsconfig.json`, resolved relative to that file — `paths` has needed no `baseUrl` since TS 4.4).
## Globals de build vs config runtime (piège du wallet partagé)
> ⚠️ **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.
`build.ts` injecte des **globals à la compilation** via `define` (p. ex. `__FESTIPOD_SHARED_WALLET_PASSWORD__` depuis `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`, et `__FESTIPOD_AUTO_SEED__` depuis `FESTIPOD_AUTO_SEED` — l'auto-seed de dev, OFF si absent). **Piège** : le serveur `src/index.ts` (utilisé par `bun run dev` ET `bun run start`) bundle `index.html` via l'import HTML de Bun, qui **n'applique aucun `define`** — ni `bun --define` ni `process.env` ne s'y propagent (vérifié). Donc une variable d'env passée à `bun run dev` n'atteint pas le bundle frontend par ce chemin.
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]]).
Pour ces chemins servis depuis `src/`, la config passe donc au **runtime** : `src/index.ts` expose `/festipod-config.json` (lu depuis l'env), et l'entrée `src/app/frontend.tsx` la **fetch d'abord**, pose le global, **puis importe l'app dynamiquement** (`await import('./App')`) — ainsi `sharedWallet.ts` lit la valeur à son évaluation. Dans un bundle `build.ts` la valeur est déjà inline par `define`, donc le fetch est court-circuité (`NODE_ENV === 'production'`). Conséquence pratique : pour exercer le flux « portefeuille partagé » en dev **de bout en bout** (téléchargement + import qui fonctionne), passer le VRAI mot de passe du wallet e2e **et** le fichier — le mot de passe affiché à l'écran doit correspondre au `.ngw` importé, sinon l'import échoue (une valeur factice comme `1` fait juste apparaître l'écran) :
## Details of `build.ts` and of the server
- `build.ts` scans `src/**/*.html` for entrypoints (today just one: `src/index.html`), `target: 'browser'`, minify plus linked sourcemap, `bun-plugin-tailwind` plugin. Adding a second `.html` would create a second bundle.
- `src/index.ts` (`Bun.serve`) serves: `/reports/cucumber` (HTML report), `/api/hello*` stubs, `/festipod-config.json` plus `/shared-wallet.ngw` (runtime config, see below), and a **catch-all `/*` → `src/index.html`** (SPA routing, which must stay last). HMR when `NODE_ENV !== 'production'`, port from `PORT`.
## Build-time globals vs runtime config (the shared wallet pitfall)
`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.
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 \
@@ -28,10 +43,10 @@ FESTIPOD_SHARED_WALLET_FILE=./festipod-e2e-tests.ngw \
bun run dev
```
## Le harness de test est buildé à part
## The test harness is built separately
⚠️ `build.ts` ne build **pas** les harness de test. Les hooks Cucumber (`src/shared/support/hooks.ts`) lancent un `bun build` **à la demande** pour `src/shared/test-harness/harness.tsx` (et `harness-ng.tsx`) → `dist/test-harness*.js`. C'est un entrypoint séparé du build app — voir concept `bdd-testing`.
⚠️ `build.ts` does **not** build the test harnesses. The Cucumber hooks (`src/shared/support/hooks.ts`) run a `bun build` **on demand** for `src/shared/test-harness/harness.tsx` (and `harness-ng.tsx`) → `dist/test-harness*.js`. It is an entrypoint separate from the app build — see concept `bdd-testing`.
## Storybook
`storybook dev -p 6006`**webpack5 + SWC** (pas Vite). Les décorateurs (`.storybook/`) injectent la pile complète de providers (Theme > NextGraph > FestipodData > Router) et importent `src/index.css` ; viewport mobile par défaut. Couplage dur au contexte projet (pas réutilisable hors Festipod).
`storybook dev -p 6006`**webpack5 + SWC** (not Vite). The decorators (`.storybook/`) inject the full provider stack (Theme > NextGraph > FestipodData > Router) and import `src/index.css`; mobile viewport by default. Tightly coupled to the project context (not reusable outside Festipod).
@@ -1,15 +1,15 @@
---
type: knowledge
summary: APIs natives Bun utilisées par le projet — Bun.serve (HTTP/WS/routes), HTML imports bundlés, bun:sqlite, Bun.redis, Bun.sql, Bun.file, Bun.$
summary: Native Bun APIs used by the project — Bun.serve (HTTP/WS/routes), bundled HTML imports, bun:sqlite, Bun.redis, Bun.sql, Bun.file, Bun.$
---
# APIs natives Bun
# Native Bun APIs
Référence des APIs Bun à privilégier (cf. [[rule_bun-first]]). Doc complète : `node_modules/bun-types/docs/**.mdx`.
Reference for the Bun APIs to favour (see [[rule_bun-first]]). Full documentation: `node_modules/bun-types/docs/**.mdx`.
## Serveur — `Bun.serve()`
## Server — `Bun.serve()`
Supporte WebSockets, HTTPS et routes. Pas besoin d'`express`/`ws`.
Supports WebSockets, HTTPS and routes. No need for `express`/`ws`.
```ts
import index from "./index.html"
@@ -23,19 +23,19 @@ Bun.serve({
})
```
C'est le mécanisme de `src/index.ts` (voir concept `app-architecture` §app-shell).
This is the mechanism behind `src/index.ts` (see concept `app-architecture` §app-shell).
## HTML imports (frontend)
`Bun.serve()` sert des HTML imports ; le bundler Bun transpile/bundle automatiquement `.tsx`/`.jsx`/`.js` et le CSS (Tailwind inclus). Un `<script type="module" src="./frontend.tsx">` dans le HTML suffit — pas de Vite.
`Bun.serve()` serves HTML imports; the Bun bundler automatically transpiles and bundles `.tsx`/`.jsx`/`.js` and the CSS (Tailwind included). A `<script type="module" src="./frontend.tsx">` in the HTML is enough — no Vite.
## Stockage & shell
## Storage & shell
- **`bun:sqlite`** pour SQLite (pas `better-sqlite3`)
- **`Bun.redis`** pour Redis (pas `ioredis`)
- **`Bun.sql`** pour Postgres (pas `pg`/`postgres.js`)
- **`WebSocket`** intégré (pas `ws`)
- **`Bun.file`** plutôt que `node:fs` readFile/writeFile
- **`Bun.$\`ls\`** plutôt qu'`execa`
- **`bun:sqlite`** for SQLite (not `better-sqlite3`)
- **`Bun.redis`** for Redis (not `ioredis`)
- **`Bun.sql`** for Postgres (not `pg`/`postgres.js`)
- **`WebSocket`** built in (not `ws`)
- **`Bun.file`** rather than `node:fs` readFile/writeFile
- **`Bun.$\`ls\`** rather than `execa`
Bun charge `.env` automatiquement → ne pas utiliser `dotenv`.
Bun loads `.env` automatically → do not use `dotenv`.
@@ -1,32 +1,61 @@
---
type: knowledge
summary: Déploiement — Dockerfile multi-stage Bun Alpine ; install via pnpm (git+node dans l'image) mais runtime bun ; lance `bun run start` depuis src/ (pas dist/), EXPOSE 3000, env PORT/NODE_ENV ; aucun CI/CD committé ; dev passe par le wrapper portless
last_checked: 2026-07-14
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; the data-layer git dependency must be pinned to a tag/commit and match `contracts.yaml`'s ref; the shared wallet reaches the container through env vars, not a mount, because it isn't a secret; no CI/CD committed; dev goes through the portless wrapper
last_checked: 2026-08-17
---
# Déploiement & infra
# Deployment & infra
Nothing has actually been deployed with this shape yet — this leaf states what a deployment needs to line up, verified against the code and manifests, not a procedure that has been run end to end.
## Dockerfile
Un `Dockerfile` existe (multi-stage Bun Alpine). **L'installation passe par pnpm, mais le runtime/build/test restent bun** (cf. [[knowledge_stack-and-commands]]) :
- `FROM oven/bun:1-alpine`, stage `install` : `apk add --no-cache git nodejs npm` puis `npm install -g pnpm@10.26.0` (l'image bun n'a ni Node ni pnpm ; l'`apk nodejs` d'Alpine n'embarque pas corepack), `COPY package.json pnpm-lock.yaml`, puis `pnpm install --frozen-lockfile`. `git` est requis car `@ng-eventually/client` est une dépendance **git+https** publique (Gitea, sans auth). Stage `release` : copie `node_modules` + source.
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/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"]`.
**Piège `bun` peer** : `bun-plugin-tailwind` déclare `bun` en peerDependency → pnpm matérialise le paquet npm `bun` et **crée un shim `node_modules/.bin/bun`** qui shadow le `bun` du PATH sous `bun run`/`pnpm run`. Son postinstall est ignoré par défaut → shim cassé → `bun run start` échoue. Corrigé en approuvant le build : `pnpm.onlyBuiltDependencies: ["bun"]` dans `package.json` (le postinstall télécharge le vrai binaire). Sans ça, toute la migration pnpm casse le démarrage.
**`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` → le conteneur **exécute la source TypeScript directement** (Bun transpile à la volée), il **n'utilise pas `dist/`**. Le `bun run build` (→ `dist/`) n'est donc **pas** sur le chemin de prod par défaut. Si on veut servir le build, il faut changer l'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.
**`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.
**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.
## CI/CD
**Aucun** pipeline committé (`.github/workflows/` absent, pas de config Coolify dans le repo). Angle mort assumé. Pour héberger l'app Bun, le skill `coolify-hosting` s'applique.
**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.
## Variables d'environnement
**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).
- `PORT` (défaut 3000), `NODE_ENV` (active/désactive HMR et l'auto-seed dev — cf. concept `data-layer`).
- Aucun `.env*` committé (`.env` est gitignored). Pas de gestion de secrets dans le repo.
## 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).
### 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`** — passe par le wrapper **`portless`** (outil externe de gestion de port), pas un `bun --hot` nu. HMR actif hors production.
`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.
**Lien local réactif du polyfill** : en prod la dépendance `@ng-eventually/client` vient de Gitea (git+https, figée par `pnpm-lock.yaml`). Pour éditer le polyfill localement et voir les changements en direct, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`, stratégie S2) remplace `node_modules/@ng-eventually/client` par une **copie réelle** de la source locale (`…/ng-eventually-js/packages/client`) — **sans** son propre `node_modules/@ng-org`et resynchronise `src/` à chaque édition. C'est ce qui garantit **une seule instance `@ng-org/web`** (un seul verifier) : un symlink vers le checkout monorepo, lui, embarque son `@ng-org` → 2ᵉ instance → SDK cassé. Revenir à l'état committé : `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,42 +1,43 @@
---
type: knowledge
summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-dans-le-build) et liste réelle des scripts package.json, dont les quirks (cucumber via node+tsx, build:orm au chemin périmé, build:ng pour le fork local, link:polyfill pour le lien local réactif)
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 & commandes
# Stack & commands
## Composants
## Components
| Couche | Techno |
| Layer | Technology |
|---|---|
| Runtime / bundler / test | **Bun** (cf. [[rule_bun-first]]) |
| **Installation des deps** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **seule** l'install passe à pnpm ; runtime/build/test restent bun. Motif : `@ng-eventually/client` est résolu depuis Gitea en **git+https** (pnpm gère proprement `git+…#main&path:/packages/client` + le dédoublonnage des peers `@ng-org`). Ne pas rebasculer l'install vers bun/npm. |
| UI | **React** (mobile-first, largeur max 768px — style dans concept `app-architecture`) |
| Données | **NextGraph** P2P local-first (concept `data-layer`) |
| Build CSS | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — présent dans le build, mais les écrans stylent via `app-*`/inline, pas d'utilitaires Tailwind (cf. concept `app-architecture`) |
| Exploration UI | **Storybook** (webpack5 + SWC, port 6006) |
| Tests | **Cucumber/Gherkin** FR multi-couches + Playwright + happy-dom + chai (concept `bdd-testing`) |
| 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/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`) |
| UI exploration | **Storybook** (webpack5 + SWC, port 6006) |
| Tests | **Cucumber/Gherkin** in French, multi-layer, plus Playwright, happy-dom and chai (concept `bdd-testing`) |
## Scripts `package.json` (réels)
## `package.json` scripts (the real ones)
| Script | Commande / rôle |
| Script | Command / role |
|---|---|
| `dev` | `portless festipod bun --hot src/index.ts` — dev HMR via wrapper `portless` (cf. [[knowledge_deployment]]) |
| `start` | `NODE_ENV=production bun src/index.ts` — prod, depuis `src/` (pas `dist/`) |
| `build` | `bun run build.ts` — bundler Bun + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
| `test:cucumber` | enchaîne `cucumber:run``cucumber:report``features:parse``steps:extract` |
| `cucumber:run` | `node --import tsx/esm /cucumber-js`**via Node+tsx, pas Bun** (compat plugins Playwright/happy-dom) |
| `test:data` | idem `--tags @data` |
| `test:auth-setup` | `bun scripts/setup-test-auth.ts` — bootstrap wallet de test persistant |
| `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/`, **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` |
| `test:auth-setup` | `bun scripts/setup-test-auth.ts` — bootstraps the persistent test wallet |
| `cucumber:report` | `bun scripts/parse-test-results.ts``cucumber-report.json` → HTML |
| `features:parse` | `bun scripts/parse-features.ts``features.ts` |
| `steps:extract` | `bun scripts/extract-step-definitions.ts` |
| `build:orm` | `rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm` |
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)build des paquets NextGraph depuis une source locale (outil optionnel) |
| `link:polyfill` | `bun scripts/link-polyfill.ts` — lien local **réactif** du polyfill `@ng-eventually/client` (stratégie S2 : copie-overlay + watcher), préserve l'instance `@ng-org` unique. Détails dans [[knowledge_deployment]]. |
| `storybook` / `build-storybook` | Storybook dev (6006) / build statique |
| `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) |
| `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 |
## Pièges
## Pitfalls
- **`cucumber:run`/`test:data` tournent sous Node+tsx**, pas Bun — les plugins de test ne chargent pas en import Bun natif. Ne pas « bunifier » ces scripts.
- **`build:orm` cible `./src/shapes/shex` et `./src/shapes/orm`**, alors que les shapes réelles vivent sous **`src/shared/shapes/`** — le chemin du script est vraisemblablement **périmé** (à corriger ou exécuter avec les bons chemins ; vérifier avant de régénérer l'ORM).
- **`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, 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]]).
+15 -15
View File
@@ -1,39 +1,39 @@
---
type: rule
summary: Par défaut utiliser Bun et ses APIs natives, jamais les équivalents Node — bun au lieu de node/ts-node, bun test/build, bunx, et pas d'express/ws/pg/dotenv. EXCEPTION : l'installation des paquets passe par pnpm (les deux repos), pas 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
---
# Règle : Bun-first
# Rule: Bun-first
Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents Node.js.
By default, use **Bun** and its native APIs rather than the Node.js equivalents.
| Au lieu de… | Utiliser |
| Instead of… | Use |
|---|---|
| `node <file>`, `ts-node` | `bun <file>` |
| `jest`, `vitest` | `bun test` |
| `npm/yarn install`, `bun install` | **`pnpm install`** (voir exception ci-dessous) |
| `npm/yarn install`, `bun install` | **`pnpm install`** (see the exception below) |
| `npm run <script>` | `bun run <script>` |
| `npx <pkg>` | `bunx <pkg>` |
| `webpack`, `esbuild`, `vite` | `bun build` / bundler Bun (HTML imports) |
| `webpack`, `esbuild`, `vite` | `bun build` / the Bun bundler (HTML imports) |
| `express` | `Bun.serve()` |
| `better-sqlite3` | `bun:sqlite` |
| `ioredis` | `Bun.redis` |
| `pg`, `postgres.js` | `Bun.sql` |
| `ws` | `WebSocket` (intégré) |
| `ws` | `WebSocket` (built in) |
| `node:fs` readFile/writeFile | `Bun.file` |
| `execa` | `Bun.$\`...\`` |
| `dotenv` | (inutile — Bun charge `.env` automatiquement) |
| `dotenv` | (pointless — Bun loads `.env` automatically) |
Détail des APIs : [[knowledge_bun-apis]].
API details: [[knowledge_bun-apis]].
## Exception : l'installation des paquets passe par pnpm
## Exception: package installation goes through pnpm
**L'installation des dépendances se fait avec `pnpm install` — pas `bun install` — dans les DEUX repos** (Festipod *et* le polyfill `@ng-eventually/client`). Tout le reste reste Bun : **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Seule l'étape d'installation change de gestionnaire.
**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.
**Pourquoi.** Le polyfill est installé en prod depuis un dépôt Gitea comme dépendance git à **sous-répertoire** : `git+https://…/ng-eventually.git#main&path:/packages/client`. pnpm (≥ 10.26) résout ce format `#<ref>&path:/…` et garantit une **seule** instance de `@ng-org/*` (un seul verifier) ; `bun install` ne couvre pas ce workflow proprement. Le lockfile de référence est donc `pnpm-lock.yaml`, et le lien local réactif du polyfill passe par `pnpm run link:polyfill` (voir [[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]]).
**Conséquence pratique.** Les scripts npm qui reposaient sur `node_modules/.bin/*` peuvent casser (pnpm y place des shims shell, pas des entrées JS) — appeler l'entrée JS réelle du paquet (ex. `node_modules/@cucumber/cucumber/bin/cucumber.js`) plutôt que le shim `.bin/`.
**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.
## Pourquoi (Bun pour tout le reste)
## Why (Bun for everything else)
Le projet est tout-Bun (runtime, bundler, test, serveur). Réintroduire un outil Node redondant ajoute une dépendance, divergerait des conventions du repo, et casse l'intégration native (HMR, transpilation TS automatique, chargement `.env`). C'est un choix de cohérence, pas une préférence cosmétique. L'exception d'installation ci-dessus est le seul écart, et il est motivé par la dépendance git à sous-répertoire.
The project is all-Bun (runtime, bundler, test, server). Reintroducing a redundant Node tool adds a dependency, diverges from the repo's conventions, and breaks the native integration (HMR, automatic TS transpilation, `.env` loading). This is a consistency choice, not a cosmetic preference. The installation exception above is the only deviation, and it is motivated by the subdirectory git dependency.
+45
View File
@@ -0,0 +1,45 @@
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
# and it consumes two — the SDK surface `@ng-eventually/polyfill` engages toward the
# applications built on it, and the indexing layer `ng-helpers` engages toward the
# applications that need to make things findable.
#
# 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. Both interfaces 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.
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
+3
View File
@@ -0,0 +1,3 @@
# To discuss
- [ ] 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`
+9 -3
View File
@@ -14,9 +14,15 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu
- **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).
## 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(...)`. **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 *comment Festipod utilise ce SDK* + le domaine + l'architecture + le contrat BDD.
Le SDK de données de Festipod est **`@ng-eventually/polyfill`**, injecté une seule fois via `ngSession.configure(...)`.
**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`).
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 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)
@@ -27,7 +33,7 @@ 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 |
+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
+5 -7
View File
@@ -133,12 +133,9 @@ const result = await Bun.build({
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
// Access gate (ON by default) + shared wallet password, baked into the
// browser bundle as globals (see src/app/AuthGate.tsx, sharedWallet.ts). The
// Shared wallet password, baked into the browser bundle as a global (see
// sharedWallet.ts) and handed to the SDK by the ONE `configure` call. The
// wallet FILE is copied into the outdir below (served at /shared-wallet.ngw).
"globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__": JSON.stringify(
process.env.ACCESS_GATE_DISABLED === "1",
),
"globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__": JSON.stringify(
process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
),
@@ -151,8 +148,9 @@ const result = await Bun.build({
...cliConfig,
});
// Staging: copy the shared wallet FILE into the bundle so the access gate can
// offer it for download (served at /shared-wallet.ngw). See sharedWallet.ts.
// Staging: copy the shared wallet FILE into the bundle so it can be offered for
// download (served at /shared-wallet.ngw — the `fileUrl` the app hands the SDK
// through `configure`). See sharedWallet.ts.
if (process.env.FESTIPOD_SHARED_WALLET_FILE) {
const { copyFileSync } = await import("fs");
copyFileSync(process.env.FESTIPOD_SHARED_WALLET_FILE, path.join(outdir, "shared-wallet.ngw"));
+9 -6
View File
@@ -8,21 +8,23 @@
"start": "NODE_ENV=production bun src/index.ts",
"build": "bun run build.ts",
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
"cucumber:run": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json",
"test:data": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json --tags @data",
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags @data",
"test:auth-setup": "bun scripts/setup-test-auth.ts",
"cucumber:report": "bun scripts/parse-test-results.ts",
"features:parse": "bun scripts/parse-features.ts",
"steps:extract": "bun scripts/extract-step-definitions.ts",
"build:orm": "rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm",
"build:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm",
"validate": "bun scripts/validate.ts",
"build:ng": "bash scripts/build-ng-packages.sh",
"link:polyfill": "bun scripts/link-polyfill.ts",
"overlay:polyfill": "bun scripts/overlay-local-checkout.ts polyfill",
"overlay:indexing": "bun scripts/overlay-local-checkout.ts indexing",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@ng-eventually/client": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#main&path:/packages/client",
"@ng-eventually/polyfill": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill",
"@ng-helpers/indexing": "git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#v1.0.1",
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
"@ng-org/orm": "0.1.2-alpha.18",
"@ng-org/shex-orm": "0.1.2-alpha.8",
@@ -64,5 +66,6 @@
"onlyBuiltDependencies": [
"bun"
]
}
},
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
}
+19 -6
View File
@@ -8,9 +8,12 @@ importers:
.:
dependencies:
'@ng-eventually/client':
specifier: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#main&path:/packages/client
version: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#1f0bae461e461c9fddd7215f972418acb2b4a989&path:/packages/client(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)
'@ng-eventually/polyfill':
specifier: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill
version: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)
'@ng-helpers/indexing':
specifier: git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#v1.0.1
version: git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#d615a72775cc9110de64b9b7fcc1d0d6c6d127ea(@ng-eventually/polyfill@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13))
'@ng-org/alien-deepsignals':
specifier: 0.1.2-alpha.11
version: 0.1.2-alpha.11(react@19.2.7)
@@ -488,8 +491,8 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@ng-eventually/client@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#1f0bae461e461c9fddd7215f972418acb2b4a989&path:/packages/client':
resolution: {commit: 1f0bae461e461c9fddd7215f972418acb2b4a989, path: /packages/client, repo: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git, type: git}
'@ng-eventually/polyfill@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill':
resolution: {commit: a8d53010c227462cc9317e9be499c2100ca8d533, path: /packages/polyfill, repo: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git, type: git}
version: 0.0.0
peerDependencies:
'@ng-org/alien-deepsignals': '*'
@@ -506,6 +509,12 @@ packages:
'@ng-org/web':
optional: true
'@ng-helpers/indexing@git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#d615a72775cc9110de64b9b7fcc1d0d6c6d127ea':
resolution: {commit: d615a72775cc9110de64b9b7fcc1d0d6c6d127ea, repo: https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git, type: git}
version: 1.0.1
peerDependencies:
'@ng-eventually/polyfill': '*'
'@ng-org/alien-deepsignals@0.1.2-alpha.11':
resolution: {integrity: sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ==}
peerDependencies:
@@ -3727,13 +3736,17 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@ng-eventually/client@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#1f0bae461e461c9fddd7215f972418acb2b4a989&path:/packages/client(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)':
'@ng-eventually/polyfill@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)':
optionalDependencies:
'@ng-org/alien-deepsignals': 0.1.2-alpha.11(react@19.2.7)
'@ng-org/orm': 0.1.2-alpha.18(react@19.2.7)
'@ng-org/shex-orm': 0.1.2-alpha.8(typescript@6.0.3)
'@ng-org/web': 0.1.2-alpha.13
'@ng-helpers/indexing@git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#d615a72775cc9110de64b9b7fcc1d0d6c6d127ea(@ng-eventually/polyfill@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13))':
dependencies:
'@ng-eventually/polyfill': git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)
'@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7)':
dependencies:
alien-signals: 2.0.8
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env bun
/**
* link-polyfill.ts Reactive local link for the @ng-eventually/client polyfill (S2).
*
* WHY S2 (copy-overlay) and not a symlink (S1):
* The committed prod dependency installs @ng-eventually/client from Gitea (git+https)
* into pnpm's store WITHOUT its own node_modules/@ng-org @ng-org/web resolves up to
* Festipod ONE @ng-org instance (one verifier). The local polyfill CHECKOUT, however,
* carries its own node_modules/@ng-org/* (symlinks into the ng-eventually-js monorepo
* store). Symlinking node_modules/@ng-eventually/client to that checkout puts the
* checkout's @ng-org in the resolution path a SECOND @ng-org instance broken SDK
* (two verifiers). So we overlay a real directory that contains ONLY the polyfill's
* source (no node_modules) and keep it in sync by copying @ng-org still resolves to
* Festipod, single instance preserved.
*
* WHAT IT DOES:
* 1. Replaces node_modules/@ng-eventually/client (the pnpm store symlink) with a real
* directory holding the local polyfill's package.json + src (NO node_modules).
* 2. Asserts the single-instance invariant (same @ng-org/web realpath from Festipod and
* from the overlay) aborts if it would break.
* 3. Watches the local polyfill src and copies each change into the overlay, so
* `bun --hot` (bun run dev) reloads the edited file live.
*
* USAGE (reactive dev):
* Terminal 1: pnpm run link:polyfill # overlays local source, then watches
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
* Edit files under packages/client/src they land in node_modules bun --hot reloads.
*
* pnpm run link:polyfill --once # overlay + verify, no watch (CI / one-shot)
* Return to the committed git-installed dependency: pnpm install
*
* Override the local checkout path with NG_EVENTUALLY_LOCAL=/path/to/packages/client.
*/
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
import { watch } from "node:fs";
import { join, dirname } from "node:path";
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
const LOCAL =
process.env.NG_EVENTUALLY_LOCAL ??
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
const TARGET = join(FESTIPOD, "node_modules", "@ng-eventually", "client");
const SRC_LOCAL = join(LOCAL, "src");
const SRC_TARGET = join(TARGET, "src");
const ONCE = process.argv.includes("--once");
function fail(msg: string): never {
console.error(`✖ link:polyfill — ${msg}`);
process.exit(1);
}
if (!existsSync(join(LOCAL, "package.json"))) {
fail(`local polyfill not found at ${LOCAL} (set NG_EVENTUALLY_LOCAL to override)`);
}
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
console.log(`→ overlaying local polyfill: ${LOCAL}`);
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
rmSync(TARGET, { recursive: true, force: true });
}
mkdirSync(TARGET, { recursive: true });
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
const from = join(LOCAL, meta);
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
}
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single @ng-org instance).
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
// 2. Assert the single-instance invariant.
const fromFestipod = realpathSync(Bun.resolveSync("@ng-org/web", FESTIPOD));
const overlayReal = realpathSync(TARGET);
const fromPolyfill = realpathSync(Bun.resolveSync("@ng-org/web", overlayReal));
console.log(` @ng-org/web (Festipod): ${fromFestipod}`);
console.log(` @ng-org/web (overlay) : ${fromPolyfill}`);
if (fromFestipod !== fromPolyfill) {
fail(
"single-instance invariant BROKEN — @ng-org/web resolves to two different realpaths.\n" +
" The overlay must not contain its own node_modules/@ng-org. Aborting.",
);
}
console.log("✓ single @ng-org/web instance preserved");
if (ONCE) {
console.log("✓ overlay ready (--once, not watching)");
process.exit(0);
}
// 3. Watch and copy on change so `bun --hot` sees live edits.
console.log(`👀 watching ${SRC_LOCAL}${SRC_TARGET} (Ctrl-C to stop)`);
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
if (!filename) return;
const from = join(SRC_LOCAL, filename);
const to = join(SRC_TARGET, filename);
try {
if (existsSync(from)) {
mkdirSync(dirname(to), { recursive: true });
copyFileSync(from, to);
console.log(`${filename}`);
} else if (existsSync(to)) {
rmSync(to, { force: true });
console.log(`${filename} (removed)`);
}
} catch (err) {
console.error(` ! failed to sync ${filename}:`, err);
}
});
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bun
/**
* overlay-local-checkout.ts Reactive local overlay of a data-layer PROVIDER's checkout
* into node_modules. One script, one provider per run; only the paths differ between them.
*
* PROVIDERS (first non-flag argument; defaults to `polyfill`):
* polyfill node_modules/@ng-eventually/polyfill override: NG_EVENTUALLY_LOCAL
* indexing node_modules/@ng-helpers/indexing override: NG_HELPERS_LOCAL
*
* WHY a copy-overlay and not a symlink (identical for every provider):
* A committed prod dependency installs from Gitea (git+https) into pnpm's store WITHOUT
* its own node_modules/@ng-org, so @ng-org/web resolves UP to Festipod ONE @ng-org
* instance (one verifier). A local CHECKOUT, however, carries its own node_modules/*
* (links into that provider's own dev tree). Symlinking node_modules/<pkg> to the
* checkout would put the checkout's copies in the resolution path a SECOND @ng-org
* (and, for `indexing`, a second @ng-eventually/polyfill) broken SDK, two verifiers.
* So we overlay a real directory containing ONLY the provider's source (no node_modules):
* shared packages still resolve up to Festipod, single instance preserved.
*
* WHAT IT DOES:
* 1. Replaces node_modules/<package> (the pnpm store symlink) with a real directory
* holding the local checkout's package.json + src (NO node_modules).
* 2. Asserts the single-instance invariant every package this provider SHARES with
* Festipod must resolve to the same realpath from Festipod and from the overlay
* and aborts if it would break.
* 3. Watches the local checkout's src and copies each change into the overlay.
*
* RESTART `bun run dev` AFTER THIS SCRIPT WRITES a rebuild is NOT a substitute.
* A running dev server NEVER picks up a package refreshed inside node_modules, not even
* across a genuine rebuild: VERIFIED in a controlled test, an application-source edit
* produced a new bundle hash and the rebuilt bundle STILL carried the stale dependency.
* The server's resolution of that import is pinned at process start and a rebuild does not
* re-resolve it. Only restarting serves the fresh copy, and nothing warns you a stale
* server looks exactly like a current one. See
* .project/concepts/tech-stack/caveat_polyfill-overlay-needs-a-dev-restart.md, which cost
* an hour to learn. Watching copies the files; it does not make anything reload them.
*
* USAGE (reactive dev):
* Terminal 1: pnpm run overlay:polyfill # or: pnpm run overlay:indexing
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
* Edit the checkout's src it lands in node_modules RESTART dev to pick it up.
*
* pnpm run overlay:indexing --once # overlay + verify, no watch (CI / one-shot)
* Return to the committed git-installed dependencies: pnpm install
*/
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
import { watch } from "node:fs";
import { join, dirname } from "node:path";
interface Provider {
/** Package as installed, e.g. "@ng-eventually/polyfill" — also its node_modules path. */
readonly packageName: string;
/** Local checkout used when the env override is unset. */
readonly defaultLocal: string;
/** Env var overriding the local checkout path. */
readonly envOverride: string;
/**
* Packages this provider SHARES with Festipod and that must stay single-instance.
* Each is resolved from Festipod and from the overlay; the realpaths must match.
*/
readonly singletons: readonly string[];
}
const PROVIDERS: Record<string, Provider> = {
polyfill: {
packageName: "@ng-eventually/polyfill",
defaultLocal: "/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill",
envOverride: "NG_EVENTUALLY_LOCAL",
singletons: ["@ng-org/web"],
},
indexing: {
packageName: "@ng-helpers/indexing",
defaultLocal: "/home/sylvain/projects/nextgraph/ng-helpers",
envOverride: "NG_HELPERS_LOCAL",
// Consumes the polyfill, so BOTH it and the verifier underneath must stay single.
singletons: ["@ng-eventually/polyfill", "@ng-org/web"],
},
};
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
const args = process.argv.slice(2);
const ONCE = args.includes("--once");
const KEY = args.find((a) => !a.startsWith("-")) ?? "polyfill";
function fail(msg: string): never {
console.error(`✖ overlay:${KEY}${msg}`);
process.exit(1);
}
const provider = PROVIDERS[KEY];
if (!provider) {
fail(`unknown provider "${KEY}" — expected one of: ${Object.keys(PROVIDERS).join(", ")}`);
}
const LOCAL = process.env[provider.envOverride] ?? provider.defaultLocal;
const TARGET = join(FESTIPOD, "node_modules", ...provider.packageName.split("/"));
const SRC_LOCAL = join(LOCAL, "src");
const SRC_TARGET = join(TARGET, "src");
if (!existsSync(join(LOCAL, "package.json"))) {
fail(`local checkout not found at ${LOCAL} (set ${provider.envOverride} to override)`);
}
if (!existsSync(SRC_LOCAL)) {
fail(`local checkout has no src/ at ${SRC_LOCAL}`);
}
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
console.log(`→ overlaying local ${provider.packageName}: ${LOCAL}`);
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
rmSync(TARGET, { recursive: true, force: true });
}
mkdirSync(TARGET, { recursive: true });
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
const from = join(LOCAL, meta);
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
}
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single instances).
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
// 2. Assert the single-instance invariant for every package shared with Festipod.
const overlayReal = realpathSync(TARGET);
for (const spec of provider.singletons) {
const fromFestipod = realpathSync(Bun.resolveSync(spec, FESTIPOD));
const fromOverlay = realpathSync(Bun.resolveSync(spec, overlayReal));
console.log(` ${spec} (Festipod): ${fromFestipod}`);
console.log(` ${spec} (overlay) : ${fromOverlay}`);
if (fromFestipod !== fromOverlay) {
fail(
`single-instance invariant BROKEN — ${spec} resolves to two different realpaths.\n` +
" The overlay must not contain its own node_modules. Aborting.",
);
}
}
console.log(`✓ single instance preserved for: ${provider.singletons.join(", ")}`);
if (ONCE) {
console.log("✓ overlay ready (--once, not watching)");
process.exit(0);
}
// 3. Watch and copy on change. This keeps the overlay CURRENT; it does NOT make a running
// dev server notice — not even across a rebuild (see the header). Restart it.
console.log(`👀 watching ${SRC_LOCAL}${SRC_TARGET} (Ctrl-C to stop)`);
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
if (!filename) return;
const from = join(SRC_LOCAL, filename);
const to = join(SRC_TARGET, filename);
try {
if (existsSync(from)) {
mkdirSync(dirname(to), { recursive: true });
copyFileSync(from, to);
console.log(`${filename}`);
} else if (existsSync(to)) {
rmSync(to, { force: true });
console.log(`${filename} (removed)`);
}
} catch (err) {
console.error(` ! failed to sync ${filename}:`, err);
}
});
+10 -13
View File
@@ -1,7 +1,6 @@
import { RouterProvider, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { AccountProvider } from '../shared/context/AccountContext';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
@@ -57,18 +56,16 @@ export function App() {
return (
<ThemeProvider>
<NextGraphProvider>
<AccountProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</AccountProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</NextGraphProvider>
</ThemeProvider>
);
+82 -70
View File
@@ -1,89 +1,101 @@
/**
* AuthGate the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow):
* 1. Access barrier + identifier (AccessGateScreen) the user names their
* virtual space (an identifier) and opens the SHARED wallet via the broker
* redirect (with the wallet file + guide it hands the user). Naming the
* space and opening it are ONE act.
* 2. The app.
* AuthGate the ONE await the application makes before it renders.
*
* The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is
* disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true`
* injected by `build.ts` (from ACCESS_GATE_DISABLED=1) for a no-gate build, or
* by the test harness via `context.addInitScript` for @e2e (which exercises the
* screens, not the auth flow). Absent gate ON.
* Signing in is `ensureIdentity()` and nothing else: it takes no identifier,
* resolves who we are and does the connection work (restoring what others shared
* with us, draining our inboxes). Whatever a user has to see or do while that
* resolves opening the wallet, loading it onto a first-time device belongs to
* the SDK: it mounts a full-screen barrier of its own on every top-level load and
* takes it down itself, and it owns the return from the broker round-trip (the
* barrier comes back prefilled, and confirming it hands the page over a second
* time; our page is never reloaded and nothing outside the barrier is touched).
* Festipod renders no access screen of its own and re-drives nothing.
*
* TWO CALLS, IN ORDER, AND THE ORDER IS CONTRACTUAL: start the session (`init`),
* then await `ensureIdentity()`. A session arrives only through `init`, and
* `ensureIdentity()` awaited before it has been called THROWS. The order is a fact
* of the statement sequence below, not of React's effect ordering which would
* get it wrong: this gate's effect runs BEFORE its parent provider's.
*
* It hands back WHO WE ARE, and this is the app's only upstream answer to that
* question everything else it knows about the user it has to read first. The
* value is published for display (`shared/utils/currentPrincipal`) and goes
* nowhere near a data call: no call takes an identity, because the session
* already belongs to one user.
*
* Nothing of the app renders before that await settles: a screen mounted earlier
* would read as an identity that is not yet settled.
*
* AND NOTHING RENDERS IF IT FAILS. A rejected `ensureIdentity()` is not a mode the
* app degrades through: an app that could not sign in but still shows its screens
* is indistinguishable from an app whose user simply owns nothing a total
* failure wearing the face of an empty account. So the rejection is SHOWN, and the
* children stay unmounted, which is also what keeps the data layer from settling
* on its empty stand-in provider for the rest of the session.
*/
import { useEffect, type ReactNode } from 'react';
import { useNextGraph } from '../shared/context/NextGraphContext';
import { useAccount, normalizeIdentifier } from '../shared/context/AccountContext';
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
import { useEffect, useState, type ReactNode } from 'react';
import { ensureIdentity } from '@ng-eventually/polyfill';
import { startNgSession } from '../shared/utils/ngSession';
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
import { useRouter, useNavigate } from './router';
declare global {
// eslint-disable-next-line no-var
var __FESTIPOD_ACCESS_GATE_DISABLED__: boolean | undefined;
}
const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
export function AuthGate({ children }: { children: ReactNode }) {
const { status, error, connect } = useNextGraph();
const { identifier, login } = useAccount();
const { route } = useRouter();
const navigate = useNavigate();
// Whether the ONE identity await has resolved.
const [identityReady, setIdentityReady] = useState(false);
// Why it did NOT resolve. Set once, never cleared: signing in is attempted once.
const [signInError, setSignInError] = useState<string | null>(null);
// Once connected AND identified, leave the disconnected welcome screen for the
// app home. The identifier is now set at the barrier (before the broker
// round-trip), so on return the app can land on '/' with a session already
// open; the removed ConnexionScreen used to do this navigate on login.
useEffect(() => {
if (!GATE_DISABLED && status === 'connected' && identifier && route.page === 'welcome') {
let cancelled = false;
// FIRST — a session arrives only through the SDK's `init`. Idempotent, so the
// provider above may have started it already; what matters is that it has been
// called before the await below, or the await throws.
void startNgSession();
void ensureIdentity()
.then(principal => {
// Publish who we are BEFORE anything renders — the identity is a fact of
// the session, not state of this component, so it is recorded even if the
// effect was torn down in between.
setCurrentPrincipal(principal);
if (!cancelled) setIdentityReady(true);
})
.catch(err => {
console.error('[Auth] ensureIdentity failed:', err);
if (!cancelled) setSignInError(err instanceof Error ? err.message : String(err));
});
return () => { cancelled = true; };
}, []);
// Once identified, leave the disconnected welcome screen for the app home.
useEffect(() => {
if (identityReady && route.page === 'welcome') {
navigate('/home');
}
}, [status, identifier, route.page, navigate]);
}, [identityReady, route.page, navigate]);
// Gate explicitly disabled (no-gate build / @e2e harness) → straight to app.
if (GATE_DISABLED) {
return <>{children}</>;
// Signing in FAILED — say so. The app has nothing legitimate to show, and
// showing it anyway would pass a broken session off as an empty one.
if (signInError) {
return (
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
<h1 className="app-title">Connexion impossible</h1>
<p className="app-text">
Festipod na pas réussi à vous connecter. Rien ne peut safficher tant que
la connexion na pas abouti les écrans seraient vides sans le dire.
</p>
<p className="app-text" data-testid="auth-error-detail">{signInError}</p>
</div>
);
}
// Access barrier — shown until BOTH the wallet is open AND the space is named.
// "Entrer" records the identifier (persisted to localStorage AND written into
// the `?id=` URL param, which is what actually survives the broker redirect
// across the partitioned frontier) and, if the wallet isn't open yet, triggers
// the connect.
//
// On return (reload / broker round-trip) the identifier is already stored, so
// we PREFILL the field with it (`initialIdentifier`) — the user never sees a
// bare empty prompt they must re-type. It is captured ONCE, at first access.
if (status !== 'connected' || !identifier) {
const onEnter = (entered: string) => {
login(entered);
// Carry the identifier across the broker frontier via the URL. localStorage
// is partitioned by top-level site, so the value written here (127.0.0.1)
// is NOT what the app reads inside the broker iframe (nextgraph.net). The
// `@ng-org/web` redirect embeds the FULL app URL (query included) in the
// broker `o=`, which is reloaded in the iframe — so writing the normalized
// id into `?id=` BEFORE connect() makes it travel. `history.replaceState`
// (not push) keeps a single history entry. See AccountContext resolution.
if (typeof window !== 'undefined') {
try {
const url = new URL(window.location.href);
url.searchParams.set('id', normalizeIdentifier(entered));
window.history.replaceState(window.history.state, '', url.toString());
} catch {
/* URL construction can't fail for a real page URL; ignore defensively */
}
}
if (status !== 'connected') connect();
};
return (
<AccessGateScreen
status={status}
error={error}
initialIdentifier={identifier ?? ''}
onEnter={onEnter}
/>
);
// Signing in is not settled yet. Render NOTHING — the SDK's own full-screen
// barrier is what is on screen, it put it there and it takes it down. Anything
// of ours here would be a second thing competing with it.
if (!identityReady) {
return null;
}
// The app.
+20 -7
View File
@@ -4,19 +4,32 @@
*
* It is included in `src/index.html`.
*
* Before loading the app tree it pulls the RUNTIME shared-wallet config (dev
* server + `bun run start`, which serve from src/ and so miss build.ts's
* compile-time `define`), sets the global, then dynamically imports `App` so
* `sharedWallet.ts` reads the value on evaluation. In a build.ts bundle the
* password is already inlined via `define`, so this step is skipped (NODE_ENV).
* Before loading the app tree it pulls the RUNTIME shared-wallet config, sets
* the global, then dynamically imports `App` so `sharedWallet.ts` reads the
* value on evaluation. A `build.ts` bundle already carries the value inlined by
* `define`, and then there is nothing to fetch.
*
* WHICH ONE APPLIES IS NOT `NODE_ENV`. This used to skip the fetch under
* `NODE_ENV=production`, on the reasoning "production means built". 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 ever serves `dist/`. So the deployed app
* skipped the only step that could give it a wallet, `ensureIdentity()` threw
* for want of one, and it could never sign anybody in while `/festipod-config.json`
* sat there, served and unasked.
*
* The question is therefore "was the value inlined?", never "am I in
* production?" ask the global itself.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
/** Fetch the runtime shared-wallet config and set the global (dev/start only). */
/** Fetch the runtime shared-wallet config and set the global, unless it is already there. */
async function loadRuntimeConfig(): Promise<void> {
if (process.env.NODE_ENV === "production") return; // build.ts define provides it
// Already inlined by `build.ts`'s `define` → nothing to fetch. Bracket access,
// so that same `define` (which rewrites the dotted global) leaves this read alone.
if ((globalThis as Record<string, unknown>)["__FESTIPOD_SHARED_WALLET_PASSWORD__"] != null) return;
try {
const res = await fetch("/festipod-config.json");
if (!res.ok) return;
+54 -4
View File
@@ -3,6 +3,24 @@ import index from "./index.html";
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
// Strict base64 check (not a mere `Buffer.from` attempt, which silently drops invalid
// characters instead of failing): reject anything that is not a well-formed base64 body
// before decoding, so a typo'd env var is reported instead of served as 810 garbage bytes.
const BASE64_SHAPE = /^[A-Za-z0-9+/]+={0,2}$/;
function decodeBase64WalletOrThrow(raw: string) {
const trimmed = raw.trim();
if (trimmed.length === 0 || trimmed.length % 4 !== 0 || !BASE64_SHAPE.test(trimmed)) {
throw new Error("not valid base64 (bad characters, or length not a multiple of 4)");
}
// Web `atob` (not Node's `Buffer`, whose `ArrayBufferLike` generic doesn't line up
// with `Response`'s `BodyInit`) — decodes to a binary string, rebuilt into bytes below.
const binary = atob(trimmed);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
const server = serve({
port,
routes: {
@@ -53,13 +71,45 @@ const server = serve({
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
}),
// The shared wallet file (download target of the access barrier), when configured.
// The shared wallet file — the `fileUrl` the app hands the SDK, when configured.
//
// Two sources, read fresh on every request (never captured at module evaluation):
// - FESTIPOD_SHARED_WALLET_FILE: a filesystem path. What local dev and the test
// harness set today — a file sitting at the working-copy root.
// - FESTIPOD_SHARED_WALLET_FILE_BASE64: the file's bytes, base64-encoded. What a
// container sets instead, since *.ngw is gitignored and nothing mounts one there.
//
// Precedence: FILE wins whenever it is set, even if the path turns out unreadable —
// it is NOT "whichever resolves". This keeps dev/test behaviour byte-for-byte
// unchanged (they set only FILE, never BASE64) and makes the rule predictable: a
// deployment picks exactly one variable to set, and setting both is a leftover, not
// an intentional fallback chain.
"/shared-wallet.ngw": async () => {
const p = process.env.FESTIPOD_SHARED_WALLET_FILE;
if (p) {
const file = Bun.file(p);
const path = process.env.FESTIPOD_SHARED_WALLET_FILE;
if (path) {
const file = Bun.file(path);
if (await file.exists()) return new Response(file);
return new Response("No shared wallet file configured.", { status: 404 });
}
const encoded = process.env.FESTIPOD_SHARED_WALLET_FILE_BASE64;
if (encoded) {
// Malformed must fail loudly: a 404 here would look identical to "not
// configured", which is exactly the confusion this project is removing.
try {
const bytes = decodeBase64WalletOrThrow(encoded);
return new Response(bytes, {
headers: { "Content-Type": "application/octet-stream" },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return new Response(
`FESTIPOD_SHARED_WALLET_FILE_BASE64 is set but ${message}.`,
{ status: 500 }
);
}
}
return new Response("No shared wallet file configured.", { status: 404 });
},
@@ -1,27 +0,0 @@
# language: fr
@AUTH @priority-1
Fonctionnalité: Barrière d'accès — l'identifiant se saisit une seule fois
En tant qu'utilisateur qui revient dans Festipod
Je veux retrouver l'identifiant que j'ai déjà choisi, pré-rempli
Afin de ne jamais avoir à le retaper à l'arrivée
# Garde-fou contre la régression rapportée : au retour (rechargement / round-trip
# broker) la barrière re-demandait un identifiant NU et VIDE alors qu'il était
# déjà stocké. L'identifiant est capturé UNE FOIS au premier accès, persisté,
# puis pré-rempli. Voir AuthGate + AccessGateScreen.
@ui
Scénario: Le champ identifiant est pré-rempli avec la valeur déjà stockée
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
Alors le champ identifiant contient "alice"
@ui
Scénario: Un premier accès sans identifiant stocké affiche un champ vide
Étant donné que la barrière d'accès s'affiche sans identifiant stocké
Alors le champ identifiant est vide
@ui
Scénario: Entrer remonte l'identifiant saisi
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
Quand je clique sur "Entrer" dans la barrière
Alors l'identifiant remonté à l'application est "alice"
@@ -6,10 +6,10 @@ Fonctionnalité: Connexion NextGraph et chargement des données
Et charger les données de test dans mon portefeuille
Afin d'utiliser l'application avec mes propres données
# NB : l'ancien écran /login (LoginScreen) a été retiré — l'accès NextGraph
# passe désormais par l'AccessGateScreen (barrière ON par défaut), cf.
# decision_2026-06-17_assisted-wallet-import. Les scénarios @ui qui testaient
# le LoginScreen ont été supprimés en conséquence.
# NB : Festipod n'affiche plus d'écran d'accès à lui. Se connecter, c'est le
# SEUL `ensureIdentity()` attendu par AuthGate ; ce qu'un utilisateur voit ou
# fait pendant cette attente appartient au SDK, qui le montre. Aucun scénario
# ici ne pilote donc une barrière d'accès.
# --- Data layer: comportement du portefeuille ---
@@ -1,42 +0,0 @@
# language: fr
@AUTH @priority-1
Fonctionnalité: Résolution de l'identifiant — le param d'URL prime sur localStorage
En tant qu'application relancée dans l'iframe du broker après le round-trip
Je veux résoudre l'identifiant depuis le param d'URL "?id="
Afin qu'il traverse la frontière top-leveliframe (que localStorage ne franchit pas)
# Le flux wallet-partagé fait tourner l'app dans DEUX contextes avec DEUX
# partitions localStorage distinctes (top-level 127.0.0.1 vs iframe
# nextgraph.net). localStorage ne traverse pas la frontière ; le param "?id="
# embarqué dans le redirect broker (o=) la traverse. AccountContext résout donc
# dans l'ordre : (1) param d'URL "?id=" (source de vérité) ; (2) sinon
# localStorage (préremplissage même-partition). Voir AccountContext + AuthGate.
@ui
Scénario: Le param d'URL est la source de vérité quand il est présent
Étant donné que localStorage contient l'identifiant "alice"
Et que l'URL porte le param id "bob"
Quand le contexte de compte résout l'identifiant
Alors l'identifiant résolu est "bob"
@ui
Scénario: Le param d'URL prime même sur une valeur localStorage différente et est persisté
Étant donné que localStorage contient l'identifiant "alice"
Et que l'URL porte le param id "carol"
Quand le contexte de compte résout l'identifiant
Alors l'identifiant résolu est "carol"
Et localStorage contient désormais l'identifiant "carol"
@ui
Scénario: Sans param d'URL, localStorage sert de repli
Étant donné que localStorage contient l'identifiant "dave"
Et que l'URL ne porte aucun param id
Quand le contexte de compte résout l'identifiant
Alors l'identifiant résolu est "dave"
@ui
Scénario: Le param d'URL est normalisé (minuscules, @ retiré)
Étant donné que localStorage ne contient aucun identifiant
Et que l'URL porte le param id "@Erin"
Quand le contexte de compte résout l'identifiant
Alors l'identifiant résolu est "erin"
@@ -1,169 +0,0 @@
/**
* AccessGateScreen the *technical access barrier* of the stopgap.
*
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
* REAL NextGraph login, shown before the app renders. Because it precedes the
* app, the user reads it as "access to the test environment", not as an app
* login. The user also types an IDENTIFIER here the id that names their
* virtual space (a technical id, a pseudo in practice, not a Festipod username).
* Clicking "Entrer" records that identifier and triggers `connect()`, which
* redirects to the broker to open the SHARED wallet. After return the identity
* is already set (persisted before the redirect), so NG auto-connects straight
* into the app there is no separate "pick a username" screen.
*
* ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a
* wallet inline during
* web-app auth: a first-time device has no wallet, so the broker redirect would
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
* File"), BEFORE they click "Entrer". The wallet FILE is the correct static
* primitive a TextCode is a transient 5-min transfer, unusable to embed. Shown
* only when a shared wallet is configured (FESTIPOD_SHARED_WALLET_PASSWORD).
*/
import { useState, type ReactNode } from 'react';
import { Button, Input, Title, Text } from '../../../shared/components/sketchy';
import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasSharedWallet } from '../sharedWallet';
interface AccessGateScreenProps {
status: 'disconnected' | 'connecting' | 'connected' | 'error';
error?: string;
/**
* The identifier already stored for this space (the persisted one), used to
* PREFILL the field so a returning user never re-types it. Empty on a truly
* first access. Normalized upstream; shown verbatim.
*/
initialIdentifier?: string;
/** Enter the space: the raw identifier the user typed (normalized upstream). */
onEnter: (identifier: string) => void;
}
// One numbered step: a badge + a title + the action for that step.
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
<div style={{ display: 'flex', gap: 12, marginBottom: 18 }}>
<div style={{
flexShrink: 0, width: 26, height: 26, borderRadius: '50%', background: '#E8590C',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 14,
}}>{n}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text style={{ margin: '2px 0 8px', fontWeight: 600, fontSize: 14 }}>{title}</Text>
{children}
</div>
</div>
);
}
export function AccessGateScreen({ status, error, initialIdentifier, onEnter }: AccessGateScreenProps) {
const connecting = status === 'connecting';
const [copied, setCopied] = useState(false);
// The identifier that names this virtual space (a technical id — a pseudo in
// practice, but not a Festipod username). Entered HERE, at wallet access, so a
// single act both names the space and opens it. Normalized (lowercased) upstream.
// PREFILLED from the stored identifier so a returning user (reload / broker
// round-trip) sees the value they already chose and never re-types it.
const [identifier, setIdentifier] = useState(initialIdentifier ?? '');
const copyPassword = async () => {
try {
await navigator.clipboard.writeText(SHARED_WALLET_PASSWORD);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard may be blocked — the password stays selectable
}
};
const canEnter = !connecting && identifier.trim().length > 0;
const enter = () => { if (canEnter) onEnter(identifier); };
// Identifier field + Entrer: naming the space and opening it are one act.
const entrer = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Input
data-testid="identifier-input"
placeholder="votre identifiant"
value={identifier}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIdentifier(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') enter(); }}
/>
<Text style={{ margin: 0, fontSize: 12, color: '#999' }}>
Il identifie votre espace (mis en minuscules).
</Text>
<Button
variant="primary"
onClick={enter}
disabled={!canEnter}
style={{ width: '100%', opacity: canEnter ? 1 : 0.6 }}
>
{connecting ? 'Accès en cours…' : 'Entrer'}
</Button>
</div>
);
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
{hasSharedWallet() && status !== 'connected' ? (
<>
<Text style={{ textAlign: 'center', fontSize: 14, color: '#666', margin: '0 0 20px', lineHeight: 1.5 }}>
Première connexion sur cet appareil ?<br />Chargez le portefeuille partagé, une seule fois.
</Text>
<Step n={1} title="Téléchargez le portefeuille">
<a
data-testid="shared-wallet-download"
href={SHARED_WALLET_FILE_URL}
download="festipod-wallet.ngw"
style={{
display: 'block', textAlign: 'center', textDecoration: 'none',
padding: 10, borderRadius: 10, background: '#E8590C', color: '#fff', fontWeight: 600, fontSize: 14,
}}
>
Télécharger le portefeuille
</a>
</Step>
<Step n={2} title="Importez-le sur NextGraph">
<Text style={{ margin: '0 0 8px', fontSize: 13, lineHeight: 1.6, color: '#666' }}>
<a href={WALLET_IMPORT_URL} target="_blank" rel="noopener noreferrer" style={{ color: '#E8590C', fontWeight: 600 }}>
Ouvrir la page d'import
</a>{' '}(nouvel onglet) « Import a Wallet File » choisissez le fichier mot de passe :
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<code
data-testid="shared-wallet-password"
style={{ flex: 1, padding: '6px 10px', background: '#fff', border: '1px solid #eee', borderRadius: 8, fontSize: 13, userSelect: 'all' }}
>
{SHARED_WALLET_PASSWORD}
</code>
<Button variant="accent-outline" onClick={copyPassword} style={{ padding: '6px 10px', fontSize: 12 }}>
{copied ? 'Copié ✓' : 'Copier'}
</Button>
</div>
</Step>
<Step n={3} title="Revenez ici, choisissez un identifiant et entrez">
{entrer}
</Step>
</>
) : (
entrer
)}
{status === 'error' && (
<Text style={{ textAlign: 'center', fontSize: 12, color: '#c92a2a', marginTop: 12 }}>
{error || "Accès à l'environnement impossible. Réessayez."}
</Text>
)}
</div>
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
Version beta
</Text>
</div>
);
}
+10 -9
View File
@@ -5,18 +5,15 @@ import type { FestipodWorld } from '../../../../shared/support/world';
// --- Setup ---
Given('le portefeuille est vide', async function (this: FestipodWorld) {
// Each @data scenario runs under a UNIQUE username (see hooks.ts
// freshScenarioUsername), so the shim hands it a FRESH, EMPTY virtual wallet:
// "le portefeuille est vide" is trivially true on entry. So this is a fast
// INSTANT CHECK — assert the reactive read already shows nothing — NOT the old
// `clearWallet` per-entity-doc fan-out (a full physical-wallet enumeration that
// was itself slow). No mutation, no polling: a fresh wallet has no docs to scan.
// A fast INSTANT CHECK — assert the reactive read already shows nothing — NOT
// the old `clearWallet` per-entity-doc fan-out (a full wallet enumeration that
// was itself slow). No mutation, no polling.
const counts = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return { events: td.events.size, users: td.users.size };
});
expect(counts.events, 'Fresh virtual wallet should have no events').to.equal(0);
expect(counts.users, 'Fresh virtual wallet should have no users').to.equal(0);
expect(counts.events, 'An empty wallet should have no events').to.equal(0);
expect(counts.users, 'An empty wallet should have no users').to.equal(0);
});
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
@@ -30,9 +27,12 @@ Given('le portefeuille contient déjà des événements', async function (this:
const td = (window as any).__testData;
td.loadTestData();
});
// Wait for data to propagate
// Wait for data to propagate. `waitForFunction(fn, arg, options)` — the
// timeout goes in the THIRD slot; passed second it is silently taken as the
// predicate's argument and the wait runs on the 30s default instead.
await this.appFrame!.waitForFunction(
() => (window as any).__testData.events.size > 0,
undefined,
{ timeout: 75000 },
);
}
@@ -72,6 +72,7 @@ When('je charge les données de test', async function (this: FestipodWorld) {
const td = (window as any).__testData;
return td.events.size > 0 && td.users.size > 0;
},
undefined,
{ timeout: 75000 },
).catch(() => {
// Timeout tolerated — the assertions below surface the real failure with a
@@ -136,6 +136,7 @@ When('l\'utilisateur attend la fin du chargement', async function (this: Festipo
const buttons = Array.from(document.querySelectorAll('button'));
return !buttons.some(b => b.textContent?.includes('Chargement...'));
},
undefined,
{ timeout: 60000 },
);
await this.appFrame!.waitForTimeout(2000);
@@ -152,6 +153,7 @@ Then('l\'écran d\'accueil affiche des événements', async function (this: Fest
const appeared = await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 15000 },
).then(() => true).catch(() => false);

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