La regle qui avait deja retire ReadCap et InboxScope (un type n'est publie que si une signature publiee l'utilise) rattrape une seconde recurrence : les reexports `Schema` (@ng-org/shex-orm) et `DeepSignalSet` (@ng-org/alien-deepsignals) n'etaient utilises par aucune signature publiee. DeepSignalSet n'apparaissait que dans la prose decrivant l'enveloppe de useShape, jamais comme un type reel dans le paquet ; Schema n'apparaissait nulle part ailleurs que dans ce reexport. Contrairement a ReadCap/InboxScope, aucun des deux n'a d'usage interne ou ils resteraient DEFINIS : ils disparaissent, sans etre deplaces. Bump majeur vers 1.0.0-dev.12 (symbole publie retire), entree de version dans le contrat concept et mise a jour de docs/api-contract.md (bloc de code, section 14, annexe d'inventaire).
91 KiB
API contract — what @ng-eventually/polyfill exposes today, and what the future SDK should expose per subject
Updated 2026-08-03, after the source layout was reorganised by migration fate (
docs/source-layout-by-fate.md). Paths, and three names, changed under this document:readModelbecame the directly-exportedreadUnion(itself removed in1.0.0-dev.8— see § 6);accounts/AccountRecord/AccountStoragebecamevirtualUsers/VirtualUserRecord/VirtualUserStorage(moduleshared-wallet/virtual-users.ts);store-registry-api.tsbecamesurface/placement.ts. Two modules were created and are covered here:emulated-verifier/branch-registers.ts(the four durable registers, split out of the shim) andshared-wallet/bootstrap.ts(the injection store, split out of the/polyfillentry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises.
Scope: the APP-FACING contract only. Everything reachable from the published entry point, and nothing else. The library's internal modules — the shim machinery, the read paths, the boundary guards — are held to the same standard (as close as possible to what NextGraph does or plans) but have their own document, docs/internal-contract.md: a consumer never reads that one, a maintainer does. This split was made on 2026-08-03, together with the export change described in § 15.
Scope. The real exported surface of @ng-eventually/polyfill (verified against the export statements in packages/polyfill/src/index.ts — package.json maps exactly one entry point, .), and, for each subject, the target signature the future NextGraph JS SDK is expected to expose. Written 2026-08-03, verified against the nextgraph-rs clone (HEAD 213338f6, 2026-05-16) and the installed @ng-org/web@0.1.2-alpha.13 type declarations (node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts, hereafter index.d.ts).
How to read the epistemic labels. Every target-side claim carries one of:
- PASSTHROUGH (level 3 / level 2, VERIFIED) — the target function exists today; the lib forwards to it. Citation into
nextgraph-rsor the installed.d.ts. Level numbers perREADME.md§ The three references: 3 = JS ORM (sdk/js/orm), 2 = wasm binding /@ng-org/web(sdk/js/lib-wasm,sdk/js/web), 1 = Rust engine (engine/). - LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED) — the engine's model constrains the shape and is cited, but no JS surface exists at any level, so the signature offered here is this library's invention. The future SDK's name and parameter order for it are unknown.
- ASSUMPTION — nothing at any layer constrains this; the bet and what bounds it are stated.
- NO COUNTERPART — the subject has no image in the target at any layer, usually because it is shared-wallet machinery that disappears at migration. That is a finding about the emulation, not a gap in the target.
Per the design principle (README.md § Design principle): an absent implementation is never treated as evidence about the future — "the engine does not do X" and "the SDK will not offer X" are kept apart throughout.
The per-symbol annotations are the authority; this document is the reasoning
Since 2026-08-16 every published symbol — and every internal one that claims to mirror the target — carries its provenance in the source, beside the declaration, pinned by packages/polyfill/test/provenance.test.ts:
// @provenance <name> kind=<kind> level=<1|2|3|none> ref=<path:symbol|none> — <note>
It exists because this document could not stop drifting from the code: it claimed a "1:1 passthrough" for docs.* that was false in two ways, and emulated-verifier/branch-registers.readLinks was classified as this library's own invention when it implements AddLinkV0 — a type the engine defines and constructs nowhere. That second mistake is the one the annotations are designed against: nothing constructs this upstream is an absence, and an absence is not evidence that a thing is ours.
The ref cites a symbol, never a line number — three citations in this document had already rotted (§ 2, § 4, § 12 below).
The prose labels above map onto the annotation's closed kind set, and where they disagree the annotation wins:
| label here | annotation kind |
|
|---|---|---|
| PASSTHROUGH | passthrough |
only when the name and shape are upstream's OWN, so the call site survives migration untouched |
| PASSTHROUGH | aligned |
when the semantics are upstream's but the name or the ergonomics are ours (docCreate for doc_create, a label parameter that is not forwarded) — § 7, § 8 |
| LEVEL-1 SHAPE | aligned / declared-not-wired |
the two are NOT the same claim: aligned means the engine does this and we follow it; declared-not-wired means the engine defines the type and constructs it nowhere |
| ASSUMPTION / NO COUNTERPART | invention |
nothing at any level answers; ref is none, deliberately |
| (no prose label) | divergent |
upstream does this and we do it differently — the inbox-address publication, the re-readable deposit list |
1. Bootstrap and configuration
Today — @ng-eventually/polyfill: one call
// shared-wallet/bootstrap.ts
export interface EventuallyConfig {
ng: NgLike; // the REAL @ng-org/web ng
useShape: UseShapeLike; // the REAL React hook: `@ng-org/orm/react`'s
// default export, or `reactUseShape` from
// `@ng-org/orm/all`. NOT on the main entry:
// `.` resolves to `core`, which has no `useShape`.
ormSubscription?: OrmSubscriptionLike; // the REAL @ng-org/orm RdfOrmSubscription.getOrCreate
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
sharedWallet?: SharedWalletConfig; // the gate's, § 2bis
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
export function configure(c: EventuallyConfig): void;
The count is the contract here. The agreed target was two polyfill-era calls, or one; it had drifted to four, and each extra one was a reason the LIBRARY has rather than a need an application has. Four became one on 2026-08-07:
| Was published | Where it went |
|---|---|
configureStoreRegistry + StoreRegistryDeps |
folded into configure — two bootstrap calls existed because the library has two internals, which is not a reason a caller should pay |
setCurrentUser |
the access gate sets the identity (§ 2bis). An application naming its own identity is the gesture that INVERTS the model; it must not have a published call to reach for |
connectedUser |
ensureIdentity awaits it. Upstream, opening the session IS the connection — no application awaits a second call |
getConfig, getStoreRegistryDeps, resetConfig, resetStoreRegistry |
internal wiring and test resets, reached by their internal path (2026-08-07, with the entry merge) |
And two FIELDS of that one call, on 2026-08-12. The count was already one; what was left inside it still made an application build things the target never asks anyone to build:
| Was published | Where it went |
|---|---|
getSession (and the RegistrySession type with it, § 12) |
the package's. Upstream a session is returned — init()'s callback delivers { status: "loggedin", session } (@ng-org/web dist/ngweb.js:124, VERIFIED) and session_start hands one back; nowhere does an application ASSEMBLE one out of session_id / private_store_id / …. Every consumer wrapped init() in a promise and wrote the same unwrapping thunk, with nothing to migrate it to. The lib's init wrapper captures the event on its way through (§ 2) and holds the session (shared-wallet/session.ts) |
normalizeId |
the package's, as normalizeIdentityId — trim, strip a leading @, lowercase. The identities it keys are the shared wallet's own virtual users, so there was never a decision here for a consumer to make; and one rule in one place is what stops the barrier, the URL and storage keying onto three different spaces |
Both remain substitutable through configureStoreRegistry (shared-wallet/bootstrap.ts), which the published entry does not re-export: the unit suites have no browser and the e2e harness holds a session the broker handed it directly, and neither is an application.
One field ARRIVED in 1.0.0-dev.6: ormSubscription. useShapeQuery reads through the SDK's own shape query now (§ 5), and the SDK is injected here rather than imported — so the query has to arrive the same way the ng and the useShape beside it do. Pass RdfOrmSubscription.getOrCreate as it stands; it is assignable to OrmSubscriptionLike with no wrapper. It is OPTIONAL because an application that never calls useShapeQuery has nothing to give, and useShapeQuery without it publishes isError naming the field rather than an empty set.
So an application's whole bootstrap is configure({ ng, useShape, ormSubscription, init, sharedWallet }) plus await ensureIdentity() — and the second of those keeps its call site after migration.
Target
NO COUNTERPART, by design. The whole subject is the polyfill bootstrap: it exists to inject the real SDK without a hard import (build-alias safety). At migration the consumer initializes the real SDK directly, with the two calls in § 2, and configure / configureStoreRegistry are deleted (docs/migration-guide.md § 7). Nothing in the target takes an "injected ng".
2. Lifecycle
Today — @ng-eventually/polyfill
// lifecycle.ts:11 — settles the identity, wraps the callback, then forwards to the real
// @ng-org/web init injected at configure()
export function init(...args: any[]): any;
// lifecycle.ts:18 - forwards to the real @ng-org/orm initNg injected at configure(),
// with the `ng` in position 0 replaced by this package's wrapped one
export function initNg(...args: any[]): any;
Target
PASSTHROUGH, VERIFIED at both levels - with one argument touched in each, deliberately. init's callback in position 0 is wrapped since 2026-08-12: the wrapper reads the event, keeps the session it carries (§ 1), and calls the caller's callback with that same event, unchanged. Everything else - the remaining arguments, the return value, what the callback observes - passes straight through, so an application's call site is what it would write against the real SDK. This is the only place the capture can sit: it is the only one that knows both what the caller asked and what the SDK will answer, and the alternative was every consumer re-implementing it (which is what it replaces). The real signatures forwarded to are:
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
export declare const init: (callback: Function | null, singleton: boolean, access_requests: any) => Promise<void>;
// level 3 — @ng-org/orm: sdk/js/orm/src/connector/initNg.ts:51 (exported as initNg from core.ts)
export function initNgSignals(ngImpl: NG, session: Session): void;
// level 2 — the Session initNg consumes: index.d.ts:265-273 (source sdk/js/web/src/index.ts:15-22)
export declare type Session = {
session_id: string | number;
protected_store_id: string;
private_store_id: string;
public_store_id: string;
ng: typeof NGModule;
[key: string]: unknown;
};
initNg's argument in position 0 is substituted since 1.0.0-dev.7, and it is the same kind of move for the same reason. That argument is what the ORM reaches the engine through afterwards - every subscription, every insertObject, every batched patch awaits the promise this call resolves - and an ORM write leaves by ng.graph_orm_update, which is where the emulated write guard now sits (§ 3). Handing the caller's raw SDK object over leaves that guard on a door nothing knocks on, and "pass the package's ng here" would be an ordering rule in prose: unenforceable, silent when missed, and in fact missed by both consumers in this repository. What is substituted is the package's own ng - the same object the entry publishes, forwarding every member to the very object configure() received - so the ORM now reaches the engine through exactly what the application does.
Divergence: none in behaviour (pure forwarding), but the wrappers erase the parameter types. A consumer typing calls against them learns nothing it must unlearn - it just gets no compile-time help the real SDK would give.
2bis. The access gate — ensureIdentity
Today — @ng-eventually/polyfill
export async function ensureIdentity(): Promise<PrincipalId>; // 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 top-level it shows the barrier anyway, with whatever it found already in the field: download the shared wallet, here is its password, import it once, and name your space. Knowing who someone is says nothing about whether their browser still holds the wallet, and the barrier is the only place it is handed out. Past the broker round-trip, inside the iframe, a known identifier stands it down.
It takes no timeout and needs none: the session it waits for arrives through this package's init and nowhere else, so awaited before that call it throws, naming the call to make first (shared-wallet/access-gate.ts, refuseAWaitNothingCanEnd).
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 and RETURNS one, deliberately. Naming an identity is the part that disappears, so it must not be a parameter; but knowing which identity you are is something an application legitimately has upstream — it passes user_id to session_start(wallet_name, user_id) (index.d.ts:276), having got it from the wallet it opened. Here the gate chooses it, so the gate hands it back. Without that, the example application had to read the gate's own private storage key.
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
SharedWalletConfigdescribes 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
Today — @ng-eventually/polyfill
// index.ts:152
export const ng: NG;
// type re-export, index.ts:86
export type { NG } from "@ng-org/web";
ng is a Proxy (ng-proxy.ts) forwarding every property to the injected real ng, overriding two things: sparql_update (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap) and, since 1.0.0-dev.7, graph_orm_update (the ORM's own write door, guarded by OWNERSHIP). session_start is intercepted but is a pure passthrough today, with a TODO for shared-wallet credentials. (login was named here until 2026-08-14 but has not existed since 2026-08-03: @ng-org/web exposes no such method, and the proxy was FABRICATING it.)
There is exactly one instance of that proxy for the whole package (wrappedNg()), because two things now hold it: the entry publishes it, and initNg hands it to the ORM (§ 2).
The passthrough itself was broken until 1.0.0-dev.7, for every one of its members, and nothing noticed. It read Reflect.get(ng, prop).bind(ng). @ng-org/web's ng is ITSELF a Proxy - an "async proxy" whose get trap answers EVERY key with a fresh path-carrying callable and whose reflection traps throw (dist/ngweb.js, the l(e = []) factory) - so .bind never reached Function.prototype.bind: it was just another key, calling the result posted a remote request named after the MEMBER carrying the bind arguments, and what came back was a Promise. A caller therefore got the SDK's own <member> is not a function. Two things hid it: this library's internals never go through this proxy (they call the injected ng directly), and every fake in the suite is a plain object, on which bind behaves. It surfaced the moment initNg handed the ORM this object - orm_start_graph was not a function, the ORM catches that and only console.errors it, so a shape read stayed isPending for ever, which is the failure mode § 5 describes. MEASURED against @ng-org/web@0.1.2-alpha.14. The member is now FORWARDED (Reflect.apply(real, ng, args)), which gives a plain object the receiver it needs and lets the async proxy use the path it already carries; test/ng-proxy.test.ts pins it against a stand-in shaped like the real one.
Target
PASSTHROUGH (level 2, VERIFIED). export declare const ng: NG with NG = typeof NGModule, 90 exported members in @ng-org/web@0.1.2-alpha.14 - 84 functions and 6 RDF classes, counted in the NGModule namespace of the installed index.d.ts. The count is named with its release because it MOVES with one: this document recorded 88, two short, and two of the ninety - graph_orm_next_page and graph_orm_previous_page - appear nowhere in the cited checkout's sdk/js/lib-wasm/src/lib.rs, the tree declaring the previous release @ng-org/web@0.1.2-alpha.13. The figure was never re-measured after the build moved. The surface is identical by construction — the proxy adds no member and removes none — and identical at the type level too since 2026-08-14, when the published ng stopped being declared Record<string, any> and took NG, upstream's own type. That escape hatch silently dropped every signature: an application got no completion, and a misspelt member typechecked.
The overrides:
session_start(wallet_name: string, user_id: any): Promise<any>(index.d.ts:276) — target signature unchanged; only the emulated credential injection disappears.graph_orm_update(subscription_id: any, diff: any, session_id: any): Promise<void>(index.d.ts:104) - target signature unchanged. This is the door an ORM write actually uses, and until1.0.0-dev.7nothing here guarded it: a mutation of a live signal object (set.add(...),set.delete(...), assigning a property) never passes throughsparql_updateat all, it is batched into patches and sent through this member. So thesparql_updateguard saw no ORM add and no ORM delete - reachable from the published surface, sinceuseShapeforwardsadd/delete/clearto the underlying set anduseShapeQuery'sdataitems ARE that set's objects. The guard reads the document out of each patch'spath, whose first segment is the synthetic id<graph>|<subject>the ORM itself minted, and asksassertMayWrite- OWNERSHIP, the same predicatedocs.sparqlUpdateasks, not the decorative write-cap list thesparql_updatebranch consults. A patch naming no NextGraph reference passes: the emulation cannot say whose document that is, and upstream does not have to guess becauseverify_permissionfires on the commit itself.sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>(index.d.ts:297) - target signature unchanged. It is deliberately left on the decorative write-cap list rather than moved toassertMayWrite: this library's own REGISTERS are written through this member, and a store document is not owned in the authorship sense, so arming it here would refuse every register write (emulated-verifier/register-write.ts). The native enforcement the guard stands in for is the engine's permission model (verify_perm,engine/repo/src/commit.rs:897), which today is called only from tests (its enclosingCommit::verifyhas no runtime caller - seedocs/nextgraph-current-state.md§ Author-signature verification). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's behaviour (a refused write) is target-shaped even though its mechanism (a JS-side check) is emulation. Known limit, documented inREADME.md: the guard fires only on this proxy, and the lib's own writers call the injectedngdirectly, so it is best-effort until cap-enforcement.
4. Reactive typed reads — useShape
Today — @ng-eventually/polyfill
// use-shape.ts
export function useShape<T extends BaseType>(
shapeType: ShapeType<T>,
conf: RdfOrmConfig<T> | string | undefined,
): ShapeResult; // { data, promise, subscription, isLoading, nextPage?, previousPage? }
// type re-exports, index.ts:48-49
export type { ShapeType, BaseType } from "@ng-org/shex-orm";
Behaviour: forwards to the injected real hook; once any emulated cap exists (caps.isEnforcing()), the RESULT OBJECT is wrapped in a read-filtered view. data and promise yield only items whose document cap the current holder has, subscription is refused (it is the ORM's own handle, which this surface does not publish), isLoading and the two page commands pass through, and any other member is refused unless what is behind it is a plain scalar - the rule is the value's type, so a member a later release adds is decided before anyone has seen it.
Target
PASSTHROUGH (level 3, VERIFIED). The real hook, as the CITED revision writes it:
// level 3 — @ng-org/orm/react: sdk/js/orm/src/frontendAdapters/react/useShape.ts:86-124
const useShape = <T extends BaseType>(
shape: ShapeType<T>,
scope: Scope | string | undefined
) => DeepSignalSet<T>;
// and as the BUILD ships it (`dist/frontendAdapters/react/useShape.d.ts`), which is the
// shape the wrapper mirrors: the collection moved inside a result object, and the second
// parameter widened from a bare `Scope` to the config. The gap between the two columns is
// the one the provenance anchor records.
const useShape = <ST extends ShapeType<T>, const CONF extends RdfOrmConfig<T>, T extends BaseType>(
shape: ST,
conf: CONF | string | undefined
) => UseShapeResult<ST, CONF, T>; // { data, promise, subscription, isLoading, nextPage, previousPage }
// its Scope — sdk/js/orm/src/types.ts:25-38 (NOT this lib's Scope, see § 12)
// *(Corrected 2026-08-16: this said `sdk/js/orm/src/model/types.ts`, a path that does not
// exist in the clone. The type is at `sdk/js/orm/src/types.ts:25`. A citation nobody can
// follow reads as verified and is not — the reason the per-symbol annotations below cite a
// SYMBOL rather than a line.)*
export type Scope = {
graphs?: string[] | string;
subjects?: string[];
};
The read filter disappears at migration: in the target, isolation is cryptographic — a repo whose cap the wallet does not hold is never decrypted, a union read over it yields nothing, and a targeted read errors RepoNotFound (engine/verifier/src/request_processor.rs:155,163 via resolve_target). VERIFIED at level 1; the consumer-visible result (you only see what you hold) is the same, which is the point of the emulation.
Divergence to note: the wrapper types everything unknown, losing the generic T. A consumer wanting typed sets today must cast; at migration the real generic signature gives it back. Nothing to unlearn, only ergonomics deferred.
5. Reactive typed reads with load state — useShapeQuery
Today — @ng-eventually/polyfill
// use-shape-query.ts — ShapeQuery
export interface ShapeQuery<T extends BaseType = BaseType> {
data: T[];
isPending: boolean;
isSuccess: boolean;
isError: boolean;
error: unknown;
}
// use-shape-query.ts — ShapeObservable
export interface ShapeObservable<T extends BaseType = BaseType> {
getSnapshot(): ShapeQuery<T>;
subscribe(onChange: () => void): () => void;
refetch(): void;
}
// use-shape-query.ts — useShapeQuery. Both parameters are the SDK's own:
// `ShapeType` (`sdk/js/shex-orm/src/types.ts`) and `Scope` (`sdk/js/orm/src/types.ts`),
// both re-exported by the entry. `Scope` is NOT a store scope.
export function useShapeQuery<T extends BaseType = BaseType>(
shapeType: ShapeType<T>,
scope: Scope,
): ShapeObservable<T>;
What it reads THROUGH, since 1.0.0-dev.6: the SDK's own shape query. RdfOrmSubscription.getOrCreate(shapeType, scope), injected at configure as ormSubscription (§ 1). Four steps went with the change and they were one mechanism: resolving the logical scope to a document set (listMyEntityDocs), opening those documents through ensureReposOpen for a sync barrier, an anchored read over each, and filtering the result by the shape's @type IRI. All four existed because the parameter had to be TRANSLATED; once it is upstream's own Scope, the question goes to the thing that answers it, and graphs / subjects / did:ng:i are read by the engine literally. It does NOT delegate to the SDK's useShape - that is React's hook, callable only from a render, and its result carries two states where this one publishes three: isLoading has no value for a read that will never answer, and there is no error state beside it. What is taken from the SDK is the QUERY; the observable, the snapshot and the state tracking stay here, and so does the capability filter (emulated-verifier/read-filter.ts, applied per @graph on what comes back, exactly where § 4 applies it).
Consequences a caller sees. data items are now the SHAPE's own objects — @id, @graph and the shape's readable predicates — where they were the property bags the then-published readUnion returned; shapeType is typed ShapeType<T> instead of unknown, so T follows the shape and the snapshot is typed without a cast; and the read no longer opens a document of its own, so a public-store document whose cap has not been fetched by some other read does not appear until it has been (the same limit § 4 carries, and the same CapRegistry.onChange recovery). (Corrected 2026-09-21, 1.0.0-dev.9: that last clause now holds only for graphs: "did:ng:i". A document NAMED in scope.graphs is put through the same public-store fetch and sync barrier docs.sparqlQuery applies to its anchor, before the query opens - and is refused out loud rather than silently emptied when its cap cannot be obtained.)
The name and the scope, since 1.0.0-dev.5. It was watchShape(shapeType, "public" | "protected" | "private"). watchShape has ZERO occurrences in nextgraph-rs and spends watch, a verb the ORM already spends on signal observation; the target's own name is useShapeQuery, announced by NextGraph's developers and on record in .project/rules/nextgraph/surface-mirrors-the-sdk.md, which is what the symbol's announced-not-written annotation cites. The three literals were this package's own and named the scope of a READ, which the SDK answers on two axes instead: graphs (one document's graph per entry, did:ng:i or "" for the whole dataset, [] or undefined for no objects at all) and subjects. Nothing is translated any more: the Scope object a caller writes is the object the SDK receives.
"There is nothing" and "I could not find out" are still two different answers — and the second one moved. Until 1.0.0-dev.6 the failure this surface had to keep apart was its own first step: listMyEntityDocs not answering, caught and logged, the empty set flowing on, and { data: [], isPending: false, isSuccess: true } published — byte for byte the synced-but-empty snapshot, so an application rendered "you have created nothing" for "the store did not answer". That step is gone with the pipeline. What can fail now is opening the shape query, and that is what isError carries: a shape the ORM cannot subscribe, or a configure() that was never given the query. A start the ENGINE refuses does NOT reach isError, and that is upstream's doing rather than a choice made here — orm_start_graph is awaited inside a catch that only logs, and the readiness promise is built with resolve alone — so such a read stays isPending for ever. The distinction holds either way (an application is never told "there is nothing" for "nobody answered"), but an interface that only handles isError will show a spinner where it used to show an error. isError stays published because this package enriches the SDK in its announced direction rather than stopping at what it exposes today; the day the ORM reports a failed start, that is where it lands.
data survives an error rather than emptying. A one-shot call rejects and is done; an observable has already handed a list to a subscriber that rendered it, and cannot un-emit. Collapsing data to [] on failure would put the empty answer back in the one field a view actually paints, for exactly the case that must never read as empty — so data keeps the last read that ANSWERED, isSuccess stays false so that array is never offered as a reply to the question that just failed, and the subscriber is notified so a view gating on the load state can say so. Before any answer there is nothing to keep and data is [], published under isError and never under isSuccess.
Target
Partly ASSUMPTION — flagged deliberately. surface/watch-shape.ts's header says it "anticipates NextGraph's planned useShape(shape, scope) upgrade, which will natively distinguish 'sync in progress' from 'synced but empty'". No provenance for that plan exists in this repo's docs or in the nextgraph-rs clone — treat the "planned upgrade" as an assumption, not a stated NextGraph direction. What IS verified at level 3 is that the distinction is expressible today, just not through the hook:
// level 3, VERIFIED — sdk/js/orm/src/connector/GraphOrmSubscription.ts:228,260,274
OrmSubscription.getOrCreate<T extends BaseType>(shape: ShapeType<T>, scope: NormalizedScope): OrmSubscription<T>;
get readyPromise(): Promise<void>; // resolves when the subscription is synced — the native "no longer pending" signal
public close(): void;
So the constraint on the bet: the target can already answer "synced?" (readyPromise), and useShape today returns "an empty set, if still loading" (its own doc comment, useShape.ts:29-31) — indistinguishable from synced-empty. useShapeQuery surfaces the distinction with a TanStack-useQuery-minimal vocabulary (isPending/isSuccess/isError). That is not a shape of this library's choosing any more: NextGraph's developers have stated it is the direction, and the statement is on record in .project/rules/nextgraph/surface-mirrors-the-sdk.md with its date — which is what the symbol's announced-not-written annotation cites. Deviating toward an announced target is alignment; the distinction it teaches (pending is not empty) is target-expressible either way.
But readyPromise answers two of those three questions, not three — VERIFIED 2026-08-17, and it bears on isError specifically. It is constructed with resolve alone (new Promise<void>((resolve) => { this.resolveReady = resolve; }), GraphOrmSubscription.ts constructor), nothing anywhere rejects it, and resolveReady() is called on one path only — the arrival of initial data. The subscription that fails does not settle it: orm_start_graph is awaited inside a try whose catch is console.error(e), so the promise stays pending forever and every await this.readyPromise_ behind it hangs. Upstream's "I could not find out" IS its "still pending". So isPending and isSuccess map onto something real at level 3, while isError has no counterpart at any level — it is this library's third state, and the reason it exists is that swallowing a failed read into a plausible-looking result is the shape that took this project a week to close everywhere else. A consumer should keep reading it; if the future hook ships without one, the failure is the pending that never ends, which is a worse thing to have to render, not a reason to have learned less.
6. One-shot reading by name — the anchored query, and nothing above it
Today — @ng-eventually/polyfill
// docs.ts — the SDK's own primitive, and the whole of what this package offers here
export async function sparqlQuery(
sessionId: string | number,
query: string,
base?: string,
anchorLike?: NuriLike,
): Promise<unknown>;
readUnion and UnionSubject were published here until 1.0.0-dev.8, and are gone. readUnion(docs) issued one anchored sparql_query per document, in parallel, tolerated a per-document failure by swallowing it, applied the capability filter inside and dropped machinery subjects. Every one of those beyond the first is a POLICY, and none of them has a counterpart at any level: it was N calls plus a batch policy, and its annotation cited sparql_query — a citation that is exact attached to a claim that is false. A composition cannot be aligned on a single function.
What replaces it is what was underneath: docs.sparqlQuery(sessionId, query, undefined, doc), once per document you want. Reading several is that call in a loop, and the two policies come back to the caller, where upstream leaves them:
- a failure is a failure. The anchored query REJECTS for a document the verifier does not hold (
resolve_target_for_sparql→NgError::RepoNotFound,engine/verifier/src/request_processor.rs), and this package refuses it earlier still for a document you hold no capability for. Whether one unreadable document ends your batch is now your decision, written at your call site rather than assumed by a primitive. - shaping is yours. The grouping into per-(document, subject) property bags was
UnionSubject, a polyfill type with nothing upstream behind it. A SELECT's rows come back as rows; a caller that wants objects should usegetObjects(§ 6bis) oruseShapeQuery(§ 5), which are the SDK's own typed reads. - the reserved prefix is yours to ignore.
readUniondropped subjects underurn:ng-eventually:for you. A raw anchored read does not — see## Guaranteesin the published contract, which now says so.
Several subjects in one document is the PROVIDED case, whichever read is used. Level 3 carries @id and @graph as two distinct read-only properties and fabricates an @id when the writer leaves it empty (sdk/js/orm/src/connector/GraphOrmSubscription.ts, ":q:"), so a read must report subjects as written and never fold a document into one bag. One document per business entity remains the recommended placement (a key is per repo, so isolating an entity needs a repo of its own), but that is a recommendation about writing.
Target
Two verified counterparts, one per level:
// level 2, VERIFIED — the primitive, published here as `docs.sparqlQuery`: index.d.ts:295,
// source sdk/js/lib-wasm/src/lib.rs:352 (nodejs) / :555 (web)
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
// level 3, VERIFIED — the one-shot typed read: sdk/js/orm/src/connector/getObjects.ts:23
export async function getObjects<T extends BaseType>(
shapeType: ShapeType<T>,
scope: Scope | string
); // returns a deep-cloned Set of matching objects
The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query to that repo's graph as default graph (resolve_target_for_sparql, engine/verifier/src/request_processor.rs:256-285), an anchorless query unions every named graph in the session store (same function, UserSite → None → set_default_graph_as_union). Reading several documents must therefore stay N ANCHORED queries: replacing them with one anchorless query is a silent widening of what a read can reach, and on a shared wallet it is O(wallet size).
6bis. The ORM's one-shot read and subscription-free write - getObjects / insertObject
Today — @ng-eventually/polyfill
// orm.ts - the SDK's own read, with the capability filter on what comes back
export async function getObjects<T extends BaseType>(
shapeType: ShapeType<T>,
config: Omit<RdfOrmConfig<T>, "pageSize" | "maxActivePages">,
): Promise<T[] | Set<T>>;
// orm.ts - the SDK's own write, with the ownership guard before it
export async function insertObject<T extends BaseType>(shapeType: ShapeType<T>, object: T): Promise<T>;
PASSTHROUGH (level 3, VERIFIED) - both at upstream's own name, parameters and return, forwarding to the real functions injected at configure() (getObjects, insertObject; § 1). Published since 1.0.0-dev.7; before that an application had to import them straight from @ng-org/orm, which is the problem rather than an inconvenience - an unwrapped ORM read returns EVERY identity's data out of the shared wallet with nothing signalling it, and an unwrapped ORM write reaches any document at all.
What each wrapper adds, and where the target does the same thing. A READ is filtered on what comes back: emulated-verifier/read-filter.ts keeps only items whose document - their @graph - the current holder holds a cap for, applied per document, exactly where § 4 and § 5 apply it. The filter keeps the collection's shape, so a Set in is a Set out and an orderByed array stays an array; before the first cap is issued the emulation is not in force and everything passes through. A WRITE is NOT filtered, and the asymmetry is upstream's: a write is bounded by write capability (verify_permission, reachable only from Commit::verify, so it fires on commits and never on reads) and nothing is hidden from the writer. So insertObject takes the same pair of guards docs.sparqlUpdate takes - assertMayReach then assertMayWrite - BEFORE the call, on the @graph the object carries. An object naming no document is forwarded untouched and the ORM's own refusal is heard (it throws without a @graph, because upstream the insert subscribes to no document and has no other way to know where the write lands).
What comes back from getObjects is DETACHED - structuredClone of the raw signal object - so mutating an item of it reaches nothing, and deleting an item of it removes nothing and reports nothing. Removal is a mutation of the LIVE set a subscription holds (§ 4), which leaves through graph_orm_update and is guarded there (§ 3).
What is deliberately NOT published from this family, each for a reason a reader can check:
RdfOrmSubscription- an application never holds a subscription: it hands the shape query toconfigureand readsuseShapeQuery's snapshot back (§ 5, § 14). AndgetOrCreatePOOLS on a key built from the shape and the scope alone - no identity in it - so a second virtual user asking the same question is handed the FIRST one's live instance. What makes that safe here is the filter this package applies on the way out; publishing the handle would hand out the instance instead.DiscreteOrmSubscription- a JSON-CRDT document. Its items carry no@graph, so the read filter has nothing to read, and its writes leave throughdiscrete_orm_update, a third channel nothing here guards. Publishing it would open a surface and a hole in the same gesture.ngSession- a module-level PROMISE resolving to the SDK handle plus the SESSION. One physical session hosts every virtual identity here, so handing it out hands every identity the same one (§ 1, § 13). Wrapping a resolved value would also force a RUNTIME@ng-org/ormimport into this package, which is what the injection discipline exists to avoid.getRaw- it returns the object BEHIND a signal, the same escape hatch the read filter refuses on a dunder key (__raw__): the unfiltered collection, every identity's items in it. There is no filtered form of it.watch/effect-@ng-org/alien-deepsignals' reactivity primitives, re-exported by the ORM's entry. They carry no document and grant nothing, so there is nothing to wrap.removeObject- NOT YET RULED ON, and it is the one gap in this section. It exists in the published@ng-org/orm@0.1.2-alpha.21(removeObject(graphNuri, subjectIri), aDELETE WHEREon that subject, stated to have the same effect asdelete()on a subscription object) and it does NOT exist at thenextgraph-rsrevision this repository reads (213338f): no file, no symbol. So nothing in the source can be cited for it, and the project's own rule refuses a claim inferred from what an npm package happens to expose. It is left unpublished until the source carries it; an application removes an object by mutating the live set, which is the path § 4 publishes and § 3 guards.
Target
// level 3, VERIFIED - sdk/js/orm/src/connector/getObjects.ts:getObjects
export async function getObjects<T extends BaseType>(shapeType: ShapeType<T>, scope: Scope | string);
// level 3, VERIFIED - sdk/js/orm/src/connector/insertObject.ts:insertObject
export async function insertObject<T extends BaseType>(shapeType: ShapeType<T>, object: T);
One difference between the cited revision and the published package, and the published one is what is mirrored here. getObjects's second parameter was Scope | string at 213338f; @ng-org/orm@0.1.2-alpha.21 widened it to the config RdfOrmConfig declares, minus the two pagination fields a one-shot read has no use for - a Scope plus where and orderBy. The wrapper takes the wider one, because that is what a consumer's TypeScript actually checks against and because narrowing it back to Scope would reject a where the SDK accepts. Both return types are inferred upstream rather than annotated; the published declaration says Promise<T[] | Set<T>> for the first and Promise<T> for the second.
At migration both wrappers disappear and the imports resolve to these two functions: same names, same arguments, same returns. What goes with them is the filtering and the guard, which the broker and the verifier then do.
7. Raw document / SPARQL primitives — docs.*
Today — @ng-eventually/polyfill (namespace docs)
// docs.ts:53
export async function docCreate(
sessionId: string | number,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri>;
// docs.ts:101
export async function sparqlUpdate(
sessionId: string | number,
query: string,
anchor?: Nuri,
label = "sparqlUpdate",
): Promise<unknown>;
// docs.ts:146
export async function sparqlQuery(
sessionId: string | number,
query: string,
base?: string,
anchor?: Nuri,
label = "sparqlQuery",
): Promise<unknown>;
// docs.ts:113 — machinery, see § 15
// NOT published since 2026-08-07 — moved to `emulated-verifier/register-write.ts`.
// It skips the boundary by design ("the one write that legitimately crosses"), and a door
// that skips a guard must not be one an application can open: holding nothing but a public
// document's bare reference, one could rewrite the inbox address posted on it and divert
// every deposit meant for its owner. Go through `inbox.post` / `inbox.share`.
Target
Annotated aligned (level 2, VERIFIED) — not passthrough, and the distinction is the point. These forward to the real methods with the same arguments in the same order, but the names are ours (docCreate for doc_create) and they take a trailing label (a lib-internal access-log tag) the real signatures have no room for. So the semantics survive migration and the call site does not — which is exactly what aligned says and what a blanket "PASSTHROUGH" hid. (This entry said PASSTHROUGH until 2026-08-16; it had already been caught claiming a 1:1 mirror, false in two ways, both fixed rather than documented as deltas:)
- The session id is
string | number, upstream's own declared type for it (Session.session_id,sdk/js/web/src/index.ts:16and the installedindex.d.ts:266) — it used to be narrowed tostringhere, which made the value the SDK hands an application impossible to pass back into this library. It is RELAYED, never converted: the wasm side deserializes au64(sdk/js/lib-wasm/src/lib.rs:352-358sparql_query,:452-457sparql_update,:1575doc_create), and stringifying it fails that deserialization for real — observed asDeserialization error of session_id JsValue("1"). sparqlUpdatereturns what the real method returns — the commits the update produced (lib.rs:481-483serialisesAppResponseV0::Commits; the installedindex.d.ts:297types itPromise<any>). It was declaredPromise<void>while already relaying the value at runtime, so the answer was thrown away for every caller. Typedunknownrather thanany, exactly assparqlQueryalready renders the same upstreamPromise<any>. A caller that ignores it is unaffected.
The real signatures forwarded to:
// index.d.ts:60 — the installed web SDK's doc_create
declare function doc_create(session_id: any, crdt: string, class_name: string, destination: string, store_repo: any): Promise<any>;
// index.d.ts:297
declare function sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>;
// index.d.ts:295
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
depositInto has NO COUNTERPART as a SPARQL write: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document — and it is no longer published (see the block above).
Store targeting — finer than "not JS-constructible". (The other docs were corrected on 2026-08-03 to match this entry; they used to state the blanket form.) Verified in the clone:
- The web wasm variant (
sdk/js/lib-wasm/src/lib.rs:1575,#[cfg(not(wasmpack_target = "nodejs"))]) deserializes its 5th argument asOption<StoreRepo>via serde — so a value CAN be passed, but no JS helper exists to build the serde form, which keeps it out of practical reach. The published.d.tsdocuments this 5-arg form. - The nodejs variant (
lib.rs:1618, 6 args) takesstore_type: Option<String>+store_repo: Option<String>and builds the store viaStoreRepo::from_type_and_repo(store_type, repo_id_str)withstore_type ∈ "public" | "protected" | "private" | "group"(sdk/rust/src/local_broker.rs:2969-2987,engine/repo/src/types.rs:819-828).
So the target's direction for scope placement is already visible in the source (level 2, VERIFIED, nodejs SDK): name the store by type + repo id strings. The migration-guide's anticipated getNativeStore(scope)-style resolver should expect to produce exactly that pair (or the serde StoreRepo once a web helper lands) — not a new concept.
8. Per-document subscription — subscribeDoc
Today — @ng-eventually/polyfill
// subscribe.ts:47,60,79
export type DocChange = unknown;
export type DocChangeType = string | undefined;
export type Unsubscribe = () => void;
// subscribe.ts:69
export function docChangeType(resp: DocChange): DocChangeType;
// subscribe.ts:104
export function subscribeDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe;
// subscribe.ts:184
export function subscribeDocs(
nuris: Nuri[],
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
): Unsubscribe;
Target
Annotated aligned (level 2, VERIFIED) — the deltas below are what makes it aligned rather than passthrough:
// index.d.ts:66, source sdk/js/lib-wasm/src/lib.rs:1908
declare function doc_subscribe(repo_o: string, session_id: any, callback: Function): Promise<any>;
- The real call is
asyncand resolves to an unsubscribe function; the wrapper returns the unsubscribe synchronously and honours an early cancel when the promise settles. A consumer coding against the sync return will keep working against the real SDK only through an adapter — a small, known unlearn, traded for not forcingawaiton every subscription site. - The real callback receives one argument, the serialized
AppResponse({ V0: { State | Patch | TabInfo | … } }); the wrapper adds a second, pre-extractedtype.docChangeTypeis a convenience over the verified payload shape (pinned by the e2e CONTRACT-3 probe), not an upstream API. subscribeDocshas NO COUNTERPART and needs none: it is client-side composition (a set ofdoc_subscribewith per-doc error isolation). The upstream fan-out primitive that looks like it (orm_start_graph(graph_scope, …),index.d.ts:243) aborts wholesale on oneRepoNotFound(sdk/js/orm→engine/verifier/src/request_processor.rs:53-66) — the reason this composition exists.
9. Inbox — deposits, and cap delivery
Today — @ng-eventually/polyfill (namespace inbox)
export interface Deposit {
from: PrincipalId | null;
payload: unknown;
ts: number;
}
export interface PostOptions {
from?: PrincipalId | null;
payload: unknown;
ts?: number;
}
export async function post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
export async function postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
export async function share(doc: NuriLike, toUser: string): Promise<void>;
export async function read(targetInbox: NuriLike): Promise<Deposit[]>;
export async function readForDocument(doc: NuriLike): Promise<Deposit[]>;
// `materialize` — a second published name for `read` — was REMOVED on 2026-08-14. It was
// an alias and nothing else: no call site, and upstream has no such member, so it was a
// symbol an application could learn and would then have to unlearn. Use `read`.
export async function readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
export async function readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
export async function processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
export function watch(
targetInbox: NuriLike,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void;
Target
LEVEL-1 SHAPE throughout — the model is VERIFIED, every JS signature here is this library's invention. There is no inbox method in @ng-org/web (none in the 77 index.d.ts exports, re-verified), and the verifier's dispatch has no InboxPost arm (arms actually handled listed at engine/verifier/src/request_processor.rs:53-1444, re-verified). The engine model that constrains the shape:
- An inbox is a keypair on exactly one repo:
pub inbox: Option<PrivKey>(engine/repo/src/repo.rs:126); routing isinboxes: HashMap<PubKey, RepoId>on the verifier (engine/verifier/src/verifier.rs:105, looked up at:1677, inserted at:1928). - A message is sealed to the inbox pubkey and carries no target document —
InboxMsgBody { to_overlay, to_inbox: PubKey, from_overlay: Option<OverlayId>, from_inbox: Option<PubKey>, … }(engine/net/src/types.rs:4265). The address identifies the recipient repo; nothing else is needed. This is whyDeposithas no document field and whyposttakes only the inbox NURI. fromoptional upstream (from_inbox: Option<PubKey>) — the "identified if known, anonymous otherwise" behaviourPostOptions.frommirrors, including thenull-means-anonymous case.- The recipient's own verifier unseals and applies queued messages when it processes its inbox (
engine/verifier/src/verifier.rs:1674-1690→process_inbox); an inbox is a consumed queue, not a store you re-read.
Consequences per function:
post/postToDocument— the sender-side act exists in the model (the broker routesInboxPostnatively,engine/net/src/server_broker.rs); its JS surface does not. The future SDK's name and signature are unknown —docs/nextgraph-current-state.md:187records that nothing is announced.postToDocument's resolution step (find the document's inbox address) rides on a deliberate divergence: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (ContactDetailscarriesng:site_inbox/ng:protected_inbox,engine/verifier/src/inbox_processor.rs:778-830; the address→repo association lives ininboxes: PubKey → RepoId, a table of the verifier — one per user,verifier.rs:105). (Corrected 2026-08-10: this said the table was "session-local, rebuilt empty". It is initialized empty (verifier.rs:520,2820) and then repopulated at every load —Verifier::load→add_repo_without_saving→add_repo_,verifier.rs:534-566,2871,2887— with the inbox private key persisted per repo,user_storage/repo.rs:61,171,207,362. The property that matters is that it is per verifier, not that it is ephemeral.) Documented indocs/briefs/2026-08-03-document-inbox-addressing.md.share— a gap upstream, not a disagreement, verified at both ends:ContactDetails.read_cap: Option<ReadCap>exists (engine/net/src/types.rs:4233) but building a message with it isunimplemented!()(types.rs:3786), its only caller passeswith_readcap: false, and the receiving arm never reads the field (inbox_processor.rs:778-830).InboxMsgContent::Linkis a unit variant carrying nothing (types.rs:4252) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real:AddLink { read_cap }on the User branch (engine/repo/src/types.rs:1939-1948). The consumer's act (share one document's cap to one inbox) is target-shaped; only the transport is emulated.read/readSynced/readSyncedForDocument/processInbox/watch— stand-ins for the recipient's own verifier processing, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is whatconnectedUserautomates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (Deposit.payloadas app data) maps toInboxMsgContentvariants upstream (types.rs:4249-4260), of which onlyContactDetailsandSocialQueryare more than unit variants today — arbitrary app payloads through the inbox are an ASSUMPTION, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.readForDocument(doc)— the owner's side of a document's inbox, named by the DOCUMENT. Same LEVEL-1 SHAPE ruling asread: it is the recipient's own processing, which has no consumer-facing JS surface upstream, and enumerating its deposits is emulation detail. It exists so an application never handles an inbox address. It is the WARM form (it delegates toread), so on a session that has just loaded it can answer[]for a document that has deposits.readSyncedForDocument(doc)— added 2026-08-17, the intersection the surface was missing:readSynced's barrier onreadForDocument's address. What an application no longer does: resolve an inbox address itself. It had to, because materializing deposits needs both halves and only one call carried each — and resolving an address is the exact gesture § Guarantees says an application never performs. The document-addressed path crosses TWO repos and a cold session loses the answer at either: the address is read off the DOCUMENT's Header branch, so an unsynced document reads as "no inbox"; the deposits are read off the INBOX, which is whatreadSyncedgates. This gates both, document first. Same LEVEL-1 SHAPE ruling asreadand the same fate — it adds no divergent ACT, it composes two published ones so the caller does not have to hold an address to reach them (packages/polyfill/test/cold-read-for-document.test.tspins the pair: on one cold state,readForDocumentanswers empty and this answers the message).share(doc, toUser)refuses an unknown recipient since 2026-08-10. It used to provision one: a mistyped name minted that name's stores and an inbox, and the cap landed where nobody looks. Upstream a deposit is sealed to an inbox pubkey that reached you through an inbound contact, so you cannot address a name you invented.watch's_opts?: { intervalMs?: number }is accepted and ignored (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
Known divergence, low impact today —
inbox.sharealways deposits on the recipient's PROTECTED inbox. Upstream the choice follows the profile through which the person was reached (a_or_b = if details.profile.is_public() { "site" } else { "protected" },engine/verifier/src/inbox_processor.rs:787). This library has no notion of "which profile I know this person by", so it picks one. It flattens a distinction the model makes; it will be wrong the day an application shares with someone met through a public profile. Recorded rather than fixed, because the fix needs a notion nothing here has established — note that theIdentityenum that would name it is entirely commented out upstream (engine/repo/src/types.rs:586-595), so there is no profile model to read yet.
10. Capabilities — possession, not ACL
Today
// @ng-eventually/polyfill — model/types.ts. The published cap surface is now ONE type.
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
// NOT published, each deliberately:
// ReadCap — `did:ng:${string}:r:${string}`. Unpublished 2026-08-10, when
// `export * from "./model/types"` became a named list. It remains the library's
// internal type for a cap-bearing reference, but NO published signature takes or
// returns one: within `surface/inbox.ts` only two private helpers use it
// (`capsSeenIn`, `capOfPayload`), plus the emulated registers. Publishing it named
// the one value the model says must never be handed over on request (§ 0 of
// `readcap-and-nuri-model.md`) — while leaving no published call able to produce
// one, since `linkTo` was removed and `mintCap` is unreachable (§ 11). A type whose
// only possible use by a consumer is a cast is worse than no type. See § 14.
// InboxScope — unpublished the same day, same rule: its only user is
// `account-registry.userInbox(id, scope)`, which is not published (§ 12).
// isNuri / hasReadCap — the type guards (`model/nuri.ts`). Unpublished since the
// permissive-in change: every entry takes `NuriLike` and validates at the door, so
// a consumer holding a plain string narrows nothing. Publishing a guard would
// invite the cast it exists to prevent.
// hasCap(doc) — removed 2026-08-06. It read like "may I read this?", and a
// document in a public store answers `false` until something asks for its cap.
// getCaps / CapRegistry / resetCaps — the emulation's engine room and its test reset.
// INTERNAL — `emulated-verifier/caps.ts` (class CapRegistry). Never published; listed for the maintainer.
constructor(holder?: () => PrincipalId | null);
mint(nuri: Nuri): ReadCap;
learn(cap: ReadCap): void;
capFor(nuri: Nuri): ReadCap | undefined;
learnFromPublicStore(cap: ReadCap): void; // a cap the public store SERVED — read only
isReadOnlyPublicCap(nuri: Nuri): boolean;
markInPublicStore(nuri: Nuri): void;
isInPublicStore(nuri: Nuri): boolean;
open(nuri: Nuri, scope: Scope): ReadCap;
isEnforcing(): boolean;
onChange(listener: () => void): () => void;
grantWrite(doc: Nuri, principal: PrincipalId): void; // decorative until cap-enforcement
governsWrite(doc: Nuri): boolean; // decorative until cap-enforcement
canWrite(doc: Nuri, principal: PrincipalId | null): boolean; // decorative until cap-enforcement
hasWritePolicy(): boolean; // decorative until cap-enforcement
clear(): void;
Target
LEVEL-1 SHAPE. There is no capability API at level 2 or 3 (no cap method in index.d.ts, none in the ORM), and there is nothing to introspect upstream: reading is key possession. The model, VERIFIED:
- A ReadCap is the serialized
ObjectRef—format!("r:{}", base64_url::encode(&ser))(BlockRef::readcap_nuri,engine/repo/src/types.rs:518-521). Ther:segment and its encoding are upstream's, reported by NextGraph's developer and verified in that function: id and key are serialized together into ONE opaque segment, unlike the:k:object/file/commit forms where they are two. The lib'sReadCaptemplate-literal type uses that segment, with the stand-in constantOKin place of the key material. - "cap-enforcement swaps the value, not the shape" is a BET, and this section stated it as a fact until 2026-08-10. What the source establishes is narrower, in three readings: (a)
readcap_nuri()is produced as a field value, never concatenated onto a NURI — every call site fillsAppTabBranchInfo.readcap: Option<String>(engine/net/src/app_protocol.rs:1334;engine/verifier/src/verifier.rs:278,320;rocksdb_user_storage.rs:162,172); (b) no upstream parser accepts a repo NURI carrying:r:—NuriV0::new_from(app_protocol.rs:643-737) triesdid:ng:i,RE_REPO_O,RE_FILE_READ_CAP,RE_REPOandRE_BRANCH, and none of the regexes atengine/net/src/types.rs:48-80has anr:form; (c) the slot the type declares for a repo read cap is a field —NuriV0.access: Vec<NgAccessV0>withNgAccessV0::ReadCap(ReadCap)(app_protocol.rs:54-62,192) — itself constructed nowhere today (onlyNgAccessV0::Key,:622). Per the design principle none of that says the target will not parse a cap-bearing repo NURI; it says nothing parses one yet, so "the shape survives, only the value changes" is an assumption and not a passthrough. If the cap turns out to belong in a field, cap-enforcement moves it there instead of swapping a substring — a change the surface absorbs, because the value is opaque and nothing published parses it (§ 11). - Caps live in two durable registers by origin: created documents →
AddRepo { read_cap }on the store's Store branch (engine/repo/src/types.rs:1890-1899, committed bydoc_createviasend_add_repo_to_store,engine/verifier/src/request_processor.rs:698); received caps →AddLink { read_cap }on the private store's User branch (types.rs:1939-1948). - The one path that loads a repo from a cap is
pub(crate)—Verifier::load_repo_from_read_cap(engine/verifier/src/verifier.rs:2237) — unexposed to JS.
capFor(nuri) asks the only question the model admits — "do I hold this document's key?" — and returning undefined is the whole possible answer. There is no "may principal P read D?" anywhere, and the future SDK cannot offer one without inventing an ACL the engine does not have. That absence is a finding about the target's model, not a missing feature: a consumer should never expect a cap-introspection API.
The CapRegistry class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). It is not published at all: the consumer surface is the ACTS (creating a document, inbox.share, processing one's inbox — and, for a document in a public store, simply reading it), never a lookup; see § 15.
11. NURI and SPARQL string utilities
Today — nothing. The entry publishes no string utility and no type guard.
// NOT published — internal, and each for a stated reason:
// surface/sparql.ts escapeLiteral, escapeIri, assertNuri
// model/nuri.ts isNuri, hasReadCap, targetOf, parseNuri, toNuri
// emulated-verifier/caps.ts mintCap (it lived in `model/nuri.ts` until the source
// layout was reorganised by migration fate; this list
// still said so until 2026-08-10)
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
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.
12. Scope resolution, per-entity documents, and the store registry
Today — @ng-eventually/polyfill (namespace storeRegistry)
Narrowed twice. 2026-08-03 the entry stopped re-exporting the whole
store-registrymodule 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 withinbox.postToDocument(doc, …)and shares withinbox.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 bydocs/internal-contract.md.
// `Scope` (this package's, `"public" | "protected" | "private"`) was published here until
// `1.0.0-dev.5`. It is now `StoreType`, DEFINED in `types.ts` and published nowhere: an
// application names a store by the reference one of the three accessors below hands it.
// The three strings are upstream's own (`doc_create`'s `store_type`), but the name `Scope`
// was covering two questions at once — see § 5 for the other one.
// store-registry.ts:90,234
// VirtualUserRecord is INTERNAL (shape kept here for the ruling below).
interface VirtualUserRecord {
id: string;
docPublic: Nuri;
docProtected: Nuri;
docPrivate: Nuri;
}
// RegistrySession is INTERNAL since 2026-08-12 (shape kept here for the ruling below).
interface RegistrySession {
sessionId: string | number; // relayed untouched — upstream's own type (§ 7)
privateStoreId: string;
protectedStoreId?: string;
publicStoreId?: string;
}
// PUBLISHED — the whole `storeRegistry` namespace, and nothing else.
// NO identity parameter, since 2026-08-10: a session belongs to one user, and the
// target's own `doc_create(session_id, …)` carries no user at all. Passing one's own
// identity to every placement call was a gesture with no successor — and it forced an
// application to KNOW its identity, which it could only do by reading the access gate's
// private storage key. `ensureIdentity()` returns it now; these take it from the session.
// A store is named by its REFERENCE, since `1.0.0-dev.5`. Upstream the session hands the
// application its three store ids (`Session.private_store_id | protected_store_id |
// public_store_id`) and `doc_create` takes one of them, the private store when none is
// given — so these three accessors answer what the session answers, and `createEntityDoc`
// takes one of them exactly where `doc_create` takes its store.
export async function privateStore(): Promise<Nuri>;
export async function protectedStore(): Promise<Nuri>;
export async function publicStore(): Promise<Nuri>;
export async function createEntityDoc(store?: NuriLike): Promise<Nuri>;
export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
// UNPUBLISHED in `1.0.0-dev.5`, each for its own reason:
// listMyEntityDocs — no counterpart at any level (no `doc_list`, nothing in the
// binding). Listing what one created is an ordinary query over the
// store's `ldp:contains`. Still internal: the read path calls it.
// resolveScopeGraph — it handed out a value whose stated use was a READ scope, and a
// store's graph holds containment triples and nothing else, so
// upstream that query matches nothing and says so with an empty set.
// resolveWriteGraph — it returned the very reference the three accessors return.
// NOT published — internal, kept here because the target rulings below still cover them.
// userInbox, documentInboxAddress, isOwnInbox, myInboxes,
// addLink, readLinks, resolveAccount, ensureAccount, reservedAccount,
// resetRegistryCache, and the VirtualUserRecord type.
// `RegistrySession` joined them on 2026-08-12: it was published for ONE reason — a consumer
// typed the session thunk it injected with it — and that thunk is gone (§ 1). Upstream a
// session is RETURNED, never assembled, so no application has a session shape to declare.
listMyEntityDocs answers a VERIFIED listing, or it rejects — since 2026-08-17. It stands on two reads of the store document, and both propagate: the Main branch says which documents are in there, the Store branch says what opens each. Handing back the listing over a key read that never answered was tolerated until then, on the ground that the array was already in hand by that point — which is exactly what made it a half-truth rather than a shortcut, since nothing distinguishes a listing you can open from one you cannot. It is the same Nuri[]; the difference shows at the next read, empty, with the cause long gone. It is the ruling the connection path already runs on, applied to the last call that escaped it: a rejection means "unknown", never "absent", and only "there was nothing to do" resolves quietly — so an empty array here means this account created no document in that scope, and never that the store went unread.
openDocumentInbox coalesces concurrent asks — and one JS realm is the honest extent of it, since 2026-08-17. The call reads the User branch to find out whether an inbox is already recorded and mints when the answer is no, with a dozen awaits between the two. Callers that arrive together therefore all read before any of them writes: each read ANSWERS, each answer is honestly "none", and each mints. Nothing fails, which is what sets this apart from the read-that-could-not-answer family swept through this document — no rejection-means-unknown ruling touches it, only coalescing does. Reported from an application: four simultaneous calls on one document registered three inboxes, after which the owner drained one while deposits arrived in another. Asks for the same (holder, document) now share one call.
What that does NOT reach, and why it cannot be fixed here. Two tabs share no in-flight map, so the durable fork survives — and it is not a matter of doing more work. Preventing it needs a conditional write ("record only if absent"), which no level of the target offers: a branch is an add-only CRDT, so two AddInboxCap records merge rather than one being refused. Reconciling it afterwards the way canonicalDoc reconciles a forked account pointer does not work either, because the two sides read different records — the owner resolves from AddInboxCap on its User branch, a depositor from the address published on the document's Header branch, and the latter is written DELETE-then-INSERT, so it is last-write-wins and need not name the same one. Making both sides agree would mean letting a document accumulate two addresses to pick a canonical one from, and that is a state the target model has no meaning for: inboxes: PubKey → RepoId is a function (engine/verifier/src/verifier.rs:105) and repo.inbox a single Option<PrivKey>. So the gap is stated in the app contract as a non-guarantee rather than papered over. At migration it closes on its own — upstream an inbox is a keypair created WITH the repo, not a document minted on demand, so there is no read-then-write to race.
Target — split by what each piece maps to
createEntityDoc(id, scope)→ level 2, VERIFIED direction. Target:doc_create(session_id, crdt, class_name, destination, store_repo)aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takesstore_type/store_repostrings). The two writes the lib performs by hand are native side effects ofdoc_createupstream: theldp:containslisting on the store's Main branch and theAddRepo { read_cap }on its Store branch (engine/verifier/src/request_processor.rs:697-710). Theidparameter is already gone from the published call (2026-08-10); expectcreateEntityDoc(scope)to becomedoc_create(sid, …, storeOf(scope))with no listing/cap bookkeeping.listMyEntityDocs(id, scope)→ level 1/2, VERIFIED mechanism. Upstream the listing is the store'sldp:containsgraph (written atrequest_processor.rs:706-708), readable with an anchoredsparql_queryon the store; the caps come back by replaying the Store branch (AddRepo::verify→load_repo_from_read_cap). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read.resolveScopeGraph(scope)/resolveWriteGraph(id, scope)→ level 2, VERIFIED. (userStoreDocstood beside them and was REMOVED in1.0.0-dev.6: it handed out the scope-index document so a reactive reader could follow it, anduseShapeQueryreading through the SDK's shape query left it with no caller.) The target answers these from the session:did:ng:+session.private_store_id | protected_store_id | public_store_id(Session,sdk/js/web/src/index.ts:Session, installedindex.d.ts:265-273). The store IS the container; the per-scope index document disappears.userInbox(id)→ level 1, VERIFIED counterpart with a different granularity. Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only twoAddInboxCapcommits in the engine (engine/verifier/src/site.rs:128,149). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.openDocumentInbox(doc)/documentInboxAddress(doc)→ level 1, VERIFIED support, no exerciser. EveryRepocarriesinbox: Option<PrivKey>(engine/repo/src/repo.rs:126);AddInboxCapV0is keyed byrepo_idwith no is-store restriction (engine/repo/src/types.rs:1973; applied atengine/verifier/src/verifier.rs:1920-1928); but no code path creates one for a plain document (doc_create→new_repo_default→Store::create_repo_default→create_repo_with_keys, which builds theRepowithinbox: None—engine/verifier/src/verifier.rs:3004,engine/repo/src/store.rs:264,284,691) and no level-2/3 API exposes any of it. So: the capability is engine-verified; the functions are invented surface; and the address publication is a real, deliberate divergence (upstream transmits addresses, never publishes them — § 9), with the ownership guard compensating our design, not mirroring an upstream rule.addLink(cap)/readLinks()→ annotateddeclared-not-wired(level 1). The emulatedAddLink { read_cap }register (engine/repo/src/types.rs:AddLinkV0— "so that a user can share with all its device a new Link they received", external repos only). Verified 2026-08-16, and this is the load-bearing fact: the type is DEFINED, itsCommitVerifier::verifyis a no-opOk(())(engine/verifier/src/commits/mod.rs), and nothing in the engine constructs one — the only other occurrences are theCommitBodyV0::AddLinkenum variant and two match arms classifying body kinds. Its siblingAddRepoV0is constructed (Verifier::send_add_repo_to_store), which is why the Store-branch register next door is annotatedalignedand this one is not. A maintainer read that absence and concluded the function was this library's own invention; it is not — an absent implementation says nothing about the target. Upstream this filing happens inside the verifier when it processes the inbox; the future SDK most likely never exposes these as calls, so consumers should not code against them (§ 15).resolveAccount/ensureAccount/VirtualUserRecord/RegistrySession/reservedAccount/resetRegistryCache→ NO COUNTERPART. The shared-wallet shim (accounts directory, pointer → doc-shim indirection) has no image in the target — the target has no central directory of identities (docs/migration-guide.md§ 3). The whole group disappears with the shim.isOwnInbox/myInboxes→ NO COUNTERPART as API. Upstream the question "which inboxes may I read" is answered inside the verifier by the User branch'sAddInboxCaprecords; nothing suggests a JS API for it. These exist for the emulated read guard and the connection drain.
13. Identity and connection
Today
// PUBLISHED: nothing. Identity is established by `ensureIdentity()` (§ 2bis) and the
// connection is awaited inside it.
//
// NOT published, and each removal is a gesture an application no longer performs:
// setCurrentUser (2026-08-07) naming one's own identity — the step that inverts the
// model. The gate does it; the e2e harness, which plays
// several identities on one page, reaches it internally.
// connectedUser (2026-08-07) awaited inside `ensureIdentity`; upstream, opening the
// session IS the connection.
// getCurrentUser (2026-08-05) an application knows who it signed in.
// IdentityStore, browserIdentityStore, VirtualUserStorage, ACCOUNT_STORAGE_KEY
// (2026-08-05) persisting an identity is the application's job
// upstream too; the gate persists what IT needs.
Target
PASSTHROUGH-to-be at level 2, VERIFIED signatures. In the target the identity is established by opening one's own wallet and starting a per-user session — there is no "set the current user" call because the session IS the user:
// index.d.ts:276, 280, 313, 315
declare function session_start(wallet_name: string, user_id: any): Promise<any>;
declare function session_stop(user_id: string): Promise<void>;
declare function user_connect(client_info: any, user_id: string, location?: string | null): Promise<any>;
declare function user_disconnect(user_id: string): Promise<void>;
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— NO COUNTERPART; the relay of an identity the broker cannot see. Disappears with the shared wallet, and is no longer published: the gate is the only caller an application needs.connectedUser()(internal since 2026-08-07) — 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 fromsetCurrentUserfor the same reason. A consumer should treat it as "await a deterministic start" (tests), not as an operation the future SDK will name.
14. Type re-exports
@ng-eventually/polyfill re-exports, type-only (erased at build, src/index.ts):
export type { ShapeType, BaseType } from "@ng-org/shex-orm";
export type { Scope } from "@ng-org/orm";
export type { RdfOrmConfig } from "@ng-org/orm/all";
export type { NG } from "@ng-org/web";
PASSTHROUGH (levels 2/3, VERIFIED) - ShapeType/BaseType at @ng-org/shex-orm dist/types.d.ts:5,12; NG at index.d.ts:136. At migration these imports point at the same packages directly; nothing changes for the consumer.
A type is erased at build, so re-exporting one adds no runtime @ng-org import and opens no path around the emulation - which is why the rule that nothing of the SDK is published as it stands is about VALUES, and these are the exception it does not reach.
RdfOrmConfig comes from the SDK's /all subpath rather than its main entry, and that is upstream's packaging rather than a choice here: the config type getObjects declares is not exported beside the function that takes it (§ 6bis). Restating it locally is how a type drifts, so it is re-exported from where the SDK puts it.
Schema (@ng-org/shex-orm) and DeepSignalSet (@ng-org/alien-deepsignals) were re-exported here until 1.0.0-dev.12 (2026-09-24), and are UNPUBLISHED now, for the same rule this section states: a type is published only when a published signature uses it. Neither was. DeepSignalSet is named only in prose describing what useShape's result envelope carries (§ 4) - never as an actual type anywhere in this package - and Schema was named nowhere at all beyond this re-export. This is a MAJOR (a published symbol removed); see the concept contract's ## Change policy (.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md) and the appendix below.
The library's own model types — published by NAME since 2026-08-10
The entry used to say export * from "./model/types", a blanket re-export publishing eight types in one gesture. It now names them, under one rule:
A type is published only if a PUBLISHED SIGNATURE uses it.
export type {
Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike, OrmSubscriptionLike,
GetObjectsLike, InsertObjectLike,
} from "./model/types";
Each one's warrant: Nuri is what every reference-returning call returns and NuriLike what every entry accepts (§ 10, § 11); Scope left this list in 1.0.0-dev.5 - the read takes the SDK's own Scope, re-exported from @ng-org/orm, and a store is named by its reference (§ 5, § 12); PrincipalId is ensureIdentity's return and a field of Deposit, PostOptions and EventuallyConfig (§ 2bis, § 9, § 1); NgLike, UseShapeLike, OrmSubscriptionLike, GetObjectsLike and InsertObjectLike type the five injected members of EventuallyConfig (§ 1), the last two since 1.0.0-dev.7 (§ 6bis). OrmSubscriptionHandle - what the last of them RETURNS - is deliberately not published: an application hands the shape query in and reads the snapshot back, and publishing the handle would offer a path around the capability filter and the shared-wallet identity boundary.
Two types the blanket export published are now internal, each because nothing published names it:
ReadCap— no published signature takes or returns one. Its users are two private helpers ofsurface/inbox.ts(capsSeenIn,capOfPayload) and the emulated registers. Publishing it advertised a value a consumer has no published call to obtain, and deliberately so:linkTowas removed precisely for handing one out (§ 0 ofreadcap-and-nuri-model.md), andmintCapis unreachable from outside (§ 11). The only use a consumer could make of it is a cast — which is what the surface's permissive-in / precise-out design exists to make unnecessary.InboxScope— used only byaccount-registry.userInbox(id, scope), unpublished since 2026-08-05 (§ 12). An application never handles an inbox address, so it never names an inbox scope.
Both remain defined in model/types.ts and are used throughout the library; only their publication changed. Nothing about the target motivates either removal — this is a statement about this surface, and the same test that pins the appendix pins it.
15. Machinery on the surface — what a consumer should NOT code against
Exported, but not SDK surface. Coding against these builds knowledge that migration deletes:
FIXED 2026-08-07. It was published "only becausedocs.depositIntoinbox.tslives in another module", with the note that a consumer must always go throughinbox.post. That note is not a mechanism: an adversarial review drove through it — bare reference to a public document, rewrite its posted inbox address, divert its owner's deposits. It now lives inemulated-verifier/register-write.ts, which nothing exports.getConfig/getStoreRegistryDeps— tagged@internalin source, exported for the lib's own wrappers.resetConfig/resetStoreRegistry/resetCaps/storeRegistry.resetRegistryCache— test/reset machinery. In particularresetCapswipes EVERY holder's caps, which no product flow should ever do.getCaps()and theCapRegistryclass — the registry is the emulation's engine room. The consumer surface is the acts that file caps: creating a document,inbox.share(grant), processing one's inbox, and reading a document a public store serves.CapRegistry.grantWrite/governsWrite/canWrite/hasWritePolicyare explicitly decorative until cap-enforcement — the guard they feed is bypassed by every internal writer.— RESOLVED 2026-08-03: no longer exported. Shim internals, now instoreRegistry.reservedAccount,resolveAccount,ensureAccount,VirtualUserRecord,RegistrySessiondocs/internal-contract.md. The consumer's legitimate touchpoint isconfigureStoreRegistry(bootstrap) plus the scope/entity resolvers.— RESOLVED 2026-08-03: no longer exported. Consumers receive caps by processing their inbox (automated at connection); calling these directly baked in a register the verifier owns upstream.storeRegistry.addLink/readLinks— RESOLVED 2026-08-03: moved tovirtualUsers.*on the SDK entry/polyfill, where its disappearance at migration is visible at the import line.inbox.watch's_opts?: { intervalMs?: number }— accepted and ignored (no polling exists). Dead compatibility surface; do not pass it.- The
labelparameters ondocs.sparqlUpdate/docs.sparqlQuery— lib-internal access-log tags, never forwarded tong. The real signatures have no such parameter.
Places the current surface teaches something to unlearn
The SDK entry is not as pure as its header claims.FIXED 2026-08-03. The header claimed the entry "exposes ONLY what@ng-org/web/@ng-org/ormexpose" while also shippingvirtualUsersand the wholestore-registrymodule. Both are gone from it, and the header now states what the entry actually promises: every symbol here has a target-SDK counterpart, verified or assumed, listed in this document. It still exportsdocs,useShapeQuery,subscribeDoc(s), the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".FIXED 2026-08-07 with the entry merge: there is one entry and oneshareis importable from both entries.share, underinbox.- One entry means the import line no longer says what disappears. Until 2026-08-07 a second import path (
/polyfill) WAS the deletion list. It is now thePOLYFILL-ERAblock insrc/index.ts, this appendix's note above, and the per-subject rulings in this document. That is a documentation-carried signal where it used to be a mechanical one — the appendix is pinned by a test, the grouping is not. inbox.readas a mailbox — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.ANSWERED inwatchShape's "planneduseShapeupgrade"1.0.0-dev.5. The claim was in the module header with no provenance anywhere. It has one now, and it is the strongest kind available to something the target has not written: NextGraph's developers stated the direction, the statement is written down in.project/rules/nextgraph/surface-mirrors-the-sdk.mdwith who said it and when, and the symbol carrieskind=announced-not-writtenciting that record. The name went with it —useShapeQuery, notwatchShape(§ 5).— REMOVED inUnionSubjectproperty bags1.0.0-dev.8, withreadUnion. Nothing of the read model is published any more: an application reads one document withdocs.sparqlQueryand shapes the rows itself, or reads by shape and gets the SDK's own objects.- The sync-returning
subscribeDocunsubscribe vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta.
Appendix — full export inventory (for diffing)
Generated from the export statements, and pinned by packages/polyfill/test/vocabulary.test.ts — if this list and the code disagree, that test fails. It went stale once, still listing storeRegistry's shim internals after the entry had been narrowed, which is what a hand-maintained inventory does.
@ng-eventually/polyfill — src/index.ts (the only entry since 2026-08-07)
direct: BaseType, DocChange, DocChangeType, EventuallyConfig, GetObjectsLike, InsertObjectLike, NG, NgLike, Nuri, NuriLike, OrmSubscriptionLike, PrincipalId, RdfOrmConfig, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, getObjects, init, initNg, insertObject, ng, subscribeDoc, subscribeDocs, useShape, useShapeQuery
docs: docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch
storeRegistry: createEntityDoc, openDocumentInbox, privateStore, protectedStore, publicStore
Of these, exactly ONE is polyfill-era with no target counterpart — configure (plus
the type EventuallyConfig; RegistrySession left the surface on 2026-08-12 with the
session thunk that was its only reason to be there). It is the deletion list, and
src/index.ts groups it under a heading that says so. ensureIdentity is a second in
substance — the shared-wallet gate — but its call site survives (§ 2bis).
Nine symbols published before 2026-08-07 are gone from the surface: configureStoreRegistry
and StoreRegistryDeps (folded into configure), setCurrentUser and connectedUser
(§ 1), getConfig / getStoreRegistryDeps (internal wiring), resetConfig /
resetStoreRegistry / resetCaps (test resets), and the direct share re-export —
inbox.share was always the same function, and publishing it twice blurred the boundary
it was meant to mark.