diff --git a/.project/concepts/app-contract/knowledge_who-processes-an-inbox.md b/.project/concepts/app-contract/knowledge_who-processes-an-inbox.md index 4153a18..ebe60fb 100644 --- a/.project/concepts/app-contract/knowledge_who-processes-an-inbox.md +++ b/.project/concepts/app-contract/knowledge_who-processes-an-inbox.md @@ -1,7 +1,7 @@ --- type: knowledge -summary: Who does what along a deposit's path — the application deposits, this package applies capabilities and only capabilities, a layer above curates its own payloads, and upstream none of it is reachable yet -last_checked: 2026-08-20 +summary: Who does what along a deposit's path — the application deposits, this package DISPATCHES on what a deposit declares itself to be and applies it arm by arm, and upstream none of it is reachable yet +last_checked: 2026-08-21 --- # Who processes an inbox @@ -22,26 +22,30 @@ What it never does: **process an inbox so that a `ReadCap` sent to it arrives**. **Continuously, while the identity is connected** — `emulated-verifier/inbox-observer.ts`, `startObservingInboxes`, whose only caller is `connectedUser`: one subscription per inbox, and every push runs `processInbox` on it. *Which* inboxes exist is itself subscribed to, on two channels that do not overlap: the **register** (the User branch of the private store, where `openDocumentInbox` appends its record — so an inbox opened mid-session is picked up) and **what this identity holds** (`CapRegistry.onChange` — which is what makes a person's *first* session watched at all, since provisioning is lazy and they connect owning nothing). Push-driven throughout; it never polls. It stops on identity change, on disconnection, and on `configure` / `resetConfig` (`stopObservingInboxes`, called only from `shared-wallet/bootstrap`). Every step re-checks its holder, so a run that outlives its identity abandons rather than file into the wrong ring. -**What it applies: capabilities, and nothing else.** VERIFIED in `surface/inbox.ts`. `read` keys on `capOfPayload`, which accepts a payload only when its `kind` equals `LINK_KIND` *and* its `cap` passes `hasReadCap` (a NURI carrying its cap segment). Those are absorbed and withheld from the caller; every other deposit is returned as consumer data. `processInbox` then iterates **the caps that read just observed** (`capsSeenIn`) — not the deposits — and files each durably with `addLink`. +### Applying is a DISPATCH — one arm per declared type -So a deposit carrying anything else is read, counted, logged under the access-log flag, and **produces no effect** — not even a reported failure, because it is not a failed application: it is a deposit nobody claimed. `processInbox` hands it back to its caller, and both internal callers (`connectedUser`'s drain loop, the observation's `applyWhatArrived`) discard what they get. +`emulated-verifier/inbox-dispatch.ts`, `applyDeposit`. It is a `switch` over what a deposit DECLARES itself to be (`kindOf`), and the shape is the target's own: `Verifier::process_inbox(msg, content)` is literally `match content { … }`, one arm per `InboxMsgContent` variant, ending `_ => Err(VerifierError::NotImplemented)` (`engine/verifier/src/inbox_processor.rs`). -Two senses of *applied*, worth keeping apart: `read` learns a cap **in memory, for this session**; only `processInbox` makes it **durable**. `inbox.watch` re-reads on every push, so it absorbs caps and files none. +**What an arm owes**, read off upstream's clearest arm (`InboxMsgContent::ContactDetails`): **validate** the envelope — a missing `from_inbox` is `InvalidInboxPost`, a hard failure and not a skip; **build** its addresses from the typed content; **read** the recipient's own store to see whether the thing is already there (a SPARQL `ASK`); **decide idempotently** — `ContactAlreadyExists` rather than a duplicate; and only then **write**. An arm is a real program, not a branch. + +The two arms this package has: + +- **A `ReadCap` delivery** (`inbox-dispatch.LINK_KIND`, whose name is upstream's own word for the variant) — held for the owner's ring and filed durably with `branch-registers.addLink`, the emulated `AddLink { read_cap }`. A payload declaring itself one of these and carrying nothing that reads is REFUSED and reported, which is the envelope validation above; it used to be handed back as consumer data. +- **A bare `reference`** — a deposit whose payload IS a `did:ng:` reference and nothing else: no operation, no claim, no copy of any value. `emulated-verifier/index-deposit.ts`, `applyIndexDeposit`, resolves it and LOOKS: which document the inbox belongs to (the owner's own `AddInboxCap` pairing, `readInboxCapPairs`), what that document declares it indexes by (`urn:ng-helpers:index:field`, on the document's own NURI as subject), whether the reference is already in, what the referenced object says for that field — and only then writes ONE entry (`urn:ng-helpers:index:value`, subject = the object's NURI). Since anyone may deposit, a payload carrying an operation would be a licence to rewrite someone else's document, and a payload carrying a value would put in the index something the object does not say. **An index only ever grows**: nothing there removes an entry and nothing there can express a removal, which is what makes an empty read harmless — absent, unreadable and failed all mean *not added this time*, and a later deposit adds it. An unresolved reference is reported and costs nothing; an already-indexed object is passed over; a document declaring no field, or several, refuses rather than write something wrong — and that refusal leaves READING its entries untouched. + +*(Those two IRIs are `@ng-helpers/indexing`'s namespace, kept letter for letter because renaming them is a data-format change nobody decided. This package writing another package's namespace is odd, and it is recorded as odd rather than acted on.)* + +**A kind with no arm says so.** A payload declaring itself in this package's reserved `urn:ng-eventually:inbox:` namespace for a variant nothing here applies is REPORTED — upstream's `NotImplemented`, which is an answer. Until 2026-08-21 it was read, counted, and produced nothing at all: no effect and no reported failure, the "failure disguised as an absence" this package has closed repeatedly elsewhere. + +**Consumer data is not a variant.** A payload that declares no kind of this package's is claimed by no arm and nothing is reported about it: it is not an unapplied message, it is not a message. That is why the RESERVED NAMESPACE is the discriminator and the mere presence of a `kind` field is not — `{ kind: "join" }` is a consumer's word for a consumer's message, and it stays one. + +**What still reaches the caller.** Only a `ReadCap` is withheld: an application must never handle one. Everything else `read` found is returned unchanged, including the payloads an arm applies — a `reference` is public information, so withholding it would buy nothing. Two senses of *applied* stay apart: `read` learns a `ReadCap` **in memory, for this session**; only `processInbox` makes it **durable** and runs the dispatch. `inbox.watch` re-reads on every push, so it absorbs and files nothing. **After a deposit whose recipient is absent.** A deposit that landed arms a deferred stand-in for the session the owner does not have. What it achieves is the only part written down outside the package: **deposits converge** — a share becomes readable without the recipient re-opening the page. *How* is deliberately not documented here. The mechanism is unpublished by design: nothing lets a caller ask for it, name another user's inbox, or turn it off, and the confidentiality of the whole shared-wallet emulation rests on its being unreachable. Its limit is stated rather than hidden — it lives in a page, so if the page goes away the deposit waits for its owner's next connection, which is the real path and not a repair. -**Nothing is ever consumed.** No path retires an applied deposit: an inbox here is re-readable where upstream it is a queue. That is what makes every abandon, every reported failure and every watch that could not be opened harmless — what was not applied is still there for the next push or the next connection. +**And it applies the registers, not the documents.** Running for an owner who is not the one holding the page, it takes the arms that write the verifier's own bookkeeping and stops there: writing application data into an absent owner's documents from a stranger's session is a different act, and this package does not take it. So a `reference` deposit is left — traced, not reported, because waiting is not failing — and the owner's own next connection applies it. Nothing is lost: see below. -## A package built on top — `@ng-helpers/indexing` - -The layer below cannot curate an index: an index deposit is a bare reference (`deposit.ts`), which is exactly what the polyfill hands back untouched. So this package does for its own payloads what the polyfill does for caps, and does it itself: - -- **finds** what to serve — `observeOwnIndexes` lists this identity's public documents and keeps those declaring an index field. Only the owner can, since reading an inbox is refused to everyone else; -- **watches, then processes**, in that order (`observation.ts`): a deposit landing between the two is processed twice, which costs nothing, where the other order loses it until the next connection; -- **applies** by resolving the reference and looking (`curator.ts`): the object is opened and indexed under the value it holds, or nothing happens. A deposit is an invitation to re-examine, never an instruction; -- **re-sees everything on every run**, since deposits are not consumed — affordable only because curation is convergent and an index only ever grows. - -It holds exactly one inbox address, obtained from `storeRegistry.openDocumentInbox` in `polyfill-adapter.ts`, and no other. +**Nothing is ever consumed.** No path retires an applied deposit: an inbox here is re-readable where upstream it is a queue. That is what makes every abandon, every reported refusal and every watch that could not be opened harmless — what was not applied is still there for the next push or the next connection. It is also what makes re-applying safe: every arm is idempotent, so seeing every deposit again on every run lands on the same result. ## NextGraph itself diff --git a/packages/polyfill/src/emulated-verifier/inbox-dispatch.ts b/packages/polyfill/src/emulated-verifier/inbox-dispatch.ts new file mode 100644 index 0000000..e275a7d --- /dev/null +++ b/packages/polyfill/src/emulated-verifier/inbox-dispatch.ts @@ -0,0 +1,235 @@ +/** + * Applying an inbox deposit is a DISPATCH on what the deposit declares itself to be — + * one arm per type, exactly as the target does it. + * + * ── The shape is upstream's, read in its source ─────────────────────────── + * `Verifier::process_inbox(msg, content)` is literally `match content { … }`, one arm per + * `InboxMsgContent` variant, and ends `_ => return Err(VerifierError::NotImplemented)` + * (`engine/verifier/src/inbox_processor.rs`). An arm is a real program, not a branch: the + * `ContactDetails` arm validates the envelope (a missing `from_inbox` is + * `InvalidInboxPost`, a hard failure and not a skip), builds its addresses from the typed + * content, queries the recipient's OWN store to see whether the thing already exists + * (a SPARQL `ASK`), decides idempotently (`ContactAlreadyExists` rather than a duplicate), + * and only then writes. Validate, read, decide, write — that is what an arm owes. + * + * ── What each of ours is ────────────────────────────────────────────────── + * - {@link LINK_KIND} — a deposit carrying a `ReadCap`. Applying it means holding it and + * filing it durably (`branch-registers.addLink`, the emulated `AddLink { read_cap }`). + * - a **bare reference** — a deposit that is a NURI and nothing else, applied by + * `index-deposit.ts`. It carries no tag on the wire because the payload IS the + * reference; upstream a `SocialQueryRequest` addresses its content the same way. + * - anything else that declares itself in this package's own namespace has **no arm**, + * and says so out loud — upstream's `NotImplemented`, which is an answer and not a + * shrug. + * + * ── Consumer data is not a variant ──────────────────────────────────────── + * Upstream every message is typed and none is handed to an application; here a deposit + * may carry an arbitrary payload the consumer alone interprets, which is a divergence + * `surface/inbox.ts` already names. Such a payload declares no kind of ours, so no arm + * claims it and nothing is reported about it — it is not an unapplied message, it is not + * a message. That is why the reserved namespace is the discriminator and a bare `kind` + * field is not: `{ kind: "join" }` is a consumer's word, and it stays one. + * + * Never exported from the package: an application must have no way to register an arm, + * to aim the dispatch, or to turn one off. + */ + +import { addLink } from "./branch-registers"; +import { applyIndexDeposit } from "./index-deposit"; +import { getCaps } from "../shared-wallet/bootstrap"; +import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log"; +import { hasReadCap, isNuri } from "../model/nuri"; +import type { Nuri, PrincipalId, ReadCap } from "../model/types"; + +/** + * A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it is the + * same one at all three stages: `InboxMsgContent::Link` is the message + * (`engine/net/src/types.rs`, declared but payload-less so far), `AddLink { read_cap }` is + * where the recipient files it (`engine/repo/src/types.rs`), `RemoveLink` withdraws it. + * So giving access is: deposit a Link, and the recipient's processing files it. + * + * It travels the SAME channel as any other deposit, which is why key ROTATION needs no + * special case on the surface — a re-delivered cap is just another Link. + */ +const LINK_KIND = "urn:ng-eventually:inbox:link"; + +/** + * The namespace every kind this package declares lives under — and the discriminator. + * + * `urn:ng-eventually:` is this library's reserved namespace; a consumer's own payload + * never lands in it, so "does this deposit declare itself to be one of ours?" has an + * answer that no application can accidentally give. Keying on the mere PRESENCE of a + * `kind` field would have claimed `{ kind: "join" }` — a consumer's word for a consumer's + * message. + */ +const DECLARED_NAMESPACE = "urn:ng-eventually:inbox:"; + +/** + * The bare-reference variant. It is a tag this module synthesizes, never one that travels: + * the payload of such a deposit is the reference itself, with no envelope at all (see + * `index-deposit.ts` for why it carries nothing else). Naming it here is what lets the + * match below have one arm per type instead of a shape test in the middle of it. + */ +const REFERENCE_KIND = "urn:ng-eventually:inbox:reference"; + +/** WHOSE inbox is being applied, and by whom — every arm acts for this identity alone. */ +export interface Applying { + /** The inbox's owner. Every act an arm performs is done for them and for nobody else. */ + readonly owner: PrincipalId; + /** The cap-registry ring of {@link owner} — `accountKey(owner)`. One rule keys it for + * both cases: the owner's own session, and a drain running under someone else's. */ + readonly ring: string; + /** The inbox the deposit was found in. Upstream the address IS the identification of + * the recipient, so no arm needs the deposit to name a target. */ + readonly inbox: Nuri; + /** + * Whether {@link owner} is ALSO the identity holding the session. + * + * It decides what an arm may do, and the line is not arbitrary. A drain running under + * SOMEONE ELSE's session (the deferred stand-in for an absent owner, + * `emulated-verifier/inbox-processor.ts`) usurps the owner's identity, and it does so + * for the verifier's own bookkeeping only — a register write on the owner's User + * branch. Writing APPLICATION data into the owner's documents from a stranger's page is + * a different act, and this package does not take it: the deposit is not consumed, so + * it stays for the owner's own next connection, which is the real path. + */ + readonly ownerHoldsTheSession: boolean; +} + +/** + * What this deposit DECLARES itself to be — `null` when it declares nothing of ours. + * + * The one place the wire form is turned into a variant, so `surface/inbox.ts` and the + * dispatch below cannot come to disagree about what counts as this package's business. + */ +// @provenance kindOf kind=aligned level=1 ref=engine/net/src/types.rs:InboxMsgContent — upstream the VARIANT is the type and nothing else can be sent; here it is read off a JSON payload, because a deposit carries an arbitrary one +export function kindOf(payload: unknown): string | null { + if (typeof payload === "string") return isNuri(payload) ? REFERENCE_KIND : null; + if (payload === null || typeof payload !== "object") return null; + const kind: unknown = (payload as { kind?: unknown }).kind; + return typeof kind === "string" && kind.startsWith(DECLARED_NAMESPACE) ? kind : null; +} + +/** The cap a Link deposit carries, or `null` when it carries none that reads. */ +// @provenance capOfPayload kind=declared-not-wired level=1 ref=engine/net/src/types.rs:InboxMsgContent — the `Link` variant is DECLARED and carries nothing, so the cap this reads has no upstream field yet +export function capOfPayload(payload: unknown): ReadCap | null { + const p = payload as { kind?: unknown; cap?: unknown } | null; + if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null; + return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null; +} + +/** The payload of a Link deposit carrying `cap` — the one place `share` composes one. */ +// @provenance linkPayload kind=declared-not-wired level=1 ref=engine/net/src/types.rs:InboxMsgContent — the composing half of the same declared-and-empty variant +export function linkPayload(cap: ReadCap): { kind: string; cap: ReadCap } { + return { kind: LINK_KIND, cap }; +} + +/** + * Where an arm's refusal goes: the package's own log stream, NOT gated by the access-log + * flag. A diagnostic may be opt-in; a message that could not be applied may not. + * + * It does not throw, and that is the rule this package already runs on: one unapplicable + * item is reported and denies nobody their session, nor the OTHER deposits their turn. A + * genuine I/O failure is a different thing and still propagates — the caller reports the + * whole inbox and moves to the next one. + */ +function report(ctx: Applying, why: string): void { + console.error( + accessLogPrefix() + " could not apply a deposit in " + shortNuri(ctx.inbox) + + " — it stays in it and the next attempt will try again: " + why, + ); +} + +/** + * Apply ONE deposit for the owner of its inbox. Answers whether it produced an effect — + * which is what a drain counts, and all any caller has ever needed from it. + * + * Nothing is consumed here, by any arm: an inbox stays re-readable, so every abandon and + * every refusal above is harmless — what was not applied is still there for the next push + * or the next connection. + */ +// @provenance applyDeposit kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — the same `match` over the declared content, one arm per variant, `NotImplemented` for the rest +export async function applyDeposit(payload: unknown, ctx: Applying): Promise { + const kind = kindOf(payload); + switch (kind) { + case null: + // Consumer data. No arm claims it, and nothing is reported: it is not an unapplied + // message, it is not a message. + return false; + case LINK_KIND: + return applyLink(payload, ctx); + case REFERENCE_KIND: + return applyReference(payload as Nuri, ctx); + default: + // Upstream's `_ => Err(NotImplemented)`. A kind in this package's own namespace that + // no arm answers is a version skew, not consumer data — and until 2026-08-21 it was + // read, seen, and produced nothing at all: no effect and no reported failure, which + // is the "failure disguised as an absence" defect this package has closed repeatedly. + report(ctx, `no arm applies deposits of kind ${JSON.stringify(kind)}`); + return false; + } +} + +/** + * The **Link** arm: validate the envelope, hold the cap, file it durably. + * + * `learnFor` before `addLink`, and both NAMED rather than resolved: the ring is the one + * the caller decided for when its guard ran, not whoever happens to be connected when the + * `await` resumes. In the owner's own session the two are the same ring and the first act + * is a no-op — `read` already learned it in memory; only this makes it durable. + * + * A payload that declares itself a Link and carries no readable cap is REFUSED rather than + * passed over. That is the envelope validation upstream's arms open with (`from_inbox` + * missing is `InvalidInboxPost`, a hard failure), and passing it over would have been the + * same silence the `_` arm above exists to end. + */ +async function applyLink(payload: unknown, ctx: Applying): Promise { + const cap = capOfPayload(payload); + if (cap === null) { + report(ctx, "a deposit declares itself a Link and carries no readable cap"); + return false; + } + getCaps().learnFor(ctx.ring, cap); + await addLink(cap, ctx.owner); + return true; +} + +/** + * The **reference** arm: resolve it and look — see `index-deposit.ts` for the whole of it. + * + * Only in the owner's OWN session, for the reason {@link Applying.ownerHoldsTheSession} + * gives: an entry is application data in the owner's document, and a drain running under a + * stranger's session writes the verifier's registers and nothing else. Left waiting is not + * left silent, and it is not a failure either — the deposit is not consumed, so the owner's + * next connection applies it, which is the path this package documents. + */ +async function applyReference(reference: Nuri, ctx: Applying): Promise { + if (!ctx.ownerHoldsTheSession) { + logStage( + "WAITING " + shortNuri(ctx.inbox) + " — a reference deposit is applied by its own " + + "owner's session; it stays for " + ctx.owner, + ); + return false; + } + const application = await applyIndexDeposit(reference, ctx.inbox); + switch (application.outcome) { + case "applied": + logStage( + "INDEXED " + shortNuri(application.object) + " in the index behind " + + shortNuri(ctx.inbox) + " under " + JSON.stringify(application.value), + ); + return true; + case "unchanged": + return false; + case "skipped": + // A property of the OBJECT, not a failure: it resolved, and there is nothing to + // index it by. Traced, not reported — see `index-deposit.ts`. + logStage( + "NOT INDEXED " + shortNuri(application.object) + " — " + application.reason, + ); + return false; + case "refused": + report(ctx, application.why); + return false; + } +} diff --git a/packages/polyfill/src/emulated-verifier/index-deposit.ts b/packages/polyfill/src/emulated-verifier/index-deposit.ts new file mode 100644 index 0000000..e831949 --- /dev/null +++ b/packages/polyfill/src/emulated-verifier/index-deposit.ts @@ -0,0 +1,255 @@ +/** + * The INDEX arm of the inbox dispatch — what this package does with a deposit that is a + * bare reference. + * + * ── The variant, and why it carries nothing else ────────────────────────── + * A reference deposit is a NURI and nothing more: no operation, no claim, no copy of any + * value. That is the shape upstream already uses — a `SocialQueryRequest` carries + * `definition_commit_body_ref`, a REFERENCE, and the recipient's `inbox_processor` + * composes its own SPARQL from what it resolves (`engine/verifier/src/inbox_processor.rs`). + * It carries no target document either, for the reason `surface/inbox.ts` gives at length: + * an inbox belongs to exactly one repo, so the ADDRESS identifies the recipient. + * + * Since anyone may deposit into any inbox, a payload that carried an operation would be a + * licence to rewrite someone else's document, and a payload that carried a value would let + * a depositor put in the index something the object does not say. So the arm does not act + * on a claim: it resolves the reference and LOOKS. What the object says is what is written. + * + * ── An index document is an ORDINARY document ───────────────────────────── + * Nothing marks one. What makes a document an index is that it declares, on its own NURI + * as subject, the FIELD it indexes by ({@link INDEX_FIELD}); each entry is then the + * indexed object's NURI as subject, carrying that object's value for the field + * ({@link ENTRY_VALUE}). The declaration lives IN the document on purpose: two + * applications pointing at the same index would otherwise curate it on two different + * fields and corrupt each other's entries. + * + * ── AN INDEX ONLY EVER GROWS ────────────────────────────────────────────── + * Nothing here removes an entry, and nothing here can express a removal. That is what + * makes the failure story trivial: since the only write is an addition, a read that comes + * back empty — object gone, unreadable, or the broker silent — can only ever mean "not + * added this time", and a later deposit adds it. Nothing has to tell an absence from a + * failure, so nothing can get that wrong. The price, stated plainly because someone will + * need it: an entry cannot be taken out of an index, by anyone, including its owner. + * + * Harmless is not the same as invisible: every reference that could not be resolved is + * REPORTED, because a failure that looks exactly like a normal outcome teaches nobody + * anything. + * + * ── The IRIs are another package's, deliberately unchanged ──────────────── + * `urn:ng-helpers:index:field` / `:value` are the IRIs `@ng-helpers/indexing` already + * writes, and they are kept letter for letter: renaming them would be a data-format change + * that no one decided, and every index already written would stop being read. It IS odd + * for this package to write another package's namespace — that oddity is flagged, not + * acted on. + */ + +import { readUnion } from "../surface/read-model"; +import { sparqlUpdate } from "../surface/docs"; +import { readInboxCapPairs } from "./branch-registers"; +import { session } from "../shared-wallet/account-registry"; +import { assertNuri, escapeLiteral } from "../surface/sparql"; +import type { UnionSubject } from "../surface/read-model"; +import type { Nuri } from "../model/types"; + +/** + * On the index document's own subject: the predicate an indexed object must carry, + * declared once by whoever created the index. + */ +// @provenance INDEX_FIELD kind=invention level=none ref=none — an index is an ordinary document upstream, and nothing there declares what a document indexes by +export const INDEX_FIELD = "urn:ng-helpers:index:field"; + +/** On an entry (subject = the indexed object's NURI): that object's value for the field. */ +// @provenance ENTRY_VALUE kind=invention level=none ref=none — same: the entry shape is ours +export const ENTRY_VALUE = "urn:ng-helpers:index:value"; + +/** What applying ONE reference deposit came to. */ +export type IndexApplication = + /** Resolved, and now in the index under the value the object holds. */ + | { readonly outcome: "applied"; readonly object: Nuri; readonly value: string } + /** Resolved, and already in the index. Nothing was read again and nothing written. */ + | { readonly outcome: "unchanged"; readonly object: Nuri } + /** Resolved, and deliberately not added — a property of the OBJECT, not a failure. */ + | { readonly outcome: "skipped"; readonly object: Nuri; readonly reason: string } + /** Nothing was written, and something is wrong enough to say out loud. */ + | { readonly outcome: "refused"; readonly why: string }; + +/** + * The values a subject carries for a predicate. + * + * OWN properties only. `props` arrives as a plain object literal (`readUnion` builds + * `const props: Record = {}`), so it inherits from `Object.prototype`: + * an index declaring `constructor`, `toString` or `valueOf` as its field would otherwise + * find a FUNCTION where a list of values belongs, and spreading it throws. The non-string + * filter is the same caution — this data crossed a process boundary, and a value that is + * not a string has no business being compared or written as a literal. + * + * The two guards are deliberately redundant; they answer different questions. `hasOwn` + * asks whether the subject really carries this predicate, `Array.isArray` whether what + * came back has the shape the type promises. + */ +export function valuesOf( + subject: UnionSubject | undefined, + predicate: string, +): readonly string[] { + const props = subject?.props; + if (props === undefined || !Object.hasOwn(props, predicate)) return []; + const values: unknown = props[predicate]; + if (!Array.isArray(values)) return []; + return values.filter((value): value is string => typeof value === "string"); +} + +/** Every field the document declares for itself — zero, one, or (wrongly) several. */ +export function declaredFields( + subjects: readonly UnionSubject[], + index: Nuri, +): readonly string[] { + return valuesOf( + subjects.find((s) => s.subject === index), + INDEX_FIELD, + ); +} + +/** + * The objects already in the index — every subject that is NOT the document itself and + * carries at least one value for {@link ENTRY_VALUE}. + * + * At least one, never exactly one. Requiring exactly one was a hole big enough to drive + * the "only ever grows" guarantee through: two values for one object — which two curating + * sessions racing each other produce, since each only ever ADDS — made the entry read as + * ABSENT, so the index could SHRINK through nothing but additions. + */ +export function indexedObjects( + subjects: readonly UnionSubject[], + index: Nuri, +): ReadonlySet { + const out = new Set(); + for (const subject of subjects) { + if (subject.subject === index) continue; + if (valuesOf(subject, ENTRY_VALUE).length > 0) out.add(subject.subject); + } + return out; +} + +/** + * WHICH document this inbox belongs to — the reverse of `openDocumentInbox`'s pairing. + * + * Read off the owner's own User branch (the emulated `AddInboxCap`, which pairs the + * document with the inbox opened on it), so the answer is the owner's own record and not + * something the deposit claimed. `null` when no document is paired with this inbox, which + * is the ordinary case for a PERSON's inbox: those belong to a user, not to a document. + */ +async function documentAddressedBy(inbox: Nuri): Promise { + const pairs = await readInboxCapPairs(); + return pairs.find((p) => p.inbox === inbox)?.doc ?? null; +} + +/** + * Apply one reference deposit, for the owner of `inbox`. + * + * Resolves and looks, in this order: which document the inbox belongs to, what that + * document declares it indexes by, whether the reference is already in, what the + * referenced object says for the field — and only then writes ONE entry. + * + * Never removes anything and never rewrites an entry, so re-applying the same deposit — + * which happens on every drain, because deposits are not consumed — lands on the same + * result. Curation is convergent, and its outcome does not depend on the order references + * arrive in. + */ +// @provenance applyIndexDeposit kind=invention level=none ref=none — nothing upstream turns a deposited reference into an index entry; the whole arm is this library's own +export async function applyIndexDeposit( + reference: Nuri, + inbox: Nuri, +): Promise { + const index = await documentAddressedBy(inbox); + if (index === null) { + return { + outcome: "refused", + why: + "a bare reference is a deposit for an INDEX document, and this inbox is not a " + + `document's — nothing here says what to index it by: ${JSON.stringify(inbox)}`, + }; + } + if (reference === index) { + return { outcome: "skipped", object: reference, reason: "self-reference" }; + } + + const subjects = await readUnion([index]); + const declared = declaredFields(subjects, index); + if (declared.length > 1) { + // Ambiguity refuses to curate and leaves READING alone. Picking one would silently + // corrupt the index: entries already written are never re-read, so they keep their + // old field's values while new ones arrive under the new field, and one list ends up + // ordered by two different properties. A quiet wrong answer is worse than a loud + // refusal — and this one is permanent, because nothing here deletes. + return { + outcome: "refused", + why: + `${index} declares ${declared.length} index fields ` + + `(${declared.map((f) => JSON.stringify(f)).join(", ")}) — refusing to curate rather ` + + "than pick one. Entries already in it stay readable. This cannot be undone " + + "(nothing here deletes): curate into a fresh index.", + }; + } + const field = declared[0]; + if (field === undefined) { + return { + outcome: "refused", + why: + `${index} declares no index field (${INDEX_FIELD}) — either it is not an index, or ` + + "it could not be read: an unreadable document and an empty one are the same empty " + + "result here. Nothing was written. Retry before concluding it is malformed.", + }; + } + + if (indexedObjects(subjects, index).has(reference)) { + // Already there. Nothing is re-read and nothing is rewritten — an entry, once made, + // is never touched again. + return { outcome: "unchanged", object: reference }; + } + + let resolved: readonly UnionSubject[]; + try { + resolved = await readUnion([reference]); + } catch (error) { + return { outcome: "refused", why: `${reference}: ${String(error)}` }; + } + if (resolved.length === 0) { + // ABSENT, unreadable, or the read failed — and this layer does not tell them apart, + // because nothing it does depends on the answer: an index only ever grows, so every + // reading leads to the same act, do not add and say so. + return { + outcome: "refused", + why: + `${reference}: the read came back empty — the object is absent, unreadable, or the ` + + "read failed; they are not distinguished here, and a later deposit adds it", + }; + } + + const values = resolved.flatMap((s) => [...valuesOf(s, field)]); + const value = values[0]; + if (value === undefined) { + // NARROW, and an open question: an object carrying nothing for the field is simply + // not added. There is no key to index it by, and inventing one (a placeholder, the + // deposit time) would put in the index something the object does not say. + return { outcome: "skipped", object: reference, reason: `carries no ${field}` }; + } + if (values.length > 1) { + return { + outcome: "skipped", + object: reference, + reason: `carries ${values.length} values for ${field} — which one would the entry hold?`, + }; + } + + const s = await session(); + // An INSERT and nothing else. The document is named ONCE, as the anchor: `sparqlUpdate` + // scopes the write to that repo's default graph, so the statement carries no `GRAPH <…>` + // wrapper — the canonical shape `surface/inbox.ts` explains beside its own write. + await sparqlUpdate( + s.sessionId, + `INSERT DATA { <${assertNuri(reference)}> <${ENTRY_VALUE}> "${escapeLiteral(value)}" }`, + index, + "indexEntry", + ); + return { outcome: "applied", object: reference, value }; +} diff --git a/packages/polyfill/src/surface/inbox.ts b/packages/polyfill/src/surface/inbox.ts index 801cba2..2a0940a 100644 --- a/packages/polyfill/src/surface/inbox.ts +++ b/packages/polyfill/src/surface/inbox.ts @@ -1,10 +1,11 @@ /** * Inbox — a generic deposit + read/materialize mechanism the consumer reuses for * its own purposes (same `inbox.post` API, same watcher — see the discovery-model - * decision). The mechanism itself knows no application domain: the consumer - * supplies the inbox document NURI and interprets the `payload`. (An example - * consumer mapping, purely illustrative: a consumer might use one inbox for a - * registration deposit and another for submitting a reference to an index.) + * decision). The consumer supplies the inbox document NURI and interprets its own + * `payload`: this module knows no application domain, and the only payloads it reads + * are the ones that DECLARE themselves to this package — see + * `emulated-verifier/inbox-dispatch.ts`, which owns that question and every arm that + * answers it. Everything else is handed back untouched. * * ── Real target vs this emulation ───────────────────────────────────────── * In real NextGraph, a message is sealed to the recipient's key and queued into @@ -31,12 +32,18 @@ import { depositInto, readForHolder } from "../emulated-verifier/register-write" import { subscribeDoc } from "./subscribe"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; -import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers"; +import { documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers"; import { mintCap } from "../emulated-verifier/caps"; import { scheduleInboxProcessing, traceProcessed, } from "../emulated-verifier/inbox-processor"; +import { + applyDeposit, + capOfPayload, + kindOf, + linkPayload, +} from "../emulated-verifier/inbox-dispatch"; import { accountKey, inboxOwner, @@ -45,7 +52,7 @@ import { lookupAccount, } from "../shared-wallet/account-registry"; import { escapeLiteral } from "./sparql"; -import { hasReadCap, toNuri } from "../model/nuri"; +import { toNuri } from "../model/nuri"; import { accessLogPrefix, enabled as accessLogEnabled, @@ -53,7 +60,7 @@ import { logStage, shortNuri, } from "../shared-wallet/access-log"; -import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types"; +import type { Nuri, NuriLike, PrincipalId } from "../model/types"; // --- deposit model -------------------------------------------------------- @@ -290,21 +297,16 @@ export async function postToDocument(docLike: NuriLike, opts: PostOptions): Prom // --- cap delivery --------------------------------------------------------- /** - * A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it - * is the same one at all three stages: `InboxMsgContent::Link` is the message - * (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far), - * `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`), - * `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection - * the recipient processes their inbox and files it. + * The payloads the last read of an inbox saw DECLARE themselves to this package — + * every variant of the dispatch, not only Links — awaiting the applying pass. * - * It travels the SAME channel as any other deposit, which is why key ROTATION needs - * no special case on the surface — a re-delivered cap is just another Link. + * Kept in memory for this session and nothing more: APPLYING them is + * {@link processInbox}'s job, because reading an inbox must not quietly write to a + * user's store. Reading the raw deposits a second time would mean re-parsing them, so + * what the read already decoded is what the pass works from. */ -const LINK_KIND = "urn:ng-eventually:inbox:link"; - -/** Links observed during the last read of an inbox, awaiting durable filing. */ -const seenByInbox = new Map(); -function capsSeenIn(inbox: Nuri): ReadCap[] { +const seenByInbox = new Map(); +function declaredIn(inbox: Nuri): unknown[] { return seenByInbox.get(inbox) ?? []; } @@ -332,12 +334,8 @@ function depositsFrom(result: unknown): Deposit[] { return deposits; } -/** The cap a deposit carries, if it is a Link rather than consumer data. */ -function capOfPayload(payload: unknown): ReadCap | null { - const p = payload as { kind?: unknown; cap?: unknown } | null; - if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null; - return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null; -} +// What a deposit DECLARES itself to be, and what each declaration means, live in +// `emulated-verifier/inbox-dispatch.ts` — the `match` this surface hands its deposits to. /** * Share ONE document with ONE recipient. @@ -426,7 +424,7 @@ export async function share(doc: NuriLike, toUser: string): Promise { `${JSON.stringify(toUser)}. Sharing does not create the person you share with.`, ); } - await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } }); + await post(await userInbox(toUser, "protected"), { payload: linkPayload(cap) }); } /** @@ -529,13 +527,19 @@ export async function read(targetInboxLike: NuriLike): Promise { // cold direct-read path opens the repo explicitly before calling `read`. const result = await sparqlQuery(sid, DEPOSITS_QUERY, undefined, targetInbox, "inboxRead"); const deposits = depositsFrom(result); - // Links are infrastructure, not consumer data: they never reach the caller. They - // are only KEPT here (in memory, for this session) — FILING them durably is - // `processInbox`'s job, because reading an inbox must not quietly write to a - // user's store. Filing fires the registry's change signal, which is what makes a - // view that was empty for want of that cap re-read instead of staying stale. + // A KEY is the one thing that never reaches the caller: it is infrastructure, and the + // model's whole point is that an application never handles one. Every other deposit is + // handed back exactly as before, INCLUDING the ones that declare themselves to this + // package — a reference is public information, so withholding it would buy nothing and + // would take from an application something it can already read. + // + // What every declared payload does get is a place in the session's list, which is what + // {@link processInbox} works from. It is only KEPT here — APPLYING is that pass's job, + // because reading an inbox must not quietly write to a user's store. Filing fires the + // registry's change signal, which is what makes a view that was empty for want of a cap + // re-read instead of staying stale. const delivered: Deposit[] = []; - const links: ReadCap[] = []; + const declared: unknown[] = []; // The ownership guard ran at entry; the filing happens several awaits later, and filing // resolves WHO is holding at that moment. So an application switching identity in the // gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the @@ -546,15 +550,15 @@ export async function read(targetInboxLike: NuriLike): Promise { // consumed by reading, so the next connection under the right identity files them. const stillOwner = getCurrentUser() === owner; for (const d of deposits) { + if (kindOf(d.payload) !== null) declared.push(d.payload); const cap = capOfPayload(d.payload); if (cap) { if (stillOwner) getCaps().learnFor(ownerKey, cap); - links.push(cap); continue; } delivered.push(d); } - if (links.length > 0) seenByInbox.set(targetInbox, links); + if (declared.length > 0) seenByInbox.set(targetInbox, declared); // Domain-level diagnostic (on top of docs.ts's generic access-path READ log // of raw triple-rows): how many DEPOSITS were found, and the decoded data of // each — the exact visibility needed to trace materialization at the owner @@ -659,16 +663,19 @@ export async function readSyncedForDocument(docLike: NuriLike): Promise { // WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see @@ -782,13 +802,17 @@ async function processForOwner(targetInbox: Nuri): Promise { ); let applied = 0; for (const deposit of deposits) { - const cap = capOfPayload(deposit.payload); - if (cap === null) continue; // consumer data — not this service's to consume - // In memory first, then durably: the same order and the same two acts as - // `read` + `processInbox`, with the holder named instead of resolved. - caps.learnFor(ownerRing, cap); - await addLink(cap, ownerId); - applied += 1; + // The SAME `match` the owner's own session runs, with the holder named instead of + // resolved — and told whether the owner is the one holding the page, because an arm + // that would write application data into the owner's documents must not run from a + // stranger's session (`emulated-verifier/inbox-dispatch.ts`). + const done = await applyDeposit(deposit.payload, { + owner: ownerId, + ring: ownerRing, + inbox: targetInbox, + ownerHoldsTheSession: getCurrentUser() === ownerId, + }); + if (done) applied += 1; } traceProcessed(targetInbox, ownerRing, applied); } diff --git a/packages/polyfill/test/inbox-dispatch.test.ts b/packages/polyfill/test/inbox-dispatch.test.ts new file mode 100644 index 0000000..9dcf7a7 --- /dev/null +++ b/packages/polyfill/test/inbox-dispatch.test.ts @@ -0,0 +1,449 @@ +/** + * Applying an inbox deposit is a DISPATCH on what the deposit declares itself to be, and + * this suite is about the two things a `match` owes: every arm does real work, and the + * arm that does not exist SAYS SO. + * + * ── What each test would have looked like before ────────────────────────── + * Before 2026-08-21 `processInbox` iterated the caps a read had observed. A deposit of any + * other shape was read, counted, logged — and produced nothing: no effect, and no reported + * failure. So a reference deposit vanished, a malformed Link vanished, and a kind nobody + * had written an arm for vanished, all three indistinguishable from an empty inbox. Every + * test below fails against that code, and fails again if the arm it exercises is removed. + * + * ── Nobody is handed an inbox address ───────────────────────────────────── + * Each depositor names the DOCUMENT (`inbox.postToDocument`), which is all an application + * has; the address is resolved inside. The one address the TEST resolves for itself is + * used only to assert what landed, never given to an actor. + */ +import { test, expect, mock, afterEach } from "bun:test"; +import { configure } from "../src/index"; +import { + configureStoreRegistry, + resetCaps, + resetConfig, + resetStoreRegistry, + setCurrentUser, +} from "../src/shared-wallet/bootstrap"; +import { + createEntityDoc, + resetRegistryCache, + resolveWriteGraph, +} from "../src/shared-wallet/account-registry"; +import type { RegistrySession } from "../src/shared-wallet/account-registry"; +import { openDocumentInbox } from "../src/emulated-verifier/branch-registers"; +import { connectedUser } from "../src/emulated-verifier/connect"; +import { + cancelScheduledInboxProcessing, + runScheduledInboxProcessingNow, +} from "../src/emulated-verifier/inbox-processor"; +import { stopObservingInboxes } from "../src/emulated-verifier/inbox-observer"; +import { resetOpenedRepos } from "../src/emulated-verifier/open-repo"; +import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store"; +import { ENTRY_VALUE, INDEX_FIELD } from "../src/emulated-verifier/index-deposit"; +import { postToDocument } from "../src/surface/inbox"; +import { readUnion } from "../src/surface/read-model"; +import { sparqlUpdate } from "../src/surface/docs"; +import type { Nuri } from "../src/model/types"; + +const SESSION: RegistrySession = { sessionId: "sid-dispatch", privateStoreId: "PRIV-DISPATCH" }; +const SHIM = "urn:ng-eventually:shim"; +const INBOX = "urn:ng-eventually:inbox"; +/** The field the index documents below declare they index by. */ +const WHEN = "urn:test:when"; + +interface Quad { g: string; s: string; p: string; o: string } + +function unescapeLiteral(s: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && i + 1 < s.length) { + const next = s[++i]; + out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!; + } else out += s[i]; + } + return out; +} + +/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored per-doc read. */ +function makeFakeNg() { + const quads: Quad[] = []; + let docCounter = 0; + + const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`); + + const sparql_update = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const anchor = a[2] as string | undefined; + if (!anchor) return undefined; + const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/); + if (del) { + const [s0, p0] = [del[1]!, del[2]!]; + for (let i = quads.length - 1; i >= 0; i--) { + const q = quads[i]!; + if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1); + } + return undefined; + } + const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, ""); + const sm = body.match(/<([^>]+)>/); + if (!sm) return undefined; + const s = sm[1]!; + const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g; + let m: RegExpExecArray | null; + const after = body.slice(body.indexOf(sm[0]) + sm[0].length); + while ((m = pairRe.exec(after)) !== null) { + const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`); + const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); + quads.push({ g: anchor, s, p, o }); + } + return undefined; + }); + + const sparql_query = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const anchor = a[3] as string | undefined; + const byPred = (pred: string, v: string) => ({ + results: { + bindings: quads + .filter((q) => q.g === anchor && q.p === pred) + .map((q) => ({ [v]: { value: q.o } })), + }, + }); + if (query.includes(`<${SHIM}:shimDoc>`)) return byPred(`${SHIM}:shimDoc`, "shimDoc"); + if (query.includes(`<${SHIM}:id>`)) { + const subjM = query.match(/<([^>]+)>\s+a\s+/); + const only = subjM ? subjM[1]! : null; + const bySubject = new Map>(); + for (const q of quads) { + if (q.g !== anchor) continue; + if (only !== null && q.s !== only) continue; + const rec = bySubject.get(q.s) ?? {}; + if (q.p === `${SHIM}:id`) rec.id = q.o; + if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o; + if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o; + if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o; + bySubject.set(q.s, rec); + } + return { + results: { + bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({ + id: { value: r.id! }, + docPublic: { value: r.docPublic ?? "" }, + docProtected: { value: r.docProtected ?? "" }, + docPrivate: { value: r.docPrivate ?? "" }, + })), + }, + }; + } + if (query.includes(`<${INBOX}:payload>`)) { + const bySubject = new Map>(); + for (const q of quads) { + if (q.g !== anchor) continue; + const rec = bySubject.get(q.s) ?? {}; + if (q.p === `${INBOX}:payload`) rec.payload = q.o; + if (q.p === `${INBOX}:ts`) rec.ts = q.o; + if (q.p === `${INBOX}:from`) rec.from = q.o; + bySubject.set(q.s, rec); + } + return { + results: { + bindings: [...bySubject.values()] + .filter((r) => r.payload !== undefined && r.ts !== undefined) + .map((r) => { + const row: Record = { + payload: { value: r.payload! }, + ts: { value: r.ts! }, + }; + if (r.from !== undefined) row.from = { value: r.from }; + return row; + }), + }, + }; + } + if (query.includes(`<${SHIM}:inboxCap>`)) return byPred(`${SHIM}:inboxCap`, "c"); + if (query.includes(`<${SHIM}:inboxAddress>`)) return byPred(`${SHIM}:inboxAddress`, "a"); + if (query.includes(`<${SHIM}:readCap>`)) return byPred(`${SHIM}:readCap`, "c"); + if (query.includes(`<${SHIM}:link>`)) return byPred(`${SHIM}:link`, "c"); + if (query.includes(`${SHIM}:isInbox`)) return byPred(`${SHIM}:isInbox`, "i"); + if (query.includes(`<${SHIM}:inboxOwner>`)) { + const sm = query.match(/<([^>]+)>\s+/); + const subj = sm ? sm[1]! : null; + return { + results: { + bindings: quads + .filter((q) => q.g === anchor && q.p === `${SHIM}:inboxOwner` && q.s === subj) + .map((q) => ({ u: { value: q.o } })), + }, + }; + } + if (query.includes(`${SHIM}:docInbox`)) { + const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/); + const pred = pm ? pm[1]! : ""; + const sm = query.match(/<([^>]+)>\s+ q.g === anchor && q.p === pred && (subj === null || q.s === subj)) + .map((q) => ({ d: { value: q.o } })), + }, + }; + } + if (query.includes(`<${SHIM}:exposedReadCap>`)) return byPred(`${SHIM}:exposedReadCap`, "c"); + if (query.includes(`<${SHIM}:contains>`)) return byPred(`${SHIM}:contains`, "e"); + return { + results: { + bindings: quads + .filter((q) => q.g === anchor) + .map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })), + }, + }; + }); + + return { doc_create, sparql_update, sparql_query, quads }; +} + +let fake: ReturnType; + +function inject() { + fake = makeFakeNg(); + configure({ ng: fake as never, useShape: (() => {}) as never }); + configureStoreRegistry({ getSession: async () => SESSION }); + resetRegistryCache(); + resetOpenedRepos(); + resetPublicStoreFetches(); + resetCaps(); + setCurrentUser(null); +} + +afterEach(() => { + cancelScheduledInboxProcessing(); + stopObservingInboxes(); + resetConfig(); + resetStoreRegistry(); + resetCaps(); + resetRegistryCache(); + resetOpenedRepos(); + resetPublicStoreFetches(); + setCurrentUser(null); +}); + +/** Write one triple into `doc`, as the consumer's own write path would. */ +async function write(doc: Nuri, p: string, o: string): Promise { + await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test"); +} + +/** Every value the index holds for `object` — one per entry written. */ +async function entryValuesFor(index: Nuri, object: Nuri): Promise { + const subjects = await readUnion([index]); + return subjects.filter((s) => s.subject === object).flatMap((s) => s.props[ENTRY_VALUE] ?? []); +} + +/** What the package reported, for the span of `run`. Restores `console.error` whatever + * happens — a suite that leaks a stub takes the next file down with it. */ +async function reportedDuring(run: () => Promise): Promise { + const lines: string[] = []; + const real = console.error; + console.error = ((...args: unknown[]) => { + lines.push(args.map((a) => String(a)).join(" ")); + }) as typeof console.error; + try { + await run(); + } finally { + console.error = real; + } + return lines; +} + +/** + * Alice's index: a public document declaring the field it indexes by, with an inbox open + * on it so that anyone can hand it a reference. Returns the index and one PUBLIC object + * carrying a value for that field. + */ +async function aliceOpensAnIndex(fields: string[]): Promise<{ index: Nuri; object: Nuri }> { + setCurrentUser("alice"); + const index = await createEntityDoc("alice", "public"); + for (const field of fields) await write(index, INDEX_FIELD, field); + await openDocumentInbox(index); + const object = await createEntityDoc("alice", "public"); + await write(object, WHEN, "2026-01-01"); + setCurrentUser(null); + return { index, object }; +} + +/** Bob has been in the page once, so he is someone the wallet knows. */ +async function bobSignsInOnce(): Promise { + setCurrentUser("bob"); + await resolveWriteGraph("bob", "protected"); + setCurrentUser(null); +} + +/** Bob hands `payload` to the owner of `doc`, naming the document and nothing else. */ +async function bobDepositsInto(doc: Nuri, payload: unknown, ts: number): Promise { + setCurrentUser("bob"); + await postToDocument(doc, { payload, ts }); + setCurrentUser(null); +} + +/** Alice connects — which restores her registers and drains every inbox she holds. */ +async function aliceConnects(): Promise { + setCurrentUser("alice"); + await connectedUser(); +} + +// --- the reference arm ------------------------------------------------------- + +test("an entry appears in the index from a bare reference deposited by a stranger", async () => { + inject(); + await bobSignsInOnce(); + const { index, object } = await aliceOpensAnIndex([WHEN]); + + // The whole payload is the reference: no operation, no claim, and no copy of the value. + // What lands in the index is what the OBJECT says, which is why a stranger depositing + // achieves exactly what the owner would have. + await bobDepositsInto(index, object, 1); + expect(await entryValuesFor(index, object)).toEqual([]); + + await aliceConnects(); + + expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]); +}); + +test("a reference nobody can resolve is reported, and costs the index nothing", async () => { + inject(); + await bobSignsInOnce(); + const { index, object } = await aliceOpensAnIndex([WHEN]); + + // One resolvable reference and one that names nothing. The second must not take the + // first down with it, and must not leave the index short of an entry either. + await bobDepositsInto(index, object, 1); + await bobDepositsInto(index, "did:ng:o:nowhere", 2); + + const reported = await reportedDuring(aliceConnects); + + expect(reported.some((l) => /did:ng:o:nowhere/.test(l))).toBe(true); + expect(reported.some((l) => /the read came back empty/.test(l))).toBe(true); + // Costs nothing: the resolvable one is in, and nothing was written for the other. + expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]); + expect(await entryValuesFor(index, "did:ng:o:nowhere")).toEqual([]); +}); + +test("an object already in the index is passed over — the same reference twice makes ONE entry", async () => { + inject(); + await bobSignsInOnce(); + const { index, object } = await aliceOpensAnIndex([WHEN]); + + // Deposits are never consumed, so a curating pass sees every deposit on every run. That + // is affordable only because a reference already in the index is passed over: without + // that check the second deposit writes a second value for the same object, and an entry + // carrying two values is exactly what once made the index SHRINK through additions. + await bobDepositsInto(index, object, 1); + await bobDepositsInto(index, object, 2); + + await aliceConnects(); + + expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]); +}); + +test("an index declaring NO field refuses, loudly, rather than writing something wrong", async () => { + inject(); + await bobSignsInOnce(); + // A document with an inbox and no declaration: either it is not an index, or it could + // not be read — and here those are the same empty result. + const { index, object } = await aliceOpensAnIndex([]); + + await bobDepositsInto(index, object, 1); + const reported = await reportedDuring(aliceConnects); + + expect(reported.some((l) => /declares no index field/.test(l))).toBe(true); + expect(await entryValuesFor(index, object)).toEqual([]); +}); + +test("an index declaring SEVERAL fields refuses rather than picking one", async () => { + inject(); + await bobSignsInOnce(); + // Entries already written are never re-read, so curating under a second field would + // leave one list ordered by two different properties — a quiet wrong answer. + const { index, object } = await aliceOpensAnIndex([WHEN, "urn:test:other"]); + + await bobDepositsInto(index, object, 1); + const reported = await reportedDuring(aliceConnects); + + expect(reported.some((l) => /declares 2 index fields/.test(l))).toBe(true); + expect(await entryValuesFor(index, object)).toEqual([]); +}); + +test("a reference deposit is left for its owner's own session, not applied from a stranger's", async () => { + inject(); + await bobSignsInOnce(); + const { index, object } = await aliceOpensAnIndex([WHEN]); + + await bobDepositsInto(index, object, 1); + // The deferred stand-in for an absent owner runs under BOB's session. It files the + // verifier's own registers for an absent owner and stops there: writing application data + // into Alice's documents from Bob's page is a different act, and it is not taken. + setCurrentUser("bob"); + const reported = await reportedDuring(async () => { + await runScheduledInboxProcessingNow(); + }); + setCurrentUser(null); + expect(await entryValuesFor(index, object)).toEqual([]); + // Waiting is not failing, and it must not be reported as one: the arm did not try and + // fall short, it is not this session's to run. Running it anyway would report a refusal + // here every twenty seconds — Bob's User branch pairs no document with Alice's inbox. + expect(reported.filter((l) => /could not apply a deposit/.test(l))).toEqual([]); + + // Nothing was consumed, so Alice's own next connection applies it — the real path. + await aliceConnects(); + expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]); +}); + +// --- the cap arm ------------------------------------------------------------- + +test("a deposit declaring itself a Link and carrying no readable cap is reported, not passed over", async () => { + inject(); + await bobSignsInOnce(); + const { index } = await aliceOpensAnIndex([WHEN]); + + // A bare reference where a ReadCap belongs — the one type confusion that would invert + // the model if it were filed, and the shape a version skew produces. Upstream an arm + // opens by validating its envelope and a malformed one is a hard failure, not a skip. + await bobDepositsInto(index, { kind: "urn:ng-eventually:inbox:link", cap: "did:ng:o:bare" }, 1); + + const reported = await reportedDuring(aliceConnects); + + expect(reported.some((l) => /declares itself a Link and carries no readable cap/.test(l))).toBe(true); +}); + +// --- the arm that does not exist --------------------------------------------- + +test("a kind no arm answers says so — the dispatch's `NotImplemented`", async () => { + inject(); + await bobSignsInOnce(); + const { index } = await aliceOpensAnIndex([WHEN]); + + // A deposit declaring itself in THIS package's reserved namespace, for a variant nothing + // here applies. Upstream that is `_ => Err(NotImplemented)`; here it used to be silence. + await bobDepositsInto(index, { kind: "urn:ng-eventually:inbox:not-a-thing" }, 1); + + const reported = await reportedDuring(aliceConnects); + + expect( + reported.some((l) => /no arm applies deposits of kind "urn:ng-eventually:inbox:not-a-thing"/.test(l)), + ).toBe(true); +}); + +test("a consumer payload is not a variant: no arm claims it and nothing is reported", async () => { + inject(); + await bobSignsInOnce(); + const { index } = await aliceOpensAnIndex([WHEN]); + + // `kind` is an ordinary word an application may use for its own messages. Keying the + // dispatch on the mere presence of the field would have turned every one of them into an + // unapplied message reported at each drain. + await bobDepositsInto(index, { kind: "join", who: "bob" }, 1); + + const reported = await reportedDuring(aliceConnects); + + expect(reported.filter((l) => /could not apply a deposit/.test(l))).toEqual([]); +});