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.
This commit is contained in:
Sylvain Duchesne
2026-10-13 18:11:34 +02:00
parent 9381c206df
commit 6ee95c9e68
4 changed files with 89 additions and 26 deletions
@@ -24,12 +24,16 @@ All three are built together, **per backend instance**, inside the seam's `build
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
Of the `getAppStatus()` result Festipod reads `upgradeNeeded` (pinned by the 2026-10-12 re-baseline) and `installed`, not pinned (the reconstruction records the pod's body, `{ upgradeNeeded }` "plus whatever the pod adds"): `true` while the app is registered, `false` once the user removed it — then Festipod logs the user out to the picker, never calling `registerApp`. See constraint 9, need 3.
### Resources and operations
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
`create` of an event or a meeting point carries `to: as:Public` among its variables: Festipod's declaration that the record is public, on which the Festipod backend's public read and discovery listing depend. See constraint 10.
### Record handling
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
@@ -47,26 +51,28 @@ What must keep holding for this backend to serve the app.
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. See need 1: this is the constraint the package does not currently let the app satisfy.
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. The provider alone cannot satisfy it (need 1).
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
9. **`getAppStatus()` must keep telling a removed registration from a current one** (`installed`). Otherwise a user who revoked the app stays in a connected view whose writes cannot land — or a current user is logged out.
10. **`create` must keep sending the caller's variables unchanged, `to` included.** A provider dropping or rewriting `to` would make every new event and meeting point private and undiscoverable, with no error.
## Frictions
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely. The re-baseline of 2026-10-12 pinned the `getAppStatus` shape (`GET /.well-known/app-status` → `{ upgradeNeeded }`), so the workaround is now implementable; wiring it is tracked in the handoff.
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule — on load and on focus or visibility change, at most once a minute, with its own teardown. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely.
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
5. **The member shape of an `apods:attendees` collection is not pinned anywhere Festipod may read at work time.** The reconstruction documents the container discovery, not what a membership record is: a bare actor URI, or an object carrying event/participant/status. The project reconnaissance records only that a join "adds the actor to the collection" and that members come back "as full objects for a reader who is allowed, and as URIs otherwise". The ActivityPods backend's participation binding is designed against that: a member maps to `{ id, userId, isConfirmed: true }` (membership IS the attendance), with `eventId` supplied by the reader — the event the collection was read from — because a member carries no event link. The first runtime read against the local platform (T04.g) must confirm or correct this mapping.
6. **The live provider's `subscribe` rejects with an uncaught `Forbidden` when the Pod refuses its push channel.** On this platform the channel creation (`POST /.notifications/WebSocketChannel2023`) answers 403 for the app's session, and each refusal surfaces as one unhandled rejection in the page — raised inside the provider, where the app cannot catch it; Festipod's end-to-end checks recognise it by its throw site. Reporting the refusal through the subscription's error path would let the app handle it.
## Needs
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations.
This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read.
The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold.
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID, so `getList('events')` returns only the current user's own events. To let a user **discover other people's public events** and see **how many people signed up**, Festipod now carries a workaround beside the provider: other users' events are found through the Festipod backend's catalog (a cross-Pod index of public records), and their records and attendee collections are read by URI with plain anonymous HTTP. Neither gets Refine's cache or live updates. The capability sits one layer down (the package's TypeIndex discovery takes a WebID; the public TypeIndex needs no grant), so the request stands: a way to scope a `getList` (and its list subscription) to a given WebID, or a set of them, through the data provider.
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
3. **`installed` pinned in the app status.** Festipod depends on it (constraint 9); stating it as part of the status, `false` once the user removed the app, would stop an upgrade from silently taking it away.
@@ -9,15 +9,15 @@ Platform checkout: `/home/sylvain/projects/activitypods/activitypods` (never mod
## Ports
pod provider 3000 (OpenID + `/auth` signup/login), Festipod backend 3003, Festipod app 4000, fuseki 3030 (admin/admin), mailcatcher 1080, redis 6390, arena 4567, tripleadmin 3033. The pod provider frontend owns :5000 — never run it while testing Festipod.
pod provider 3000 (OpenID + `/auth` signup/login), pod provider frontend 5000, Festipod backend 3003, Festipod app 4000, fuseki 3030 (admin/admin), mailcatcher 1080, redis 6390, arena 4567, tripleadmin 3033. The pod provider frontend on :5000 must run whenever Festipod signs a user in through the browser: the pod's login and app-consent screens are its pages (the UI smoke spawns its own app and does not need it; `test:e2e-flow`, `test:e2e-rights` and `test:e2e-revocation` do). The Festipod app stays on :4000, the only registered OIDC redirect origin.
## Bring up
1. Infra: `cd pod-provider && docker compose up -d`. If host port 6379 is already taken, add an override file publishing redis on another port (6390 was used) and pass `SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/1` and `SEMAPPS_REDIS_OIDC_PROVIDER_URL=redis://localhost:6390/2` when starting the backend — dotenv-flow does not override the process environment.
2. Install once: `cd backend && COREPACK_ENABLE_AUTO_PIN=0 corepack yarn install --frozen-lockfile`, same in `frontend/`. Keep `COREPACK_ENABLE_AUTO_PIN=0`: corepack auto-pin rewrites `packageManager` in package.json. Note: the pod-provider README says `yarn start` for the frontend; only the `dev` script exists — use `BROWSER=none corepack yarn dev`.
3. Backend: `cd backend && corepack yarn start`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s).
4. Frontend: `cd frontend && BROWSER=none corepack yarn dev`. Verify: `GET http://localhost:5000/` → 200 and `GET http://localhost:3000/.well-known/config.js` → 200.
5. Festipod stack (this repo root): `cp backend/.env.example backend/.env` before `docker compose up`. The compose reads `backend/.env` as an optional `env_file`; on a fresh clone the file is gitignored and absent, so this copy is what feeds the backend its ActivityPods settings. With the coherent layout the app is served on :4000 and the Festipod backend on :3003 — keep the pod provider frontend (which owns :5000) stopped while testing Festipod. To serve the app manually (`bun run start` on :4000) ALSO copy the root env: `cp .env.example .env` — without `VITE_BACKEND_URL` the config route serves an empty backend URL and the app deliberately falls back to the local demo (seeded data, no login).
4. Frontend: `cd frontend && VITE_CONFIG_URL=http://localhost:3000/.well-known/config.js BROWSER=none corepack yarn dev`. Without `VITE_CONFIG_URL` the page's `window.CONFIG` stays undefined and the consent flow dies with an opaque "Failed to fetch". Verify: `GET http://localhost:5000/` → 200 and `GET http://localhost:3000/.well-known/config.js` → 200.
5. Festipod stack (this repo root): `cp backend/.env.example backend/.env` before `docker compose up`. The compose reads `backend/.env` as an optional `env_file`; on a fresh clone the file is gitignored and absent, so this copy is what feeds the backend its ActivityPods settings. With the coherent layout the app is served on :4000 and the Festipod backend on :3003 (the pod provider frontend keeps :5000 and must run for any browser sign-in, see Ports). To serve the app manually (`bun run start` on :4000) ALSO copy the root env: `cp .env.example .env` — without `VITE_BACKEND_URL` the config route serves an empty backend URL and the app deliberately falls back to the local demo (seeded data, no login).
## Account and resource flow (all VERIFIED 2026-10-09)
@@ -1,11 +1,11 @@
---
type: knowledge
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it; other users' events are found through a shared index, not by reading the public scope
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it; other users' events are found through a shared index (the Festipod backend's catalog on ActivityPods), not by reading the public scope
---
# Data scopes and discovery
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* is the `@ng-eventually/polyfill` data SDK's business — the app only states **the business intent**.
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* belongs to the data layer behind the active backend (concept `data-layer`) — the app only states **the business intent**.
## Three scopes per piece of data
@@ -21,21 +21,24 @@ Every entity is stored in the **scope** matching who must be able to read it:
| Private profile (settings, email, preferences) | **private** | the holder alone |
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
**Deviation on the ActivityPods backend (user decision 2026-10-12, open):** a public meeting point's attendee list — its `apods:attendees` collection, whose members are WebIDs, which embed usernames — is **world-readable**, the pod's default, instead of protected. Moving it to the protected scope is a later task; until then the end-to-end rights matrix tracks it as a KNOWN-DEVIATION line, never as a positive assertion. When the meeting point is switched private, its attendee list stops being readable anonymously with it.
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.
- **Meeting points / events are universally public.** Any user can read them and subscribe; creating a meeting point makes you its host, declaring an event makes you its declarer (no prerequisite).
- **The host is the sole holder of write rights** on their meeting point; the declarer has no particular right over the meeting points grafted onto their event.
- **Bilateral connection**: `DemandeDeConnexion` (unilateral, transient) → `Connexion` (bilateral, persistent) — the latter opens access to the other person's *protected* data.
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data SDK**, not by application code (see concept `app-security`).
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data layer** (the SDK on NextGraph; the pod's access control and the Festipod backend's public-read grants on ActivityPods), not by screen code (see concept `app-security`).
## Event discovery
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.
A user's own reads only ever return **their own** records — on either backend, no call unions every user's public data (concept `data-layer`). Discovering events one did not create — the product's premise — therefore goes through a **shared index**: the **primary** discovery axis. A **secondary**, relational axis stays layered on top: the connections' *protected* participations ("my friends are attending…").
**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…").
The intent is the same on both backends — every public event is findable by everyone — but the index is not:
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]].
- **ActivityPods (the default backend): the Festipod backend's catalog.** An event or meeting point is public when Festipod creates it addressed to the ActivityStreams Public collection (`to: as:Public`). Such a record is readable by anyone at its URI, and the Festipod backend lists it in its catalog at once — no curation. The listing follows the record: the owner's edits update it; a deletion, or a switch to private, removes it and makes the record and its attendee list unreadable anonymously again. Only a record's owner can change its listing. Découvrir shows the user's own events plus the catalog's; opening another user's event reads that event, its meeting points and their attendee counts anonymously, by URI. Costs, accepted: a record created while the backend was not receiving the pod's notifications is never listed (no backfill), and the catalog lists meeting points too, so its own total is not an event count. Verified end to end by `bun run test:e2e-rights` (groups catalog, discovery, visibility).
- **NextGraph (backend suspended): a curated shared-index document** — [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`), no code consumes it yet. An ordinary public document the app reaches by a reference it hardcodes; anyone may deposit a reference to their event, only the index's owner curates deposits into visible entries, ordered by start date. Its accepted costs: an event is findable only once **someone curates** the index (an operator role, no fixed schedule); an event declared before its document could carry the indexed field never becomes findable; a withdrawn or corrected event **stays listed**, so a reader must tolerate a reference that no longer resolves or resolves to something changed; an entry's position is **frozen at curation**.
> **Sign-up notification (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.
+64 -10
View File
@@ -8,7 +8,7 @@ status: open
## Where the work lives
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`, pushed to origin; the tip records the T04 tree close plus the adversarial-review round — `## Current gate state` below names the states by date and tip.
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`. On top of the T04 tree close and its adversarial-review round sits the T05 tree (public data, discovery, the end-to-end rights matrix), commits `e21c408` → `88f23b7` plus the commit recording its close — `## T05` and `## Current gate state` below name the states by date and tip.
It is based on **`ng-eventually`** at `c7cda38` — the most advanced branch, and the reason this branch exists. An earlier attempt, **`festipod-refine`** (tip `352d1f1`, worktree `.claude/worktrees/festipod-64`), was built on `main`, which turned out to be far behind; the Refine work was transferred here and that branch is now only an archive. It holds the **only** worked implementation of a `@ng-org/refine-providers` backend (`src/shared/refine/providers/nextgraph.ts`, in its history up to the commit that withdrew it) — the reference for the shape of the next backend.
@@ -32,12 +32,13 @@ Recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`; the essent
In `src/shared/refine/`:
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects. 8 tests covering the case space, including the one true today (empty registry → every URL is the default).
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects, tested over the case space.
- `providers/types.ts` — the contract a backend must satisfy (`RefineBackend`, `RecordBinding`), each field derived from a measured encounter rather than anticipated.
- `providers/index.ts` — the registry. **EMPTY on purpose**: no backend is implemented yet.
- `providers/activitypods.ts` (+ test) — the ActivityPods backend: the three providers of `@activitypods/refine-providers` and the bindings of the four entities (events, meeting points, participations, user profiles); events and meeting points are created addressed `to: as:Public`.
- `providers/index.ts` — the registry: ActivityPods is the one registered backend, hence the default; the NextGraph+polyfill backend would be the second when it resumes.
- `RefineProvider.tsx` — mounts `<Refine>` over the backend the URL named.
**None of it is wired.** Nothing imports `RefineProvider`; `FestipodDataContext` on this base is untouched and still talks to the polyfill directly. Wiring it means rewriting that context against this base's much-evolved version — that is the step everything else waits on.
**Wired since 2026-10-09 (T04).** `FestipodDataContext` runs over Refine with the ActivityPods backend (`src/shared/context/refineData.tsx`). Beside Refine, by design: joins and leaves posted to the user's outbox (`src/shared/data/outbox.ts`); discovery through the Festipod backend catalog and anonymous reads of other users' records and attendee collections (`src/shared/data/catalog.ts`, `publicRead.ts`, `attendees.ts`); sign-in, consent and the app-status probe (`src/app/auth/`). The Festipod backend (`backend/`, Moleculer + `@activitypods/app`) acts on users' pods as the app actor: attendee collections, public-read grants, the catalog, and the allow-listed public surface.
In `.project/`:
@@ -109,27 +110,80 @@ So both platforms count deposits rather than people, and the gap sits in **neith
- **The migration was delegated to a subagent** and verified independently afterwards: the five transferred files are byte-identical, the manifest merge preserved both pre-existing entries, and the gates were re-run by hand. Its one worry — that a missing local `node_modules` had made the baseline incomparable — was **unfounded** (pnpm had installed the full tree locally; 22 entries against the main checkout's 21). Its debt block was better than the specification it was given: it caught the `__FESTIPOD_BACKEND__` consequence and two branch-relative phrasings.
- **Moving base falsified three claims in the transferred code**, all corrected: the inbox the count comment points at is local here; the worked provider implementation is not in this branch's ancestry; and the `MeetingPoint` SHEX shape **does** exist here, so its binding stopped being optional (a backend that cannot carry one of the four entities cannot serve the app).
## Current gate state (2026-10-10, adversarial-review round closed, tip = the commit recording this state)
## Current gate state (2026-10-13, T05 closed, tip = the commit recording this state)
Verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean.
The gate, every command run from the worktree root, the live ones against the local stack (pod provider :3000, pod frontend :5000, Festipod backend :3003, the app served on :4000 — the only registered OIDC redirect origin; bring-up: `data-layer`, `cookbook_local-activitypods-platform`):
- `bunx tsc --noEmit` — exit 0, no output
- `bun test` — 395 pass / 0 fail (33 files)
- the UI layer, `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json` — 125 scenarios (71 skipped, 54 passed), 0 failed (the 71 are `Scénario non implémenté` placeholders, skipped by design); restore `reports/` afterwards with `git checkout -- reports/`
- the smoke, same command with `--config cucumber.smoke.json` — 5 scenarios (5 passed), 22 steps
- `bun run test:e2e-flow` — the banked login flow, fresh user (picker → pod login → consent → install verified → event → meeting point) — `"ok": true` (install asserted: the user's `as/event` container answers 200)
- `bun run test:e2e-rights` — the rights matrix: three fresh users plus anonymous, positive AND negative lines with live controls (backend public surface, public reads, refused writes, profile privacy, catalog and public intent, discovery, join/leave and counters, isolation, deletion, visibility toggles); exit 0 only when every hard line holds — 98 hard lines, 98 PASS / 0 FAIL, plus KNOWN-DEVIATION 1, KNOWN-DEFECT 1, MANUAL-CHECK 3, INFO 7 — identical on three consecutive runs with fresh users (2026-10-13)
- `bun run test:e2e-revocation` — revoking the app on the pod logs the user out to the picker; signing in again goes through consent — exit 0 (positive, negative and recovered phases `ok: true`)
- `bun run validate` — runs the UI layer, the smoke, and the two e2e probes above as stack-gated layers: the stack is probed first; down → both SKIPPED, loudly, never counted as passed; up → a non-zero exit fails the layer. The smoke layer never skips (an unreachable stack fails it), so a fully-down stack still fails `validate` — all four layers passed (2026-10-13); with `APP_URL` pointed at a dead port, the UI layer and the smoke pass and the two probes are reported SKIPPED, the unreachable URL named
The three e2e scripts share one sign-in drive, `driveLogin` in `scripts/e2e-rights-matrix-support.ts`: a state machine over observed hops (the browser must leave the app for the pod, Accept is clicked once stable and must land, the shell counts only with the bottom navigation shown and no picker); the login-flow probe and the matrix also assert the install after it.
Earlier state (2026-10-10, the T04 adversarial-review round), verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean.
The round: three adversarial passes (auth & security, data seam & backend, gates & ops); every confirmed finding was fixed in the commit recording this state. Fix themes — auth: `redirect` param validated (same-origin leading-slash only), callback re-entry guarded against StrictMode double-exchange, stale `?code=` URLs degrade to the picker instead of the fatal screen, `register_app` without a session falls back to the picker (was a permanent blank screen), ActivityPods logout wired through the provider, deep links survive login, clientId trailing slash normalized. Data fidelity: the meeting point's host is read from the Pod-stamped `dc:creator` (the `organizer` term is absent from the platform context and never round-tripped, so the Join lost its addressee), `location`/`city` are written as inline named nodes (`as:location` is `@type: @id`; a free-text string resolved into a garbage IRI), record ids are URL-encoded (absolute IRIs broke the router's `/events/:id` parse), participant resolution is keyed by actor URI (profiles were stored under their document URI and never matched), one delayed re-invalidation reconciles the count after join/leave, a Join/Leave without addressee throws instead of posting to no one, anonymous placeholder rows render on the event detail screen. Backend & ops: the attendees collection gets a public `acl:Read` grant in `after.createAndAttach` (the count is readable by everyone; anonymity lives in the profiles' own WAC), Join/Leave handlers retry while the collection is still attaching, `tsx` promoted to dependencies so the `--prod` image starts, compose `env_file` made optional with fuseki/redis services added and the frontend published on :4000 (the SEMAPPS_FRONT_URL default — fixes the shipped redirect_uri mismatch; :5000 belongs to the pod provider frontend and is deliberately avoided), `test:data` removed (zero matching scenarios since the suspension — a vacuous green), `strict: true` on both cucumber configs.
`contracts.py check` also reports, pre-existing: `polyfill-surface: provider moved: a33fb8a214 → a8d53010c2` — the pulled copy is behind the pinned ref.
## T05 — public data, discovery, rights matrix (closed 2026-10-13)
What was missing for Festipod's premise — discover other users' public events and sign up to their meeting points — and what closed it, by commit:
- **Public read (events, meeting points, attendee lists)** — `a0d2cb8`: a record is world-readable only when Festipod marks it public, i.e. creates it addressed `to: as:Public` (events and meeting points both); `bto`/`bcc`/`audience` never count. The grant on the attendee collection follows. An event POSTed with `apods:attendees` pre-attached no longer crashes the backend.
- **Discovery in the app (catalog read path)** — `e21c408`: Découvrir lists the user's own events plus the backend catalog's, de-duplicated, own first; meeting points filtered out.
- **Opening another user's event** — `fbb1f6f`, then `6bcf403`: detail, meeting points, per-point counts (event = sum), join; foreign reads are anonymous direct reads by URI (the session token only goes to the session's own origin); a join or leave stays on screen until a read issued after it confirms it.
- **Catalog persistence** — `f32e166`: the index survives a backend restart (backend `settings` dataset, default graph); an owner's deletion de-indexes.
- **Catalog hardening** — `f14cc37`: only LISTED-public records are listed (`to: as:Public`; `cc`-only = unlisted: readable, never listed); only the owner's own outbox can change an entry.
- **Revocation detection** — `4fc856f`, `d4fcb7e`: an app-status probe on load, focus and visibility, at most once a minute; `installed: false` → logout + picker notice (never `registerApp`); `upgradeNeeded: true` → consent.
- **Backend trust surface** — `88f23b7`: an allow-list in front of every backend route (9 public endpoints, everything else 404 for every caller — `/sparql` and `/key` included); every decision fed by a pod notification is taken on the activity and object re-read from the pod, serialized per object; public read and catalog listing follow the owner's visibility changes both ways, attendee collections included.
- **Rights matrix** — `d38f8a7`, then extended with the public-surface and visibility groups at the close: `bun run test:e2e-rights` (see `## Current gate state`).
**User decisions recorded during T05:**
- **Attendee lists stay world-readable for now** (2026-10-12) — an explicit deviation from the domain doctrine, where participation is in the protected scope (`functional-domain`, `knowledge_data-scopes-and-discovery`): anonymous readers of a public meeting point's attendee collection see its members' WebIDs, which embed usernames. The matrix tracks it as a KNOWN-DEVIATION line (printed, non-fatal, flags loudly the day it changes). Moving participation to the protected scope is a later task.
- **A revoked app logs the user out** (2026-10-12): picker with a notice, never sent back to consent.
- **Defensive testing only** (2026-10-13): no automated check sends a forged or spoofed activity; such checks are MANUAL-CHECK lines (below).
- **Trusted local network** (2026-10-13): no key rotation after the exposure below; it is recorded as a risk. Until `88f23b7` the Festipod backend served, anonymously and on all interfaces (reached from the LAN), an open `/sparql` over its `settings` dataset — the app actor's private key, the pod provider's password hashes and emails (the dataset is shared with the pod provider locally), webhook URLs, catalog snapshots — and the private key again at `/key` through the LDP catch-all. Fuseki still listens on all interfaces with `admin/admin`.
**MANUAL checks** — kept out of the automated matrix because they need a crafted request; each is covered by backend unit tests and owes one manual run by a human: (1) a Delete posted to a user's OWN outbox that embeds a Tombstone of ANOTHER user's event must not de-index it (`catalog.service` ownership, `emitted-activity` re-read); (2) a forged pod notification (webhook) must not publish a private event, grant or revoke a read, or list/de-list anything; (3) a crafted delivery to `POST /as/actor/app/inbox` or `/.webhooks/<id>`, and any write outside them, must change nothing (`public-surface`, `emitted-activity`).
**Platform defects to report upstream** (pre-existing, platform-side, never patched here):
- Anonymous `PATCH` on an attendee collection is accepted (204) although its only public rule is `acl:Read` — an `as:items` insert LANDS (attendee forgery; arbitrary predicates are dropped). Tracked by the matrix's KNOWN-DEFECT line.
- `pod-permissions.remove` removes nothing for a public read: it compares the full `foaf:Agent` IRI to the compacted form the pod stores, and would PUT an empty rule set the pod refuses (Festipod revokes through its own ACL rewrite instead).
- The collections mixin crashes the backend on a record POSTed with `apods:attendees` already set (`getGroupSlugFromCollectionUri(undefined)` → `new URL(undefined)`).
- The webhook listener does not await its handlers: any handler throw, or a webhook arriving during backend startup, kills the backend process.
- The delete matcher compares `formerType` `as:Event` to the compacted `Event`, so `onDelete` never fires for events (it would call `onUpdate`).
- The refine-providers live provider's `subscribe` throws an uncaught `Forbidden` when the pod refuses its push channel (`POST /.notifications/WebSocketChannel2023` → 403), one per refusal.
**Known limitations** (T05.i, accepted): (a) an attendance add or remove already sent when the 30 s serialization limit hits still lands, unrecorded — if a newer act of the same participant was applied meanwhile, the collection shows the older act's effect until her next act; (b) a public-read or catalog write that lands late leaves a wrong state until its single re-decision runs, and that re-decision is not itself followed up; (c) a share the owner adds between Festipod's ACL read and its PATCH is recorded as Festipod's (the pod has no conditional write); (d) a timeout between the ledger record and the PATCH leaves a record without a grant; (e) grants made before the ledger existed are never revoked.
**Open follow-ups:**
- A person who joined two meeting points of one event is counted twice in the event's total (sum semantics).
- Webfinger and the app actor's ActivityPub collections now answer 404 (not on the allow-list): add them only if a pod or remote server turns out to need them.
- No backfill: pre-ledger grants (development data) are never revoked, events made private before `88f23b7` keep a public attendee collection until their next update, and a record created while the backend was not receiving notifications is never listed.
- Also noted by the children: an owner's card ignores other users' meeting points until opened; other users' joins show only after a reload; does leaving revoke the organizer's read on the participant's profile (the join grants it); provider calls have no timeout; a hanging `provider.logout` leaves the connected view until reload.
## Open points
- Adding `## Needs` to the polyfill declaration is where the per-person discriminator belongs. It is **outward-facing** (the provider reads it on its next pull), so it awaits the user's go.
- `acl:Append` behaviour, as above.
- The router's prefix stripping, with the second backend.
- The four provider contributions.
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. Known remainder: a revoked app is not detected client-side (getAppStatus wiring — now implementable, `{upgradeNeeded}` shape pinned). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
- Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path.
- Driving `getAppStatus()` is parked: the pinned contract documents the method but not the shape of the `AppStatus` result, so "stale" is not decidable without guessing. Belongs in the declaration's Frictions/Needs when the outward channel opens.
- Driving `getAppStatus()`: DONE (`4fc856f`, `d4fcb7e`). Festipod relies on its `installed` member, which the reconstruction does not pin — recorded in the activitypods-providers usage declaration (constraint 9, need 3), together with its reliance on `create` sending `to` unchanged (constraint 10).
- The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision).
- The attendees collection is publicly readable by decision (count for everyone; member URIs are not secret; anonymity lives in the profiles' WAC). Revisit if a product rule ever hides membership.
- The attendees collection of a public meeting point is world-readable — an explicit deviation from the protected participation scope, decided 2026-10-12 (see `## T05`); moving participation to the protected scope is a later task.
- The dedup defi (same event + same place + same time) is confirmed absent across the seam; the 2026-10-03 brief marks it an open challenge — a product call, not a regression.
- The catalog service's discovery index is process memory: a backend restart empties it while the Pods still hold the events. Latent until the cross-pod catalog provider lands.
- Discovery through the backend catalog: DONE — read path `e21c408`, persistence `f32e166`, hardening `f14cc37` (see `## T05`).
- `src/modules/home/**` screens still build raw `/events/${id}` paths; `parsePath` tolerates them, but they should adopt `eventPath`/`userPath` next time they are touched.
- The dockerized path is validated by `docker compose config` only — the backend image has never been built or started by the gates; one manual operator run is owed.