Skip to content

fix(ocap-kernel): make c-list import accounting symmetric - #1020

Merged
sirtimid merged 17 commits into
mainfrom
sirtimid/clist-refcount-symmetry
Aug 20, 2026
Merged

fix(ocap-kernel): make c-list import accounting symmetric#1020
sirtimid merged 17 commits into
mainfrom
sirtimid/clist-refcount-symmetry

Conversation

@sirtimid

@sirtimidsirtimid commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes#1006. Replaces #1010, which carried this plus three unrelated fixes; it is split into four PRs, this one first.

The defect

Creating an import c-list entry changed no refcount; tearing one down decremented both reachable and recognizable. initKernelObject compensated by minting every object at (1, 1), which is exactly right for one importer — the only topology our tests exercised. There is no setReachableFlag in the repo; it was never ported.

That single unit was also claimed by two parties: importer-side (object.ts: born at 1 "on the assumption that the new object corresponds to an object that has just been imported") and owner-side (vat.ts: "the baseline decrement below corresponds to the implicit reference exportFromEndpoint installed…"). Both an importer's drop and the owner's termination were entitled to spend it.

All four symptoms in the issue reproduced against the real store before the fix, and are covered by regression tests now.

main has since grown a second compensation for this

While this was in review, #983 landed this in cleanupTerminatedVat:

// Skip baseline decrement if GC already zeroed reachable via dropImports.const{ reachable }=getObjectRefCount(kref);if(reachable>0){decrementRefCount(kref,'cleanup|export|baseline');}

That is a guard against the phantom baseline, at the same site this PR deletes the baseline decrement outright. This branch removes it; the condition is moot once no phantom unit exists. #983's parallel-launch tests pass unchanged under the audit.

Approach

Followed the issue's proposed path, in order.

Step 1 — the invariant checker, first.store/methods/refcount-audit.ts recomputes each kref's counts from ground truth — c-list entries and their reachable flags, run-queue and promise-queue messages, promise resolution values, pins — and reports drift in both directions: counts too low, which lets a live capability be collected, and counts too high, which keeps a dead one alive (the issue's symptom 4 would pass an underflow-only check). It compares against the holders it finds, so a holder that should have been torn down but wasn't justifies its own count and is not detectable this way. The credits mirror incrementRefCount case for case.

Enabled per kernel via Kernel.make({ auditRefCounts: true }), run after every crank, and on for every kernel kernel-test builds. The audit reports by throwing, which kills the run loop, and the kernel hands run loop death to onRunLoopFailure rather than rethrowing it — so kernel-test passes a handler that fails the test, and a violation on a GC-only crank or after a test's last assertion fails the build too.

Step 2 — restore the increment, rebase the baseline.initKernelObject(0, 0); addCListEntry takes the entry's reference, mirroring deleteCListEntry; new setReachableFlag; owner-side baseline decrements deleted. collectGarbage is already a faithful port of processRefcounts, so this hands it the inputs it was written for.

Step 3 — remove the compensations. This is where the checker earned its keep. It found four more unbalanced paths the phantom baseline had been absorbing:

  • #deliverSend charged the target against the routed kref, not the run-queue item's own. For a message routed through a resolved promise those differ, so it decremented an object nobody charged and leaked the promise.
  • #deliverNotify released its reference only on the success path, leaking it on both early returns, and decremented promises retired alongside it that nobody had taken.
  • A message queued on an unresolved promise duplicated every reference it carried when re-enqueued on resolution.
  • resolve|kpid incremented with no matching release. (I had assumed resolve|decider cancelled it; that releases the distinct unsettled-promise reference.)

Two things the baseline was silently standing in for, now explicit:

  • Vat roots are pinned for their vat's lifetime, released on termination. A root is addressable whether or not anyone imports it — SwingSet pins static vat roots for exactly this reason. pinVatRoot already existed and was never called internally.
  • GC action delivery moves the kernel's own c-list: dropExports clears the owner's flag, retireExports/retireImports tear the entry down. krefsToExistingErefskrefsToErefs, which throws rather than silently dropping an unmapped kref.

Migration

There is none, and none is planned at this version: a store written before this change must be reset.

kernel-store has no schema version and no migration path, so such a store opens under this code with every object still at (1, 1), no pin recorded for any vat root, and its pin and retention records in a layout this code does not read. Both consequences land on the crank path, against an existing user's database:

  • the second importer's dropImports throws "ko1" underflow -1,1 from inside performDropImports;
  • initializeAllVats uses runVat, which does not pin, and relies on the persisted pin a legacy store does not have — so the last importer's drop can retire a live vat's root.

recomputeRefCounts rebuilds the counts from ground truth, but it cannot restore the root pins, so it is a diagnostic for a drifted store rather than an upgrade path. Reach it by calling makeKernelStore over the kernel's own database; RefCountViolation is now exported from the package root.

Judgment call worth review

The gc.ts:169 assert is not re-enabled. The issue asks for it; I believe it would fire legitimately. Left as a comment explaining why, and the audit covers the same ground from outside.

Changes since review

