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 };
}