docs+test: le contrat avait dérivé — et le mécanisme ne voyait pas les règles

En vérifiant l'alignement de la surface, cinq sections du contrat s'étaient
désynchronisées du code sans que rien ne rougisse :

- § 1 montrait `getConfig`, `getStoreRegistryDeps`, `resetConfig` et
  `resetStoreRegistry` comme exportés — retirés à la fusion des portes ;
- § 11 documentait `escapeLiteral` / `escapeIri` / `assertNuri` comme publiés — ils ne
  le sont plus, et l'absence de garde de type est désormais expliquée par sa raison :
  les portes valident elles-mêmes (`NuriLike`), publier une garde inviterait le cast
  que les types servent à empêcher ;
- § 12 listait sept fonctions `storeRegistry` — il y en a cinq depuis que les deux
  fonctions d'ADRESSE d'inbox sont parties (une app nomme un document ou une personne,
  jamais une adresse) ;
- § 13 listait `IdentityStore`, `browserIdentityStore` et `getCurrentUser` comme
  publiés — retirés le 2026-08-05 ;
- `ensureIdentity` était publié **sans aucune règle**, et l'annexe renvoyait à un
  « § 2bis » qui n'existait pas.

**§ 2bis est écrit** : le portail d'accès n'a aucune contrepartie en substance — en
amont un utilisateur ouvre SON portefeuille et il n'y a rien à nommer — mais son SITE
D'APPEL survit, et c'est pourquoi sa signature ne prend pas d'identifiant : nommer son
identité est précisément la partie qui disparaît, donc elle ne doit pas figurer dans les
paramètres.

**Le mécanisme est étendu.** `test/vocabulary.test.ts` épinglait l'annexe — les NOMS —
et ne voyait pas les sections, là où vivent les règles. Une règle périmée est pire
qu'une règle absente : elle se lit comme vérifiée. Désormais tout `export` montré dans
un bloc « ### Today » doit être réellement exporté ; ce qu'on garde pour mémoire passe
en commentaire, que le contrôle ignore par construction. Les cinq dérives ci-dessus
auraient été rouges le jour même.

Nettoyé aussi : deux commentaires de doc orphelins dans `surface/placement.ts`,
restés au-dessus de l'accolade fermante après le retrait des fonctions qu'ils
décrivaient.

