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.
This commit is contained in:
Sylvain Duchesne
2026-08-17 00:10:16 +02:00
parent 0d925c7cb9
commit e780c5246c
11 changed files with 50 additions and 74 deletions
-15
View File
@@ -1,15 +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/utils/documentNuri.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/resolveOnce.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/serialTask.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/resolveOnce.test.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/serialTask.test.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/storeRegistry.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/data/registration.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/data/entityWrites.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+2 -5
View File
@@ -27,13 +27,10 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
- [[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
## Open defects — the sign-up flow does not deliver an honest count
- [[bug_participant-count-stays-at-zero]] — the count stays at 0 through the session that signs up and through the first reconnect, only catching up on the second
## Pitfalls (read before touching deletions / event fields)
## Pitfalls (read before touching deletions / event fields / the participant count)
- [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… not covered by the Event shape → lost when connected
- [[caveat_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,38 +0,0 @@
---
type: bug
severity: major
opened: 2026-08-16
last_checked: 2026-08-16
summary: After a sign-up the count stays at 0 for the rest of the session and through the first reconnect — it only reaches the true value on the SECOND reconnect, one connection later than expected. Withdrawal converges in a single connection, for a reason not yet established.
---
# The participant count takes one connection more than expected to converge
## What happens, VERIFIED
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** while the button reads « ✓ Je participe ». VERIFIED 2 runs out of 2, the count still 0 **120 s** and **75 s** after the sign-up.
The count starting at 0 on creation is correct and is not the defect ([[knowledge_context-internals]] §participantCount: no host baseline). The defect is that it does not move within the session — and, now measured, not on the reconnect that immediately follows it either.
**The "owner offline" explanation does not apply.** In this flow the signer **is** the event's owner, so the counter's only writer is present, connected, and watching the inbox it deposited into. Eventual delivery to an absent owner explains nothing here.
## The convergence, now measured, VERIFIED
The count does converge, but **one connection later than expected**: the first reconnect after the sign-up still reads 0; the count only reaches the true value on the **second** reconnect.
INFERRED: the first reconnect is the one that drains the deposit and writes the counter, and the write's own value shows up only on the load that follows it, not on the load that triggered it.
**Withdrawal, by contrast, converges in a single connection — VERIFIED.** Why it converges one connection sooner than sign-up does is NOT established: `leaveEvent` deposits its own marker (`depositLeave`) into the same inbox, processed by the same owner materializer as a join ([[knowledge_context-internals]] §participantCount), so the asymmetry is not explained by withdrawal skipping the deposit — that explanation does not survive a look at how `leaveEvent` is wired. Record the one-connection gap as open rather than reach for a mechanism.
## What is still open
Which side is at fault for the extra connection is still open — never written, or written and not re-read. The pair of measurement points laid down for exactly this question (the owner's materializer logging `participantCount` before → after its write, and the display read logging the value as exposed to the render — [[knowledge_context-internals]] §logging) is where a diagnosis starts.
## 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 — every individual step reports success.
@@ -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,7 +1,7 @@
---
type: knowledge
summary: Internal pitfalls of FestipodDataContext — the signed-in identity and the profile are two unrelated things, "my profile" is the profile document I own, mutations reject instead of succeeding silently, participantCount is derived by the event's owner, and local mode is a no-op
last_checked: 2026-08-16
last_checked: 2026-08-17
---
# Internals & pitfalls of `FestipodDataContext`
@@ -70,12 +70,14 @@ The counter is **not** incremented by whoever joins: only a document's owner wri
- A participant writes their **own** participation document (protected), then **deposits** a marker into the event's inbox (`depositRegistration` / `depositLeave`, `src/shared/data/registration.ts`).
- The event **owner's** session watches the inboxes of the events it owns (`inbox.watch`, no polling) and **recomputes** `participantCount` on its own event document. It is the counter's only writer, and it reads through `inbox.readSynced` — the synced view — not `inbox.read`.
- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. 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.
- **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.
> ⚠️ **This section describes the design, and the design is not what a live run does.** Driven end to end with the owner present and connected — the signer *being* the owner — the count stayed at **0** for the rest of the session after a sign-up. Read the convergence properties above as intent to be re-established, not as observed behaviour: [[bug_participant-count-stays-at-zero]].
- 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
@@ -27,4 +27,4 @@ The app has **two modes**, both consumed through the `useFestipodData()` hook:
> 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, and the sign-up flow driven end to end still does not show the user what happened: the count stays at 0 through the session and the first reconnect, only catching up on the second — [[bug_participant-count-stays-at-zero]]. Do not read the paragraph above as "signing up works".
> **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".
@@ -42,7 +42,7 @@ So: **write = direct SPARQL into the entity's document** (immediate, per-documen
**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`).
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: DELETE then INSERT of the triple) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
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:…`.
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]].