@grypez's seven in-scope items and @FUDCo's, one commit each.

  1. incrementRefCount guards at the primitive. It now Fails on a missing object row, symmetric with the decrement's guard — the guard was at two call sites, so pinObject, resolve|slot and every other path could still resurrect a deleted object. The call-site guards stay: they refuse before an eref is allocated or a ledger entry is written, and name what was attempted.
  2. The audit actually fails the build.kernel-test passes an onRunLoopFailure that reports the failure to afterEach/afterAll hooks, so the test fails with the message naming the drifted kref. An async rethrow was the first attempt and is worse: under endoify-node it exits the worker with process.exit unexpectedly called with "-1" and the real error nowhere in sight. io.test.ts and endowment-globals.test.ts build kernels directly and are audited now too. Verified by injecting a double increment into pinObject: two cluster-launch tests fail with the violation, where before they passed.
  3. The audit compares the raw refcount row instead of reading it back through getObjectRefCount, which Fails on reachable > recognizable — one of the two drifts it exists to report. A malformed row is now reported as it stands.
  4. Both headline fixes are pinned by tests. A send routed through a promise that fulfilled to an object, where the queued and routed targets differ; and the notify release on both early returns, plus a batch retiring a sibling promise.
  5. resolvePromises charges data.slots after the state and decider checks, so an illegal syscall.resolve leaves nothing behind.
  6. Migration decision stated above.
  7. Changelog: the rename moved to ### Changed as its own BREAKING bullet, the "counts too high (a leak)" claim corrected to name the blind spot, undoOcapURLRetention added, and the blank lines my formatting commit put inside the feat(ocap-kernel): reference-marker sigil at queueMessage RPC boundary #984 entry reverted.
  8. Retentions and pins are counted per object, not listed in one row. Which objects get URLs is the holder's choice, so neither list was bounded by anything the kernel controls, and each issuance rewrote the whole row. A count per object is one write per issuance and keeps the per-issuance semantics: overlapping issuances share the one pin. And ending a retention is two operations, not one — undoOcapURLRetention unwinds a single failed issuance, releaseOcapURLRetentions drops the object's whole retention for a disavowal. getPinnedObjects names each object once; getPinCount gives the count.

The follow-ups from the reviews that are not this PR's — the settled-promise requeue, unpinVatRoot, addCListEntry idempotency, incRefCount/decRefCount, and wiring revocation to releaseOcapURLRetentions — are noted and will be raised separately.

What moved to the other PRs in this stack

This is the first of four. The rest are being prepared now and will be linked here as they open; #1010, #1011, #1012 and #1018 stay open until then, so nothing looks dropped.

Reviewing in order is worthwhile; each one's diff is much smaller than #1010's was.

Testing

yarn lint clean, yarn build 31/31. @metamask/ocap-kernel and @ocap/kernel-test fully green, with auditRefCounts on for every kernel kernel-test builds and a violation now failing the test that provoked it.

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, README.md, CHANGELOG.md) as appropriate

Note

High Risk
Touches core capability GC, refcount invariants, and persistent store layout with a mandatory reset for existing databases; incorrect accounting can collect live objects or leak capabilities.

Overview
Fixes #1006 by making import c-list creation/release symmetric: new objects start at (0, 0), addCListEntry takes a reference (with setReachableFlag for re-handoffs), and owner-side baseline decrements are removed. Vat roots are pinned for the vat lifetime; GC deliveries now update the kernel’s own c-list (dropExports / retire paths).

Adds reference-count auditing (auditRefCounts, recomputeRefCounts, …) and optional Kernel.make({ auditRefCounts: true }) checks after each crank; kernel-test enables this via makeAuditedKernelOptions so drift fails tests through onRunLoopFailure.

Corrects several refcount leaks: send delivery charges item.target (not the routed object), promise requeue transfers refs, notify releases early, promise-queue messages are charged/released consistently, resolvePromises only increments slots after legal resolve, and getPromisesByDecider scans the real ${endpoint}.c. layout.

Ocap URL issuance retains targets (per-URL issuance counts, pinned.${kref} pin counts); krefsToExistingErefskrefsToErefs (throws if unmapped); incrementRefCount refuses deleted krefs.

BREAKING: existing stores must be reset (no migration); tests/assertions updated for new baselines (e.g. createObject refcounts, v3 root pin in e2e).

Reviewed by Cursor Bugbot for commit 2f9ef11. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment threadpackages/ocap-kernel/src/store/methods/refcount-audit.ts
@github-actions

github-actionsBot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

StatusCategoryPercentageCovered / Total
🔵Lines72.47%
⬆️ +0.42%
9540 / 13163
🔵Statements72.31%
⬆️ +0.42%
9695 / 13407
🔵Functions73.09%
⬆️ +0.22%
2255 / 3085
🔵Branches66.48%
⬆️ +0.68%
3890 / 5851
File Coverage
FileStmtsBranchesFunctionsLinesUncovered Lines
Changed Files
packages/kernel-test/src/utils.ts85.18%
⬇️ -1.77%
68.42%
⬇️ -2.16%
90.47%
⬇️ -3.97%
84.9%
⬇️ -1.76%
37, 65, 96, 166, 171, 212-227
packages/ocap-kernel/src/Kernel.ts89.92%
⬆️ +0.16%
79.54%
⬆️ +0.97%
85.41%
🟰 ±0%
89.92%
⬆️ +0.16%
324-326, 397, 421, 496-506, 594, 662, 738-741, 754, 764-765, 818, 841
packages/ocap-kernel/src/KernelQueue.ts98.56%
🟰 ±0%
90.27%
🟰 ±0%
100%
🟰 ±0%
98.56%
🟰 ±0%
148, 518
packages/ocap-kernel/src/KernelRouter.ts94.2%
⬆️ +0.27%
80.59%
⬆️ +2.13%
100%
🟰 ±0%
94.2%
⬆️ +0.27%
110, 173, 190, 264, 319, 379, 397, 400
packages/ocap-kernel/src/KernelServiceManager.ts98.52%
⬆️ +2.94%
92.3%
⬆️ +7.69%
100%
🟰 ±0%
98.52%
⬆️ +2.94%
310
packages/ocap-kernel/src/index.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/index.ts98.9%
⬆️ +0.29%
95.23%
⬆️ +4.33%
100%
🟰 ±0%
98.88%
⬆️ +0.29%
376
packages/ocap-kernel/src/store/types.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/base.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/clist.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/gc.ts90.41%
⬆️ +1.37%
78.72%
⬆️ +4.26%
100%
🟰 ±0%
90.41%
⬆️ +1.37%
138, 150, 181-188
packages/ocap-kernel/src/store/methods/object.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/pinned.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/promise.ts100%
🟰 ±0%
95.23%
⬆️ +0.79%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/reachable.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/refcount-audit.ts100%94.82%100%100%
packages/ocap-kernel/src/store/methods/refcount.ts100%
🟰 ±0%
100%
⬆️ +3.13%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/translators.ts98.48%
⬆️ +0.10%
96.66%
⬆️ +0.24%
100%
🟰 ±0%
98.48%
⬆️ +0.10%
161
packages/ocap-kernel/src/store/methods/vat.ts98.5%
⬆️ +1.21%
90%
⬆️ +8.19%
100%
🟰 ±0%
98.49%
⬆️ +1.22%
308-309
packages/ocap-kernel/src/vats/SubclusterManager.ts96.21%
⬆️ +0.02%
90.12%
🟰 ±0%
100%
🟰 ±0%
96.15%
⬆️ +0.02%
155-158, 236-239, 293, 373, 393, 410
packages/ocap-kernel/src/vats/VatManager.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #4656 for commit 2f9ef11 by the Vitest Coverage Report Action

