--- subject: Refine as Festipod's provider-swap abstraction layer created: 2026-10-08 status: open --- # Refine as the provider-swap abstraction layer ## Where the work lives Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`. On top of the T04 tree close and its adversarial-review round sits the T05 tree (public data, discovery, the end-to-end rights matrix), commits `e21c408` → `88f23b7` plus the commit recording its close — `## T05` and `## Current gate state` below name the states by date and tip. It is based on **`ng-eventually`** at `c7cda38` — the most advanced branch, and the reason this branch exists. An earlier attempt, **`festipod-refine`** (tip `352d1f1`, worktree `.claude/worktrees/festipod-64`), was built on `main`, which turned out to be far behind; the Refine work was transferred here and that branch is now only an archive. It holds the **only** worked implementation of a `@ng-org/refine-providers` backend (`src/shared/refine/providers/nextgraph.ts`, in its history up to the commit that withdrew it) — the reference for the shape of the next backend. The main checkout `/home/sylvain/projects/festipod/festipod` is itself on `ng-eventually`. Do not modify it. Note that the concept hook reads it, so it announces leaves that may not exist in whichever worktree you are in. ## The goal Put all of Festipod's persistence behind Refine so that several **provider sets** can serve the same app. **ActivityPods is the default and the sole priority** — directive of 2026-10-09 (recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`): everything that can goes through `@activitypods/refine-providers`, and a **backend for actions on behalf of a user** is to be added. A Refine provider over NextGraph plus its polyfill remains the declared second target, but its development is **suspended, temporarily** — no work goes into it while ActivityPods is wired. ## Directives of 2026-10-09 (resumption) — and first reconnaissance Recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`; the essentials and what the reconnaissance (three read-only passes over the user-designated reference repos) established: - **The on-behalf mechanism is mapped.** An app backend IS an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer). It signs pod requests with HTTP signatures as the app actor; the pod provider checks the app's registration and grants, then impersonates the user (`pod-provider/backend/middlewares/app-control.js`). Services call `pod-outbox.post`, `pod-resources.*`, `pod-collections.*`, `pod-wac-groups.*`, `pod-permissions.*` with an `actorUri`. Frontend calls use the Solid-OIDC dual-identity token instead (`webId` = app client_id, `impersonatedUser` = user). Reference implementations: `welcometomyplace/backend/services/registration.service.js`, `attendees-matcher.service.js`; both example apps run the same stack (`@activitypods/app` 2.3 + `@semapps/*` 1.2, Moleculer backend + Vite/React/Refine frontend, Docker-deployed). - **The refine-providers package is NOT in the platform monorepo** — it lives at `/home/sylvain/projects/activitypods/refine-providers` (a `/home/sylvain/projects/nextgraph/activitypodes-refine-providers` checkout also exists; the pinned reconstruction remains the work-time specification). - **Shapes**: 15 shape trees published at `https://shapes.activitypods.org` from `/home/sylvain/projects/activitypods/shapes` (repo evolvable by us). The 2026-10-09 directives **amended the first mapping proposal — minimise new shapes, interoperability first**: a MeetingPoint is a plain `as/Event` carrying an `as:context` link to its parent event (event within an event; the `as/Event` shape is open — no `sh:closed` — so nothing breaks and no new shape is needed). For Participation the existing vocabulary was surveyed: see the registration precedent in the next bullet. - **Registration/Participation — production precedent found.** welcometomyplace persists no registration resource: join = `as:Join` activity in the user's outbox, handled by the backend service which adds the actor to the event's `apods:attendees` collection (`PodCollectionsHandlerMixin`, `attachPredicate`); leave = `as:Leave`; reads via `pod-collections.getItems`. The one open design question this raises — collection creator-side (this pattern) vs participant-owned participation documents (the 2026-10-03 structure) — is recorded in the brief, to arbitrate before implementation. - **No unattended per-user backend token exists** on the platform: server-side per-user actions ride the app-actor signature + grants; the platform-idiomatic channel for user-facing reactions is the app outbox → user inbox (notifications style). ## What exists, and what deliberately does not In `src/shared/refine/`: - `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects, tested over the case space. - `providers/types.ts` — the contract a backend must satisfy (`RefineBackend`, `RecordBinding`), each field derived from a measured encounter rather than anticipated. - `providers/activitypods.ts` (+ test) — the ActivityPods backend: the three providers of `@activitypods/refine-providers` and the bindings of the four entities (events, meeting points, participations, user profiles); events and meeting points are created addressed `to: as:Public`. - `providers/index.ts` — the registry: ActivityPods is the one registered backend, hence the default; the NextGraph+polyfill backend would be the second when it resumes. - `RefineProvider.tsx` — mounts `` over the backend the URL named. **Wired since 2026-10-09 (T04).** `FestipodDataContext` runs over Refine with the ActivityPods backend (`src/shared/context/refineData.tsx`). Beside Refine, by design: joins and leaves posted to the user's outbox (`src/shared/data/outbox.ts`); discovery through the Festipod backend catalog and anonymous reads of other users' records and attendee collections (`src/shared/data/catalog.ts`, `publicRead.ts`, `attendees.ts`); sign-in, consent and the app-status probe (`src/app/auth/`). The Festipod backend (`backend/`, Moleculer + `@activitypods/app`) acts on users' pods as the app actor: attendee collections, public-read grants, the catalog, and the allow-listed public surface. In `.project/`: - `concepts/data-layer/activitypods-providers/contract_activitypods-providers.md` — a **reconstruction**, not a pull: that package publishes no engagement, so this is our own observation, pinned (`built_version: 66616484b9…`). It is the specification to code against. - `concepts/data-layer/activitypods-providers/usage_festipod.md` — our declaration toward them. Its Frictions/Needs are the channel to the provider. - `concepts/data-layer/brief_2026-10-03_structure-for-two-rights-models.md` — the data-shape design, below. - `concepts/data-layer/brief_2026-10-09_activitypods-first-backend.md` — the 2026-10-09 directives (ActivityPods priority, on-behalf backend, NextGraph suspended) and the reference material; the brief this hand-off's resumption added. - `handoff/refine-provider-abstraction.md` — this document. - `contracts.yaml` — `publish:` (both declarations, keys qualified by interface) and three `consume:` entries (polyfill-surface, indexing-layer, activitypods-providers). - `contracts.local.yaml` — gitignored, per-worktree. Must exist for `contracts.py check` to resolve the ActivityPods entry. ## Decisions, and why **One app variant per provider set, selected by a URL path prefix.** The default at the root, another behind a prefix naming it; switching is a plain link and costs a full page reload. The reload is the mechanism, not a cost: it discards the previous backend's Refine cache and session with no code to undo them, and losing access to the left backend's data is accepted. An earlier plan for in-session hot switching was dropped. The deep reason: **the sign-in experience is not abstractable** — the NextGraph SDK mounts its own full-screen barrier and owns the whole wallet experience (`src/app/AuthGate.tsx` renders no access screen), while a Solid backend hands the app an OIDC flow it must render itself (Pod-provider picker, callback route). One confiscates the screen, the other delegates a flow. The prefix **is** the backend's name, so a backend must never be named after a top-level route (`events`, `profile`, `home`, `settings`, `users`). **The router does not yet strip the prefix, deliberately.** `parsePath`/`navigate` in `src/app/router.tsx` are a shared primitive every screen depends on; the suites that would prove a change there are not run in this worktree; and while one backend is registered no URL carries a prefix, so nothing is broken. That half lands with the second backend, when it becomes testable. **The data shape: references, not embedded content.** Three levels — Event (own document, public, written by its creator, carrying an attached list of *references*); Participation (own document, owned by **the participant**, carrying `event`, `active`, `participant`); Profile (own document, protected). Three facts force it: the participation must be writable by its participant and nobody else, and NextGraph grants write per document; per-reader variation is impossible inside one document where encryption is per document, so it is obtained by **indirection** (the reference is always present, the ability to *follow* it varies); and `rule_document-per-entity` already says this for that reason. The non-visibility mechanism differs and both land on the same application state — *a reference one may or may not resolve*. On NextGraph the target is **undecipherable** for want of a key (nothing is absent). On ActivityPods it is refused by WAC, or omitted by server-side projection. So the domain type models the NextGraph shape, where everything is present: `active` and `participant` are **not** optional; only resolution fails. (Proposing them optional was an error — it lifted an ActivityPods adaptation into the common model.) **No stored counter, ever.** The participant count is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document. **NextGraph is the common denominator.** Its model is the strict subset: no WAC groups, no revocation, sharing wholesale per document, but writes work offline. Structure for it and ActivityPods serves the structure, more cheaply even. The converse fails: anything needing a server projection or a revocation is untenable on NextGraph. **The Refine boundary rule.** Refine's `DataProvider` is flat CRUD (`getList`, `getMany?`, `getOne`, `create`, `createMany?`, `update`, `updateMany?`, `deleteOne`, `deleteMany?`, `getApiUrl`, `custom?`) — **no relation or join concept at all**, and no `getManyReference`. Everything that makes Festipod social (append to someone else's collection, grant/revoke read, discovery, offline queueing) is outside its vocabulary. Since cache, live subscription and optimistic update are bought **per resource**, the rule is: *anything the app displays and that changes must be a Refine resource; anything one-shot and invisible may live in the seam.* A write passed beside Refine does not invalidate its cache, so the list would not refresh. Refine does support **several data providers at once** (`Resource.dataProviderName`, `useList({dataProviderName})`). Deliberately unused for switching, but it is the tool for reading one entity from another backend (discovery from a shared index) or for migration. **The seam should shrink.** `types.ts` was written from measurements against one raw provider that could not be changed. Since we can contribute to the providers, several of its fields are symptoms rather than requirements: `updateReplaces` + `toPatch` should vanish (a provider exposes one semantic), and `recordKey` vs `domainId` should collapse to `id` (ActivityPods already does). `toDomain`/`toValues` shrink to vocabulary mapping; `visibility` and `resource` stay — visibility is a product declaration, not a transport fact. **The measure of success is how small `RecordBinding` gets.** Capability differences belong inside the provider, never in the app. ## What is known about `@activitypods/refine-providers` The reconstruction is the specification. Its source was read **once, deliberately, at adoption time** (that is what a reconstruction is); it is not to be read again at work time — a gap goes into the declaration's Frictions/Needs. Checkouts, if ever needed again: the package at `/home/sylvain/projects/nextgraph/activitypodes-refine-providers`, the platform at `/home/sylvain/projects/activitypods/activitypods`. - A record's `id` is the LDP resource URI, and it is the **only** identifier — `getOne`/`update`/`deleteOne` use it directly as the URL. The seam's two identifiers collapse into one here. - `update` fetches the current resource, strips `id`/`@context`, and PUTs `{...current, ...variables}` — so **a partial write is safe**, the merge being **shallow**. - `getList` resolves the resource's container from the **session's own WebID** (public Solid TypeIndex, best-effort private), so it returns only what the current user holds. `getOne`/`getMany` fetch a URI directly and so **do** cross Pods — they just need the URI. - No server-side filtering, sorting or pagination: the whole container is fetched, then filtered in memory. - **`getMany` silently drops failed fetches** (`allSettled` then filter) — it erases exactly the "unreadable reference" state the model needs. - Module-level caches (shape trees, type registrations per WebID, subscription endpoints) are **never invalidated**. - `authProvider` installs a `setInterval` and a `visibilitychange` listener at construction with **no teardown**; the only opt-out is `appStatusCheckInterval: false`. - Live updates are real push (`notify:WebSocketChannel2023`), but a **list** subscription reports membership only (`Add`/`Remove`), never a member's field change. - `types` takes precedence over `shapeTreeUri` when both are set — the **opposite** of what the configuration type documents. - `custom` is not implemented, so there is **no escape hatch through Refine** for non-CRUD operations. - Collections are POSTable LDP resources (`/as/collection`) with `semapps:dereferenceItems` — *"all items will be dereferenced (if permissions allow)"*. That is how `event.participations` can come back as full objects for a reader who is allowed, and as URIs otherwise, in one read. Items are added/removed by SparqlPatch on `as:items`. - `acl:Append` exists among the WAC modes and is really used in app access needs. **Whether it lets a third party insert an item without being able to remove one is NOT VERIFIED** — enforcement lives in `@semapps/webacl`, absent from the checkout. The structure does not depend on the answer: if it works that way the participant appends directly; otherwise the route is `apods:PostOutbox` to the owner's inbox, which is exactly the NextGraph shape, and only latency changes. - An app declares `accessNeeds` (`shapeTreeUri` + `accessMode`) plus special needs: `apods:CreateCollection`, `apods:CreateWacGroup`, `apods:PostOutbox`, `apods:QuerySparqlEndpoint`, `apods:UpdateWebId`, inbox/outbox reads. - **Backend-less apps are supported**: `onlineBackend` is false only when the app description document at the `clientId` URI is not fetchable. A static file suffices. - A custom ontology prefix **must be registered at prefix.cc** (the Pod provider requires every ontology to have a known prefix) — this concerns Festipod's `fp:`. Shape trees must sit at a public URL, one per entity, and **cannot reference other shapes** (no sharing cascade). **Four contributions this points to** (we may contribute to these providers): `getMany` must signal the unreadable instead of swallowing it; an append path into a collection one does not own; collection support as a resource with `dereferenceItems`; and a teardown for the auth provider's timer. ## The deduplication question — answered, and still open The attendance model (`brief_2026-07-20_attendance-set-model`, PO 2026-07-27) requires the creator to tell two references from the *same* person apart from two from different people **without knowing who**, or the count is not a count of people and a participant can inflate it. **Checked on this base: not solved.** A different problem is, and well. `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges — that is **replay idempotence**, not person identity. The count counts **deposits**: the same person joining twice counts twice. Further: the **plaintext `userId` travels in the deposit** and the cancellation fallback depends on it (a leave without a known `regUid` cancels the earliest still-active join for that `userId`), so the creator *does* know who and the anonymity the model rests on does not hold. Participations are in the **protected** scope rather than public, and withdrawal is still a deletion. And the gap was **never raised** through the channel meant for it. So both platforms count deposits rather than people, and the gap sits in **neither Refine provider** — polyfill on one side, server on the other. ActivityPods could arbitrate server-side because the server sees everything. ## Problems hit, and how they turned out - **Two false claims were written into durable records and corrected.** First, that the participant count needs an inbox and that a CRUD-only backend therefore could not carry the model — wrong: every participation is readable by everyone, only the link to a person is protected, so counting is ordinary CRUD. Second, a declared "need" for a WebID filter on `getList` — Festipod does not need it: `getOne`/`getMany` already cross Pods, the participations arrive with the event, and discovery is an **index** problem (already decided on this base: `decision_2026-08-17_discovery-through-a-shared-index`), not a provider one. Both corrections are in the files. - **`__FESTIPOD_BACKEND__` was a lie in a comment** claiming `build.ts` defined it. Nothing did, and nothing could: this project serves from `src/` in production too (`bun run start`), so `build.ts`'s `define` block never reaches what runs (`tech-stack`, `knowledge_build-pipeline`). The runtime mechanism this base already uses instead is a config route — `/festipod-config.json` served by `src/index.ts`, read by `src/app/frontend.tsx` *before* it imports `App`. Replaced by the URL read. - **Three separate times the answer was already on `ng-eventually`** (the runtime config route, the delegation of wallet access to the SDK, the dedup implementation). That is what triggered rebasing onto it. - **The migration was delegated to a subagent** and verified independently afterwards: the five transferred files are byte-identical, the manifest merge preserved both pre-existing entries, and the gates were re-run by hand. Its one worry — that a missing local `node_modules` had made the baseline incomparable — was **unfounded** (pnpm had installed the full tree locally; 22 entries against the main checkout's 21). Its debt block was better than the specification it was given: it caught the `__FESTIPOD_BACKEND__` consequence and two branch-relative phrasings. - **Moving base falsified three claims in the transferred code**, all corrected: the inbox the count comment points at is local here; the worked provider implementation is not in this branch's ancestry; and the `MeetingPoint` SHEX shape **does** exist here, so its binding stopped being optional (a backend that cannot carry one of the four entities cannot serve the app). ## Current gate state (2026-10-13, T05 closed, tip = the commit recording this state) The gate, every command run from the worktree root, the live ones against the local stack (pod provider :3000, pod frontend :5000, Festipod backend :3003, the app served on :4000 — the only registered OIDC redirect origin; bring-up: `data-layer`, `cookbook_local-activitypods-platform`): - `bunx tsc --noEmit` — exit 0, no output - `bun test` — 395 pass / 0 fail (33 files) - the UI layer, `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json` — 125 scenarios (71 skipped, 54 passed), 0 failed (the 71 are `Scénario non implémenté` placeholders, skipped by design); restore `reports/` afterwards with `git checkout -- reports/` - the smoke, same command with `--config cucumber.smoke.json` — 5 scenarios (5 passed), 22 steps - `bun run test:e2e-flow` — the banked login flow, fresh user (picker → pod login → consent → install verified → event → meeting point) — `"ok": true` (install asserted: the user's `as/event` container answers 200) - `bun run test:e2e-rights` — the rights matrix: three fresh users plus anonymous, positive AND negative lines with live controls (backend public surface, public reads, refused writes, profile privacy, catalog and public intent, discovery, join/leave and counters, isolation, deletion, visibility toggles); exit 0 only when every hard line holds — 98 hard lines, 98 PASS / 0 FAIL, plus KNOWN-DEVIATION 1, KNOWN-DEFECT 1, MANUAL-CHECK 3, INFO 7 — identical on three consecutive runs with fresh users (2026-10-13) - `bun run test:e2e-revocation` — revoking the app on the pod logs the user out to the picker; signing in again goes through consent — exit 0 (positive, negative and recovered phases `ok: true`) - `bun run validate` — runs the UI layer, the smoke, and the two e2e probes above as stack-gated layers: the stack is probed first; down → both SKIPPED, loudly, never counted as passed; up → a non-zero exit fails the layer. The smoke layer never skips (an unreachable stack fails it), so a fully-down stack still fails `validate` — all four layers passed (2026-10-13); with `APP_URL` pointed at a dead port, the UI layer and the smoke pass and the two probes are reported SKIPPED, the unreachable URL named The three e2e scripts share one sign-in drive, `driveLogin` in `scripts/e2e-rights-matrix-support.ts`: a state machine over observed hops (the browser must leave the app for the pod, Accept is clicked once stable and must land, the shell counts only with the bottom navigation shown and no picker); the login-flow probe and the matrix also assert the install after it. Earlier state (2026-10-10, the T04 adversarial-review round), verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean. The round: three adversarial passes (auth & security, data seam & backend, gates & ops); every confirmed finding was fixed in the commit recording this state. Fix themes — auth: `redirect` param validated (same-origin leading-slash only), callback re-entry guarded against StrictMode double-exchange, stale `?code=` URLs degrade to the picker instead of the fatal screen, `register_app` without a session falls back to the picker (was a permanent blank screen), ActivityPods logout wired through the provider, deep links survive login, clientId trailing slash normalized. Data fidelity: the meeting point's host is read from the Pod-stamped `dc:creator` (the `organizer` term is absent from the platform context and never round-tripped, so the Join lost its addressee), `location`/`city` are written as inline named nodes (`as:location` is `@type: @id`; a free-text string resolved into a garbage IRI), record ids are URL-encoded (absolute IRIs broke the router's `/events/:id` parse), participant resolution is keyed by actor URI (profiles were stored under their document URI and never matched), one delayed re-invalidation reconciles the count after join/leave, a Join/Leave without addressee throws instead of posting to no one, anonymous placeholder rows render on the event detail screen. Backend & ops: the attendees collection gets a public `acl:Read` grant in `after.createAndAttach` (the count is readable by everyone; anonymity lives in the profiles' own WAC), Join/Leave handlers retry while the collection is still attaching, `tsx` promoted to dependencies so the `--prod` image starts, compose `env_file` made optional with fuseki/redis services added and the frontend published on :4000 (the SEMAPPS_FRONT_URL default — fixes the shipped redirect_uri mismatch; :5000 belongs to the pod provider frontend and is deliberately avoided), `test:data` removed (zero matching scenarios since the suspension — a vacuous green), `strict: true` on both cucumber configs. `contracts.py check` also reports, pre-existing: `polyfill-surface: provider moved: a33fb8a214 → a8d53010c2` — the pulled copy is behind the pinned ref. ## T05 — public data, discovery, rights matrix (closed 2026-10-13) What was missing for Festipod's premise — discover other users' public events and sign up to their meeting points — and what closed it, by commit: - **Public read (events, meeting points, attendee lists)** — `a0d2cb8`: a record is world-readable only when Festipod marks it public, i.e. creates it addressed `to: as:Public` (events and meeting points both); `bto`/`bcc`/`audience` never count. The grant on the attendee collection follows. An event POSTed with `apods:attendees` pre-attached no longer crashes the backend. - **Discovery in the app (catalog read path)** — `e21c408`: Découvrir lists the user's own events plus the backend catalog's, de-duplicated, own first; meeting points filtered out. - **Opening another user's event** — `fbb1f6f`, then `6bcf403`: detail, meeting points, per-point counts (event = sum), join; foreign reads are anonymous direct reads by URI (the session token only goes to the session's own origin); a join or leave stays on screen until a read issued after it confirms it. - **Catalog persistence** — `f32e166`: the index survives a backend restart (backend `settings` dataset, default graph); an owner's deletion de-indexes. - **Catalog hardening** — `f14cc37`: only LISTED-public records are listed (`to: as:Public`; `cc`-only = unlisted: readable, never listed); only the owner's own outbox can change an entry. - **Revocation detection** — `4fc856f`, `d4fcb7e`: an app-status probe on load, focus and visibility, at most once a minute; `installed: false` → logout + picker notice (never `registerApp`); `upgradeNeeded: true` → consent. - **Backend trust surface** — `88f23b7`: an allow-list in front of every backend route (9 public endpoints, everything else 404 for every caller — `/sparql` and `/key` included); every decision fed by a pod notification is taken on the activity and object re-read from the pod, serialized per object; public read and catalog listing follow the owner's visibility changes both ways, attendee collections included. - **Rights matrix** — `d38f8a7`, then extended with the public-surface and visibility groups at the close: `bun run test:e2e-rights` (see `## Current gate state`). **User decisions recorded during T05:** - **Attendee lists stay world-readable for now** (2026-10-12) — an explicit deviation from the domain doctrine, where participation is in the protected scope (`functional-domain`, `knowledge_data-scopes-and-discovery`): anonymous readers of a public meeting point's attendee collection see its members' WebIDs, which embed usernames. The matrix tracks it as a KNOWN-DEVIATION line (printed, non-fatal, flags loudly the day it changes). Moving participation to the protected scope is a later task. - **A revoked app logs the user out** (2026-10-12): picker with a notice, never sent back to consent. - **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/`, 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. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side. - Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path. - Driving `getAppStatus()`: DONE (`4fc856f`, `d4fcb7e`). Festipod relies on its `installed` member, which the reconstruction does not pin — recorded in the activitypods-providers usage declaration (constraint 9, need 3), together with its reliance on `create` sending `to` unchanged (constraint 10). - The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision). - The attendees collection of a public meeting point is world-readable — an explicit deviation from the protected participation scope, decided 2026-10-12 (see `## T05`); moving participation to the protected scope is a later task. - The dedup defi (same event + same place + same time) is confirmed absent across the seam; the 2026-10-03 brief marks it an open challenge — a product call, not a regression. - Discovery through the backend catalog: DONE — read path `e21c408`, persistence `f32e166`, hardening `f14cc37` (see `## T05`). - `src/modules/home/**` screens still build raw `/events/${id}` paths; `parsePath` tolerates them, but they should adopt `eventPath`/`userPath` next time they are touched. - The dockerized path is validated by `docker compose config` only — the backend image has never been built or started by the gates; one manual operator run is owed. ## Standing constraints - A pulled or reconstructed contract **is** the specification. Never read a provider's source at work time; a gap goes into the declaration's Frictions/Needs and is signalled out of band (content lives in the document). Never describe in this repo how the data layer is implemented. - **No attribution to Claude** anywhere in produced artifacts — commits, PRs, docs, comments. This overrides harness defaults. - Project documentation in **English**, never hard-wrapped (one line per paragraph or list item). Conversation with the user in **French**. - **No effort or timeline estimates**, anywhere. - Package installation goes through **pnpm**, never `bun install`; Bun-first otherwise. - Project facts are documented through `/concept document`, not written freehand into `.project/`. - Commit freely on a branch; push at validated states without being asked (Gitea SSH needs the sandbox disabled). - Never bare `git stash` — the stack is shared across worktrees and sessions. - `pre-push` blocks while any `_debt.md` exists.