61 lines
13 KiB
Markdown
61 lines
13 KiB
Markdown
---
|
|
type: knowledge
|
|
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
|
|
|
|
[[knowledge_what-an-inbox-is-upstream]] settles what the target *offers*: a document may be given an inbox, nothing above the engine exposes one, and the processor set admits nothing from outside. It says nothing about who does the work **here**. That is this leaf.
|
|
|
|
The question is hard to answer out loud — *"does the polyfill process the inbox automatically?"* took one agent a long exchange to fail at — because **processing means a different thing at each layer**. Naming the actor first makes it easy. Everything below is read off the code; symbols are cited so `git grep` settles any doubt.
|
|
|
|
## The application
|
|
|
|
It **deposits** by naming a document (`inbox.postToDocument`) or a person (`inbox.share`), **reads its own** with `inbox.readForDocument` / `readSyncedForDocument`, and may **watch** one with `inbox.watch`.
|
|
|
|
What it never does: **process an inbox so that a `ReadCap` sent to it arrives**. `inbox.processInbox` is published, but no application has to call it — connecting drains, and every arrival afterwards is applied. It never resolves someone else's inbox address (the only published call that returns one is `storeRegistry.openDocumentInbox`, on a document it owns), and it has no way to reach, hurry or disable the deferred stand-in below.
|
|
|
|
## `@ng-eventually/polyfill`
|
|
|
|
**At connection** — `emulated-verifier/connect.ts`, `connectedUser`: restore every durable register, then `processInbox` on each inbox this identity holds, then start the continuous observation. Failing to apply one queue is reported and denies nobody their session; failing to *reach* the queues rejects.
|
|
|
|
**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.
|
|
|
|
### Applying is a DISPATCH — one arm per declared type
|
|
|
|
`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`).
|
|
|
|
**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. Being refused does not save it: the arm ran, so the deposit was already consumed.
|
|
- **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. That once made an empty read harmless — absent, unreadable and failed all meant *not added this time*, and a later drain settled it. Consumption ends that, and forces a distinction the read layer erases: `read-model.readDoc` answers `[]` whether the document is empty or the broker failed. Three sources of "no rows" are now told apart (`index-deposit.noRowsMeansAbsent`). A read that **threw** — reported through the new `onUnreadable` hook — is `unreadable`. A repo whose sync this session never confirmed is also `unreadable`, and this is the one that is easy to miss: on a fresh session over a persistent wallet the repo is present but its commits have not arrived, so the anchored query matches nothing **and raises nothing** — `open-repo` keeps `synced` and `timed-out` apart for exactly this, and says so in those words. A document this holder cannot even attempt — no cap, so the read layer drops it before issuing a query — is `unreadable` too, and that one is counter-intuitive enough to state plainly: "no cap" LOOKS deterministic and is not, because for a stranger's public document possession comes from a network fetch that swallows its errors and memoises the failure, so one outage is indistinguishable from a document that never existed. Only a document that was actually QUERIED, from a confirmed repo, and still read empty is genuinely absent — that is `refused`: reported, costs its own deposit, and the deposits behind it keep their turn. `unreadable` THROWS and stops the drain, so an outage cannot walk the queue. Getting the split wrong is costly in both directions and both were shipped before it settled: treating a failure as an absence destroyed whole queues on a cold start, and treating an absence as a failure let one dead reference — deposited by anyone — stall an index's queue one deposit per connection. An unresolved reference costs its own deposit and is reported; an already-indexed object is passed over; a document declaring no field, or several, refuses rather than write something wrong (both are clean reads, so both are `refused` and neither stops the drain) — 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.
|
|
|
|
**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. It is left in the QUEUE too, not merely unapplied: what a stranger's drain declines to apply it must not consume either, or the owner would lose it to a session that was never going to apply it. See below.
|
|
|
|
**What is applied is CONSUMED — one act, one place.** Since 2026-08-26 a deposit this package claims is retired from its inbox by `emulated-verifier/inbox-consume.ts`, which holds the only removal and the only loop that performs it; both triggers above call it and neither carries a copy. The order is upstream's, read in its source: the broker removes the message from the queue *before* delivering it (`take_first_msg` → `MSGS.take_first_value`, a read followed by a `del`), and the verifier then applies it and merely logs what it could not (`log_err!("Error during process_inbox {e}")`, never re-enqueued). So **remove, apply, log, move on** — a deposit is delivered once whether or not its arm succeeded, and a refusal is reported once instead of at every push and every connection for ever.
|
|
|
|
**What is NOT claimed is not consumed**, and the distinction carries the safety of the whole thing. Two things are left in the queue: a consumer's own payload, which declares no kind of this package's and is not a message at all; and an arm that belongs to the owner's own session while a stranger's is running — the `reference` case above. `inbox-dispatch.claimsDeposit` asks that question *before* the removal, precisely so that what a stranger's drain declines to apply it also declines to pop. Upstream the question does not arise, because only a registered reader is ever popped for (`inbox_pop_for_user`); here one inbox mixes both kinds, so it has to be read off the payload. Getting it backwards is the one way consuming could lose data outright — a stranger's timer retiring a reference the owner never indexed.
|
|
|
|
**A failure that happens before the pop still leaves the deposit.** A drain whose *read* fails takes nothing out, so what is queued waits for the next attempt; a drain whose *arm* fails has already consumed what it was given. The two are different sentences and the package's log says which is which.
|
|
|
|
**The idempotence that remains is not a replay compensation.** Filing the same Link twice is still a no-op, and a reference already in an index is still passed over — because the same cap or the same object can legitimately arrive as **two distinct deposits** (a re-share, a rotation, two depositors pointing at one thing), not because one deposit is seen twice. Upstream keeps the same check with a queue that pops (`ContactAlreadyExists`), which is the proof it never answered the replay.
|
|
|
|
## NextGraph itself
|
|
|
|
The **broker** routes a sealed message by inbox key (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs`) and queues it while the recipient is away. The **recipient's own verifier** applies it inline as it arrives — `LocalBrokerMessage::Inbox` → `session.verifier.inbox(&msg, from_queue)` in `sdk/rust/src/local_broker.rs`; `from_queue` distinguishes the backlog handed over at connection from the messages that follow, and **both go through the same door**, which is why the continuous regime above is the rule and the connection drain the special case.
|
|
|
|
And **none of it is reachable from JS**: the processor set is closed, the variants that would carry a payload carry nothing, and the post command has no handler arm — all in [[knowledge_what-an-inbox-is-upstream]]. Every actor above except the broker and that verifier is therefore ours, emulating one thing: a session applying its own inbox, continuously.
|