sirtimid added a commit that referenced this pull request Aug 13, 2026
…ring
`retireKernelObjects` deletes an object and queues a `retireImport` for each
importer in the same breath, so until that action is delivered an importer's
c-list entry names a kref the kernel has already dropped. The audit counted
those entries as holders and reported a violation against the collector's own
output — and since `assertRefCountsIfAuditing` throws from inside the crank,
that killed the run loop for good.
Reachable from an ordinary `terminateVat` while a surviving vat holds the
dying vat's export in liveslots' dropped-but-recognizable state. No current
test produced it; found by Cursor Bugbot on #1020 and reproduced against the
real store.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid

Copy link
Copy Markdown
MemberAuthor

Audit rejects valid orphan retirement (Cursor Bugbot, High)

Confirmed and fixed in 5e3be431f. This was a real latent run-loop kill, not a false positive — thanks Bugbot.

Reproduced against the real store (makeKernelStore(makeMapKernelDatabase()), no mocks): an object owned by a terminated-and-cleaned-up vat, still named by a surviving vat's dropped-but-recognizable import. collectGarbage() then auditRefCounts() yields

ko1: stored (deleted), expected 0,1 (held by: v3 c-list import o-1)

The ordering is as reported: retireKernelObjects queues the retireImport actions and calls deleteKernelObject in the same loop (gc.ts:106-117), deleteKernelObject removes only owner/refCount/revoked and never the importers' c-list entries, and processGCActionSet can only return the action as a future run-queue item. So assertRefCountsIfAuditing() at KernelQueue.ts:347 runs while those entries still exist. And it is fatal rather than noisy: the throw unwinds out of #runLoop into #failRunLoop.

Fix:computeExpectedRefCounts no longer credits an importer entry that has a matching retireImport already queued. Those entries are scheduled for teardown and are not holders.

One correction to the report's framing:retireKernelObjects is reachable only via the orphaned branch (gc.ts:198-204), so it needs an owner that is terminated or already cleaned up — it is not any retirement. That doesn't reduce the severity, because #runLoop calls nextTerminatedVatCleanup() inside the crank, which orphans the dead vat's exports into maybeFreeKrefs, and collectGarbage() consumes them at the end of that same crank. An ordinary terminateVat reaches it.

Exposure: real but previously unexercised. Nothing in the suite produced this state, which is why it was green. Nothing exotic is needed either — terminating a vat while a surviving vat holds its export in liveslots' normal dropped-but-still-recognizing state is enough.

Pinned by tolerates an importer entry that outlives the object it names in clist-accounting.test.ts, mutation-verified: reverting the guard fails that test and only that test, with the error above.

