feat(server): let liveSubscriptionAuth revoke a single subscriber without ending its subscription - #2039
Conversation
…hout ending its subscription
registerLiveSubscription now accepts an optional `revoke?: () => void`, used as the
entry's terminate instead of the hard-coded end()/close()/emit('close') on the
subscription. It also returns an `{ unregister }` handle; when `revoke` is supplied
the subscription object is left untouched (no end() wrapping, no 'close' listener) —
the caller owns unregistration.
This is the prerequisite seam for sharing one live-subscription feed per topic across
its subscribers (a production node held 157,888 Subscription objects across 29
topics purely for per-connection bookkeeping). Wiring terminate to a shared feed
today would disconnect every subscriber on one user's revocation or token expiry;
this seam keeps revocation per-subscriber while feed sharing lands in a follow-up.
When `revoke` is omitted, behavior is byte-for-byte #1414: default terminate and the
existing end-wrapping/'close' self-wiring are unchanged, covered by regression tests.
Refs #1414
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tests Independent pre-push review (codex+gemini+grok+harper-domain, adjudicated) found two related majors in the seam: a custom `revoke` that throws was invoked a second time by sweep()'s fail-closed catch (the default terminate already caught its own errors, so this was latent but harmless before; a caller's non-idempotent revoke — e.g. a shared-feed refcount decrement — makes it consequential). And `unregister()` racing an in-flight `recheck()` was invisible to sweep, so terminate/revoke could still fire on an entry the caller had already torn down itself. Both share one missing invariant: terminate/revoke must run at most once per entry, and only while that entry is still registered. Enforce it with `claimAndTerminate`, using `Set.delete`'s boolean return as a lock-free claim token — no change to the recheck contract, fail-closed behavior, or interval/ITC triggers. Also gives terminate failures their own log message instead of being misattributed to "recheck error". Also: unitTests/server/liveSubscriptionAuth.test.js used sinon, which AGENTS.md forbids for new test files (unitTests/server/ has older sinon-based tests but is not the target shape). Replaced with a plain call-recording closure and added regression tests for both majors above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-2 pre-push review (codex+gemini+harper-domain, adjudicated) found that
`revoke?: () => void` type-checks against an `async () => {...}` callback, but
claimAndTerminate's `try { entry.terminate() } catch` only ever caught a synchronous
throw — a rejected promise from an async revoke (the realistic shape for the
shared-feed use case this seam exists for, e.g. an awaited refcount release against a
backing store) would escape as an unhandled rejection and take down the whole worker,
dropping every unrelated connection on it. There's no production unhandledRejection
handler to fall back on.
Widen `terminate`/`revoke` to `() => void | Promise<void>` and `await` inside
claimAndTerminate's try/catch so a rejection is caught the same way a synchronous
throw already was. Also stop logging non-Error throws as "undefined" (a plain string
or object thrown by caller code is plausible, and swallowed the actual cause) via a
small errorMessage() helper, applied to both the terminate-failure and recheck-error
log sites this diff already touches. Added a docblock note on the two invariants a
`revoke` caller must uphold that this module can't enforce: owning the entry's full
lifetime, and not mutating recheck-shared state across subscribers on one feed.
Added a regression test for the rejecting-async-revoke case: the module-level
unhandledRejection handler in unitTests/testUtils.js would fail the whole suite were
this not fixed, so its passing is itself part of the proof.
Two review findings deliberately NOT addressed here, as out of scope for this seam
(flagged in the PR description instead): retrying a failed terminate (currently the
entry is dropped from tracking on the first failure — a design question for whoever
builds the shared-feed follow-up, not fixable without restructuring sweep()'s
iteration and risking new Set-mid-iteration bugs), and an AbortSignal-based backstop
for a caller that forgets unregister() (new API surface beyond this seam's scope).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g tracking Round-3 delta review (codex+gemini+grok+harper-domain, adjudicated across two consecutive rounds) escalated a finding first raised as moderate: claimAndTerminate used registry.delete()'s boolean return as both the claim AND the commit, so a revoke/terminate that throws or rejects still permanently removed the entry from the registry — the deauthorized subscriber keeps receiving data indefinitely, with only a log line as any trace. That's fail-open dressed as fail-closed. Split the claim from the commit: claimAndTerminate now checks `registry.has(entry)` (the caller hasn't already unregistered it) and only calls `registry.delete()` AFTER `terminate()` resolves successfully. On failure the entry is left exactly where it was in the Set — not re-added, so it can't be revisited within the same sweep pass (the delete-then-readd shape that would risk that was explicitly avoided) — and is picked up again on the next interval tick or ITC event. Since recheck() will fail the same way next time, retry is naturally bounded and self-terminating once teardown actually succeeds. This also fixes, for free, the round-2 minor about the recheck-error log line asserting a revocation that didn't happen: claimAndTerminate's return value is now strictly "did I just tear this down," so callers only log success when it's true. Consolidated the two ad-hoc log call sites into claimAndTerminate itself, at `error` (an actual failed security control, not a warning) for a failed attempt and `info` for a successful one — closing another minor about revocations never appearing in the log on the normal path. Updated the two tests that pinned the old drop-on-failure behavior to instead prove retry-until-success: revoke fails once, the entry stays registered and revoke is not called on other subscribers sharing the object, then a second sweep retries and the entry is removed once revoke actually succeeds. Two related findings deliberately left as documented, out-of-scope risk (called out explicitly in both this and the prior round's review as pre-existing, not introduced by this diff): an unbounded await inside sweep()'s serialized loop can wedge `sweeping` forever if a caller's recheck or terminate never settles (recheck already had this property before this PR); and the sweeping-reentrancy guard drops a concurrent ITC broadcast rather than deferring it. Both would require restructuring sweep()'s timing/reentrancy semantics, which this seam-only PR's brief explicitly excludes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round-4 full review (codex+gemini+grok+harper-domain, adjudicated) confirmed as major, across all four lenses: sweep()'s reentrancy latch (`sweeping`) is only cleared in a `finally` that never runs if an awaited call inside the loop never settles. `await entry.recheck()` already had this shape, but it calls registry-controlled Harper code (findAndValidateUser); `await entry.terminate()` is now arbitrary caller code (a shared-feed refcount release against a store that can wedge), which is a materially wider hole. A revoke that never settles would silently disable continuous re-authorization for every subscription on the worker, forever, with no log line. Race terminate() against a bounded timeout (5s default, env-overridable via HARPER_SUBSCRIPTION_TERMINATE_TIMEOUT_MS, read fresh per call so tests can override it without a require-cache reset) and treat a timeout as a terminate failure — same fail-closed retry claimAndTerminate already does for a throw/rejection. Deliberately does NOT touch recheck()'s await: that's pre-existing, registry-controlled, and outside this seam's brief. Also: made errorMessage() itself defensive (a thrown value whose own String() conversion throws — e.g. Object.create(null) — must not turn a contained failure into a new one propagating out of a catch handler with no outer guard), and added a third documented invariant a `revoke` caller must uphold: idempotency, since a failed attempt is retried and the registry.has() claim check only closes the pre-await race, not a caller's own teardown running concurrently with an in-flight revoke. Added a regression test: a subscription sharing an object with a hung revoke and a normal one — the hung entry doesn't block the normal one in the same sweep pass, and stays registered for retry rather than wedging sweep() indefinitely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ation; dedupe retries of a still-pending revoke Round-5 delta review (codex+gemini+grok+harper-domain, adjudicated) found two more concrete bugs: 1. registerLiveSubscription's validity guard (`!subscription || ... || subscription.closed`) ran unconditionally, even though the `revoke` path never reads `subscription` at all. A shared-feed caller with no meaningful per-subscriber subscription object — the expected shape for this seam — would silently get NOOP_HANDLE back, indistinguishable from a real registration, and that subscriber would never be re-authorized again. The guard is now skipped when `revoke` is supplied; it still applies in full to the default (registry-owned) path, unchanged. 2. The timeout added last round bounds how long a hung revoke can block one sweep, but didn't stop the NEXT sweep from invoking the same still-hanging revoke a second time (and a third, ...) — an unbounded pile of concurrent in-flight calls to a non-idempotent shared-feed teardown (e.g. a refcount decrement) rather than one contained hang. claimAndTerminate now caches the in-flight attempt on the entry (`pendingTerminate`) and reuses it across retries instead of calling terminate()/revoke() again, clearing the cache only once that specific attempt actually settles (success or failure) so a merely slow call gets retried fresh once it's actually done, while a genuinely stuck one is never re-invoked. Also, two cheap defensive fixes flagged as real but minor: `terminateTimeoutMs()` was read twice per attempt (now once), and the two `void sweep()` fire-and-forget trigger sites (the interval and the ITC listener) had no `.catch()` — a throw from claimAndTerminate's fail- closed catch arm (e.g. a broken logger) would have escaped as an unhandled rejection. Added tests for both fixes: registering with revoke against a null/undefined/closed subscription still tracks and revokes correctly; a revoke that stays pending across two sweep passes is invoked exactly once, and committing its eventual resolution never re-invokes it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… the racing sweep Round-6 delta review found a real bug in last round's fix, with a standalone repro: a revoke that times out (still pending) and then succeeds BETWEEN sweeps — after the timeout window closes but before the next sweep starts — was silently discarded. claimAndTerminate's own commit logic (registry.delete + success log) only ran after its own `await withTimeout(...)` resolved; if nothing was actively awaiting the cached attempt when it finally settled, that success was lost, and the next sweep, seeing `pendingTerminate` already cleared by the settle handler, invoked `revoke` again — exactly the double-invocation this cache was built to prevent, just shifted to a different timing window. The included test for this only passed because `resolveRevoke()` and the following `_sweepNow()` ran in the same synchronous turn in the test, which isn't representative of the real ~30s gap between sweeps. Fixed by moving the commit (and the failure log) into the handler attached to the cached attempt itself, at the point it's created — that handler fires whenever the attempt settles, whether or not a sweep happens to be actively racing it via `withTimeout` at that moment. `claimAndTerminate`'s own post-race code path no longer does any committing; it just reports whether ITS wait succeeded. A timeout with the attempt still pending logs a distinct "still pending, will retry" message so a persistently hung revoke doesn't go completely silent, without double-logging once the attempt's own handler has already logged its definitive outcome. Hardened the affected test to resolve the revoke and yield via `setImmediate` with no active sweep, asserting the entry is removed before any further sweep runs — this is the shape that would have caught the bug the same-turn version missed. Also (real but minor, cheap to close): wrapped every `hdbLogger.*` call this file makes in a new `safeLog()` helper. `hdbLogger.error` sits on top of an unguarded `fs.appendFileSync`; a throw from a log call inside the fail-closed catch arm would otherwise abort the sweep pass at that entry (leaving every entry after it unauthorized-but-still-delivering for the rest of that pass) and then escape `triggerSweep`'s own handler as an unhandled rejection. Two items raised again this round were deliberately NOT changed, with reasons now made explicit for the PR: - Bounding `recheck()` with the same timeout, as suggested, would change behavior on the DEFAULT (no-`revoke`) path — a legitimately slow-but-correct recheck under load could get spuriously treated as unauthorized — which conflicts directly with this seam's own acceptance criterion that revoke-absent behavior stay identical to #1414 in every respect. Bounding `terminate` is safe because it only ever engages on the brand-new, currently-unused `revoke` path; bounding `recheck` is not. - Serial per-entry timeout accumulation and the sweeping-guard dropping a concurrent ITC broadcast were reassessed this round as a latency regression bounded by the 30s interval backstop, not a re-authorization hole — deferred to whoever designs the real shared-feed sweep architecture, which is explicitly out of this task's scope. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…top re-racing an already-pending revoke Round-7 delta review found the success log added two commits ago never actually fires on the DEFAULT (no-`revoke`) path — the only path any production caller uses today. The settle handler gated the log on `if (registry.delete(entry))`, but on the default path `entry.terminate()` calls `subscription.end()`, which is the registry's own wrapper — it synchronously calls `unregister()` (`registry.delete(entry)`) before the settle handler's microtask ever runs. By the time that handler checks, the entry is already gone, `registry.delete` returns false, and the log is skipped — silently, on every single default-path revocation. Since claimAndTerminate is the sole place that commits a successful termination, and an attempt's settle handler is only ever attached once per attempt, there was never a double-log risk to guard against; the guard was removed. Second finding: since the settle handler (not the racing `await` in claimAndTerminate) is what commits an outcome, re-racing an entry whose attempt is already in flight from a prior sweep serves no purpose — the boolean it produces isn't used by anything. It only costs a full `terminateTimeoutMs()` of serialized sweep time per stuck entry, on every single sweep pass, forever, while the entry accumulates more never-firing `.then()` reactions on the same hung promise. `claimAndTerminate` now returns immediately (no wait, no new log) when it finds `entry.pendingTerminate` already set — only the FIRST attempt for a given entry is ever raced against the timeout; every outcome, however it eventually arrives, is still handled by that first attempt's settle handler. Added a regression test asserting the default (`end()`-wrapping) path actually emits the info log — this would have failed against the previous two commits. Added a timing assertion to the existing pending-revoke test proving a sweep that finds an already-pending attempt returns near-instantly rather than re-racing the timeout. Two more re-raised findings again declined, unchanged from the reasoning already on record: bounding `recheck()` the same way would change default-path behavior, conflicting with this seam's own acceptance criterion that revoke-absent behavior stay byte-for-byte #1414; and an escalation ceiling / hard-teardown fallback for a permanently-failing `revoke` contradicts the seam's purpose (never end a shared feed over one subscriber's failure) — both are design questions for the shared-feed follow-up PR, not defects in this one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…a recheck on it; honest success wording Round-8 delta review found that last round's "don't re-race an already-pending attempt" fix, while correct on its own terms, had two side effects that made a permanently-stuck revoke strictly worse to operate against than before: 1. A revoke that never settles now produces exactly ONE log line, ever, then goes completely silent for the rest of the worker's life — worse than the previous round's "log every sweep" behavior, even though that behavior was also functionally inert (nothing was actually being retried). Restored a cheap, no-wait log on every sweep that finds an already-pending attempt, so a persistently stuck entry stays visible without reintroducing the timeout cost the previous fix removed. 2. `sweep()` still ran a full `recheck()` — in production a storage read (`findAndValidateUser`) plus user-overridable `allowRead` — for every entry whose terminate is already known to be pending, every single sweep, for as long as it stays stuck. `sweep()` now skips straight to `claimAndTerminate` (which just logs and returns) for an entry with `pendingTerminate` already set, and only computes `Date.now()` per entry rather than once before the loop, so a slow entry earlier in a pass can't make a later entry's expiry check stale (pre-existing #1414 shape, cheap to correct now that per-entry timeouts can stretch a pass). Also fixed, confirmed via the reviewer's probe: on the DEFAULT (no-`revoke`) path, a throwing `end()`/`close()` is swallowed internally (unchanged #1414 behavior) but the settle handler still logged "revoked subscription" as if delivery were confirmed stopped. Reworded the success log to "terminate completed" — honest about what this module actually knows (terminate ran without an error reaching it), not a claim about delivery state it can't verify on the swallow-everything legacy path. Replaced a `<15ms` wall-clock assertion (AGENTS.md names this exact pattern as a flakiness root cause) with a deterministic race: raise the timeout to 100s and race the sweep against a short marker — an implementation that re-raced the pending attempt could not win regardless of runner load. Extended the hung-revoke test to cover a second sweep, proving no re-invocation and the restored per-sweep log. One more re-raised finding declined again, same reasoning as the last three rounds: an escalation ceiling / re-invocation-after-N-sweeps for a permanently failing `revoke` is a design decision for the shared-feed follow-up (how many sweeps, and whether re-invoking a call that might still genuinely be in-flight is even safe, given nothing here can distinguish "dead" from "slow"), not a defect in this seam. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request enhances the live subscription authorization mechanism in liveSubscriptionAuth.ts by introducing support for a caller-supplied revoke callback, allowing shared feeds to manage revocation per subscriber. It also adds robust error handling, logging safety, and timeout bounds to prevent hung terminations from blocking the sweep process. Comprehensive unit tests are added to verify these behaviors. The reviewer recommends snapshotting the registry Set into an array before iterating over it in sweep() to avoid mutating the Set during iteration.
|
Reviewed; no blockers found. |
Snapshot the authorization registry before sweep mutation, preserve default-level revocation visibility, clamp terminate timeouts to Node's supported range, and stop the timer after late settlement. Add realistic JWT expiry coverage and regression tests for the new guards. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Remove contradictory reviewer-directed narration, make the timeout log accurately describe wait-only handling for hung attempts, and make the timeout-clamp assertion robust to unrelated timers. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
CI triage: all three failures were unrelated to this PR — checks are green nowRe-ran the failed jobs on the identical commit
Why none of them can be this diff:
Verified locally on this branch: No code change was needed, so nothing was pushed — the branch is still at Two things worth a separate look, both pre-existing and outside this PR:
— Claude Opus 5 |
…y invariant `claimAndTerminate`'s docblock promises "a pending attempt is not invoked again", but the guard enforcing it was dropped in b679649, leaving the guarantee to `sweep()`'s `continue`. Both of today's callers sit after that `continue`, so there is no live bug — but the seam exists so harper-pro can add callers, and an event-driven immediate revoke would call this directly and double-invoke a `revoke` still in flight. Put the guarantee back in the function that documents it. The log line stays in `sweep()`. `registerLiveSubscription` invariant (3) said a failed attempt is retried on the next sweep. That holds for a rejecting `revoke`, not for a hung one — and a hung release is exactly the shape a shared-feed refcount decrement against a wedged store takes, so it is the case a `revoke` author most needs to know is not retried. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…etry invariant Pre-push review (codex + gemini, adjudicated) found the options type still required `subscription` even though the runtime and the tests both accept a revoke-only registration without one — a shared-feed caller that owns no subscription object had to pass a meaningless `subscription: undefined` to type-check. Make it optional; the `!revoke && !subscription` guard already handles the rest. The domain leg also caught that the invariant sentence is still not true after the previous commit: a *rejected* attempt is only re-invoked if the next sweep's `recheck` again denies, so a subscription whose permission was restored in between is left half-torn-down and read as healthy. Say that. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…wording Round 2 of the pre-push review caught that making `subscription` plainly optional went too far: a caller supplying neither `subscription` nor `revoke` type-checked, got NOOP_HANDLE, and silently left its live subscription untracked. Model the options as a union so exactly one of the two teardown modes is required — verified that the three valid shapes compile and the neither-mode call is a type error. The rejected-terminate log still said "will retry next sweep" unconditionally, which is the same overstatement just corrected in the docblock. Say "on the next sweep that still denies". Co-Authored-By: Claude Opus <noreply@anthropic.com>
Follow-up: both requested one-liners applied, plus two things the pre-push review found in themPushed Requested —
Found by the review, in my own fixes —
Verification: For the human reviewer — deliberately not addressedThe review re-raised these across three rounds and I left every one of them alone: they are design decisions on your PR, not defects I should be resolving unsupervised. Round 3 confirmed the delta "introduces no new correctness issue"; these are all carried forward from the original diff.
Two review findings were adjudicated away rather than left open, worth recording so they don't come back: Gemini's Independent pre-push review: 3 rounds, — Claude Opus 5 |
The pending/timeout/late-commit state machine had no caller in this repo, so its only exercise was its own tests. Drop it: terminate is dispatched once, best effort, after the entry is untracked, which is the pre-existing default-path behavior extended to an async revoke. A revoke that throws, rejects or never settles is logged and not retried — recovery belongs to the caller that supplies revoke, where the timeout and idempotency requirements are actually known. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…t overlap Review round 2: a per-subscriber `warn` on every revocation turns one mass role change into a warn storm, so expected revocations (expiry, deny) log at info and `warn` stays for the recheck-failure path it covered on main. The suite also ran against the module's real 30s interval, where a background tick makes `_sweepNow()` a no-op through the `sweeping` guard; pin the interval before the import. Co-Authored-By: Claude Opus <noreply@anthropic.com>
… leak it Co-Authored-By: Claude Opus <noreply@anthropic.com>
Scope reduced to the seam (option A), per the owner's decision
Two things came out of the pre-push review of those commits and are also in:
One outside finding is refuted rather than declined. The last round flagged as major that Verification on this head: all CI checks green; locally — |
…t log level Harper ships logging.level: warn, so the per-subscriber info line a routine revocation emits is dropped on a default deployment — an expiry or a role change tearing down live streams left no trace in the log an operator reads. Keeping the detail at info avoids a warn per subscriber on a mass role change, so the pass now also emits one aggregate warn with the count and the reasons. Co-Authored-By: Claude Opus <noreply@anthropic.com>
terminate is fire-and-forget, so a past-tense aggregate claims more than the sweep knows; the per-subscriber line already says "revoking", and a teardown that fails still gets its own error line. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Summary
registerLiveSubscription(the #1414 continuous re-authorization registry) now accepts an optionalrevoke?: () => void | Promise<void>, used as the entry's terminate instead of the hard-codedend()/close()/emit('close')on the subscription object, and returns an{ unregister }handle. Whenrevokeis supplied the subscription object is left completely untouched — noendwrapping, no'close'listener — and the caller unregisters through the returned handle instead.Why: profiling a production node found ~157,888 live
Subscriptionobjects across only 29 distinct topics, almost entirely per-connection bookkeeping (each carries its ownDatabaseTransaction,TableResource,Generator). The fix is to share one feed per topic across its subscribers while keeping authorization per subscriber — but revocation today works by destroying the subscription object, which is exactly wrong once that object is shared: wiringterminateto a shared feed would disconnect every subscriber on one user's revocation or token expiry (and, sincesweep()fails closed on recheck errors, one transient error would tear the feed down for everyone). This PR is only the prerequisite seam — no shared feeds, no refcounting, no subscriber-list machinery.Revocation granularity must stay per subscriber even once feed granularity becomes per topic — that's the invariant this seam exists to establish.
Scope reduced from the earlier revision
Earlier revisions of this branch also shipped a revocation state machine —
pendingTerminate, a bounded terminate timeout, settle-handler late-commit, and fail-closed retry across sweeps — roughly 120 lines whose only exercise was its own tests, since nothing in this repo suppliesrevoke. That is now removed, per the owner's decision on review feedback ("reduce scope"): the timeout and idempotency requirements are unknowable without a real caller, and the shared-feed follow-up may want different ones.What remains is the seam plus the one change the seam genuinely forces:
terminatemay now return a promise, soterminateEntryuntracks the entry first and dispatches teardown once, fire-and-forget, containing a synchronous throw and a rejection alike. Untracking first is what keeps a hungrevokefrom wedging the sweep — awaiting it would hold thesweepingmutex forever — and it matches the pre-existing default-path behavior, which also deleted the entry before callingterminate(). The consequence, stated in the contract: arevokethat throws, rejects or never settles is logged and never retried.Three smaller changes came out of this branch's review rounds:
infoline, and every sweep that revoked anything closes with one aggregatewarncarrying the total and a per-reason breakdown. Harper shipslogging.level: warn(static/defaultConfig.yaml:46), so aninfo-only line would make every expected revocation invisible on a default deployment; awarnper subscriber would turn one mass role change into a 10k-line storm. The aggregate is emitted fromsweep()'sfinally, and it says revoking, not revoked, becauseterminateis dispatched fire-and-forget — a teardown that fails still gets its ownerrorline.warnalso still covers the per-entry recheck-failure path it covered onmain.HARPER_SUBSCRIPTION_REAUTH_INTERVAL_MSbefore importing the module. It previously ran against the real 30s interval, where a background tick makes_sweepNow()a no-op through thesweepingguard and assertions then run against a sweep that never happened.When
revokeis omitted, behavior is unchanged from #1414 in every respect except the new revocation logging; the sole existing caller (registerLiveSubscriptionForContextinresources/Resource.ts) doesn't passrevoke.For the human reviewer
The load-bearing judgment call is fail-open teardown: with the state machine gone, a
revokethat never settles leaves a deauthorized subscriber attached with no further authorization evaluation. Reviewers raised this in every round. It is documented on the API and cannot manifest on this branch (no caller suppliesrevoke), and it is deliberately the shared-feed caller's problem to solve where the timeout/idempotency semantics are actually known — but it should be ratified before that wiring lands, and it is cheap to revisit now while the seam is unused.Declined this round, all pre-existing on
mainand out of scope for a scope-reduction change — each is worth its own issue:sweep()is N serializedrecheck()round-trips behind one global mutex (adjudicated major). The productionrecheckdoes afindAndValidateUser+allowReadper entry with no memoization across entries sharing a username, and there is no timeout on it. This seam does raise registry cardinality from per-stream-object to per-subscriber, which makes N bigger — but the serialization, the mutex, and the unbounded await are all unchanged frommain.if (sweeping) returndiscards a user-change broadcast arriving mid-sweep, so revocation falls back to the 30s backstop. A dirty flag re-run insweep()'sfinallycloses it. (One reviewer described the exposure as "up to 24 hours" — that reads the test interval override; production is the 30s backstop.)main— each of those lines carries a distinct error message, so collapsing it into the aggregate would cost diagnostics rather than noise. The aggregate counts those entries too, underrecheck error.'close'listener and the never-restoredendwrapper on the default path:unregister()removes the registry entry but does notremoveListener('close', …)or put back the originalend. Pre-existing from Live subscriptions (SSE/MQTT/WS) continue delivering events after drop_user / role revocation — stale-auth leak #1414 and harmless for a per-connection subscription object that is discarded on close, but it would matter for a pooled or reused stream object._sweepNow/_liveSubscriptionCountexported from the production module — both flagged as API-shape decisions, both cheapest to change now.Refuted, not declined: the last review round's remaining "major" claims
hdbLogger.info/warn/errorare unbound methods, so passing them as references (safeLog(notice, …),notice = hdbLogger.info) throws athisTypeErrorthatsafeLogswallows, silently suppressing every revocation log. They are module-level free functions —export function info(...args) { mainLogger.info(...args); }atutility/logging/harper_logger.ts:807,:825,:861— with nothisreference, so a bare reference is safe, and the suite'slogs each expected revocation at info and one aggregate warn per sweeptest asserts the lines are actually emitted. TheHuman-Review-Need: 4footer below still counts it: that round's adjudication leg was auto-pruned as a narrow low-risk delta, so the grade is "outside findings unadjudicated", not a live defect.Verification
npm run build,npm run lint:required,npm run format:write— clean.npx mocha "unitTests/server/**/*.test.js"— 715 passing, including the 20 tests in this file. Two of them cover the aggregate: one info line per subscriber plus exactly one warn with the count and reasons, and no aggregate at all for a pass that revokes nothing.npm run test:integration -- integrationTests/security/subscription-revocation.test.ts— 6/6 passing, unmodified, covering SSE/WS/MQTT revocation ondrop_user,alter_role, and token expiry. This is what proves the sole in-repo caller is unaffected; therevokepath itself has no end-to-end route until the shared-feed caller lands, and is covered only by unit tests (rejection containment, hang containment, no subscription mutation).npm run test:unit:mainandnpm run test:unit:resourcesboth have failures on this machine that reproduce identically withorigin/main's copy of both changed files:test:unit:mainaborts at import withLZ4 not supported in this buildagainst a stale local database dir, andtest:unit:resourcesfails 3 tests inreplayStructures/randomAccessFields, neither of which imports this module.Refs #1414
Generated with Claude Opus 5.
🤖 Generated with Claude Code
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=8 @ 096f399
Human-Review-Need: 4 @ 096f399