71 Commits

Author SHA1 Message Date
Sylvain Duchesne 9e2df2478b Deliver Join and Leave to the app's inbox instead of an unaddressed outbox post
A participation activity is now addressed to the Festipod app actor alone and
handled on reception in the app's inbox, the way the ActivityPods developer
advised: use the ActivityPub protocol rather than a side channel. Any
compatible sender may deliver; the inbox accepts only the activity's own
signer, and the backend still requires the activity to lie in that actor's pod
and acts on the stored copy.

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

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

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

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

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

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

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
2026-10-30 17:06:38 +01:00
Sylvain Duchesne 6ee95c9e68 Record the public-data and rights-matrix work in the handoff, the usage declaration and the doctrine
The handoff gets the closed state of the T05 work with its commits, the new gate commands, the user decisions (attendee lists stay world-readable for now, a revoked app logs the user out, the local network is trusted), the checks kept manual, the platform defects to report upstream, the documented limits of the backend hardening and the open follow-ups. The usage declaration records that Festipod drives the app-status probe, relies on the installed field and on sending to through create, and the subscribe Forbidden friction; the discovery leaf and the local-platform cookbook match the current design and bring-up.
2026-10-13 18:11:34 +02:00
Sylvain Duchesne a61c8704c9 Close the login loop end-to-end: client_id is the app actor uri, actor path serves the OIDC client document via a boot-safe service, startup heals the access-need-group link, stack aligned on pod provider v2.3.12 2026-10-12 13:12:38 +02:00
Sylvain Duchesne 84a0882d32 Document the root .env copy for manual app serving — without it the app falls back to the local demo 2026-10-12 10:37:35 +02:00
Sylvain Duchesne 659d7652c4 Move the Festipod app to :4000 — :5000 belongs to the pod provider frontend in the local stack 2026-10-12 10:33:07 +02:00
Sylvain Duchesne 8dfc574346 Close the adversarial review round: auth hardening, data fidelity against the platform context, backend grants and docker coherence 2026-10-10 09:54:53 +02:00
Sylvain Duchesne c195211629 Rewrite FestipodDataContext over the Refine seam, meeting-point centric 2026-10-09 21:04:35 +02:00
Sylvain Duchesne e27cfa4e0a The ActivityPods backend registers as the default behind the seam 2026-10-09 13:37:28 +02:00
Sylvain Duchesne 9823a45bab Decide the participation arbitration route, and document the local platform setup 2026-10-09 11:28:46 +02:00
Sylvain Duchesne b1add6d8c8 Record the registration precedent, and the event-in-event feasibility without new shapes 2026-10-09 10:26:25 +02:00
Sylvain Duchesne 184998a343 Record the ActivityPods-first directives, and what the reference reconnaissance established 2026-10-09 09:48:52 +02:00
Sylvain Duchesne df1db790ad The URL says which provider set runs, and the seam stops claiming otherwise
The seam arrived carrying a switching design that no longer holds. It presented
runtime hot-switching as what shaped it — selection in state, a key on <Refine>
to drop the previous backend's cache — and it read the backend from a build
global whose comment claimed build.ts defined it. Nothing ever did, and nothing
could have: this project serves from src/ in production too, so that define
block never reaches what runs.

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

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

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

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

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

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

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

Two comment zones came over knowingly superseded — runtime hot-switching as what
shapes the provider, and a sign-in barrier waiting on something `AuthGate` no
longer does. They are recorded as data-layer doc-debt instead of being quietly
rewritten, so the rewrite lands with the wiring that settles what they should
say.
2026-10-05 18:46:32 +02:00
Sylvain Duchesne 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 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 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 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 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 a21d9b0735 docs(concept): durabilité écriture↔déconnexion, décision wallet-partagé-unique, rule_bun-first (install pnpm)
caveat_write-durability-across-disconnect + decision_2026-07-20 (wallet partagé = seul mode ; identifiant ≠ username profil) + amendement bun-first. Marqueurs _debt.md inclus (voyagent avec la branche, à réconcilier avant push).

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 82004a30b0 fix(data): participantCount fiable à la connexion du propriétaire + source unique
Bug: user2 crée un événement, user1 s'inscrit et voit "1", mais user2 (créateur)
reste à 0. Recadrage (spec existante): l'exigence est "le propriétaire traite son
inbox à sa PROCHAINE CONNEXION", pas une notif live temps-réel.