sirtimidand others added 3 commits August 13, 2026 19:37
Creating an import c-list entry changed no refcount while tearing one
down decremented both, and `initKernelObject` compensated by minting
every object at (1, 1). That constant is correct for exactly one
importer, which is why nothing caught it: with two importers a live
capability gets dropped and retired out from under a holder, and the
same unit is claimed by both an importer's drop and the owner's
termination, so cleanup underflows and leaves a vat half-cleaned.
Restore the increment and rebase the baseline to (0, 0), matching
SwingSet, so `collectGarbage` — already a faithful port — receives the
inputs it was written for.
Build the invariant checker first, since every existing compensation
becomes a double-count the moment the increment lands. It recomputes
each kref's counts from ground truth (c-list entries and their reachable
flags, run-queue and promise-queue messages, promise resolution values,
pins) and reports drift in both directions: too low collects a live
capability, too high leaks it. Enabled via `Kernel.make`'s
`auditRefCounts` and run after every crank; on in kernel-test.
The audit found four more unbalanced paths that the phantom baseline had
been absorbing, each fixed here: a delivered message charged its target
against the routed kref rather than the run-queue item's own, so a
message routed through a resolved promise decremented an object nobody
charged and leaked the promise; a notification leaked its reference on
both early-return paths and decremented promises retired alongside it
that nobody had taken; a message queued on an unresolved promise
duplicated every reference it carried on re-enqueue; and `resolve|kpid`
incremented with no matching release.
Two things the baseline was silently standing in for, now explicit: vat
roots are pinned for the lifetime of their vat (a root is addressable
whether or not anyone imports it), and GC action delivery moves the
kernel's own c-list so a dropped export's flag clears and retired
entries don't outlive their objects.
Also fixes the stale `cle.`/`clk.` key prefixes in
`getPromisesByDecider` and `deleteEndpoint`, which stopped matching the
`${endpointId}.c.` layout. `getPromisesByDecider` matched nothing, so
promises a terminating vat was deciding were never rejected — load
bearing here, because releasing a promise's unsettled reference is what
makes the cleanup path's accounting add up.
Refcounts are persisted, so counts written under the old scheme are
recomputed from ground truth on first open, keyed off a new
`refCountScheme` entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prettier wanted a blank line before the entry following a nested bullet,
and the entries still cited #1010, which this PR replaces.
…ring
`retireKernelObjects` deletes an object and queues a `retireImport` for each
importer in the same breath, so until that action is delivered an importer's
c-list entry names a kref the kernel has already dropped. The audit counted
those entries as holders and reported a violation against the collector's own
output — and since `assertRefCountsIfAuditing` throws from inside the crank,
that killed the run loop for good.
Reachable from an ordinary `terminateVat` while a surviving vat holds the
dying vat's export in liveslots' dropped-but-recognizable state. No current
test produced it; found by Cursor Bugbot on #1020 and reproduced against the
real store.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimidforce-pushed the sirtimid/clist-refcount-symmetry branch from 5e3be43 to 1f9c888CompareAugust 13, 2026 17:46
Rebasing the baseline to (0, 0) made every reference explicit, which
exposed the holders that were never references at all. An ocap URL
carries its kref inside an encrypted bearer token and nothing else, so
the kernel cannot discover from its own state that a holder exists:
`issueOcapURL` took no reference of any kind. Under the old baseline
nothing exported was collectable and it never showed; at (0, 0) the
target is collected as soon as the message that carried it to the issuer
is delivered, and the URL names a dead capability. The audit is silent
on it by construction — the object genuinely has no holder it can see.
Retain the target when the URL is issued, before the token exists, since
the token is unretractable once it does. One pin per kref however many
URLs name it, and no release: the token is persistent and unexpiring, so
`revoke` is how the capability dies. Pinning also puts the holder inside
the reference graph, so the audit can see it rather than being taught to
excuse it.
The same shape had a second door. `incrementRefCount` has no
`kernelRefExists` guard where `decrementRefCount` does, so importing a
deleted kref read its missing counts as (0, 0) and wrote them back,
resurrecting a live-looking object with no owner — deliverable to by
nobody, and endorsed by the audit, since the new c-list entry is a
legitimate holder for exactly the count it finds. Reached by redeeming a
URL issued for an object since collected. Guard the point of corruption,
`translateRefKtoE`, rather than `incrementRefCount` itself: creating an
entry for a deleted kref is the invariant, and releasing a reference to
something already gone is how GC teardown is allowed to race deletion.
Also release a vat's root pin when `deleteSubcluster` retires vats that
never ran here. It bypasses `stopVat`, so nothing released the pin
`launchVat` took in the incarnation that did run them, leaving the root's
count permanently above zero and `pinnedObjects` naming a vat that no
longer exists. `stopVat` and `deleteSubcluster` now share
`releaseVatRootPin`.
Vat root pinning had no unit coverage at all, so pin-on-launch,
release-on-terminate and keep-across-restart are asserted now; the last
is what the comment claims and what would break silently. Restores the
`maybeFreeKrefs` assertion on `forgetEndpointImports`' ownership-migrated
branch, which lost its `not.toHaveBeenCalled` when that branch stopped
returning early.
Corrects three claims that the (0, 0) birth falsified and that shipped as
documentation: both `KernelServiceManager` comments asserting its delete
branch cannot fire, when it now does, and a changelog entry asserting
(1, 1) birth two dozen lines above one asserting (0, 0). `recomputeRefCounts`
no longer describes itself as a migration; nothing calls it, and opening
an existing store does not migrate one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadpackages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts
Retaining before minting is right: minting awaits, so a collection crank can run in that window. But nothing undid the retention when minting then failed. A rejected kernel-service call is reported to the caller rather than thrown out of the crank, so the crank commits and the pin outlives the kernel that took it, naming a URL that never existed.
retainForOcapURL now reports whether this call took the pin, and undoOcapURLRetention unwinds one that never backed a URL. Guarded on the ledger rather than the pin list, so it can only remove the pin it put there: a kref some live URL already names keeps the pin that URL depends on, and a vat root keeps its lifetime pin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadpackages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts
SherfeyInv pushed a commit to SherfeyInv/ocap-kernel that referenced this pull request Aug 17, 2026
…hem (MetaMask#1024)
Fixes a pre-existing e2e flake on `main`, surfaced while rebasing the
MetaMask#1020MetaMask#1023 stack. Small and self-contained so the whole stack inherits
it.
## The defect
`control-panel.test.ts` › `should collect garbage` asserted that Carol's
root object is `ko6` and Bob's is `ko5`, and that their promises are
`kp4` and `kp3`:
```js
'{"key":"ko6.owner","value":"v3"}',
'{"key":"v3.c.ko6","value":"R o+0"}',
```
Since MetaMask#983, subcluster vats launch **in parallel**. Each vat's root is
exported when its own launch finishes, so which of `ko5`/`ko6` belongs
to Bob and which to Carol changes between runs. When they come back the
other way round, the test fails — and `database-inspector.test.ts` fails
alongside it, because it reads the same kv dump.
Observed directly: a failing run had `ko6.owner = v2` and `ko5.owner =
v3`, the exact inverse of what is asserted.
The vat ids themselves are stable — they are handed out in config order,
so alice is always `v1` — so only the object and promise krefs need
deriving.
## Approach
Three small helpers read the dump and look up what the assertions used
to hardcode: `rootKrefOf(dump, vatId)` by owner, `promiseKrefOf(dump,
vatId)` by c-list entry, and `erefOf(dump, vatId, kref)`.
The erefs are derived in **full** rather than matched by prefix. A
c-list entry's reverse direction is keyed by eref and valued by kref, so
a loose `,"value":"ko5"}` also matches the *owning* vat's own `v2.c.o+0`
entry. That passed while both vats were alive and broke the negative
assertions the moment one outlived the other — which is what the test
checks after terminating v3.
## Testing
`yarn lint` clean. Extension e2e run three times: the kref failure is
gone, and the two clean runs finish in ~50s rather than ~2.7m because no
retries are needed.
**What this does not fix.** The extension e2e suite has separate
instability that this change does not touch and does not claim to:
`object-registry.test.ts` failures, and a UI timing flake where
`Terminated vat "v1"` does not render because the panel is still showing
query output. One of the three runs hit those. They are unrelated to
kref assignment and were present before this change.
## Checklist
- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, `README.md`, `CHANGELOG.md`) as
appropriate — test-only change, no changelog entry
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Low Risk**
> Test-only change to e2e assertions and helpers; no production or
runtime behavior is modified.
> > **Overview**
> Fixes flaky **`should collect garbage`** assertions in
`control-panel.test.ts` that assumed fixed kernel refs (`ko5`/`ko6`,
`kp3`/`kp4`) for Bob and Carol. Parallel subcluster launches mean those
object and promise krefs can swap between runs while vat ids (`v2`/`v3`)
stay stable.
> > Adds helpers to parse the Database Inspector kv dump and **derive**
root krefs (via `.owner`), promise krefs (via c-list), and v1’s
**erefs** (full c-list lookup so reverse entries don’t false-match). The
garbage-collection expectations are built from those values instead of
literals.
> > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d8e81f7. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retention was deduplicated by kref, and the failure path undid it if this
call was the one that took it. Minting awaits, though, so issuances for the
same target overlap: a second `issue` can mint a URL while the first is still
in flight, having taken no retention of its own because the ledger already
named the kref. If the first then fails it unwinds the retention the second's
live URL depends on, and collection can take the capability out from under it.
The ledger is a multiset now, one entry and one pin per issuance, so a failed
mint releases only what it took. Pins were already a multiset, and each pin
here is either released by its own failure or held by its own live URL, so
none is left unreleasable — the concern that motivated deduplicating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@grypezgrypez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

