fix: un dépôt est délivré une fois et retiré, comme en amont
This commit is contained in:
@@ -30,8 +30,8 @@ What it never does: **process an inbox so that a `ReadCap` sent to it arrives**.
|
||||
|
||||
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.
|
||||
- **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.)*
|
||||
|
||||
@@ -43,9 +43,15 @@ The two arms this package has:
|
||||
|
||||
**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. Nothing is lost: see below.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ What was shared with you becomes readable after `ensureIdentity()`.
|
||||
|
||||
`urn:ng-eventually:` is reserved, and the reservation has two faces. In a document: triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned. In a deposit: two payload shapes are this package's own and it acts on them — a payload that IS a reference (a bare `did:ng:…` string and nothing else), and an object whose `kind` property is a string starting with `urn:ng-eventually:inbox:`. **Every other payload is yours, and is delivered to you untouched.**
|
||||
|
||||
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal does not clear on its own the way an unreachable inbox does: a deposit is not consumed, so the same refusal is reported again at every arrival on that inbox and at every connection, for as long as the deposit sits there. Carry your own data in an object with a `kind` of your own — `{ kind: "join", … }` is yours and stays yours — and never as a bare reference on its own.
|
||||
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal is reported ONCE and then the deposit is gone: this package consumes what it claims, whether or not the arm succeeded. Until `1.0.0-dev.4` the same refusal came back at every arrival on that inbox and at every connection, for as long as the deposit sat there. The same now applies when the referenced document resolves but carries **nothing for the field its index declares** — a draft, say: the reference is discarded and reported, where before it sat in the queue and would have been indexed by a later drain once the document gained that field. Deposit the reference again after the document says what it needs to say. Carry your own data in an object with a `kind` of your own — `{ kind: "join", … }` is yours and stays yours — and never as a bare reference on its own.
|
||||
|
||||
Only a document's owner writes to it. Holding its read key never grants a write.
|
||||
|
||||
@@ -162,7 +162,15 @@ The same rule reaches what a call hands BACK, not only what it looked up first:
|
||||
|
||||
**The watching is in place by the time `ensureIdentity()` resolves**, whatever else that call made of its own work: a connection that could not restore something still rejects, and the identity it settled is watched all the same. It lasts exactly as long as that identity stays connected — changing identity or clearing it stops it, and whoever connects next is watched in their own right, so nothing of the previous one keeps applying.
|
||||
|
||||
**Failing to apply one inbox denies nothing.** It is reported on this package's own log stream (`console.error`, carrying this package's prefix) and never gated by `debugAccessLog` — a diagnostic may be opt-in, a failure may not. The deposit stays in its queue, so the next arrival on that inbox, or the next connection, applies it; the other inboxes were never involved, and nobody is refused anything.
|
||||
**A deposit this package applies is delivered ONCE, and a failure is not retried.** It is taken out of its inbox before the arm that handles it runs, so it is gone whether that arm succeeded or not — the same order the target has, where the broker removes a message from the queue as it hands it over and the verifier merely logs what it could not apply. A failure is reported on this package's own log stream (`console.error`, carrying this package's prefix), never gated by `debugAccessLog` — a diagnostic may be opt-in, a failure may not — and then the next deposit is processed. **Do not count on a failed deposit being applied later: there is no retry, no dead-letter queue and no way to ask what was lost.** If an arrival matters to your application, the record you keep of it must be your own. What this buys is the end of the opposite behaviour: an inbox that grew for ever, and one malformed deposit re-reported at every arrival and every connection for the life of the session.
|
||||
|
||||
**An unapplicable item and a failed one are not the same event.** A deposit this package cannot make sense of is reported and the next one is processed — one malformed item never holds up the queue behind it. A deposit whose handling could not be COMPLETED is different: the drain STOPS, the inbox is reported, and everything not yet taken out is preserved. Only the deposit in flight is lost. So a failing broker costs one deposit per attempt, not the whole queue, and a drain that stopped is retried by your next `ensureIdentity()`.
|
||||
|
||||
**A reference this package cannot read counts as failed, not as absent** — including a document you hold no capability for. That is deliberate and it costs latency: such a deposit stops one drain, and the deposits behind it land on the next connection instead of this one. The alternative was worse and was measured: treating "cannot read it" as "it is not there" destroyed deposits on a transient outage, and destroyed the re-deposits sent to repair them, because a failed capability fetch is remembered for the session. Only a document actually queried, from a synced repo, that still reads empty is called absent. **Consequence to plan for:** a queue holding many references you cannot read drains one per connection.
|
||||
|
||||
**`inbox.processInbox` now REJECTS on a broker failure**, where it used to resolve — including when a document a deposit referenced could not be READ. A referenced document that is merely absent is a different case: that is reported and the queue carries on. Awaiting it, handle the rejection: the deposit it was applying at that moment is already consumed, the rest of that inbox is intact, and calling again resumes from the next one. Connection-time draining is unaffected — `ensureIdentity()` reports such a queue and still grants the session, exactly as before.
|
||||
|
||||
**Failing to apply one inbox still denies nothing else.** The other inboxes were never involved, the session is not refused, and deposits this package does not claim — your own payloads — are untouched and stay readable through `inbox.read` and `inbox.watch`.
|
||||
|
||||
**Any number of subscriptions on one document coexist.** Opening a document, watching an inbox and following a scope no longer silence one another. Until 2026-08-17 a second `subscribeDoc` on a document killed the first, silently — nothing rejected, the first caller's unsubscribe still appeared to work, and what an application saw was a view that stopped re-reading and an inbox that stopped notifying, with no trace anywhere near the cause. A subscriber that joins a document somebody else already opened is handed the initial `State` its own subscription would have pushed it, so joining late is not the same as never firing; and unsubscribing silences that caller and no other, including when it happens from inside a push.
|
||||
|
||||
@@ -202,10 +210,10 @@ What each level means here, in this package's own terms:
|
||||
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
|
||||
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
|
||||
|
||||
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.4`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.3` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.4` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
|
||||
|
||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.3`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.3`, the string you pinned, never the version it is heading for. `1.0.0-dev.3` narrows an existing call rather than moving a signature: two payload shapes an application could carry freely are now this package's own, and one it cannot apply is reported — a state you did not have to handle before, which is a **major** by the rules above. It lands inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
|
||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.4`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.4`, the string you pinned, never the version it is heading for. `1.0.0-dev.4` narrows a **guarantee** and adds a rejection: a deposit this package applies is now consumed, so a failure that used to be retried at the next arrival or the next connection is now final and reported once; and `inbox.processInbox` rejects on a broker failure where it used to resolve. An application that leaned on the retry — however implicitly — has to keep its own record instead, and one that awaited `processInbox` without a `catch` now has a rejection to handle. Either is a **major** by the rules above. `1.0.0-dev.3` before it narrowed an existing call the same way: two payload shapes an application could carry freely became this package's own, and one it cannot apply is reported. It lands inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
|
||||
|
||||
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|
||||
|
||||
Reference in New Issue
Block a user