docs(contract): le contrat décrit l'API, et rien d'autre
Récriture complète de `contract_sdk-surface.md` sur le cadrage du propriétaire du projet : **le contrat dit ce que le SDK met à disposition, point.** Ce qui en sort, et pourquoi ça n'y avait pas sa place : - **tout état d'implémentation** — chiffrement, confidentialité, valeur de remplacement, ce qui est émulé, ce qui n'est pas encore fait. Le lecteur est un agent qui développe une application appelante : s'il lit qu'une chose est provisoire, il conçoit des compensations — sa propre couche de chiffrement, un choix de ne pas stocker telle donnée, un avertissement d'interface — toutes fausses et toutes à retirer. Il doit pouvoir considérer que ce SDK **est** celui de NextGraph ; - **la fabrique** — « polyfill », « portefeuille partagé », « multi-utilisateurs », la migration, ce que l'application supprimera un jour, les écarts par rapport à la cible ; - **l'argumentaire** — ce que le modèle « permet », ce que telle règle « achète », la confidentialité composable. Un appelant a besoin de savoir qu'une référence rendue ne porte pas de clé, pas de savoir ce que ça lui apporte. Ce qui entre : les **trois obligations de déploiement**, vérifiées dans le code — servir un `.ngw` depuis son bundle et le passer à `configure`, être ouverte via la redirection du broker, appeler `ensureIdentity()` dans un contexte navigateur avant de rendre — et trois clauses contraignantes qui manquaient : l'identifiant rendu est **opaque**, le préfixe `urn:ng-eventually:` est **réservé sur les sujets**, et le placement recommandé est un document par entité métier, plusieurs objets dans un document restant permis. `## Guarantees` devient une suite d'énoncés plats. `## Non-guarantees` ne liste que des **absences de capacité** — pas de nom d'affichage, pas de révocation, rien par lecteur sur un document en store public, pas d'écriture déléguée — jamais un manque par rapport à autre chose. L'application d'exemple n'affiche plus l'identifiant comme un nom : elle le montre pour ce qu'il est, un identifiant technique. C'était exactement ce que la clause « opaque » interdit, dans le fichier censé montrer le bon geste. 202 tests, typechecks propres, `lint` sans erreur. 148 → 135 lignes.
This commit is contained in:
@@ -1,29 +1,33 @@
|
|||||||
---
|
---
|
||||||
type: contract
|
type: contract
|
||||||
summary: What @ng-eventually/sdk offers an application, what it guarantees, and what it refuses to promise
|
summary: The API @ng-eventually/sdk exposes to an application — signatures, guaranteed behaviour, and what it does not offer
|
||||||
---
|
---
|
||||||
|
|
||||||
# contract_sdk-surface — `@ng-eventually/sdk`
|
# contract_sdk-surface — `@ng-eventually/sdk`
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
`@ng-eventually/sdk` is the surface an application codes against **instead of** `@ng-org/web` / `@ng-org/orm`, during the period where NextGraph's multi-user model is not yet shipped. It emulates, over a single shared wallet and a single broker, the parts of that model an application needs: distinct users, per-document keys, inboxes, and a public store that serves what it holds.
|
This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
|
||||||
|
|
||||||
The engagement is not "these functions work". It is: **an application written against this surface keeps its code when the real SDK arrives.** Most of what is published has a target counterpart and is replaced in place; one call does not, and is listed as such below.
|
### Deployment requirements
|
||||||
|
|
||||||
Out of scope: confidentiality of any kind (see Non-guarantees), anything about a NextGraph deployment's operation, and the shape of the future SDK's own names — where this document says a call is polyfill-era, it means it disappears, not that a differently-named successor is promised.
|
An application using this package must:
|
||||||
|
|
||||||
|
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
|
||||||
|
- be opened through the broker redirect, `https://nextgraph.net/redir/#/?o=<the app's url>` — outside it there is no session;
|
||||||
|
- call `ensureIdentity()` in a browser context before rendering its interface; it mounts a barrier in the document.
|
||||||
|
|
||||||
## Surface
|
## Surface
|
||||||
|
|
||||||
Full typed shape: the package's `types` entry, `@ng-eventually/sdk`. The load-bearing signatures:
|
Full typed shape: the package's `types` entry, `@ng-eventually/sdk`. A type is published only when a published signature uses it. The load-bearing signatures:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// ── bootstrap — the ONE polyfill-era call ────────────────────────────────
|
// ── bootstrap ────────────────────────────────────────────────────────────
|
||||||
export function configure(c: EventuallyConfig): void;
|
export function configure(c: EventuallyConfig): void;
|
||||||
export interface EventuallyConfig {
|
export interface EventuallyConfig {
|
||||||
ng: NgLike; // the real @ng-org/web `ng`
|
ng: NgLike; // the `ng` object from @ng-org/web
|
||||||
useShape: UseShapeLike; // the real @ng-org/orm `useShape`
|
useShape: UseShapeLike; // `useShape` from @ng-org/orm
|
||||||
getSession?: () => Promise<RegistrySession>; // resolve the wallet session (a thunk)
|
getSession?: () => Promise<RegistrySession>; // resolve the session (a thunk)
|
||||||
normalizeId?: (id: string) => string;
|
normalizeId?: (id: string) => string;
|
||||||
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
|
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
|
||||||
currentUser?: PrincipalId;
|
currentUser?: PrincipalId;
|
||||||
@@ -36,15 +40,12 @@ export interface EventuallyConfig {
|
|||||||
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
|
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
|
||||||
|
|
||||||
// ── addressing ───────────────────────────────────────────────────────────
|
// ── addressing ───────────────────────────────────────────────────────────
|
||||||
// A type is published only when a published signature uses it. `ReadCap` and
|
|
||||||
// `InboxScope` were withdrawn on 2026-08-10: no published call takes or returns
|
|
||||||
// either. They still exist inside the library — they are simply not yours to hold.
|
|
||||||
export type Nuri = `did:ng:${string}`;
|
export type Nuri = `did:ng:${string}`;
|
||||||
export type NuriLike = Nuri | string;
|
export type NuriLike = Nuri | string;
|
||||||
export type Scope = "public" | "protected" | "private";
|
export type Scope = "public" | "protected" | "private";
|
||||||
|
|
||||||
// ── placement: where an application's documents live ─────────────────────
|
// ── placement: where an application's documents live ─────────────────────
|
||||||
export const storeRegistry: { // no identity parameter — the session is one user's
|
export const storeRegistry: { // no identity parameter — a session is one user's
|
||||||
createEntityDoc(scope: Scope): Promise<Nuri>;
|
createEntityDoc(scope: Scope): Promise<Nuri>;
|
||||||
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
|
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
|
||||||
resolveScopeGraph(scope: Scope): Promise<Nuri>;
|
resolveScopeGraph(scope: Scope): Promise<Nuri>;
|
||||||
@@ -60,7 +61,7 @@ export function watchShape(query: ShapeQuery): ShapeObservable;
|
|||||||
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||||
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||||
|
|
||||||
// ── raw document / SPARQL primitives ─────────────────────────────────────
|
// ── low-level document / SPARQL primitives ───────────────────────────────
|
||||||
export const docs: {
|
export const docs: {
|
||||||
docCreate(sessionId: string, crdt?: string, cls?: string, dest?: string, store?: unknown): Promise<Nuri>;
|
docCreate(sessionId: string, crdt?: string, cls?: string, dest?: string, store?: unknown): Promise<Nuri>;
|
||||||
sparqlQuery(sessionId: string, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
sparqlQuery(sessionId: string, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
||||||
@@ -82,67 +83,53 @@ export const inbox: {
|
|||||||
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
|
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
|
||||||
|
|
||||||
// ── the wrapped SDK objects ──────────────────────────────────────────────
|
// ── the wrapped SDK objects ──────────────────────────────────────────────
|
||||||
export const ng: Record<string, any>; // drop-in for @ng-org/web's `ng`
|
export const ng: Record<string, any>; // call this instead of the `ng` passed to `configure`
|
||||||
export function init(...args: any[]): any;
|
export function init(...args: any[]): any;
|
||||||
export function initNg(...args: any[]): any;
|
export function initNg(...args: any[]): any;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Guarantees
|
## Guarantees
|
||||||
|
|
||||||
**Permissive in, precise out.** Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. A value read from storage, a URL, a form or JSON goes straight in — no guard to call, no cast to write. No type guard is published, deliberately: publishing one would invite the cast the types exist to prevent.
|
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
|
||||||
|
|
||||||
**Every reference this surface returns is BARE.** It names a document and grants nothing. No call returns a key, ever — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject`/`.graph`. What an application circulates (a message, a QR code, another document) is that bare reference.
|
A returned reference carries no key — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
|
||||||
|
|
||||||
**Reading is possession of the key, and nothing else is consulted.** You read a document whose key you hold — because you created it, because someone gave it to you, or because the document sits in a **public store**, which serves its read key to whoever asks. There is no authorization list anywhere, and no call answers "may I read this?": you read, and you get what you get.
|
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
|
||||||
|
|
||||||
**A reference is not recursive.** A widely circulated document may point at a restricted one; following the reference yields a name, not a key. This is what lets confidentiality be composed inside a document you share, and it holds through every read path here.
|
What was shared with you becomes readable after `ensureIdentity()`.
|
||||||
|
|
||||||
**A document may hold several objects, and reading returns them separately.** `readUnion` yields one entry per distinct subject present in a document: `subject` is that subject's IRI exactly as it was written, `graph` is the document reference you passed in. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Only `graph` is a `Nuri`, and it is the field to hand back to this surface; `subject` is a `string`, because a subject may be any IRI. **One document per business entity stays the recommended placement** — a key is per document, so isolating an entity requires a document of its own — but it is a recommendation, and reading reports the objects a document actually holds.
|
`readUnion` returns one entry per distinct subject present in a document. `subject` is that subject's IRI exactly as written, and is a `string`, because a subject may be any IRI; `graph` is the document reference you passed in, and is the `Nuri` to hand back to this surface. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Several objects in one document are allowed. Recommended placement is one document per business entity: access is granted per document.
|
||||||
|
|
||||||
**`urn:ng-eventually:` is a reserved name space.** Subjects under that prefix belong to the library and are not returned by `readUnion`. An application that writes its own data under it will not read it back; every other IRI is yours.
|
`urn:ng-eventually:` is reserved. Triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned.
|
||||||
|
|
||||||
**Writing is ownership.** Only a document's owner writes to it. Holding its read key — however it arrived — never grants a write.
|
Only a document's owner writes to it. Holding its read key never grants a write.
|
||||||
|
|
||||||
**Giving to read is ONE act, and the recipient calls nothing.** `inbox.share(doc, toUser)` names the document and the person; the key is looked up and sealed into a deposit, and the recipient applies it by connecting. There is no "receive" operation, and an application never handles a key or an inbox address.
|
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
|
||||||
|
|
||||||
**A deposit is addressed to an inbox, never to a document.** `inbox.post` refuses a target that is not an inbox. To reach a document's owner, name the document: `inbox.postToDocument(doc, …)`.
|
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
|
||||||
|
|
||||||
**An inbox is read only by its owner.** Anyone may deposit; only the owner reads.
|
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
|
||||||
|
|
||||||
**`ensureIdentity()` is the whole of signing in, and it tells you who you are.** It settles the identity, waits for the connection work (restoring what others shared with you, draining your inboxes), and **returns the identity**. It takes no identifier — naming your own identity is the part that disappears — but it hands one back, because knowing which user you are is something an application legitimately has upstream too. Keep it for display; **no call takes it**: a session belongs to one user, so placement is named by scope alone.
|
`createEntityDoc` throws if the document cannot be recorded in its store.
|
||||||
|
|
||||||
**Sharing names a person who exists.** `inbox.share(doc, toUser)` refuses a recipient nobody has signed in as, rather than creating them — you cannot address a name you invented.
|
|
||||||
|
|
||||||
**A failed creation fails.** `createEntityDoc` throws if the document cannot be recorded in its store, instead of returning a reference that would read empty forever.
|
|
||||||
|
|
||||||
**One polyfill-era call.** `configure` is the only published symbol with no counterpart in the target, and therefore the whole of what an application deletes at migration. Everything else is replaced in place by the real SDK.
|
|
||||||
|
|
||||||
## Non-guarantees
|
## Non-guarantees
|
||||||
|
|
||||||
**No confidentiality. None.** The stand-in key is a constant, nothing is encrypted, and several read paths still bypass the boundary — notably a `docs.sparqlQuery` issued **without an anchor**, which spans every document in the shared wallet. Nothing built on this may be described as private, anonymous or secure. The shape of the access model is real; the protection is not.
|
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
|
||||||
|
|
||||||
**Nothing per-reader on a document in a public store.** No grant, no revocation, no audience list — upstream has none, so a UI that enumerates or revokes "the readers of my public document" is built on nothing.
|
**No revocation.** `inbox.share` cannot be undone.
|
||||||
|
|
||||||
**No revocation of a shared key.** `inbox.share` is irreversible: nothing is checked later, and there is no taking back a key already handed out.
|
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
|
||||||
|
|
||||||
**No delegated writing.** Adding members or permissions is not emulated, so only the owner writes. An application must not build shared editing on a received key: it works here and cannot work upstream.
|
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
|
||||||
|
|
||||||
**References travel within one deployment only.** Nothing produced here carries a locator, so a reference is resolvable by users of the same broker and not beyond.
|
**No mailbox model.** Do not build on the raw deposit list.
|
||||||
|
|
||||||
**Enumerating inbox deposits is emulation detail.** Rely on *"my inbox is processed when I connect, and what was shared with me becomes readable"*. A mailbox UI built on the raw deposit list should expect that surface to change shape entirely.
|
**No cross-broker reference.** A returned reference resolves for users of the same broker.
|
||||||
|
|
||||||
**Two shapes are known departures from the target**, small and deliberate: `subscribeDoc` returns its unsubscribe **synchronously** where the real call is async (an adapter is one line at migration), and `watchShape`'s load-state shape is this library's invention, not an announced API.
|
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
|
||||||
|
|
||||||
**The read-filtered view of `useShape` refuses what it cannot filter.** Members that yield items are filtered; mutations pass through; anything else throws rather than returning unfiltered items. A document in a public store reached through that view alone — read nowhere else first — does not appear, because the view is synchronous and cannot ask the network.
|
|
||||||
|
|
||||||
**`docs.*` are raw primitives, not an application's normal path.** They exist because the emulation needs them; prefer `storeRegistry`, `readUnion`, `inbox`.
|
|
||||||
|
|
||||||
## Change policy
|
## Change policy
|
||||||
|
|
||||||
**This surface changes when it gets CLOSER to the target — that is the point, not a cost.** The package is pre-1.0 and does not offer semver stability; a consuming application should expect the surface to shrink over time, and should re-pull this contract at every upgrade.
|
This surface changes, and shrinks. The package does not offer semantic-version stability.
|
||||||
|
|
||||||
**A symbol is removed when an application coding against it would learn something to unlearn.** That test outranks convenience, and it has already removed: a call returning a document's key alongside its reference (it converted "reference AND key" into "reference alone" and collapsed composable confidentiality); a "do I hold this key?" predicate (it read like "may I read this?", and a readable public document answered `false`); a "set my identity" call (naming one's own identity is the gesture that inverts the model); an "await the connection" call (folded into `ensureIdentity`); and a second bootstrap call (folded into `configure`).
|
Re-pull this contract at every upgrade.
|
||||||
|
|
||||||
**A removal is a shrinking of what you delete later**, so it is never a regression of the engagement — but it is a breaking change to your code, and it is announced by this document changing.
|
|
||||||
|
|
||||||
**At migration**, the build alias resolving `@ng-eventually/sdk` is removed, `configure` and its config type are deleted with their call site, and every other import resolves to the real SDK unchanged. `ensureIdentity` keeps its call site — an application still awaits a session before it renders — while the barrier it shows today stops appearing.
|
|
||||||
|
|||||||
@@ -216,7 +216,12 @@ async function refresh(): Promise<void> {
|
|||||||
</li>`,
|
</li>`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
el("who").textContent = identity;
|
// What `ensureIdentity()` returns is an OPAQUE identifier, not a display name: the SDK
|
||||||
|
// publishes none. So it is shown verbatim and marked as an identifier — never parsed,
|
||||||
|
// never split, never dressed up as a person's name.
|
||||||
|
const idTag = document.createElement("code");
|
||||||
|
idTag.textContent = identity;
|
||||||
|
el("who").replaceChildren("id ", idTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wire(): void {
|
function wire(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user