The invariant-checker-first ordering earned its keep — four of the bugs fixed here were found by the checker. I verified #1006's symptoms 1–3 against the real store and through the real GC handlers: genuinely fixed. add/deleteCListEntry are exactly symmetric, setReachableFlag is idempotent, the (0, 0) birth window is safe, and all four delivery fixes are correct and complete. getPromisesByDecider was previously dead code, so vat and remote termination never rejected orphaned promises at all — good catch. One detail in the design's favour: the stack later adds a third early return to #deliverNotify, and releasing the notification's reference up front covers it by construction; had the decrement stayed after deliverNotify, that new path would have leaked.

On gc.ts:169: you're right and the issue is wrong. A single collectGarbage pass legitimately emits dropExport and retireExport together, so the owner's flag is still set when retireExport is derived — I reproduced that at both ends of the stack. Upstream SwingSet carries the same assert commented out under the same TODO. Keep it disabled; replace the TODO with your explanation.

I reviewed this against the tip of the stack as well, so the notes below distinguish what later PRs fix from what nothing does.

Sequencing

This PR turns auditRefCounts on for every kernel kernel-test builds, and the audit throws into the run loop. Three of its violators are elsewhere:

  • At this commit getImporters (vat.ts:150) filters getVatIDs(), so a remote importer never gets a retireImport and its entry dangles on a deleted kref, which the retiring exemption cannot forgive because it only excuses entries that have a queued action. #1023 closes this.
  • #1021's description says of the stale gcActions cache: "harmless with auditing off; fatal with it on, which is every kernel-test kernel." This is the PR that turns it on. Verified fixed at the tip, on a savepoint-capable database.
  • Still live at the tip: the same asymmetry survives for vats. getImporters reads registration rows while the audit scans the whole store, and #retireVat deletes vatConfig.<vatId> while the vat's c-list lives until cleanup — so a terminated-but-uncleaned importer is invisible. The recognizable unit it still holds buys no protection: the orphan path queues one retireImport per visible importer and then deletes the object unconditionally. If the owner is marked terminated first and both terminations land between cranks — cleanup handles one vat per crank, FIFO by mark order — the object is deleted with no action for that importer and the audit reports dangling, killing the run loop one crank before cleanup would have swept the entry. Reproduced at the store level; the window closes on the next crank, so it is transient and invisible with auditing off. Best fixed in #1023, where getImporters is already being changed — deriving importers from c-list entries rather than registration rows closes the class. Raising it there separately.

So the stack is sound as a unit; merged one at a time, main spends three PRs able to die on a remote importer or an aborted retire, and the vat case can still flake kernel-test at the tip. Either land the stack together, or defer auditRefCounts: true in kernel-test/src/utils.ts until getImporters and the audit agree on ground truth.

Belongs in this PR

These all survive to the tip, so nothing downstream will catch them.

  1. incrementRefCount has no kernelRefExists guard (refcount.ts:108) while decrementRefCount does (:152). The guard went to two call sites instead of the primitive, so other paths still resurrect a deleted row — pinObject('ko99') on a kref that never existed yields kernelRefExists → true, (1, 1), owner undefined. Reachable with no remote involved: retireKernelObjects queues the retireImport and calls deleteKernelObject in the same breath, so there is always a window where the row is gone while an importer's entry is live. The audit correctly exempts that window, but an increment inside it resurrects from zero, losing the surviving entry's recognizable unit, and the next setReachableFlag throws refMismatch(set) "ko1" 2,1 in the crank path. Also reached by local ocap-URL redemption, which gets to incrementRefCount(slot, 'resolve|slot') with only insistKRef where the remote path fails loudly at translators.ts:88. Putting the guard in the primitive closes the class.
  2. The audit does not reliably fail the build. The throw at refcount-audit.ts:354 kills the run loop, but Kernel.ts:347 routes it to #handleRunLoopFailure, which deliberately does not rethrow, and kernel-test's makeKernel passes no onRunLoopFailure. A violation fails a test only if that crank has a pending queueMessage subscription to reject — on a GC-only or reap crank, or after the last assertion, it is only logger.error'd into a vi.fn() nobody asserts. endowment-globals.test.ts:37 and io.test.ts:73 also build kernels directly and are not audited.
  3. refcount-audit.ts:280 cannot report the corruption it exists to catch.storedText goes through getObjectRefCount, which Fails on reachable > recognizable; the operator gets refMismatch(get) ko7 3,1 with no holder list. Parse the raw string as the promise branch already does.
  4. The two headline fixes are untested. Reverting item.target to target at KernelRouter.ts:321 keeps the whole suite green — there is no delivery test where routed and queued targets differ. The notify-leak fix is untested on exactly the two early returns it exists for (KernelRouter.test.ts:552, :585 assert only the return value).
  5. KernelQueue increments data.slots before the state/decider checks that Fail. An illegal resolve leaves the target at (1, 1) with no holder, which the audit reports — so in audit mode this kills the kernel rather than leaking quietly. Move the increments below the checks.
  6. Please state the migration decision.kernel-store has no schema version or migration, so a store from the current release opens with every object at (1, 1) and no root pins; with two importers the second clearReachableFlag throws "ko1" underflow -1,1 from inside performDropImports, on a dropImports syscall against an existing database. Roots there have no pin either, so the last importer's drop can retire a live vat's root. Still true at the tip — nothing in the stack adds a version, a migration, or a refusal to open a pre-migration store. The BREAKING marker may make "reset required" the right answer; it just needs saying, along with the fact that recomputeRefCounts is currently only reachable by constructing a second makeKernelStore over the same database, with RefCountViolation not re-exported from the package root.
  7. Changelog. The BREAKING entry sits under ### Fixed while its sub-bullets are Changed-shaped — the (0, 0) birth and the krefsToExistingErefskrefsToErefs rename-and-throw. The rename deserves its own ### Changed bullet so a consumer scanning for breakage finds it. And #1022 walks back this entry's "counts too high (a leak)" claim — "it compares counts against the holders it finds… 'a leak' overstated it" — better to state the limit correctly here than to correct it two PRs later. undoOcapURLRetention is missing from the Added list, and the formatting commit added blank lines inside the unrelated #984 entry.

