feat(inbox): l'inbox d'un document est adressable par tout détenteur

Répond au brief 2026-08-03 remonté depuis le consommateur. `documentInbox(doc)`
répondait « quelle inbox est-ce que MOI je connais pour ce document » et en
créait une quand la réponse était « aucune » : un tiers n'atteignait jamais
l'inbox du propriétaire, il en obtenait une à lui, que personne ne lit, et son
dépôt disparaissait sans erreur. C'est l'acte central du consommateur —
s'inscrire à l'événement d'un autre — qui était silencieusement perdu.

Lire une inbox et savoir où y déposer sont deux actes opposés, avec des publics
opposés. Ils sont désormais deux fonctions :

- `openDocumentInbox(doc)` — le PROPRIÉTAIRE ouvre une inbox dédiée. Refuse sur
  la PROPRIÉTÉ (lue depuis les branches Store), pas sur la possession du cap :
  un cap se reçoit, et un destinataire ne doit pas pouvoir rediriger vers lui
  les dépôts destinés au propriétaire.
- `documentInboxAddress(doc)` — n'importe quel détenteur trouve où déposer. Ne
  crée jamais rien.

L'adresse est publiée dès la CRÉATION, sur la branche Header émulée du document
— un sujet réservé à l'intérieur du document, donc lisible par qui détient le
document. Publier seulement le jour où le propriétaire ouvre une inbox dédiée
laisserait une fenêtre pendant laquelle un tiers lit le document, ne trouve
aucune adresse, et ne peut pas joindre le propriétaire du tout.

Sur le coût mesuré par le brief (9m37 → 21m30) : il venait de la création d'un
DOCUMENT supplémentaire par document. L'adresse publiée pointe vers l'inbox
propre du propriétaire, qui existe déjà et s'amortit sur tous ses documents ;
la création grandit d'un triple, pas d'un document. Le dépôt porte le document
concerné, donc le propriétaire matérialise toujours par document. La forme
« dérivable » du brief n'était pas disponible : notre inbox est un document, et
un NURI dérivé nommerait un repo que `doc_create` n'a jamais créé.

Le tout reflète la séparation d'amont : un déposant scelle avec la clé PUBLIQUE
de l'inbox et n'a besoin de rien d'autre, seul le propriétaire détient la
moitié privée — une adresse est donc publique par nature.

`src/machinery.ts` : l'espace de noms `urn:ng-eventually:` que la bibliothèque
se réserve, et le prédicat que le chemin de lecture utilise. La branche Header
est le premier compartiment logé dans un document que le consommateur lit ;
`read-model` écarte désormais tout sujet de cet espace, par SUJET et non par
prédicat — ce qui couvre toutes les branches émulées, présentes et futures.

Question ouverte du brief, tranchée : « une inbox de document adressable par
tout détenteur » est une invention de cette bibliothèque, pas de l'amont — aucun
document n'y a d'inbox, ni le store privé. Ce qui EST vérifié, c'est la forme
qui rend l'anticipation défendable : `AddInboxCapV0` est clé par `repo_id`.

Tests : le test qui validait « n'importe qui dépose » passait le NURI d'inbox au
déposant par une variable du test — chemin qu'aucune app n'a. Réécrit avec les
deux acteurs cloisonnés : le déposant reçoit le lien du document, qui est la
seule chose qui circule dans ce modèle, et doit trouver l'adresse lui-même. Le
fake `ng` gagne le SELECT de la branche Header et le `DELETE WHERE` (sans quoi
un remplacement devenait une accumulation, précisément le bug qu'il évite).

157 tests unitaires, e2e 40/40 contre le broker en ligne.
This commit is contained in:
Sylvain Duchesne
2026-08-03 16:02:11 +02:00
parent e24a20cc46
commit 8a382f29f8
11 changed files with 487 additions and 26 deletions
+51
View File
@@ -0,0 +1,51 @@
/**
* The reserved-namespace predicate, in isolation.
*
* It is one `startsWith`, but it is the seam that keeps the polyfill's emulated
* branches out of the consumer's data (see `machinery.ts`), so its edges are worth
* pinning: get it wrong in one direction and machinery leaks into domain properties;
* wrong in the other and real data silently disappears from reads.
*/
import { test, expect } from "bun:test";
import { MACHINERY_NS, isMachinerySubject } from "../src/machinery";
test("the emulated branch subjects are all machinery", () => {
// The four compartments store-registry emulates, verbatim.
for (const s of [
"urn:ng-eventually:shim:index",
"urn:ng-eventually:shim:storeBranch",
"urn:ng-eventually:shim:userBranch",
"urn:ng-eventually:shim:headerBranch",
]) {
expect(isMachinerySubject(s)).toBe(true);
}
});
test("inbox deposits are machinery too — a second prefix under the same namespace", () => {
expect(isMachinerySubject("urn:ng-eventually:inbox:deposit:1700:abc")).toBe(true);
});
test("consumer subjects are not machinery — including a NURI, which is what entities use", () => {
expect(isMachinerySubject("did:ng:o:doc1")).toBe(false);
expect(isMachinerySubject("urn:e2e:secret")).toBe(false);
expect(isMachinerySubject("http://example.org/thing")).toBe(false);
});
test("a look-alike prefix is NOT machinery — the boundary is exact, not fuzzy", () => {
// Anything that merely resembles the namespace must fall on the data side, or a
// consumer's own vocabulary could vanish from its reads.
expect(isMachinerySubject("urn:ng-eventuallyX:thing")).toBe(false);
expect(isMachinerySubject("urn:ng-event:thing")).toBe(false);
expect(isMachinerySubject("x-urn:ng-eventually:shim:index")).toBe(false);
});
test("an absent subject is not machinery — read paths hand bindings straight in", () => {
expect(isMachinerySubject(undefined)).toBe(false);
expect(isMachinerySubject("")).toBe(false);
});
test("the namespace is the prefix both writers actually use", () => {
// Guards against the constant drifting away from store-registry/inbox.
expect("urn:ng-eventually:shim".startsWith(MACHINERY_NS)).toBe(true);
expect("urn:ng-eventually:inbox".startsWith(MACHINERY_NS)).toBe(true);
});