diff --git a/.project/concepts/app-contract/_debt.md b/.project/concepts/app-contract/_debt.md new file mode 100644 index 0000000..d5ec681 --- /dev/null +++ b/.project/concepts/app-contract/_debt.md @@ -0,0 +1,8 @@ +# Doc-debt — app-contract + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED docs/api-contract.md @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/index.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/docs/api-contract.md b/docs/api-contract.md index 5c88cb1..6f0a579 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -15,6 +15,28 @@ 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`: + +```text +// @provenance kind= level=<1|2|3|none> ref= +``` + +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 @@ -84,7 +106,7 @@ export declare const init: (callback: Function | null, singleton: boolean, acces // 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:264-272 +// 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; @@ -179,7 +201,11 @@ const useShape = ( scope: Scope | string | undefined ) => DeepSignalSet; -// its Scope — sdk/js/orm/src/model/types.ts:25-38 (NOT this lib's Scope, see § 12) +// 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[]; @@ -309,7 +335,7 @@ export async function sparqlQuery( ### Target -**PASSTHROUGH (level 2, VERIFIED)** — these forward to the real methods, and the ONLY argument they do not pass on is the trailing `label` (a lib-internal access-log tag). Until 2026-08-14 this entry claimed a 1:1 mirror, which was false in two ways, both now fixed rather than documented as deltas: +**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:16` and the installed `index.d.ts:266`) — it used to be narrowed to `string` here, 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 a `u64` (`sdk/js/lib-wasm/src/lib.rs:352-358` `sparql_query`, `:452-457` `sparql_update`, `:1575` `doc_create`), and stringifying it fails that deserialization for real — observed as `Deserialization error of session_id JsValue("1")`. - **`sparqlUpdate` returns what the real method returns** — the commits the update produced (`lib.rs:481-483` serialises `AppResponseV0::Commits`; the installed `index.d.ts:297` types it `Promise`). It was declared `Promise` while already relaying the value at runtime, so the answer was thrown away for every caller. Typed `unknown` rather than `any`, exactly as `sparqlQuery` already renders the same upstream `Promise`. A caller that ignores it is unaffected. @@ -361,7 +387,7 @@ export function subscribeDocs( ### Target -**PASSTHROUGH (level 2, VERIFIED) with two deliberate ergonomic deltas:** +**Annotated `aligned` (level 2, VERIFIED) — the deltas below are what makes it `aligned` rather than `passthrough`:** ```ts // index.d.ts:66, source sdk/js/lib-wasm/src/lib.rs:1908 @@ -569,10 +595,10 @@ export async function openDocumentInbox(doc: NuriLike): Promise; - **`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 takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter is already gone from the published call (2026-08-10); expect `createEntityDoc(scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping. - **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on 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. -- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `index.d.ts:264-272`). The store IS the container; the per-scope index document disappears. +- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** 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`, installed `index.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 two `AddInboxCap` commits 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.** Every `Repo` carries `inbox: Option` (`engine/repo/src/repo.rs:126`); `AddInboxCapV0` is keyed by `repo_id` with no is-store restriction (`engine/repo/src/types.rs:1973`; applied at `engine/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 the `Repo` with `inbox: 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()` → level 1, VERIFIED model, no JS surface.** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:1939-1948` — *"so that a user can share with all its device a new Link they received"*, external repos only). 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). +- **`addLink(cap)` / `readLinks()` → annotated `declared-not-wired` (level 1).** The emulated `AddLink { 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, its `CommitVerifier::verify` is a no-op `Ok(())` (`engine/verifier/src/commits/mod.rs`), and **nothing in the engine constructs one** — the only other occurrences are the `CommitBodyV0::AddLink` enum variant and two match arms classifying body kinds. Its sibling `AddRepoV0` *is* constructed (`Verifier::send_add_repo_to_store`), which is why the Store-branch register next door is annotated `aligned` and 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's `AddInboxCap` records; nothing suggests a JS API for it. These exist for the emulated read guard and the connection drain. diff --git a/packages/polyfill/src/emulated-verifier/branch-registers.ts b/packages/polyfill/src/emulated-verifier/branch-registers.ts index f50e29c..03a3c21 100644 --- a/packages/polyfill/src/emulated-verifier/branch-registers.ts +++ b/packages/polyfill/src/emulated-verifier/branch-registers.ts @@ -68,6 +68,7 @@ import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, Scope } from ".. * inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false * for everyone until an identity is set. */ +// @provenance isOwnInbox kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — 'which inboxes may I read' is answered by the User branch's records export async function isOwnInbox(nuri: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) return false; @@ -100,6 +101,7 @@ export async function isOwnInbox(nuri: Nuri): Promise { * not ours to hold. `id` is compared through the shim key, so it matches however * the consumer spells the identity. */ +// @provenance holdOwnCap kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the cap of a document you created, filed on the store's Store branch. WIRED upstream — `send_add_repo_to_store` constructs it export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; @@ -124,6 +126,7 @@ export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): v * Scoped to the current holder, like {@link holdOwnCap}: another user's stores are * emphatically not ours to hold. */ +// @provenance fileOwnStructure kind=aligned level=1 ref=engine/verifier/src/site.rs:create_individual_ — a user's three stores are its own repos; it holds their caps by BEING a user export function fileOwnStructure(id: string, record: VirtualUserRecord): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; @@ -135,6 +138,7 @@ export function fileOwnStructure(id: string, record: VirtualUserRecord): void { /** Same, for the user's own inbox — it is its document, and it must be able to * read it. Depositing into someone else's needs no cap (see `register-write.depositInto`). */ +// @provenance fileOwnInbox kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — upstream the record IS the key (`priv_key`); here the cap is minted for the holder the record belongs to export function fileOwnInbox(id: string, inbox: Nuri): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; @@ -160,6 +164,7 @@ export function fileOwnInbox(id: string, inbox: Nuri): void { * did not: the address that WAS published is gone, and every future deposit on that * document is refused as "this document has no inbox". */ +// @provenance publishInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — upstream an inbox address is TRANSMITTED (a message, a profile QR) and lands in the verifier's own per-user `inboxes` table; PUBLISHING it on the document is this library's deliberate divergence export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise { const s = await session(); try { @@ -197,6 +202,7 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise * its whole contract is that the connection either did the restore or says it did not. * A caller that genuinely prefers to carry on catches it itself, and one of them does. */ +// @provenance readStoreCaps kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the replay of the Store branch — what reloads a store's documents with their keys export async function readStoreCaps(storeDoc: Nuri): Promise { const s = await session(); const out: ReadCap[] = []; @@ -246,6 +252,7 @@ export async function readStoreCaps(storeDoc: Nuri): Promise { * read. A failed READ is one, and it propagates — the caller is `connect.connectedUser`, * whose contract is that it did the work or said it did not. */ +// @provenance restoreOwnCaps kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — `AddRepo::verify` loads each repo from its read cap at replay; this is that replay, at connection export async function restoreOwnCaps(holderKey: string): Promise { const holder = getCurrentUser(); if (holder === null) return; @@ -294,6 +301,7 @@ export async function restoreOwnCaps(holderKey: string): Promise { * **Never creates.** Asking where to deposit must not bring an inbox into existence — * only its owner opens one, and only on its own document. */ +// @provenance documentInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — the deposit-side half of the same divergence: upstream you are TOLD an address, you never read one off a document export async function documentInboxAddress(doc: Nuri): Promise { // RULE 2 — do not even attempt. Not holding the document, we have no address to // find: upstream the inbox pubkey travels WITH what you can read, so "where do I @@ -333,6 +341,7 @@ export async function documentInboxAddress(doc: Nuri): Promise * place that records authorship. Holding a cap is NOT ownership: a cap can be * received, and a recipient must not be able to open an inbox on what it merely reads. */ +// @provenance ownsDocument kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — authorship is what the Store branch records; holding a cap is NOT ownership, since a cap can be received export async function ownsDocument(doc: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) return false; @@ -381,6 +390,7 @@ function encodeInboxCap(doc: Nuri, inbox: Nuri): string { * make a document's queue silently un-drained — a share that was delivered and never * applied, with nothing to see anywhere. */ +// @provenance readInboxCapPairs kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the record is keyed by `repo_id` and `update_inbox_cap_v0` applies it with no is-store check — but the engine only ever commits one for the two STORE repos, never for a plain document export async function readInboxCapPairs(): Promise> { const holder = getCurrentUser(); if (holder === null) return []; @@ -411,6 +421,7 @@ export async function readInboxCapPairs(): Promise { return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox; } @@ -437,6 +448,7 @@ export async function readInboxCapsFor(doc: Nuri): Promise { * sign-in, on a document the application had never named, repeating at every connection * because the record is durable. Reported live, three sign-ins out of three. */ +// @provenance myInboxes kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the User branch answers 'which inboxes may I read'. The document-inbox half of this list is declared-not-wired — see `readInboxCapPairs` export async function myInboxes(): Promise { const holder = getCurrentUser(); if (holder === null) return []; @@ -473,6 +485,7 @@ export async function myInboxes(): Promise { * wrong is not a near-miss: a Link filed under the wrong holder gives one user another's * capability and leaves the real recipient with nothing. */ +// @provenance addLink kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddLinkV0 — the type is DEFINED and its `CommitVerifier::verify` is a no-op `Ok(())`; nothing in the engine constructs one export async function addLink(cap: ReadCap, forHolder?: PrincipalId): Promise { const holder = forHolder ?? getCurrentUser(); if (holder === null) return; @@ -525,6 +538,7 @@ function holderRing(forHolder: PrincipalId | undefined): string | undefined { * `forHolder` names whose User branch is read when it is not the connected identity's — * see {@link addLink}, whose idempotence check is the caller that needs it. */ +// @provenance readLinks kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddLinkV0 — same: defined, verifier is a no-op, constructed nowhere. NOT this library's invention — an absent implementation says nothing about the target export async function readLinks(forHolder?: PrincipalId): Promise { const holder = forHolder ?? getCurrentUser(); if (holder === null) return []; @@ -608,6 +622,7 @@ export async function readLinks(forHolder?: PrincipalId): Promise { * owner. To deposit into someone else's document, resolve * {@link documentInboxAddress} and `inbox.post` into it. */ +// @provenance storeRegistry.openDocumentInbox kind=declared-not-wired level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — every `Repo` carries `inbox: Option` and the record is keyed by any `repo_id`, but `new_store_default` attaches one only to non-private STORES and `doc_create` leaves `inbox: None`. PUBLISHING the address is a separate, divergent act — see `publishInboxAddress` export async function openDocumentInbox(docLike: NuriLike): Promise { // Permissive in, precise out — see `model/nuri.ts`. Published through // `surface/placement.ts`, so it is a door an application types against. diff --git a/packages/polyfill/src/emulated-verifier/caps.ts b/packages/polyfill/src/emulated-verifier/caps.ts index b881273..b06f13e 100644 --- a/packages/polyfill/src/emulated-verifier/caps.ts +++ b/packages/polyfill/src/emulated-verifier/caps.ts @@ -83,6 +83,7 @@ const STAND_IN_CAP = "OK"; * Build the cap-bearing form of `nuri` — `{target}:r:OK`. Passing an already * cap-bearing reference yields the same value. INTERNAL to the emulated verifier. */ +// @provenance mintCap kind=divergent level=1 ref=engine/repo/src/types.rs:readcap_nuri — upstream a cap is SERIALIZED from the ObjectRef at repo creation; here it is DERIVED from a NURI with a stand-in key. Unreachable from outside on purpose export function mintCap(nuri: Nuri): ReadCap { return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`; } @@ -90,6 +91,7 @@ export function mintCap(nuri: Nuri): ReadCap { /** The map key of the anonymous holder (no identity established yet). */ const ANONYMOUS = ""; +// @provenance CapRegistry kind=aligned level=1 ref=engine/repo/src/repo.rs:Repo — the session's record of which repos it holds a `read_cap` for; upstream that record is the verifier's opened repos, never a queryable registry export class CapRegistry { /** holder → the caps they hold, indexed by the cap-less NURI. */ private heldByHolder = new Map>(); diff --git a/packages/polyfill/src/emulated-verifier/connect.ts b/packages/polyfill/src/emulated-verifier/connect.ts index 9baa0b7..e07aa91 100644 --- a/packages/polyfill/src/emulated-verifier/connect.ts +++ b/packages/polyfill/src/emulated-verifier/connect.ts @@ -126,6 +126,7 @@ const inFlight = new Map>(); * Not fixed with a retry, a timeout or a flag on purpose: deciding *what to do* about a * broker that cannot answer belongs to the caller, and it can only decide if it is told. */ +// @provenance connectedUser kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream the verifier restores and processes with nothing for a caller to await; this is the awaitable form, for a deterministic start export async function connectedUser(): Promise { const holder = getCurrentUser(); if (holder === null) return; @@ -223,6 +224,7 @@ export async function connectedUser(): Promise { * inherits it, failure included. That is what `ensureIdentity()` does, and it is the path * on which the guarantee is published. */ +// @provenance startConnect kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — the fire-and-forget form of the same — upstream no application asks for it at all export function startConnect(): void { void connectedUser().catch((error: unknown) => { console.error(accessLogPrefix() + " connect failed:", error); diff --git a/packages/polyfill/src/emulated-verifier/inbox-processor.ts b/packages/polyfill/src/emulated-verifier/inbox-processor.ts index 5769d5f..03ed1aa 100644 --- a/packages/polyfill/src/emulated-verifier/inbox-processor.ts +++ b/packages/polyfill/src/emulated-verifier/inbox-processor.ts @@ -126,6 +126,7 @@ function fire(inbox: Nuri, process: () => Promise): void { * * Idempotent inside the window: a second deposit into the same inbox joins the pending run. */ +// @provenance scheduleInboxProcessing kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream the RECIPIENT's own verifier applies its inbox; here another identity's session drains it on a timer. Deliberate, unpublished, and only possible on one shared wallet export function scheduleInboxProcessing(inbox: Nuri, process: () => Promise): void { if (scheduled.has(inbox)) return; const handle = setTimeout(() => fire(inbox, process), PROCESSING_DELAY_MS); @@ -145,6 +146,7 @@ export function scheduleInboxProcessing(inbox: Nuri, process: () => Promise { for (const [inbox, entry] of [...scheduled]) { clearTimeout(entry.handle); @@ -161,12 +163,14 @@ export async function runScheduledInboxProcessingNow(): Promise { * session that no longer exists. In-flight runs are not cancelled (nothing can un-issue a * write already sent); they end where they end. */ +// @provenance cancelScheduledInboxProcessing kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — cancels a pending drain — upstream there is no pending anything to cancel export function cancelScheduledInboxProcessing(): void { for (const entry of scheduled.values()) clearTimeout(entry.handle); scheduled.clear(); } /** Trace one completed drain — diagnostics, so gated by the access-log flag. */ +// @provenance traceProcessed kind=invention level=none ref=none — a diagnostic line in this package's own log stream export function traceProcessed(inbox: Nuri, holder: string, applied: number): void { logStage( "DEFERRED PROCESS " + shortNuri(inbox) + " for " + holder + " → " + applied + " link(s) applied", diff --git a/packages/polyfill/src/emulated-verifier/machinery.ts b/packages/polyfill/src/emulated-verifier/machinery.ts index 0bd600c..617cff1 100644 --- a/packages/polyfill/src/emulated-verifier/machinery.ts +++ b/packages/polyfill/src/emulated-verifier/machinery.ts @@ -29,6 +29,7 @@ * its reads. That is a deliberate reservation, not a hazard to guard against: the * namespace names this library. */ +// @provenance MACHINERY_NS kind=invention level=none ref=none — upstream a branch is a distinct CRDT with its own topic; a reserved subject prefix exists only because this library has no branches export const MACHINERY_NS = "urn:ng-eventually:"; /** @@ -37,6 +38,7 @@ export const MACHINERY_NS = "urn:ng-eventually:"; * Tolerant of `undefined` so a read path can hand it a possibly-absent binding * without a preliminary check — an absent subject is not machinery. */ +// @provenance isMachinerySubject kind=invention level=none ref=none — same — upstream nothing has to be filtered out of a document's own graph export function isMachinerySubject(subject: string | undefined): boolean { return subject !== undefined && subject.startsWith(MACHINERY_NS); } diff --git a/packages/polyfill/src/emulated-verifier/open-repo.ts b/packages/polyfill/src/emulated-verifier/open-repo.ts index 09488a2..398d7f6 100644 --- a/packages/polyfill/src/emulated-verifier/open-repo.ts +++ b/packages/polyfill/src/emulated-verifier/open-repo.ts @@ -85,6 +85,7 @@ import type { Nuri } from "../model/types"; * - `"timed-out"` fallback fired without a `State` — open proceeded, sync UNconfirmed; * `"unknown"` (from {@link getSyncState}) means "never requested / no `State` semantics". */ +// @provenance SyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — the first `State` push IS the barrier; `timed-out` names the bounded fallback and is deliberately not `synced` export type SyncState = "syncing" | "synced" | "timed-out"; /** Repos whose bootstrap open has completed (first `State` received OR timed out). */ @@ -114,12 +115,14 @@ let OPEN_TIMEOUT_MS = 8000; * 8s production wait, too slow for a unit test. Production never calls this — the * default stands. `resetOpenedRepos` restores the default. */ +// @provenance setOpenTimeoutForTests kind=invention level=none ref=none — a test seam over the bounded fallback export function setOpenTimeoutForTests(ms: number): void { OPEN_TIMEOUT_MS = ms; } /** Reset the open registry (mainly for tests / a switched wallet). Tears down the * held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */ +// @provenance resetOpenedRepos kind=invention level=none ref=none — per-session bookkeeping reset; upstream the verifier owns `self.repos` and nothing resets it from outside export function resetOpenedRepos(): void { for (const unsub of held.values()) { try { @@ -145,6 +148,7 @@ export function resetOpenedRepos(): void { * `"timed-out"` (fallback fired without a `State`). `"synced"` and `"timed-out"` * are deliberately distinct — a later readiness signal must not confuse them. */ +// @provenance getSyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — reports whether the first `State` arrived; upstream this is not observable from JS export function getSyncState(nuri: Nuri): SyncState | "unknown" { return syncState.get(nuri) ?? "unknown"; } @@ -184,6 +188,7 @@ async function syncSession(): Promise { * another holder would put a cap in the wrong ring, and a caller on that path has already * established what its holder holds. */ +// @provenance ensureRepoOpen kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — a real subscription held for the session — upstream `doc_subscribe` is what brings a repo into `self.repos`, which `resolve_target_for_sparql` then requires export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise { if (!nuri) return; // A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on @@ -204,6 +209,7 @@ export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise { // Drop the registry if the session changed (in-page re-login → fresh verifier). await syncSession(); @@ -274,6 +280,7 @@ export async function openRepoUnguarded(nuri: Nuri): Promise { * Open a SET of repos before an anchored batch read, in parallel, each tolerant * ({@link ensureRepoOpen} never throws). Empty / falsy entries are ignored. */ +// @provenance ensureReposOpen kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — the batch form, with per-doc isolation export async function ensureReposOpen(nuris: Nuri[]): Promise { const unique = [...new Set(nuris.filter(Boolean))]; if (unique.length === 0) return; diff --git a/packages/polyfill/src/emulated-verifier/public-store.ts b/packages/polyfill/src/emulated-verifier/public-store.ts index c5707f7..b54f346 100644 --- a/packages/polyfill/src/emulated-verifier/public-store.ts +++ b/packages/polyfill/src/emulated-verifier/public-store.ts @@ -110,6 +110,7 @@ import type { Nuri, ReadCap } from "../model/types"; const attempted = new Map>(); /** Forget every outer-overlay fetch (tests / a switched session or wallet). */ +// @provenance resetPublicStoreFetches kind=invention level=none ref=none — forgets the memoised fetches; upstream there is nothing to memoise, the broker answers export function resetPublicStoreFetches(): void { attempted.clear(); } @@ -130,6 +131,7 @@ export function resetPublicStoreFetches(): void { * caller `createEntityDoc` already refuses to hand back a reference whose bookkeeping did * not land, for the same reason and in the same words. */ +// @provenance exposeReadCap kind=divergent level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — upstream NOTHING is written to make a repo public: the store is public and the broker exposes its outer overlay. Here the cap is written on the document's Header branch export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise { const s = await session(); try { @@ -164,6 +166,7 @@ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise { * Never throws — a document that is not in a public store simply answers nothing, which * is not an error but the normal case. */ +// @provenance fetchReadCap kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the type DECLARES the cap is downloaded from the outer overlay if the brokers allow; it is constructed nowhere, both `PinRepo` constructors hard-code `expose_outer: false`, and `ExtTopicSyncReq` is `unimplemented!()` export async function fetchReadCap(docLike: Nuri): Promise { const doc = targetOf(docLike); const caps = getCaps(); @@ -221,6 +224,7 @@ async function downloadReadCap(doc: Nuri): Promise { * Ask for a SET of documents' caps, in parallel — what a batch read does before it * decides which documents it may touch. Each fetch is independent and tolerant. */ +// @provenance fetchReadCaps kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the batch form of the same unwired mechanism export async function fetchReadCaps(docs: Nuri[]): Promise { const unique = [...new Set(docs.filter(Boolean))]; if (unique.length === 0) return; diff --git a/packages/polyfill/src/emulated-verifier/reach.ts b/packages/polyfill/src/emulated-verifier/reach.ts index 3eef759..b14c78b 100644 --- a/packages/polyfill/src/emulated-verifier/reach.ts +++ b/packages/polyfill/src/emulated-verifier/reach.ts @@ -71,6 +71,7 @@ import type { Nuri } from "../model/types"; * fixes is which ring the question is about, so that work decided for one identity is not * judged against another's possession. */ +// @provenance mayReach kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — upstream possession decides by whether the key opens the repo at all; here it is a lookup, because nothing is encrypted yet export function mayReach(nuri: Nuri, holderKey?: string): boolean { const caps = getCaps(); if (!caps.isEnforcing()) return true; @@ -94,6 +95,7 @@ export function mayReach(nuri: Nuri, holderKey?: string): boolean { * {@link mayReach}. The refusal names it too, or a processor's refusal would read as the * connected identity's, which is the one thing nobody would then go and check. */ +// @provenance assertMayReach kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — the throwing form, at the passage points; upstream the equivalent failure is a repo that simply never decrypts export function assertMayReach(nuri: Nuri, op: string, holderKey?: string): void { if (mayReach(nuri, holderKey)) return; const who = holderKey === undefined ? "the connected user" : `holder ${JSON.stringify(holderKey)}`; @@ -144,6 +146,7 @@ export function assertMayReach(nuri: Nuri, op: string, holderKey?: string): void * `docs.registerUpdate`, because a store document is not OWNED in this sense — it IS a * store, and the verifier commits to it on its own behalf. */ +// @provenance assertMayWrite kind=aligned level=1 ref=engine/repo/src/commit.rs:verify_perm — upstream the right to write is membership of the repo, checked on commits only; ownership stands in for it here export async function assertMayWrite(nuri: Nuri, op: string): Promise { const caps = getCaps(); if (!caps.isEnforcing()) return; @@ -175,6 +178,7 @@ export async function assertMayWrite(nuri: Nuri, op: string): Promise { * * `holderKey` names the ring the question is about — see {@link mayReach}. */ +// @provenance mustNotAttempt kind=aligned level=1 ref=engine/verifier/src/request_processor.rs:resolve_target — upstream a repo you cannot open answers `RepoNotFound`; this is the do-not-even-attempt form of the same absence export function mustNotAttempt(nuri: Nuri, holderKey?: string): boolean { return !mayReach(nuri, holderKey); } diff --git a/packages/polyfill/src/emulated-verifier/read-filter.ts b/packages/polyfill/src/emulated-verifier/read-filter.ts index ec5b389..0d112c3 100644 --- a/packages/polyfill/src/emulated-verifier/read-filter.ts +++ b/packages/polyfill/src/emulated-verifier/read-filter.ts @@ -54,6 +54,7 @@ function readable(item: unknown, caps: CapRegistry): boolean { } /** Pure: keep only the items whose document the current holder holds. */ +// @provenance filterReadable kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — upstream the broker only delivers what the wallet's keys decrypt; the access unit is the DOCUMENT, which is why this filters per `@graph` export function filterReadable(items: Iterable, caps: CapRegistry): T[] { const out: T[] = []; for (const item of items) if (readable(item, caps)) out.push(item); @@ -85,6 +86,7 @@ export function filterReadable(items: Iterable, caps: CapRegistry): T[] { * Everything that is not a function passes through untouched (`size` is handled above): * a plain property carries no items. */ +// @provenance makeReadFilteredView kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — the same filter as a live view over a reactive set; removed at migration export function makeReadFilteredView(set: S, caps: CapRegistry): S { const keep = (item: unknown): boolean => readable(item, caps); /** The readable items, as a plain array — what every handled member works from. */ diff --git a/packages/polyfill/src/emulated-verifier/register-write.ts b/packages/polyfill/src/emulated-verifier/register-write.ts index de8e89e..14274f6 100644 --- a/packages/polyfill/src/emulated-verifier/register-write.ts +++ b/packages/polyfill/src/emulated-verifier/register-write.ts @@ -41,6 +41,7 @@ import type { Nuri } from "../model/types"; * during someone else's session. The guard still fires, against the named ring; see * {@link assertMayReach}. */ +// @provenance registerUpdate kind=aligned level=1 ref=engine/repo/src/types.rs:BranchCrdt — upstream these are service commits on branches whose CRDT is `None`, made by the verifier on its own behalf; here they are SPARQL writes that skip the ownership guard export async function registerUpdate( sessionId: string | number, query: string, @@ -79,6 +80,7 @@ function forHolderSuffix(holderKey: string | undefined): string { * * Never exported from the package — it reads a document the connected identity may not. */ +// @provenance readForHolder kind=invention level=none ref=none — reading on behalf of an identity other than the session's exists only because one wallet hosts every identity export async function readForHolder( sessionId: string | number, query: string, @@ -115,6 +117,7 @@ export async function readForHolder( * The recipient's ownership of the inbox is what bounds this: `inbox.post` is the * only caller, and reading is guarded separately (`inbox.read`). */ +// @provenance depositInto kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:post_to_inbox — the one write that legitimately crosses: upstream a deposit is an anonymous sealed box, so it holds no cap and gets nothing back export async function depositInto( sessionId: string | number, query: string, diff --git a/packages/polyfill/src/index.ts b/packages/polyfill/src/index.ts index b44a580..9050f89 100644 --- a/packages/polyfill/src/index.ts +++ b/packages/polyfill/src/index.ts @@ -43,8 +43,20 @@ */ // ── SDK-SHAPED — a target counterpart for every symbol ────────────────────── -// At migration the build alias is removed and these resolve to the real SDK. The -// per-symbol ruling, with its epistemic label, is in `docs/api-contract.md`. +// At migration the build alias is removed and these resolve to the real SDK. +// +// WHAT each symbol is aligned on is recorded BESIDE IT, not in a document that drifts: +// +// // @provenance kind= level=<1|2|3|none> ref= +// +// `level` numbers the target's stack from the bottom (1 engine, 2 wasm binding / +// `@ng-org/web`, 3 JS ORM); `ref` cites a SYMBOL in `nextgraph-rs`, never a line number. +// `kind` is closed: `passthrough` (upstream's own symbol, name and shape), `aligned` (our +// name or ergonomics, upstream's semantics), `declared-not-wired` (upstream DEFINES the +// shape and constructs it nowhere — an absence, which is NOT evidence the thing is ours), +// `invention` (nothing answers, a documented bet), `divergent` (upstream does this, we do +// it differently). Pinned by `test/provenance.test.ts`; the reasoning behind each ruling +// stays in `docs/api-contract.md`, and where the two disagree the annotation wins. // A type is published only when a PUBLISHED SIGNATURE uses it. `export *` published // eight in one gesture (2026-08-10: it was a blanket re-export), of which two named @@ -81,8 +93,13 @@ export * as storeRegistry from "./surface/placement"; // SDK type re-exports — so the app imports these from @ng-eventually/polyfill too, not from // @ng-org. `export type` is ERASED at build, so this adds NO runtime @ng-org import to // the lib (no risk of a duplicate SDK copy in the bundle). +// @provenance ShapeType kind=passthrough level=3 ref=sdk/js/shex-orm/src/types.ts:ShapeType +// @provenance BaseType kind=passthrough level=3 ref=sdk/js/shex-orm/src/types.ts:BaseType +// @provenance Schema kind=passthrough level=3 ref=sdk/js/shex-orm/src/types.ts:Schema export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm"; +// @provenance DeepSignalSet kind=passthrough level=3 ref=sdk/js/alien-deepsignals/src/types.ts:DeepSignalSet export type { DeepSignalSet } from "@ng-org/alien-deepsignals"; +// @provenance NG kind=passthrough level=2 ref=sdk/js/web/src/index.ts:NG — 88 members, all forwarded; the proxy adds none and removes none export type { NG } from "@ng-org/web"; // ── POLYFILL-ERA — THE DELETION LIST ──────────────────────────────────────── @@ -150,4 +167,5 @@ import { makeNg } from "./surface/ng-proxy"; * `Record` until 2026-08-14, which announced a different surface from the one * it forwards to: an application got no completion and no check on any of the 88 members. */ +// @provenance ng kind=passthrough level=2 ref=sdk/js/web/src/index.ts:ng — a Proxy over the injected real `ng`; only `sparql_update` is overridden (the emulated write guard) export const ng: NG = makeNg(); diff --git a/packages/polyfill/src/model/types.ts b/packages/polyfill/src/model/types.ts index cc15412..4a925ab 100644 --- a/packages/polyfill/src/model/types.ts +++ b/packages/polyfill/src/model/types.ts @@ -8,6 +8,7 @@ * `did:ng:` is the URI scheme prefix, not a "without cap" marker; the discriminant * is the `:r:` segment (see {@link ReadCap}). */ +// @provenance Nuri kind=aligned level=1 ref=engine/net/src/app_protocol.rs:NuriV0 — the cap-less reference form upstream's parser accepts; `:r:` is the cap-bearing discriminant export type Nuri = `did:ng:${string}`; /** @@ -31,14 +32,17 @@ export type Nuri = `did:ng:${string}`; * out. The runtime checks stay regardless — a JavaScript consumer never meets the * compiler, and a cast bypasses it. */ +// @provenance ReadCap kind=aligned level=1 ref=engine/repo/src/types.rs:readcap_nuri — the `r:` segment and its one-opaque-segment encoding are upstream's; the key material is a stand-in constant export type ReadCap = `did:ng:${string}:r:${string}`; /** NextGraph-native store scopes. The *mapping* of entities to scopes is the * consumer's concern; this layer only knows the three scopes exist. */ +// @provenance Scope kind=aligned level=1 ref=engine/repo/src/types.rs:from_type_and_repo — upstream's store types; `group` is omitted here and `dialog` is `unimplemented!()` upstream export type Scope = "public" | "protected" | "private"; /** The current identity id. Target: the wallet user (`session.user`). Polyfill: * a chosen id, because everyone shares one wallet. */ +// @provenance PrincipalId kind=divergent level=1 ref=engine/repo/src/types.rs:UserId — upstream a user id is a PubKey obtained FROM the wallet; here it is a chosen name, because one wallet hosts every identity export type PrincipalId = string; /** @@ -48,9 +52,11 @@ export type PrincipalId = string; * makes the wrapper testable with a fake. Permissive on purpose: the real `ng` * carries non-function members too, so we accept any property bag. */ +// @provenance NgLike kind=invention level=none ref=none — nothing upstream is injected; the type exists only to describe what `configure` receives export type NgLike = Record; /** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */ +// @provenance UseShapeLike kind=invention level=none ref=none — same, for the injected `useShape` export type UseShapeLike = (...args: any[]) => any; /** @@ -76,6 +82,8 @@ export type UseShapeLike = (...args: any[]) => any; * So: precise on the way out, permissive on the way in, and validated inside * (`assertNuri`). The guards remain, internal, where the validation happens. */ +// @provenance NuriLike kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — the binding takes `repo_o: String`, so permissive-in matches the target's own door export type NuriLike = Nuri | string; +// @provenance InboxScope kind=aligned level=1 ref=engine/verifier/src/verifier.rs:new_store_default — an inbox is attached only `if !private`, so the private scope cannot carry one export type InboxScope = Extract; diff --git a/packages/polyfill/src/shared-wallet/access-gate.ts b/packages/polyfill/src/shared-wallet/access-gate.ts index c0cc5c2..194fa8e 100644 --- a/packages/polyfill/src/shared-wallet/access-gate.ts +++ b/packages/polyfill/src/shared-wallet/access-gate.ts @@ -150,6 +150,7 @@ const URL_PARAM = "ng-id"; * passes the VALUES here. A library that read `process.env` would impose its build * system on every consumer, and would be untestable with other values. */ +// @provenance SharedWalletConfig kind=invention level=none ref=none — describes what a DEPLOYMENT hands out for the one shared wallet; it goes with the gate export interface SharedWalletConfig { /** URL of the `.ngw` file served by the application's own bundle. */ fileUrl: string; @@ -555,6 +556,7 @@ async function resolveIdentity(): Promise { * application had to read the gate's own private storage key — a boundary no consumer * should be able to see, let alone depend on. */ +// @provenance ensureIdentity kind=invention level=none ref=none — nothing answers 'gate a shared wallet'. Upstream a user opens THEIR wallet and `session_start(wallet_name, user_id)` takes an id that came from it — so the CALL SITE survives, the substance does not export async function ensureIdentity(): Promise { const settled = await settleIdentity(); refuseAWaitNothingCanEnd(); diff --git a/packages/polyfill/src/shared-wallet/account-registry.ts b/packages/polyfill/src/shared-wallet/account-registry.ts index fc3f6ea..f71104e 100644 --- a/packages/polyfill/src/shared-wallet/account-registry.ts +++ b/packages/polyfill/src/shared-wallet/account-registry.ts @@ -968,6 +968,7 @@ async function scopeStoreNuri(scope: Scope): Promise { * store-id and builds no NURI. Use the returned value as both the read scope * (`useShape(shape, nuri)`) and the `@graph` write target. */ +// @provenance storeRegistry.resolveScopeGraph kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — `did:ng:` + `private_store_id | protected_store_id | public_store_id`, straight off the session export async function resolveScopeGraph(scope: Scope): Promise { return scopeStoreNuri(scope); } @@ -1043,6 +1044,7 @@ const inboxCache = new Map(); * At migration these become the site's native store inboxes and the resolution moves * there — the consumer-facing act (deposit to an inbox, process my own) is unchanged. */ +// @provenance userInbox kind=aligned level=1 ref=engine/verifier/src/site.rs:create_individual_ — a user's inboxes are its public and protected STORE repos' — the engine's only two `AddInboxCap` commits export async function userInbox(id: string, scope: InboxScope): Promise { const key = `${accountKey(id)}\u0000${scope}`; const cached = inboxCache.get(key); @@ -1261,6 +1263,7 @@ export async function readUserStore(indexDoc: Nuri): Promise { * here). Idempotent via `ensureAccount`'s cache. At migration this becomes the * user's real per-scope store NURI (the container the store itself provides). */ +// @provenance userStoreDoc kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — the per-scope index document stands in for the store the session names directly export async function userStoreDoc(id: string, scope: Scope): Promise { const record = await ensureAccount(id); return storeOf(record, scope); diff --git a/packages/polyfill/src/shared-wallet/bootstrap.ts b/packages/polyfill/src/shared-wallet/bootstrap.ts index bf52525..ded013b 100644 --- a/packages/polyfill/src/shared-wallet/bootstrap.ts +++ b/packages/polyfill/src/shared-wallet/bootstrap.ts @@ -105,6 +105,7 @@ export interface StoreRegistryDeps { * carried two defects at once. Both remain substitutable through {@link StoreRegistryDeps}, * which the published entry cannot reach. */ +// @provenance EventuallyConfig kind=invention level=none ref=none — nothing upstream takes an injected SDK; this is the shape of the build-alias, and it is on the deletion list export interface EventuallyConfig { /** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */ ng: NgLike; @@ -188,6 +189,7 @@ function capsHolder(): PrincipalId | null { */ let caps = new CapRegistry(capsHolder); +// @provenance configure kind=invention level=none ref=none — same — the whole call disappears at migration, when the consumer initialises the real SDK directly export function configure(c: EventuallyConfig): void { cfg = c; // Not taken from the config: an application never supplies its own identity — upstream diff --git a/packages/polyfill/src/surface/docs.ts b/packages/polyfill/src/surface/docs.ts index 05d2aa5..ffd1a57 100644 --- a/packages/polyfill/src/surface/docs.ts +++ b/packages/polyfill/src/surface/docs.ts @@ -51,6 +51,7 @@ function rowCount(result: unknown): number { // through this chain — never normalise it, and above all never stringify it: a JS string // fails that deserialization, observed live as // `Deserialization error of session_id JsValue("1")`. +// @provenance docs.docCreate kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_create — same arguments in the same order; JS-cased name, and the broker's answer is validated into a `Nuri` export async function docCreate( sessionId: string | number, crdt: string, @@ -98,6 +99,7 @@ export async function docCreate( * {@link sparqlQuery} already renders the same upstream `Promise`: the value is * the broker's to shape, and a caller that ignores it is unaffected. */ +// @provenance docs.sparqlUpdate kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:sparql_update — same arguments; the trailing `label` is a lib-internal access-log tag, never forwarded export async function sparqlUpdate( sessionId: string | number, query: string, @@ -131,6 +133,7 @@ export async function sparqlUpdate( * Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the * query base IRI (usually `undefined`); `anchor` is the document NURI to query. */ +// @provenance docs.sparqlQuery kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:sparql_query — same arguments; the trailing `label` is a lib-internal access-log tag, never forwarded export async function sparqlQuery( sessionId: string | number, query: string, diff --git a/packages/polyfill/src/surface/inbox.ts b/packages/polyfill/src/surface/inbox.ts index a159785..4ae49d4 100644 --- a/packages/polyfill/src/surface/inbox.ts +++ b/packages/polyfill/src/surface/inbox.ts @@ -58,6 +58,7 @@ import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types"; // --- deposit model -------------------------------------------------------- /** One deposit as materialized from an inbox document. */ +// @provenance inbox.Deposit kind=aligned level=1 ref=engine/net/src/types.rs:InboxMsgBody — `from` optional like `from_inbox`, and NO target-document field because the address identifies the recipient repo; `payload`/`ts` have no upstream field export interface Deposit { /** The sender, if identified; `null` when the deposit was anonymous. */ from: PrincipalId | null; @@ -68,6 +69,7 @@ export interface Deposit { } /** Options for {@link post}. `from` and `ts` are both optional. */ +// @provenance inbox.PostOptions kind=aligned level=1 ref=engine/net/src/types.rs:InboxMsgBody — same shape as Deposit on the way in; `from: null` mirrors `from_inbox: None` export interface PostOptions { /** * Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a @@ -169,6 +171,7 @@ function readBindings(result: unknown): Array> * another's identity. This check is redundant once the seal enforces it, but * until then it closes the spoof the shared wallet would otherwise allow. */ +// @provenance inbox.post kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:post_to_inbox — the act exists at level 1 and the broker routes it (`inbox_post`); no JS surface at level 2/3. An arbitrary `payload` is a BET — `InboxMsgContent` has no generic variant export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.post"); const current = getCurrentUser(); @@ -269,6 +272,7 @@ export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promis * address in advance, because an application must name a document or a person, never an * inbox. */ +// @provenance inbox.postToDocument kind=declared-not-wired level=1 ref=engine/repo/src/repo.rs:Repo — every repo carries `inbox: Option`, but nothing opens one for a plain document. Resolving the address by READING the document is our divergence — see `publishInboxAddress` export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise { const doc = toNuri(docLike, "inbox.postToDocument"); const target = await documentInboxAddress(doc); @@ -364,6 +368,7 @@ function capOfPayload(payload: unknown): ReadCap | null { * The shape is right; the implementation is absent at both ends, so we emulate it * meanwhile. */ +// @provenance inbox.share kind=declared-not-wired level=1 ref=engine/net/src/types.rs:ContactDetails — `read_cap: Option` is defined; building the message is `unimplemented!()`, its only caller passes `with_readcap: false`, and the receiving arm never reads the field export async function share(doc: NuriLike, toUser: string): Promise { const target = toNuri(doc, "inbox.share"); // Names the DOCUMENT and the PERSON — the two things an application has. Neither the @@ -431,6 +436,7 @@ export async function share(doc: NuriLike, toUser: string): Promise { * no more reason to handle an inbox address than a depositor does. Empty when the * document has no inbox, which is a state and not an error. */ +// @provenance inbox.readForDocument kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — upstream an inbox is a queue the verifier consumes and APPLIES; this enumerates it instead, non-destructively export async function readForDocument(docLike: NuriLike): Promise { const doc = toNuri(docLike, "inbox.readForDocument"); const address = await documentInboxAddress(doc); @@ -484,6 +490,7 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise { * watching its inbox gets them, and the resulting change re-triggers the * reads that were empty for want of that cap. */ +// @provenance inbox.read kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — same: a re-readable list where upstream has a consumed queue. Coding a mailbox UI on this is coding against emulation detail export async function read(targetInboxLike: NuriLike): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.read"); await assertOwnInbox(targetInbox, "read"); @@ -578,6 +585,7 @@ export async function read(targetInboxLike: NuriLike): Promise { * `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on * the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected. */ +// @provenance inbox.readSynced kind=divergent level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — same as `read`, gated on the sync barrier first export async function readSynced(targetInboxLike: NuriLike): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.readSynced"); // Marks the cold, connection-triggered entry point in the trace — the BARRIER @@ -604,6 +612,7 @@ export async function readSynced(targetInboxLike: NuriLike): Promise * second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as * {@link read} does — Links are never surfaced. */ +// @provenance inbox.processInbox kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — unseal-and-apply: Links are filed durably and never surfaced. Returning the remaining deposits is the divergent half it shares with `read` export async function processInbox(targetInboxLike: NuriLike): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.processInbox"); const deposits = await readSynced(targetInbox); @@ -711,6 +720,7 @@ async function processForOwner(targetInbox: Nuri): Promise { * there is no polling. (The inbox document is a single doc, so this is immune to * the ORM fan-out hang — see {@link subscribeDoc}.) */ +// @provenance inbox.watch kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream a message is applied on arrival and there is nothing to subscribe to; this pushes the raw deposit list on every change export function watch( targetInboxLike: NuriLike, onDeposits: (deposits: Deposit[]) => void, diff --git a/packages/polyfill/src/surface/lifecycle.ts b/packages/polyfill/src/surface/lifecycle.ts index 8a77a3f..1dfb594 100644 --- a/packages/polyfill/src/surface/lifecycle.ts +++ b/packages/polyfill/src/surface/lifecycle.ts @@ -58,6 +58,7 @@ import { captureSession, expectSession } from "../shared-wallet/session"; * it (`callback: Function | null`, and the call site is guarded). The wrapper still goes in, * so the package gets its session either way, and calls nothing afterwards. */ +// @provenance init kind=passthrough level=2 ref=sdk/js/web/src/index.ts:init — arguments and return pass straight through; the callback in position 0 is wrapped to keep the session it carries export function init(...args: any[]): any { const f = getConfig().init; if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()"); @@ -90,6 +91,7 @@ export function init(...args: any[]): any { } /** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */ +// @provenance initNg kind=passthrough level=3 ref=sdk/js/orm/src/core.ts:initNg — upstream's own exported name for `initNgSignals` export function initNg(...args: any[]): any { const f = getConfig().initNg; if (!f) throw new Error("[ng-eventually] initNg() not injected — pass it to configure()"); diff --git a/packages/polyfill/src/surface/placement.ts b/packages/polyfill/src/surface/placement.ts index 527d7cd..caad0fc 100644 --- a/packages/polyfill/src/surface/placement.ts +++ b/packages/polyfill/src/surface/placement.ts @@ -61,16 +61,19 @@ function connectedIdentity(op: string): string { } /** Create a document for ONE entity in `scope`, and record it in that scope's store. */ +// @provenance storeRegistry.createEntityDoc kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_create — upstream `doc_create` performs the `ldp:contains` listing and the `AddRepo { read_cap }` natively; here they are two explicit writes export async function createEntityDoc(scope: Scope): Promise { return registryCreateEntityDoc(connectedIdentity("createEntityDoc"), scope); } /** The entity documents this user owns in `scope` — with their caps recovered. */ +// @provenance storeRegistry.listMyEntityDocs kind=aligned level=1 ref=engine/verifier/src/request_processor.rs:doc_create — reads back the `ldp:contains` listing that `doc_create` writes on the store's Main branch export async function listMyEntityDocs(scope: Scope): Promise { return registryListMyEntityDocs(connectedIdentity("listMyEntityDocs"), scope); } /** The NURI where GROUPED entities of `scope` are written (no per-entity document). */ +// @provenance storeRegistry.resolveWriteGraph kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — a scope resolves to the session's own store id; upstream the store IS the container and the per-scope index document disappears export async function resolveWriteGraph(scope: Scope): Promise { return registryResolveWriteGraph(connectedIdentity("resolveWriteGraph"), scope); } diff --git a/packages/polyfill/src/surface/read-model.ts b/packages/polyfill/src/surface/read-model.ts index 0ef3a86..6eeb1bb 100644 --- a/packages/polyfill/src/surface/read-model.ts +++ b/packages/polyfill/src/surface/read-model.ts @@ -57,6 +57,7 @@ void docCreate; void sparqlUpdate; /** One subject read from a doc, with its properties (predicate → values). */ +// @provenance UnionSubject kind=invention level=none ref=none — no upstream type groups triples per (document, subject); a polyfill property bag, to be mapped into app types at the boundary export interface UnionSubject { /** * The subject IRI (`?s`), exactly as the document carries it. @@ -163,6 +164,7 @@ async function readDoc( * read with an anchored default-graph query, O(1) per doc, independent of wallet * size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`. */ +// @provenance readUnion kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:sparql_query — one anchored query per document — upstream's own anchoring (`resolve_target_for_sparql`), composed client-side export async function readUnion(docsLike: NuriLike[]): Promise { const sid = await sessionId(); // Drop the empties BEFORE validating, not after: this call has always tolerated a diff --git a/packages/polyfill/src/surface/subscribe.ts b/packages/polyfill/src/surface/subscribe.ts index 1fd14b9..4fc4e95 100644 --- a/packages/polyfill/src/surface/subscribe.ts +++ b/packages/polyfill/src/surface/subscribe.ts @@ -45,6 +45,7 @@ import type { Nuri, NuriLike } from "../model/types"; * change SIGNAL (re-query on change — the read-model pattern), so this stays * permissive rather than modelling every AppResponse variant. */ +// @provenance DocChange kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — the raw serialized response, left `unknown` rather than modelling every variant export type DocChange = unknown; /** @@ -58,6 +59,7 @@ export type DocChange = unknown; * `TabInfo`/`Patch` pushes — see `open-repo.ts`. Most callers ignore it and use * any push as a plain change signal. */ +// @provenance DocChangeType kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — the variant discriminant; a bare string because the enum is open (State | Patch | TabInfo | …) export type DocChangeType = string | undefined; /** @@ -67,6 +69,7 @@ export type DocChangeType = string | undefined; * is not a recognisable `{ V0: { : … } }` object. Inspects the variant * proplerly (no `any`-cast to force it) so a `State` push is identifiable. */ +// @provenance docChangeType kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — reads the variant key out of the payload — a convenience over a verified shape, not an upstream call export function docChangeType(resp: DocChange): DocChangeType { if (!resp || typeof resp !== "object") return undefined; const outer = resp as { V0?: unknown; v0?: unknown }; @@ -77,6 +80,7 @@ export function docChangeType(resp: DocChange): DocChangeType { } /** An unsubscribe function — idempotent (calling it twice is a no-op). */ +// @provenance Unsubscribe kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — upstream resolves to this function; ours is returned synchronously export type Unsubscribe = () => void; async function sessionId(): Promise { @@ -102,6 +106,7 @@ async function sessionId(): Promise { * * Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`). */ +// @provenance subscribeDoc kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — two deliberate deltas: the unsubscribe is returned synchronously, and the callback gets a pre-extracted `type` export function subscribeDoc( nuriLike: NuriLike, onChange: (r: DocChange, type: DocChangeType) => void, @@ -118,6 +123,7 @@ export function subscribeDoc( * owns the machinery's entire privileged door — and for nobody else. It is not * re-exported by either entry point; the `Unguarded` suffix is the warning. */ +// @provenance subscribeDocUnguarded kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — the same call without the reach guard; internal, never published export function subscribeDocUnguarded( nuri: Nuri, onChange: (r: DocChange, type: DocChangeType) => void, @@ -177,6 +183,7 @@ export function subscribeDocUnguarded( * ORM fan-out hang — do NOT replace this with `orm_start_graph(graphs:[…])`). The * set is deduplicated; an empty set returns a no-op unsubscribe. */ +// @provenance subscribeDocs kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — client-side composition with per-doc error isolation; `orm_start_graph` is deliberately unused — it aborts wholesale on one RepoNotFound export function subscribeDocs( nuris: Nuri[], onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void, diff --git a/packages/polyfill/src/surface/use-shape.ts b/packages/polyfill/src/surface/use-shape.ts index 635e53c..50b90e8 100644 --- a/packages/polyfill/src/surface/use-shape.ts +++ b/packages/polyfill/src/surface/use-shape.ts @@ -9,6 +9,7 @@ import { getConfig, getCaps } from "../shared-wallet/bootstrap"; import { makeReadFilteredView } from "../emulated-verifier/read-filter"; +// @provenance useShape kind=passthrough level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape — forwards to the injected real hook; the read-filtered view emulates cryptographic isolation and the generic `T` is widened to `unknown` export function useShape(shapeType: unknown, scope: unknown): unknown { const set = getConfig().useShape(shapeType, scope) as object; const caps = getCaps(); diff --git a/packages/polyfill/src/surface/watch-shape.ts b/packages/polyfill/src/surface/watch-shape.ts index b9d0a57..e709673 100644 --- a/packages/polyfill/src/surface/watch-shape.ts +++ b/packages/polyfill/src/surface/watch-shape.ts @@ -70,6 +70,7 @@ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; * the generic per-subject property bags of the read-model (NO application domain); * the app maps them to its own entity types in phase B. */ +// @provenance ShapeQuery kind=invention level=none ref=none — no upstream type carries load state; the distinction IS expressible (`OrmSubscription.readyPromise`) but this vocabulary is ours export interface ShapeQuery { /** The subjects of the requested shape/scope. Empty array when none (never undefined). */ data: T[]; @@ -87,6 +88,7 @@ export interface ShapeQuery { } /** The observable a caller binds with `useSyncExternalStore` (phase B). */ +// @provenance ShapeObservable kind=invention level=none ref=none — no upstream observable exists; the ORM exposes a hook returning a DeepSignalSet and nothing else export interface ShapeObservable { /** The current snapshot. STABLE across calls until it actually changes (so * `useSyncExternalStore` does not loop): the same reference is returned until a @@ -163,6 +165,7 @@ function barrierReached(docs: Nuri[]): boolean { * what kicks off resolution, opening and the first read; before then `getSnapshot` * reports the initial pending snapshot. */ +// @provenance watchShape kind=invention level=none ref=none — nothing upstream distinguishes syncing from synced-empty at the hook; the header's 'planned useShape upgrade' has NO upstream provenance export function watchShape( shapeType: unknown, scope: Scope, diff --git a/packages/polyfill/test/provenance.test.ts b/packages/polyfill/test/provenance.test.ts new file mode 100644 index 0000000..5ee86ac --- /dev/null +++ b/packages/polyfill/test/provenance.test.ts @@ -0,0 +1,202 @@ +/** + * Every published symbol must SAY what it is aligned on — in a machine-checkable line + * beside the code, not in a document that drifts away from it. + * + * ── Why this is a test, and why the line lives in the source ─────────────── + * The library's governing rule is that nothing may diverge from the target. Nothing in + * the code recorded *what each symbol is aligned on*, and it cost twice in one week: + * `docs/api-contract.md` claimed a "1:1 passthrough" that was false in two ways, and a + * maintainer classified `branch-registers.readLinks` as this library's own invention when + * it implements `AddLinkV0` — a type NextGraph **defines** (`engine/repo/src/types.rs`) + * and constructs nowhere. Reading "nothing constructs this upstream" and concluding "so + * it is ours" is inference from an ABSENT IMPLEMENTATION, which the project's own rule + * forbids: a definition is a fact, its being unwired is an absence, and the absence says + * nothing about the target. + * + * So the provenance is recorded per symbol, next to it, and this test pins it. + * + * ── The format ──────────────────────────────────────────────────────────── + * + * // @provenance kind= level=<1|2|3|none> ref= + * + * - **name** — the PUBLISHED name, qualified for a namespace member (`inbox.post`, + * `storeRegistry.createEntityDoc`). Internal symbols carry their own name. It is in + * the line so this file can map an annotation to a symbol without a TypeScript parser. + * - **level** — which layer of the target answers, numbered from the bottom like the + * stack: `1` the engine (`engine/`), `2` the wasm binding / `@ng-org/web` + * (`sdk/js/lib-wasm`, `sdk/js/web`), `3` the JS ORM (`sdk/js/orm`). `none` only when + * nothing answers. + * - **ref** — `path:symbol` into `nextgraph-rs` (a SYMBOL, never a line number: line + * numbers rot and three citations in `docs/api-contract.md` already had). `none` only + * for `invention`. + * - **kind** — from a closed set: + * + * | kind | meaning | + * |----------------------|----------------------------------------------------------------| + * | `passthrough` | upstream's own symbol, same name and shape | + * | `aligned` | our name or ergonomics, upstream's semantics and model | + * | `declared-not-wired` | upstream DEFINES the type or shape; nothing constructs it yet | + * | `invention` | nothing at any level answers — a deliberate, documented bet | + * | `divergent` | upstream does this, and we do it differently | + * + * ── What this test deliberately does NOT check ──────────────────────────── + * That the `ref` path exists on disk. `nextgraph-rs` is a sibling checkout that may be + * absent on another machine, and a test that goes red for that reason teaches nothing — + * it just trains people to skip it. The claim is verified by a human reading the source; + * this file pins that a claim was MADE, is well-formed, and covers the whole surface. + */ +import { test, expect } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const SRC = path.join(import.meta.dir, "..", "src"); + +const KINDS = new Set(["passthrough", "aligned", "declared-not-wired", "invention", "divergent"]); +const LEVELS = new Set(["1", "2", "3", "none"]); +/** `path/to/file.ext:Symbol` — a path and a symbol, never a line number. */ +const REF_SHAPE = /^[A-Za-z0-9_.@/-]+\.[a-z]+:[A-Za-z0-9_]+$/; + +interface Provenance { + name: string; + kind: string; + level: string; + ref: string; + file: string; +} + +/** + * The names a module really EXPORTS — declarations and named re-exports, comments + * stripped first so a name that only appears in prose (an explained removal, a + * `{@link}`) is not mistaken for a live export. Same rule as `vocabulary.test.ts`, + * for the same reason: a gate that says yes to a name that is not there reads as + * verified while proving nothing. + */ +function moduleExports(file: string): Set { + const out = new Set(); + if (!fs.existsSync(file)) return out; + const text = fs + .readFileSync(file, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + for (const m of text.matchAll( + /^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/gm, + )) { + out.add(m[1]!); + } + for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) { + for (const raw of m[1]!.split(",")) { + const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim(); + if (name) out.add(name); + } + } + return out; +} + +/** namespace name → the names it really carries (`export * as ns from "./x"`). */ +function namespaces(): Map> { + const text = fs.readFileSync(path.join(SRC, "index.ts"), "utf8"); + const out = new Map>(); + for (const m of text.matchAll(/export \* as (\w+) from "\.\/([^"]+)"/g)) { + out.set(m[1]!, moduleExports(path.join(SRC, m[2]! + ".ts"))); + } + return out; +} + +/** + * Every symbol the entry publishes, as a consumer names it: bare for a direct export, + * `ns.member` for a namespace member. The namespace NAMES themselves are groupings, not + * symbols — a consumer never holds `docs`, it calls `docs.docCreate` — so they carry no + * provenance of their own and are excluded. + */ +function publishedSymbols(): string[] { + const ns = namespaces(); + const out = new Set(); + for (const name of moduleExports(path.join(SRC, "index.ts"))) { + if (!ns.has(name)) out.add(name); + } + for (const [name, members] of ns) for (const m of members) out.add(`${name}.${m}`); + return [...out].sort(); +} + +/** Every `@provenance` line in the source tree, parsed. */ +function annotations(): Provenance[] { + const out: Provenance[] = []; + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith(".ts")) { + for (const line of fs.readFileSync(full, "utf8").split("\n")) { + // Anchored to a REAL annotation line — a comment that IS the annotation, not + // one that quotes the format. `src/index.ts` documents the shape in its header + // (a `// @provenance kind= …` template nested inside a `//` block); + // an unanchored match read that template as a symbol called `` with a kind + // called ``, and the well-formedness check went red on the documentation + // of its own format. A `<…>` name is skipped for the same reason, belt and + // braces: a placeholder is never a symbol. + const m = line.match( + /^\s*\/\/ @provenance\s+(\S+)\s+kind=(\S+)\s+level=(\S+)\s+ref=(\S+)/, + ); + if (m && !m[1]!.startsWith("<")) { + out.push({ + name: m[1]!, + kind: m[2]!, + level: m[3]!, + ref: m[4]!, + file: path.relative(SRC, full), + }); + } + } + } + } + }; + walk(SRC); + return out; +} + +test("every published symbol carries a provenance annotation", () => { + const annotated = new Set(annotations().map((a) => a.name)); + const missing = publishedSymbols().filter((s) => !annotated.has(s)); + // A failure is a question, not a formality: on what does this symbol align? Read the + // nextgraph-rs source and say so — including "nothing answers", which is `invention`. + expect(missing).toEqual([]); +}); + +test("no symbol is annotated twice", () => { + const seen = new Map(); + for (const a of annotations()) seen.set(a.name, [...(seen.get(a.name) ?? []), a.file]); + const duplicated = Object.fromEntries([...seen].filter(([, files]) => files.length > 1)); + // Two annotations for one name is two answers to one question, and nothing says which + // is current — the drift this file exists to prevent, reintroduced inside it. + expect(duplicated).toEqual({}); +}); + +test("every provenance annotation is well-formed", () => { + const malformed: string[] = []; + for (const a of annotations()) { + const where = `${a.file} :: ${a.name}`; + if (!KINDS.has(a.kind)) malformed.push(`${where} — kind '${a.kind}' is not one of ${[...KINDS].join(" | ")}`); + if (!LEVELS.has(a.level)) malformed.push(`${where} — level '${a.level}' is not 1 | 2 | 3 | none`); + if (a.kind === "invention") { + // Nothing answers, at any level. A ref here would name a counterpart the kind + // denies exists — the two halves have to agree or the label means nothing. + if (a.ref !== "none") malformed.push(`${where} — kind=invention must carry ref=none, not '${a.ref}'`); + if (a.level !== "none") malformed.push(`${where} — kind=invention must carry level=none, not '${a.level}'`); + } else { + // Every other kind CLAIMS a counterpart, so it must cite one. + if (a.ref === "none") malformed.push(`${where} — kind=${a.kind} claims a counterpart, so ref may not be 'none'`); + else if (!REF_SHAPE.test(a.ref)) malformed.push(`${where} — ref '${a.ref}' is not 'path:symbol' (a symbol, never a line number)`); + if (a.level === "none") malformed.push(`${where} — kind=${a.kind} claims a counterpart, so level must be 1, 2 or 3`); + } + } + expect(malformed).toEqual([]); +}); + +test("the published surface is annotated at every kind that claims a counterpart", () => { + // Not a quota — a smoke check that the closed set is actually being USED. If every + // published symbol ever lands on one kind, the vocabulary has stopped discriminating + // and the annotations have become decoration. + const published = new Set(publishedSymbols()); + const kinds = new Set(annotations().filter((a) => published.has(a.name)).map((a) => a.kind)); + expect(kinds.size).toBeGreaterThan(1); +});