Follow-ups, not this PR

  • A message can be transferred onto a settled promise's queue: routeAsRequeue is reached from the fulfilled arm without re-checking state, resolveKernelPromise already deleted that queue, and provideStoredQueue silently recreates head/tail. Verified at the tip — the message is never delivered, the caller's result promise never settles, and the audit stays empty because the recreated entry justifies its own count. The message loss pre-dates this PR; making that entry the sole holder is what turns a visible over-count into a permanent invisible one. Take the requeue path only for unresolved.
  • unpinVatRoot (VatManager.ts:339) spends the lifetime pin — on main an unbalanced call was a no-op, now it retires a running vat's root. Blast radius at the tip is an OBJECT_DELETED rejection rather than a dead kernel, but the doc comment "does not make it collectable while the vat lives" is wrong for that path.
  • The ocap-URL ledger is O(n²) in issuances for one kref (5000 issuances → ~20 KB rows, 868 ms, permanent); encoding counts per kref keeps per-issuance semantics without per-issuance storage. And revoke only writes ko.revoked, so "revoke is the way to kill the capability" reclaims nothing.
  • addCListEntry is not idempotent — a re-add double-counts recognizable and deleteCListEntry releases one. Both in-tree callers are guarded, but it is public and silently gained a refcount side effect here.
  • incRefCount/decRefCount are dead code, and worse: calling incRefCount on a live object writes NaN into the row and permanently breaks getObjectRefCount for that kref. Worth deleting rather than leaving beside incrementRefCount.
  • The settled-promise c-list TODO. The tip documents the cost accurately ("holds a count forever, so it is never collected") but does not fix it, and three kernel-test assertions bake it in — worth a tracked issue.

Two notes on the #1010-era repros, if they are reused as regression tests: the remote-importer one registers its remote with a bare initEndpoint, which production never does (establishRemote writes the info row first), and the rollback one runs on makeMapKernelDatabase, whose savepoints are no-ops — so it fails at every commit. Both need corrected setups before they mean anything.

Pin balance across launch/restart/terminate/reload/deleteSubcluster is clean, and krefsToErefs throwing is safe: shouldProcessAction gates all three action types on hasCListEntry in the same synchronous stretch as delivery, and I could not construct a legitimate state reaching the throw.

@grypezgrypez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on the seven items from my earlier review that belong in this PR, now anchored inline. All seven survive to the tip of the stack (c9b917b96), so nothing in #1021/#1022/#1023 will pick them up.

To be clear about what this is not blocking on: the remote-importer dangle and the retired-export zombie are genuinely fixed downstream (verified by execution at the tip), and the gc.ts:169 judgment call is right. The sequencing concern and the lower-severity follow-ups stay in the earlier comment; this review is only the in-scope asks.

Items 3 and 7 carry concrete suggestions. Items 1, 2, 4, 5 and 6 are judgment calls or need changes outside this diff, so they are comments rather than patches.

