Raising this as partially confirmed. The receiver-side behaviour is demonstrated by a failing test against real stores. The link-level consequence is read from code and not executed, so treat the second half as a hypothesis.
The convention
An rref's +/- is a relative orientation, read in the frame of whichever endpoint receives it. translateRefKtoE flips it on the way out for remote endpoints (store/methods/translators.ts:85-107, and the comment there explains why). Two traversals of a link compose to the identity; one does not. So the invariant is that each hop applies the flip exactly once — it doesn't matter which side does it, only that the count per hop is odd.
Observed
Three tests against real kernel stores, one failing:
✓ flips polarity when naming a kref to a remote (stored ro-1 -> sent ro+1)
× names a kref the same way in a gc action as in any other delivery
AssertionError: expected [ 'ro-1' ] to strictly equal [ 'ro+1' ]
✓ mints a second kernel object for a gc action that arrives unflipped
The third test needs two stores — a round trip within one store is not an identity for an rref, so a single-store test cannot express this. It shows that feeding a receiver a sender-framed rref:
- returns a different kref, owned by the peer endpoint rather than the original owner
- leaves the real object completely untouched:
{ reachable: 1, recognizable: 1 }, flag still set. So a cross-link drop or retire has no effect on the object it was about.
- is stable on repeat — the same phantom is found again, so this is a quiet leak rather than escalating corruption
- in the export direction the phantom is born flagged reachable, so
performRetireImports throws retireImports but ko2 is still reachable. That assertion runs the real handler from garbage-collection/gc-handlers.ts, not a stand-in.
Zero flips per hop, checked on both sides
Send: deliverDropExports / deliverRetireExports / deliverRetireImports (remotes/kernel/RemoteHandle.ts:686-719) put erefs into params verbatim. #sendRemoteCommand handles location hints, capacity and seq numbering — no ref translation.
Receive: #handleRemoteDeliver (:836-849) passes them unchanged to #dropExports / #retireExports / #retireImports, each of which calls a bare translateRefEtoK (:772, :784, :796).
Compare the message case in the same switch: translateMessageKtoE outbound (one flip), translateMessageEtoK inbound (none). One flip per hop. notify likewise. GC is the only delivery kind with zero, and nothing downstream compensates.
Note there is a real inversion documented for the GC path at RemoteHandle.ts:741-763 — receiving dropExports runs dropImports logic, and so on. That one is semantic, not polarity. It's easy to read that table as "direction is already handled here"; these are two independent inversions and only one of them is applied.
Pre-existing
krefsToExistingErefs didn't flip either. #1010 renames it to krefsToErefs and, if anything, improves the surrounding handling: before #1010 a remote GC delivery throw escaped and killed the run loop, whereas the new isVatId catch logs and survives. Filing separately for that reason.
What I have not verified
Everything above is store-level and handler-level. The link-level chain — receiver rolls back and rethrows, seq never advances, sender retransmits, ACK timeout × MAX_RETRIES → #onGiveUp — is read from code and not executed. Whether the sender sees a throw, a timeout, or nothing at all, I don't know. A genuine two-kernel test belongs in kernel-test's remote-comms suite; the two stores in my test are wired by hand, passing the introduced rref across directly rather than over a link.
Where a fix would go, which is not obvious
krefsToErefs is also used for vat endpoints, where not flipping is correct (isRemoteId guard at translators.ts:85). Flipping inside it unconditionally would break vats. So the flip belongs at the remote delivery boundary, or in a remote-aware variant — and my failing assertion should probably be restated as isRemoteId-conditional before it is treated as the contract.
Worth deciding separately: should a GC delivery ever be allowed to allocate? translateRefEtoK mints unconditionally (translators.ts:161-165), and a GC action naming a ref the receiver doesn't hold is arguably a no-op rather than an introduction.
Found while reviewing #1010.
Raising this as partially confirmed. The receiver-side behaviour is demonstrated by a failing test against real stores. The link-level consequence is read from code and not executed, so treat the second half as a hypothesis.
The convention
An rref's
+/-is a relative orientation, read in the frame of whichever endpoint receives it.translateRefKtoEflips it on the way out for remote endpoints (store/methods/translators.ts:85-107, and the comment there explains why). Two traversals of a link compose to the identity; one does not. So the invariant is that each hop applies the flip exactly once — it doesn't matter which side does it, only that the count per hop is odd.Observed
Three tests against real kernel stores, one failing:
The third test needs two stores — a round trip within one store is not an identity for an rref, so a single-store test cannot express this. It shows that feeding a receiver a sender-framed rref:
{ reachable: 1, recognizable: 1 }, flag still set. So a cross-link drop or retire has no effect on the object it was about.performRetireImportsthrowsretireImports but ko2 is still reachable. That assertion runs the real handler fromgarbage-collection/gc-handlers.ts, not a stand-in.Zero flips per hop, checked on both sides
Send:
deliverDropExports/deliverRetireExports/deliverRetireImports(remotes/kernel/RemoteHandle.ts:686-719) puterefsintoparamsverbatim.#sendRemoteCommandhandles location hints, capacity and seq numbering — no ref translation.Receive:
#handleRemoteDeliver(:836-849) passes them unchanged to#dropExports/#retireExports/#retireImports, each of which calls a baretranslateRefEtoK(:772,:784,:796).Compare the
messagecase in the same switch:translateMessageKtoEoutbound (one flip),translateMessageEtoKinbound (none). One flip per hop.notifylikewise. GC is the only delivery kind with zero, and nothing downstream compensates.Note there is a real inversion documented for the GC path at
RemoteHandle.ts:741-763— receivingdropExportsrunsdropImportslogic, and so on. That one is semantic, not polarity. It's easy to read that table as "direction is already handled here"; these are two independent inversions and only one of them is applied.Pre-existing
krefsToExistingErefsdidn't flip either. #1010 renames it tokrefsToErefsand, if anything, improves the surrounding handling: before #1010 a remote GC delivery throw escaped and killed the run loop, whereas the newisVatIdcatch logs and survives. Filing separately for that reason.What I have not verified
Everything above is store-level and handler-level. The link-level chain — receiver rolls back and rethrows, seq never advances, sender retransmits, ACK timeout ×
MAX_RETRIES→#onGiveUp— is read from code and not executed. Whether the sender sees a throw, a timeout, or nothing at all, I don't know. A genuine two-kernel test belongs inkernel-test's remote-comms suite; the two stores in my test are wired by hand, passing the introduced rref across directly rather than over a link.Where a fix would go, which is not obvious
krefsToErefsis also used for vat endpoints, where not flipping is correct (isRemoteIdguard attranslators.ts:85). Flipping inside it unconditionally would break vats. So the flip belongs at the remote delivery boundary, or in a remote-aware variant — and my failing assertion should probably be restated asisRemoteId-conditional before it is treated as the contract.Worth deciding separately: should a GC delivery ever be allowed to allocate?
translateRefEtoKmints unconditionally (translators.ts:161-165), and a GC action naming a ref the receiver doesn't hold is arguably a no-op rather than an introduction.Found while reviewing #1010.