Cause: le owner-materializer lisait l'inbox AVANT sa synchronisation → active=0 →
écrit 0 → mémoïse 0 → ne retraite plus.

Fix:
- Lecture inbox gated sur barrière: inbox.readSynced (ensureRepoOpen attend le 1er
  State, puis read — comme discovery.readIndex) au lieu de inbox.read. Un dépôt déjà
  synchronisé EST vu à la connexion. Pas de polling.
- Materializer déclenché directement à la connexion ([ready, ownedKey]).
- materializedCountRef ne verrouille plus un 0 prématuré (rôle = anti-boucle seul).
- Source UNIQUE du nombre = event.participantCount: le littéral participantCount:1
  de CreateEventScreen retiré (démarre à 0), l'affichage ne calcule plus de nombre
  local (ParticipantsListScreen). Le statut "Je participe" optimiste est intact.
- Logs [Attendance] sur tout le chemin dépôt→matérialisation→écriture.

Test: e2e-multibrowser "converge à la prochaine connexion" reframé + dé-@wip,
ROUGE avant / VERT après sur profil frais. Non-régression @multibrowser 4/4, @data 7/7.

Doctrine: knowledge_context-internals (caveat BUG ACTIF → CORRIGÉ), brief_2026-07-06
(cadrage "sans reload" = sur-cadrage; exigence = fiable à la connexion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:40:37 +02:00
Sylvain Duchesne c0fd69344b feat(data): auto-seed opt-in (FESTIPOD_AUTO_SEED) + logs data lisibles; diag bug participantCount
Seed: l'auto-seed sur wallet vide est désormais OPT-IN, OFF par défaut — ne se
déclenche que si FESTIPOD_AUTO_SEED=1 (livré en dev via /festipod-config.json +
define build.ts, comme le shared-wallet). Le seed répété bloatait le wallet
(lenteurs de lecture). Seed explicite (loadTestData, tests @data) inchangé.

Logs: chaque useShapeQuery logge à la réception du set le nombre d'objets + le
type + des compteurs globaux cumulés :
  [FestipodData] set reçu: 9 objets Event (public) en 1234ms
  [FestipodData] totaux — Event: 9, Participation: 3, UserProfile: 10 (5 sets)
(polyfill docs.ts: "N rows" -> "N triple-rows" pour clarifier que ce sont des
triplets RDF, pas des objets métier.)

Diagnostic bug participantCount (NON corrigé, design-sensible): le propriétaire
d'un événement reste à participantCount=0 quand un inscrit d'un AUTRE verifier
dépose. Cause: le owner-materializer n'est re-déclenché que par ownedKey, jamais
par un push d'inbox — doc_subscribe ne délivre aucun Patch cross-session. Le
bloat de wallet MASQUAIT le bug (faux-vert). La théorie "StorageError" était
fausse. Scénario réactif @wip = test ROUGE qui documente le bug.

Doctrine: knowledge_context-internals (caveat BUG ACTIF + auto-seed opt-in),
brief_2026-07-06 (claim D.2 "prouvé vert" REFUTÉ), build-pipeline (nouvelle var).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:38:38 +02:00
Sylvain Duchesne 39b67feea0 feat(ui): spinner global près du titre Festipod + log du délai des requêtes
Chaque useShapeQuery s'enregistre dans un store module-level pendingQueries au
début de son cycle et se résout à son premier résultat (isPending→isSuccess|isError,
équivalent readPromise). HomeScreen affiche un Spinner à côté du titre "Festipod"
tant qu'au moins une requête est en attente ; il ne s'arrête que quand TOUTES ont
reçu leur premier résultat. Toute future useShapeQuery y contribue automatiquement.

À la 1re résolution, chaque cycle logge son délai :
  [FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)
→ le délai d'obtention des événements (Event/public) est visible nommément.

Store idempotent (Set d'ids, sûr sous StrictMode) ; cycleId mémoïsé sur
[shapeKey, scope] → re-begin sur switch d'identité, cleanup résout au démontage
(spinner jamais bloqué). Spinner = Loader2 lucide + @keyframes app-spin dans index.css.

Tests: pendingQueries.test.ts (6, dont "off seulement quand toutes résolues").
Doctrine: data-layer/knowledge_context-internals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:51:36 +02:00
Sylvain Duchesne c7e924abe7 fix(auth): porter l'identité par param d'URL (?id=), pas localStorage; renommer username→identifier
Cause racine du décalage d'identité : l'app tourne dans DEUX contextes avec DEUX
partitions de localStorage — top-level (127.0.0.1:3000 direct, barrière) et iframe
(embarquée sous nextgraph.net après le round-trip broker). Le navigateur partitionne
le storage par site top-level, donc l'identifiant saisi en top-level n'est jamais
celui que l'app connectée lit dans l'iframe (symptôme: deux valeurs divergentes).

Fix : le param d'URL ?id= devient la SOURCE DE VÉRITÉ. Le SDK redirige avec
encodeURIComponent(window.location.href) (URL app complète, query comprise), donc
un param d'URL TRAVERSE la frontière contrairement à localStorage. AuthGate écrit
?id=<identifiant> (replaceState) avant connect(); AccountContext résout par priorité
(1) ?id= puis (2) localStorage (préremplissage same-partition seulement).

Renommage username→identifier (champ useAccount, normalizeIdentifier, clé
festipod.account.identifier) — c'est un id technique d'espace, pas un username.
Le username de PROFIL (nom d'affichage) est laissé intact.

Test garde-fou @ui (identifiant-resolution.feature) : la priorité param>localStorage,
rouge si on l'inverse. Le flux de barrière étant désactivé en @e2e, ces @ui sont la
seule couche qui le garde.

Doctrine: knowledge_authentication (porteur URL + partition) + knowledge_context-internals (vocab).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:55:09 +02:00
Sylvain Duchesne f366ee29a7 doctrine(data-layer): context-internals — lecture via watchShape + overlay optimiste + auto-seed sur isSuccess
Rafraîchit les sections périmées : la lecture passe par `useShapeQuery`/`watchShape`
(plus readEntities/subscribeDocs/bumpRead/relist) ; visibilité immédiate des
mutations par overlay optimiste (plus registerDoc) ; auto-seed gardé sur `isSuccess`
(plus le setTimeout 3s qui causait le re-seed à chaque reconnexion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 01:19:45 +02:00
Sylvain Duchesne 38266d96f8 refactor(app): l'app lit via watchShape (useShapeQuery), plus de machinerie bespoke
Phase B — FestipodDataContext lit désormais via la surface SDK `watchShape`
(binding `useSyncExternalStore` dans `useShapeQuery`) + adaptateurs Fp
(`shapeAdapters.ts`), au lieu de sa machinerie maison. Applique
rule_app-uses-sdk-surface-only : l'app ne consomme que la surface SDK.

Supprimé : `readEntities.ts`, `subscribeDocs`+`bumpRead`+`readTick`+`readDocKey`,
le listing manuel (`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture,
`readDiscoveredEvents`), et les commentaires raisonnant sur le hang ORM. Gardé
découplé : `listMyEntityDocs(owner,'public')` → `ownedEventIds` pour le seul
matérialiseur propriétaire.

Auto-seed : chronomètre 3 s → gate `isSuccess` (seed uniquement si synchronisé ET
vide) — fix du re-seed « First time… » au 3ᵉ connect. Mode démo inchangé.

Non-régression VÉRIFIÉE (broker réel, wallet frais) : inscription (1 passed),
isolation « identité fraîche ne voit pas » (re-run local, 5 steps passed), compteur
dérivé/Q4 (1 passed). tsc propre, build OK.

Résiduel PRÉ-EXISTANT (pas causé par ce refactor, vérifié par stash sur baseline) :
- reconnexion « relit ses propres données » → RE-@wip : défaut cold-read de l'index
  de scope PUBLIC côté lib (une page fraîche relit vide) — prochaine cible.
- un @AUTH « données pas rechargées » (timing loadFire-and-forget vs step 30 s).

Doctrine : rule_app-uses-sdk-surface-only « déviation résolue ».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:47:03 +02:00
Sylvain Duchesne 2295af610a doctrine+dev: règle « app = surface SDK seule » + access-log par défaut
- rule_app-uses-sdk-surface-only : l'app se comporte comme si NextGraph était fini
  et sans défaut ; elle lit via `useShape` (scopé wallet virtuel, fourni par le
  polyfill), jamais via des internes (readModel/subscribeDoc) ni en raisonnant sur
  un problème NextGraph. Raison d'être du polyfill = le WALLET VIRTUEL (pas le hang
  ORM, qui n'est qu'un détail interne). Cible : `useShape` polyfill à la forme
  TanStack useQuery (data + isPending/isSuccess…), en anticipation de la mise à jour
  prévue de useShape par NextGraph — distingue nativement sync-en-cours de vide.
  Déviation actuelle notée : readEntities/subscribeDocs/bumpRead côté app.
- ngSession : access-log ON par défaut (le toggle opt-in était fragile), opt-out via
  localStorage festipod.debug.accessLog=0 ; ligne de diagnostic au démarrage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:36:29 +02:00
Sylvain Duchesne 65bd67cc20 Isolation deux-identités: test permanent + le créateur ne participe plus
Deux corrections produit/tests demandées, empiriquement validées au broker réel.

1. Créateur ≠ hôte (décision produit). Il n'y a PAS de notion d'hôte : un
   événement est public, simplement signalé par le créateur, qui n'est PAS
   obligé de participer. `createEvent` n'écrit plus de participation-hôte et
   `participantCount` démarre à 0 ; le matérialiseur du propriétaire dérive
   `participantCount = |inscriptions actives|` (plus de base « +1 hôte »).

2. Isolation deux-identités : le trou réel était l'ABSENCE d'un test de
   régression, pas un bug de code actif. Reproduction empirique (DIAG instrumenté,
   retiré) : la fuite n'apparaît QUE si le reset `useEffect([username])` est
   désactivé ET les caps vides (docs persistés d'une session antérieure sur wallet
   gonflé) — le reset en place la neutralise. La sighting live venait d'un état
   wallet pré-fix + identifiant réutilisé. Ajout du test permanent manquant :
   - isolation-deux-identites.feature (@data) : A crée+rejoint E, une identité
     fraîche B sur le même wallet ne voit E ni sur son accueil, ni via
     isParticipating(E,B), et ne lit aucune participation portant le principal de A.
   - us-13 : « Le créateur ne participe pas automatiquement » (count 0,
     isParticipating false autoritatif, puis join→1, leave→0).

Harness: 4 helpers permanents (switchIdentity, currentIdentifier, homeEventTitles,
currentParticipations) pour piloter/observer l'identité en test.
Scénarios @multibrowser/us-7 réalignés (compteur 0→1 au lieu de 1→2).
Doctrine mise à jour (context-internals, actors-and-concepts).

Gates: build OK, tsc propre, @data verts (inscription, désinscription,
idempotence, compteur dérivé, auth ×4), lib @ng-eventually/client non touchée.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:55:42 +02:00
Sylvain Duchesne 0f164300f0 refactor(data): canonical event-id matching for the owner-materializer (defensive)
Guard the Option-B owner-materializer against overlay-form drift: match inbox
deposits to owned events on the CANONICAL base repo id (canonicalEventId strips any
✌️<overlay> suffix), applied at the matching boundary in materializeAttendance /
readRegistrationNotifications and to dedup ownedEventIds (ownedKey). The count is
still WRITTEN on the real owned NURI — a stripped id is never a write/anchor target.

Honest framing: this is DEFENSIVE, not a fix for an active bug. On the current tree
create-time, listMyEntityDocs and the read @id already carry the identical NURI
(readUnion pins the subject to the input NURI, 63ecfee) — verified: the count
converges for an event owned via listMyEntityDocs. A prior investigation's 'never
matches' reading was the seeded-but-not-owned artifact (a prior-run identity owned
the seed → reached via discovery, not ownedEventIds — correct behavior).

Un-@wip the @data convergence scenario (asserts the just-joined uid enters the
owner-derived active set — deterministic despite shared-inbox accumulation); it
now passes. Fix authParticipationCount already landed separately. Doctrine:
knowledge_context-internals (canonical id-form invariant). Build + tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 15:14:25 +02:00