Comment threadpackages/ocap-kernel/src/store/methods/refcount.ts Outdated
Comment threadpackages/ocap-kernel/src/store/methods/refcount-audit.ts
Comment threadpackages/ocap-kernel/src/store/methods/refcount-audit.ts Outdated
Comment threadpackages/ocap-kernel/src/KernelRouter.ts
Comment threadpackages/ocap-kernel/src/KernelRouter.ts
Comment threadpackages/ocap-kernel/src/KernelQueue.ts Outdated
Comment threadpackages/ocap-kernel/src/store/methods/object.ts
Comment threadpackages/ocap-kernel/CHANGELOG.md Outdated
Comment threadpackages/ocap-kernel/CHANGELOG.md Outdated
Comment threadpackages/ocap-kernel/CHANGELOG.md Outdated
sirtimidand others added 6 commits August 17, 2026 16:05
`getObjectRefCount` reads a missing row as (0, 0), so incrementing one writes
it back and resurrects a live-looking object with no owner — deliverable to by
nobody, and endorsed by the audit, since whatever took the reference is a
legitimate holder for exactly the count it finds. `retireKernelObjects` deletes
the object and queues the `retireImport` in the same breath, so there is always
a window where the row is gone while an importer's entry is still live; an
increment inside it loses that entry's recognizable unit, and the next
`setReachableFlag` pushes reachable past recognizable and throws mid-crank.
This PR guarded the two paths it had found — importing into a c-list, issuing
an ocap URL — but `pinObject`, `resolve|slot` and everything else still
resurrect. `decrementRefCount` has always guarded the same missing row at the
primitive; `incrementRefCount` now does too, and fails rather than returning:
releasing a reference to something already gone is ordinary teardown, taking
one is always a bug.
The two call-site guards stay. They refuse before an eref is allocated or a
ledger entry written, and name what was attempted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n it
The audit read each object's stored counts back through `getObjectRefCount`,
which `Fail`s when reachable exceeds recognizable — one of the two drifts this
module exists to diagnose. Hitting it meant the operator got `refMismatch(get)
ko7 3,1` with no holder list, no expected value, and none of the other
violations from the same sweep.
Objects store the same "reachable,recognizable" encoding the audit renders, so
the raw row compares directly. A malformed row is now reported as it stands
rather than taking the sweep down with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s legal
`resolvePromises` incremented every slot before checking the promise's state
and decider, so a vat's illegal `syscall.resolve` threw out of those checks
having already charged a unit per slot with nobody holding it. This PR removed
the `resolve|kpid` increment from the same spot but left the slots, and the
audit it adds is what makes the leftover fatal rather than merely leaky: the
next crank reports a kref stored at (1, 1) with no holder and kills the kernel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were untested on exactly the paths they exist for. Every assertion on
`deliver|send|target` used an object target, where the run queue item's target
and the routed target are the same kref, so reverting that fix left the suite
green; there was no delivery test at all where a message reaches an object
through a promise that fulfilled to it.
The notify fix is the same story: the two early returns it moved the release
in front of asserted only the return value, and the sibling-promise decrement
it deletes was never exercised, since the one batch test mocks
`getKpidsToRetire` to return the notified promise itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turning the audit on for every kernel `kernel-test` builds did not make a
violation fail the build. The audit reports by throwing, which kills the run
loop, and the kernel deliberately hands run loop death to `onRunLoopFailure`
rather than rethrowing it — so with no handler a violation surfaced only if
that crank happened to have a caller waiting on it. On a garbage collection or
reap crank, or one landing after a test's last assertion, it was logged into a
mock nobody asserts on and forgotten.
`makeAuditedKernelOptions` records the failure and hooks report it, so it fails
the test with the message that names the drifted kref rather than an unhandled
error that takes the worker down with a useless one. Two kernels built directly
rather than through `makeKernel` were not audited at all; they are now.
Verified by injecting a double increment into `pinObject`: two `kernel-test`
tests fail with the violation, where before this they passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g rename
A store written before this change has every object at (1, 1) and no root pins,
and `kernel-store` has no schema version to notice: the second importer's
`dropImports` underflows mid-crank, and a legacy store's roots have no pin for
the last importer's drop to lose to. There is no migration and none is planned
at this version, so say so where an upgrading consumer will read it.
The `krefsToExistingErefs` rename moves to `### Changed`, where this file puts
its other breaking API changes and where a consumer scanning for breakage
looks. The audit entry claimed to catch leaks; it compares counts against the
holders it finds, so a holder that should have been torn down but wasn't
justifies its own count and is invisible to it. `undoOcapURLRetention` is as
public as the two methods listed beside it, and `RefCountViolation` is now
exported from the package root, as the entry said it was.
Also reverts blank lines this branch's formatting commit inserted into an
unrelated entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid

Copy link
Copy Markdown
MemberAuthor

All seven items are done, one commit each, pushed as 577c62746. Details are in each thread, and the PR body now has a "Migration" section and a "Changes since review" list.

Short version:

  1. incrementRefCount fails on a missing object row now, like the decrement. The two call-site guards stay, since they refuse earlier and say what was attempted — 2bf8a5c
  2. kernel-test passes an onRunLoopFailure, so a violation fails the test that provoked it. Verified by injecting a double increment in pinObject3b516ab
  3. The audit compares the raw row, so it can report 3,1 instead of throwing on it — 6fb6332
  4. Tests for the send and notify fixes, on the paths where they matter — 42cc2a2
  5. Resolution slots are charged after the state and decider checks — b619532
  6. Migration: reset required, no migration at this version. Stated in the PR body and the changelog — 577c627
  7. Changelog: rename under ### Changed, the "leak" claim corrected, undoOcapURLRetention added, stray blank lines reverted — 577c627

yarn build 31/31, lint clean, @metamask/ocap-kernel and @ocap/kernel-test green.

On sequencing: I agree the stack is sound as a unit. I would rather land it together than defer auditRefCounts: true, but tell me if you prefer the other way and I will move that line to the last PR.

Your follow-ups that are not this PR (settled-promise requeue, unpinVatRoot, the O(n²) ocap-URL ledger, addCListEntry idempotency, the dead incRefCount/decRefCount) are noted and I will raise them separately. The getImporters point I will pick up in #1023, where it is already being changed.

