feat: un dépôt est aiguillé sur son type, comme en amont, et l'index a son bras

This commit is contained in:
Sylvain Duchesne
2026-08-21 19:28:48 +02:00
parent 1b4fcb567e
commit 3dfc231005
5 changed files with 1043 additions and 76 deletions
+84 -60
View File
@@ -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<Nuri, ReadCap[]>();
function capsSeenIn(inbox: Nuri): ReadCap[] {
const seenByInbox = new Map<Nuri, unknown[]>();
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<void> {
`${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<Deposit[]> {
// 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<Deposit[]> {
// 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<Deposit[
/**
* PROCESS an inbox: read it, and **apply** what it contains.
*
* Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`,
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
* the cap survives the session. Upstream this is what a verifier does when it
* processes queued messages: an inbox is a **queue you consume**, not a store you
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
* database, and it is the thing this replaces.
* Applying is a DISPATCH on what each deposit declares itself to be, one arm per type,
* exactly as `Verifier::process_inbox` is a `match` over `InboxMsgContent` — the arms,
* and what a kind with no arm gets, are `emulated-verifier/inbox-dispatch.ts`. Applying a
* {@link share} Link, for instance, means filing it durably (`branch-registers.addLink`,
* the emulated `AddLink { read_cap }` on the User branch of the private store) so the cap
* survives the session. Upstream this is what a verifier does when it processes queued
* messages: an inbox is a **queue you consume**, not a store you re-read. Re-reading an
* inbox every session to recover caps is using a queue as a database, and it is the thing
* this replaces.
*
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
* {@link read} does — Links are never surfaced.
* Idempotent, arm by arm — `addLink` ignores a cap it already holds, an object already in
* an index is passed over — so processing twice (a second tab, a reconnect) costs nothing.
* Returns exactly what {@link read} returned: only a cap is ever withheld.
*
* ── The holder is captured, not re-resolved after the read ────────────────
* The ownership guard runs at entry (inside {@link readSynced}) and the FILING happens a
@@ -693,13 +700,18 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
// WHO this processing is for, captured before the read that authorises it — see above.
const holder = getCurrentUser();
const deposits = await readSynced(targetInbox);
// `readSynced` already put every Link in memory for this session; now make
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
// are taken from what the read just observed.
const seen = capsSeenIn(targetInbox);
// `readSynced` already decoded every declared payload for this session; now APPLY them.
// Reading the raw deposits again would mean re-parsing, so the pass works from what the
// read just observed.
const declared = declaredIn(targetInbox);
seenByInbox.delete(targetInbox);
for (const cap of seen) {
// Re-checked per cap, not once: `addLink` reads and writes, so the identity can move
// `readSynced`'s guard refuses a session with no identity, so the holder captured above
// cannot be null past it. Narrowed rather than defaulted: applying "for nobody" is
// precisely the mis-filing the capture exists to prevent, and it must not have a value.
if (holder === null) return deposits;
const ring = accountKey(holder);
for (const payload of declared) {
// Re-checked per item, not once: an arm reads and writes, so the identity can move
// between two of them just as easily as during the read.
if (getCurrentUser() !== holder) {
logStage(
@@ -708,7 +720,13 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
);
return deposits;
}
await addLink(cap, holder ?? undefined);
// The `match`, one arm per declared type — `emulated-verifier/inbox-dispatch.ts`.
await applyDeposit(payload, {
owner: holder,
ring,
inbox: targetInbox,
ownerHoldsTheSession: true,
});
}
return deposits;
}
@@ -733,8 +751,10 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
* (`branch-registers.fileOwnStructure` / `fileOwnInbox`). It grants nothing new; it names
* the ring those facts belong to.
*
* Consumer deposits are left where they are, exactly as {@link read} leaves them: an inbox
* is a queue its owner consumes, and a Link is the only thing this may consume for it.
* Consumer deposits are left where they are, exactly as {@link read} leaves them — and so
* is every deposit whose arm would write application data into the owner's own documents:
* an inbox is a queue its OWNER consumes, and what this may apply on somebody else's
* behalf is the verifier's own bookkeeping (see `Applying.ownerHoldsTheSession`).
*/
async function processForOwner(targetInbox: Nuri): Promise<void> {
// WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see
@@ -782,13 +802,17 @@ async function processForOwner(targetInbox: Nuri): Promise<void> {
);
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);
}