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
@@ -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<boolean> {
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<boolean> {
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<boolean> {
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;
}
}
@@ -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<string, string[]> = {}`), 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<string> {
const out = new Set<string>();
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<Nuri | null> {
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<IndexApplication> {
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 };
}
+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);
}