@sirtimid
sirtimid requested a review from grypezAugust 17, 2026 18:06
Comment threadpackages/ocap-kernel/src/store/index.ts Outdated
sirtimidand others added 2 commits August 18, 2026 15:58
An ocap URL retains its target for as long as any URL names it, and which
objects get URLs is the holder's choice, not the kernel's — so neither the
retention ledger nor the pin list it writes into is bounded by anything the
kernel controls. Both kept every entry in a single row, so each issuance read,
rewrote and re-sorted the whole thing, and the row grew without limit.
Both are now a count per object in a row of its own, which is one write per
issuance whatever else is retained. Counting keeps the per-issuance semantics
the previous shape needed a multiset for: overlapping issuances for one target
share the single pin, and a failed mint spends its own issuance without
touching the retention a URL minted alongside it depends on.
Ending a retention was one method doing two jobs it cannot both do.
`undoOcapURLRetention` unwinds a single issuance, which is right for a mint
that failed and wrong for disavowing an object, where every URL naming it goes
at once — that case is `releaseOcapURLRetentions`. Revocation is still neither:
it writes only its flag, so a revoked object's URLs stay retained.
`getPinnedObjects` now names each object once however many pins it holds, and
`getPinCount` reports that number, which is what the audit needs to credit a
unit per pin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Where pins and ocap URL retentions are stored is the store's own business: a
consumer sees the methods and what they mean, not the keys they write. The
entries keep the API changes — the new methods, and `getPinnedObjects` naming
each object once — and drop the key layout and the reasoning behind it, which
live in the code that implements them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid requested a review from FUDCoAugust 18, 2026 14:13
Three behaviour fixes, each the sibling of something already fixed here.
`incrementRefCount`'s deleted-kref guard now covers promises. It sat below
the `isPromise` early return, so a promise increment on a missing row still
wrote `NaN` back — a row that reads as existing and that no decrement can
bring to zero, so the promise could never be collected. The guard is placed
in front of both paths that read a row and write it back, rather than at the
top: an object export mutates no count, so it still needs no row to exist.
The audit no longer dies on a settled promise that lost its value. Reading
it with `getRequired` took the whole sweep down over one row, in the module
whose premise is that the store might be wrong; `gc.ts` and
`getKpidsToRetire` both allow that state. Read tolerantly, the slots it
would have credited are reported as counts too high, which is what they are.
`deleteEndpoint` releases the references its c-list entries hold instead of
deleting the keys. The prefix fix made this loop live for the first time, and
a bare delete leaves the target held by a holder that no longer exists —
pinned alive forever, and reported by the audit as a count nothing accounts
for. No in-tree change: `cleanupTerminatedVat` has emptied the c-list before
it gets here.
`clist.test.ts` needed a promise refcount row for the same reason the object
tests needed one in 2bf8a5c.
Also: `unpinVatRoot`'s doc claimed the opposite of what it does, since pins
are fungible and an unbalanced call spends the lifetime pin; four keys in the
store's layout block were wrong, which is the class of staleness that caused
two of the bugs this PR fixes; and the e2e asserts a root's pin, which is the
one invariant the audit cannot check — a root that lost its pin agrees with
its refcount and the audit stays silent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ceb6c50. Configure here.

Comment threadpackages/ocap-kernel/src/store/methods/refcount-audit.ts
sirtimidand others added 2 commits August 18, 2026 17:55
`v3Values` is checked three times, spanning v3's termination, so a pin
assertion cannot live there: terminating the vat releases the pin its launch
took. Asserted on either side of the termination instead, which is worth more
than one reading anyway — it pins the release too.
The counts come from an actual run rather than derivation: a live root is its
own pin plus v1's import at `2,2`, and `1,1` once the pin is spent. That run
also showed bob's root at `3,3` behind two pins, the second being the ocap URL
retention its issued URL holds, which is the accounting this branch added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tolerant read added for a missing value row still asserted `slots` was
there, so a row that parsed but carried none returned `undefined` for the
caller to iterate — throwing outside the `try`, which is the crash the helper
exists to prevent. Reported by Bugbot.
A row whose `slots` is a string was worse and unreported: it iterated
character by character and credited krefs that never existed, so the audit
invented violations against `k`, `o` and `1` instead of dying. A tool whose
only value is being believed must not do that, so this checks for an array
rather than trusting a cast.
Parameterized over all six shapes a value row can take; three of them fail
against the previous code, including the fabricated-kref one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +415 to +434
// name a capability that can never be delivered to. Refusing here names
// what was attempted, and does it before anything has been written.
this.kernelRefExists(kref) ||
Fail`cannot issue an ocap URL for deleted kref ${kref}`;
const krefs = this.getOcapURLObjects();
krefs.push(kref);
kv.set('ocapURLObjects', krefs.sort().join(','));
this.pinObject(kref);
const issuances = this.getOcapURLIssuanceCount(kref);
if (issuances === 0) {
this.pinObject(kref);
}
kv.set(`${OCAP_URL_PREFIX}${kref}`, `${issuances + 1}`);
},
undoOcapURLRetention(kref: KRef): void {
const krefs = this.getOcapURLObjects();
const index = krefs.indexOf(kref);
if (index === -1) {
const issuances = this.getOcapURLIssuanceCount(kref);
if (issuances > 1) {
kv.set(`${OCAP_URL_PREFIX}${kref}`, `${issuances - 1}`);
return;
}
krefs.splice(index, 1);
if (krefs.length === 0) {
kv.delete('ocapURLObjects');
} else {
kv.set('ocapURLObjects', krefs.join(','));
// The last issuance, or none at all: either way, what is left of the
// retention is exactly what a release drops.
this.releaseOcapURLRetentions(kref);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mechanism might be overkill. Once the count has been successfully incremented once (i.e., one successful URL issuance that wasn't rolled back due to a failure) it can never return to 0, as there is no GC of issued URLs possible and no notion of URL retirement. It basically needs to be single sticky bit, not a count (a count is certainly one way to implement the sticky bit, but it costs an extra write each time). I'm going to approve the PR, as this is a quibble not worth holding things up for, but if the spirit moves you to address this I promise a quick re-approval.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I think the count does one more thing than a sticky bit can do.

It is not counting live URLs. It counts issuances that are still in flight. Minting awaits, so a second issue for the same kref can finish while the first one is still waiting. Then if the first one fails, its undo has to know if it is the only one holding the pin.

With one bit it can go two ways and both are wrong:

  • clear the bit and unpin → we just unpinned an object that a live URL points to
  • keep the bit → a single failed issuance leaks the pin forever, which is the exact thing undoOcapURLRetention exists for

The test for this is store/index.test.ts:400.

Returning "did I set the bit?" to the caller does not help either. The first issuance is both the one that sets the bit and the one that can fail last.

Also releaseOcapURLRetentions takes the count back to 0 on purpose. Wiring revoke/disavow to it is a follow up, so 0 is a real state, not only the start state.

If you still prefer the bit and you are ok with leaking one pin when an issuance fails, happy to do it — but as a follow up, since 3 PRs are stacked on this one.

@FUDCoFUDCo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Onward! Or not!

@sirtimid
sirtimid added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit f02677dAug 20, 2026
38 checks passed
@sirtimid
sirtimid deleted the sirtimid/clist-refcount-symmetry branch August 20, 2026 13:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

C-list import accounting is asymmetric: the refcount increment on import is missing

3 participants

@sirtimid@FUDCo@grypez