180 tests unitaires, typecheck bibliothèque / exemple / harnais.
This commit is contained in:
Sylvain Duchesne
2026-08-07 11:51:24 +02:00
parent aa6bbc436e
commit b98fcaa77d
3 changed files with 110 additions and 60 deletions
+67 -58
View File
@@ -22,35 +22,29 @@ Per the design principle (`README.md` § *Design principle*): an absent implemen
### Today — `@ng-eventually/sdk` (the POLYFILL-ERA block of `src/index.ts`; everything here is removed at migration) ### Today — `@ng-eventually/sdk` (the POLYFILL-ERA block of `src/index.ts`; everything here is removed at migration)
```ts ```ts
// shared-wallet/bootstrap.ts // all from shared-wallet/bootstrap.ts
export interface EventuallyConfig { export interface EventuallyConfig {
ng: NgLike; ng: NgLike;
useShape: UseShapeLike; useShape: UseShapeLike;
sharedWallet?: { name: string; secret: string }; sharedWallet?: SharedWalletConfig; // the gate's, § 2bis
currentUser?: PrincipalId; currentUser?: PrincipalId;
debugAccessLog?: boolean; debugAccessLog?: boolean;
init?: (...args: any[]) => any; init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any; initNg?: (...args: any[]) => any;
} }
// shared-wallet/bootstrap.ts
export function configure(c: EventuallyConfig): void; export function configure(c: EventuallyConfig): void;
// shared-wallet/bootstrap.ts — tests only
export function resetConfig(): void;
// shared-wallet/bootstrap.ts
export interface StoreRegistryDeps { export interface StoreRegistryDeps {
getSession: () => Promise<RegistrySession>; getSession: () => Promise<RegistrySession>;
normalizeId?: (id: string) => string; normalizeId?: (id: string) => string;
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number }; pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
} }
// shared-wallet/bootstrap.ts
export function configureStoreRegistry(deps: StoreRegistryDeps): void; export function configureStoreRegistry(deps: StoreRegistryDeps): void;
// shared-wallet/bootstrap.ts — tests only
export function resetStoreRegistry(): void;
// shared-wallet/bootstrap.ts — both tagged @internal, exported so the SDK-shaped wrappers can reach the injected SDK // NOT published (2026-08-07, with the entry merge) — merging the two doors made
export function getConfig(): EventuallyConfig; // publishing these a visible choice rather than an inherited one, and the choice is no:
export function getStoreRegistryDeps(): ResolvedRegistryDeps; // getConfig, getStoreRegistryDeps internal wiring; the surface reaches them by import
// resetConfig, resetStoreRegistry test resets; the suite reaches them the same way
``` ```
### Target ### Target
@@ -96,6 +90,35 @@ Divergence: none in behaviour (pure forwarding), but the wrapper erases the para
--- ---
## 2bis. The access gate — `ensureIdentity`
### Today — `@ng-eventually/sdk`
```ts
export async function ensureIdentity(): Promise<void>; // shared-wallet/access-gate.ts
export interface SharedWalletConfig { fileUrl: string; password: string; importUrl?: string }
```
One call, before the application renders. It resolves the identity from the URL (`?ng-id=`), failing that from browser storage, and only if neither answers does it show a barrier: download the shared wallet, here is its password, import it once, and name your space.
### Target
**NO COUNTERPART in substance, and a surviving CALL SITE — this section exists because those two are not the same thing.**
The substance is pure scaffolding. Every step it performs exists only because one wallet hosts several identities: upstream a user opens THEIR wallet, it contains THEIR site (`SensitiveWalletV0.personal_identity()`, `engine/wallet/src/types.rs:576-579`), and `session_start(wallet_name, user_id)` takes an id that came FROM the wallet. There is nothing to name and nothing to choose. The step that takes an identifier is the one that inverts the model, and it is the reason the whole gate is scaffolding.
The call site is a different matter. An application still has to wait for a session before it renders, and that will still be one awaited call at the same place. So the signature was designed to survive: **it takes no identifier**, deliberately — naming one is the part that disappears, so it must not appear in the parameters. The day the wallet supplies the identity, `ensureIdentity` resolves without showing anything and the caller's line is unchanged.
What a consumer must NOT conclude:
- that it may pass an identity in (it cannot — that is the point);
- that the barrier is a product screen. It is a technical gate, rendered in plain DOM inside a shadow root so no application stylesheet reshapes it and its own leaks nowhere. It is deliberately not bound to a UI framework: a screen that is going away must not make every consumer adopt one.
- that `SharedWalletConfig` describes a user setting. It describes what a DEPLOYMENT hands out, and it disappears with the gate. The library reads no environment variable, ever — the application resolves these values at its own build and passes them.
Why it lives here and not in the consumer application: the first consumer had ~300 lines of it — a gate component, a screen, a wallet module, an identity context, three BDD features. That is code an application would have to delete, and worse, code that teaches its authors a model NextGraph does not have (*"I name my identity"*).
---
## 3. The `ng` object ## 3. The `ng` object
### Today — `@ng-eventually/sdk` ### Today — `@ng-eventually/sdk`
@@ -446,20 +469,25 @@ The `CapRegistry` class itself is machinery (the in-memory record of what the co
## 11. NURI and SPARQL string utilities ## 11. NURI and SPARQL string utilities
### Today — `@ng-eventually/sdk` ### Today — nothing. The entry publishes **no** string utility and **no** type guard.
```ts ```ts
// sparql.ts:32,66,95 // NOT published — internal, and each for a stated reason:
export function escapeLiteral(value: string): string; // surface/sparql.ts escapeLiteral, escapeIri, assertNuri
export function escapeIri(value: string): string; // model/nuri.ts isNuri, hasReadCap, targetOf, parseNuri, toNuri, mintCap
export function assertNuri<T extends string>(nuri: T): T;
``` ```
(`isNuri`, `hasReadCap`, `targetOf`, `parseNuri`, `toNuri` and `mintCap` all exist in `model/nuri.ts` and **none** is exported — deliberately: nothing on the surface turns a bare reference into a cap, and validation happens at the door rather than in the caller's hands.) Two decisions meet here, and both point the same way.
**No guard, because the doors validate.** Every public entry takes `NuriLike` (`Nuri | string`) and runs `toNuri` itself — permissive in, precise out. A consumer holding a string from storage, a URL or a form passes it straight in; publishing a guard would invite the cast the types exist to prevent, and would put validation in the caller's hands where the door already does it.
**No `mintCap`, ever.** Nothing on the surface may turn a bare reference into a cap — that is the model's central invariant (§ 0 of `readcap-and-nuri-model.md`), so the function that could is unreachable from outside.
The escaping helpers were published until the surface was narrowed. Their removal costs a consumer nothing it will miss: they are generic injection-safety utilities, and neither `@ng-org/web` nor the ORM exposes an equivalent (re-verified against `index.d.ts` and `sdk/js/orm/src` — the engine escapes ad hoc where it builds SPARQL, e.g. `update_header`, `engine/verifier/src/request_processor.rs:196-208`). An application that interpolates SPARQL writes its own two-line escaper, against this lib or the real SDK alike.
### Target ### Target
**NO COUNTERPART at any level, and none expected.** Neither `@ng-org/web` nor the ORM exposes SPARQL escaping helpers (re-verified against `index.d.ts` and `sdk/js/orm/src`); the engine does its own ad-hoc literal escaping internally where it builds SPARQL (e.g. `update_header`, `engine/verifier/src/request_processor.rs:196-208`). These are generic injection-safety utilities, not SDK anticipation: they stay useful to any app that builds SPARQL by interpolation, against this lib or the real SDK. Nothing to unlearn; also nothing that migration replaces. **NO COUNTERPART at any level, and none expected** — which is precisely why none of it is published: a symbol with no successor, on a surface that promises one, is the thing this document exists to catch.
--- ---
@@ -467,14 +495,15 @@ export function assertNuri<T extends string>(nuri: T): T;
### Today — `@ng-eventually/sdk` (namespace `storeRegistry`) — plus `Scope` from `types.ts` ### Today — `@ng-eventually/sdk` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
> **Narrowed 2026-08-03.** The entry used to re-export the WHOLE `store-registry` module. It now re-exports an app-facing slice (`src/surface/placement.ts`): `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`. The rest — `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `resolveAccount`, `ensureAccount`, `reservedAccount`, `resetRegistryCache`, and the `VirtualUserRecord` / `RegistrySession` types — is **no longer importable from `@ng-eventually/sdk`** and is covered by `docs/internal-contract.md`. The signatures below are kept for the record, marked accordingly. > **Narrowed twice.** 2026-08-03 the entry stopped re-exporting the whole `store-registry` module and kept an app-facing slice (`src/surface/placement.ts`). 2026-08-05 that slice lost its two inbox-ADDRESS functions as well: an application deposits with `inbox.postToDocument(doc, …)` and shares with `inbox.share(doc, toUser)` — always naming a document or a person, never an address, because upstream an address is resolved from a profile and never handled by a caller. **Five functions remain published**, listed first below; everything after them is kept for the record and is covered by `docs/internal-contract.md`.
```ts ```ts
// types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope // types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope
export type Scope = "public" | "protected" | "private"; export type Scope = "public" | "protected" | "private";
// store-registry.ts:90,234 // store-registry.ts:90,234
export interface VirtualUserRecord { // VirtualUserRecord is INTERNAL (shape kept here for the ruling below).
interface VirtualUserRecord {
id: string; id: string;
docPublic: Nuri; docPublic: Nuri;
docProtected: Nuri; docProtected: Nuri;
@@ -487,29 +516,18 @@ export interface RegistrySession {
publicStoreId?: string; publicStoreId?: string;
} }
// consumer-facing, designed to survive migration (store-registry.ts:917, 1358, 1079, 735, 698) // PUBLISHED — the whole `storeRegistry` namespace, and nothing else.
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri>; export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri>;
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>; export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>;
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
export async function resolveScopeGraph(scope: Scope): Promise<Nuri>; export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>; export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>;
// inbox-side (store-registry.ts:772, 837, 1133, 1203, 1286)
export async function userInbox(id: string): Promise<Nuri>;
export async function isOwnInbox(nuri: Nuri): Promise<boolean>;
export async function openDocumentInbox(doc: Nuri): Promise<Nuri>; export async function openDocumentInbox(doc: Nuri): Promise<Nuri>;
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>;
export async function myInboxes(): Promise<Nuri[]>;
// User-branch registers (store-registry.ts:1307, 1331) // NOT published — internal, kept here because the target rulings below still cover them.
export async function addLink(cap: ReadCap): Promise<void>; // userStoreDoc, userInbox, documentInboxAddress, isOwnInbox, myInboxes,
export async function readLinks(): Promise<ReadCap[]>; // addLink, readLinks, resolveAccount, ensureAccount, reservedAccount,
// resetRegistryCache, and the VirtualUserRecord type.
// shim machinery (store-registry.ts:542, 631, 213, 278) // `RegistrySession` IS published: a consumer types its injected `getSession` with it.
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null>;
export async function ensureAccount(id: string): Promise<VirtualUserRecord>;
export function reservedAccount(name: string): string;
export function resetRegistryCache(): void;
``` ```
### Target — split by what each piece maps to ### Target — split by what each piece maps to
@@ -530,25 +548,16 @@ export function resetRegistryCache(): void;
### Today ### Today
```ts ```ts
// @ng-eventually/sdk — virtualUsers.ts (namespace accounts) // PUBLISHED — the POLYFILL-ERA block of `src/index.ts`, both with no counterpart.
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id"; // :18
export interface VirtualUserStorage { // :26
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export class IdentityStore { // :37
constructor(storage: VirtualUserStorage | null, key?: string);
get(): string | null;
set(id: string): string | null;
clear(): void;
}
export function browserIdentityStore(key?: string): IdentityStore; // :89
// @ng-eventually/sdk — the POLYFILL-ERA block
export function setCurrentUser(id: PrincipalId | null): void; // shared-wallet/bootstrap.ts export function setCurrentUser(id: PrincipalId | null): void; // shared-wallet/bootstrap.ts
export function getCurrentUser(): PrincipalId | null; // shared-wallet/bootstrap.ts export async function connectedUser(): Promise<void>; // emulated-verifier/connect.ts
export async function connectedUser(): Promise<void>; // connect.ts:52
// NOT published (removed 2026-08-05) — identity persistence is the application's job
// upstream too, and asking the library who you signed in is a shared-wallet convenience:
// shared-wallet/virtual-users.ts IdentityStore, browserIdentityStore,
// VirtualUserStorage, ACCOUNT_STORAGE_KEY
// shared-wallet/bootstrap.ts getCurrentUser
// The access gate persists what IT needs (§ 2bis); nothing else has to be exposed.
``` ```
### Target ### Target
@@ -563,8 +572,8 @@ declare function user_connect(client_info: any, user_id: string, location?: stri
declare function user_disconnect(user_id: string): Promise<void>; declare function user_disconnect(user_id: string): Promise<void>;
``` ```
- `virtualUsers.*` (the persisted identity id) — **NO COUNTERPART**; removed at migration (`docs/migration-guide.md` § 5). It exists only because every virtual user shares one wallet. It is exported from the SDK entry, which is a placement wart (§ 15). - `IdentityStore` / `browserIdentityStore` (the persisted identity id) — **NO COUNTERPART**; they exist only because every virtual user shares one wallet, and they are no longer published at all. Removed at migration (`docs/migration-guide.md` § 5).
- `setCurrentUser` / `getCurrentUser`**NO COUNTERPART**; the relay of an identity the broker cannot see. Disappears with the shared wallet. - `setCurrentUser`**NO COUNTERPART**; the relay of an identity the broker cannot see. Disappears with the shared wallet. `getCurrentUser` was its read side and is gone from the surface: an application knows who it signed in.
- `connectedUser()` — the awaitable form of what the target does **automatically**: the recipient's verifier processes its inbox as messages arrive/at connection (`Verifier::inbox`, `engine/verifier/src/verifier.rs:1674`). VERIFIED at level 1 that no consumer call is needed upstream; the polyfill fires it from `setCurrentUser` for the same reason. A consumer should treat it as "await a deterministic start" (tests), not as an operation the future SDK will name. - `connectedUser()` — the awaitable form of what the target does **automatically**: the recipient's verifier processes its inbox as messages arrive/at connection (`Verifier::inbox`, `engine/verifier/src/verifier.rs:1674`). VERIFIED at level 1 that no consumer call is needed upstream; the polyfill fires it from `setCurrentUser` for the same reason. A consumer should treat it as "await a deterministic start" (tests), not as an operation the future SDK will name.
--- ---
+1 -2
View File
@@ -36,10 +36,9 @@ export {
resolveScopeGraph, resolveScopeGraph,
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */ /** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
resolveWriteGraph, resolveWriteGraph,
/** Open an inbox on a document you OWN, so others can deposit into it. */
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
} from "../shared-wallet/account-registry"; } from "../shared-wallet/account-registry";
/** Open an inbox on a document you OWN, so others can deposit into it. */
export { openDocumentInbox } from "../emulated-verifier/branch-registers"; export { openDocumentInbox } from "../emulated-verifier/branch-registers";
// No `linkTo` here, and its absence is deliberate (it existed 2026-08-06, one day). // No `linkTo` here, and its absence is deliberate (it existed 2026-08-06, one day).
+42
View File
@@ -172,6 +172,48 @@ test("a reserved-namespace key cannot be produced by a consumer's normalizeId",
// --- the contract's inventory must match the code ------------------------ // --- the contract's inventory must match the code ------------------------
/**
* Every `export` the contract SHOWS in a "### Today" block must actually be exported.
*
* ── Why this exists beside the appendix check ─────────────────────────────
* The appendix check pins the NAMES. It cannot see the rulings — the per-subject
* sections where each symbol gets its epistemic label — and on 2026-08-07 three of them
* had drifted without anything going red: § 11 documented `escapeLiteral` / `assertNuri`
* as published (they are not), § 12 listed seven `storeRegistry` functions (there are
* five), § 13 listed `IdentityStore` / `getCurrentUser` (removed two days earlier). A
* reader trusting the sections was reading the surface of a fortnight ago.
*
* A stale ruling is worse than a missing one: it reads as verified. So the sections are
* held to the same standard as the appendix — if a block shows `export function X`, X is
* exported. Anything kept for the record goes in a comment, which this check ignores by
* construction (it only looks at lines beginning with `export`).
*/
test("every `export` shown in a contract '### Today' block is actually exported", () => {
const md = fs.readFileSync(
path.join(import.meta.dir, "..", "..", "..", "docs", "api-contract.md"),
"utf8",
);
const published = new Set(publishedNames());
const stale: string[] = [];
// Sections run from a "### Today" heading to the next heading of any level.
for (const m of md.matchAll(/### Today[^\n]*\n([\s\S]*?)(?=\n#{2,3} )/g)) {
for (const block of m[1]!.matchAll(/```ts\n([\s\S]*?)```/g)) {
for (const line of block[1]!.split("\n")) {
const decl = line.match(
/^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/,
);
// Namespace members (`inbox.post`, `storeRegistry.createEntityDoc`) are exported
// from their module, not from the entry — the same allowance the appendix makes.
const name = decl?.[1];
if (name && !published.has(name) && !isNamespaceMember(name)) stale.push(name);
}
}
}
// A failure is a question, not a rename: has the symbol been removed (then say so in a
// comment and rule on why), or has the entry lost something it should still publish?
expect([...new Set(stale)]).toEqual([]);
});
/** /**
* Read the appendix of `docs/api-contract.md` back into `{ group: names }`. * Read the appendix of `docs/api-contract.md` back into `{ group: names }`.
* The appendix is a generated block; this parses the same shape. * The appendix is a generated block; this parses the same shape.