Allow a component-registered operation to be granted in a role's operations allowlist - #2260
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request implements cross-thread mirroring of grantable operations from worker threads to the main thread, allowing the main thread to validate worker-registered operations in role allowlists. The review feedback suggests two performance optimizations in server/serverHelpers/registeredOperations.ts: first, to only perform cleanup logic during thread exit if the exiting thread actually registered the operation, and second, to avoid redundant Map/Set deletions in setWorkerGrantable when removing a thread's grantable claim.
This comment has been minimized.
This comment has been minimized.
`server.registerOperation({ requiresSuperUser })` marks an operation
grantable in a role's `operations` allowlist, but that mark landed only in
the worker that registered it (components load per-worker). Meanwhile
`validateOperations` is consulted on the main thread — by add_role and
alter_role, by impersonation payload validation, and by OIDC trust
policies — so naming a component-registered operation in any of those was
rejected as "not a valid operation name or group" even though the
operation existed and was designed to be grantable.
The OPERATION_REGISTERED announcement already crosses that boundary for
execution routing, so carry grantability on it too and mirror the mark on
main. This only widens what an allowlist may name; enforcement is
unchanged, still running on the worker's own `chooseOperation`.
Also arm the thread-exit cleanup when the registry gains its first entry
rather than on the first forwarded call, so a worker that registers and
exits without ever being called no longer leaks its entries, and revoke
the mirrored mark when the last registering worker is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claim a mirrored name for thread-exit cleanup only when the mirror is what made it admissible. As written, the ownership set was unconditional, so a name that main had already registered itself — or an enum/group name — was revoked when the last worker offering the same name exited, which is the opposite of what the set exists to prevent and of what its comment claimed. Flagged independently by both review lenses. Route both prune paths through one `dropRegistration`: the failed-send path in `executeRemoteOperation` dropped the routing entry without revoking the mark, so routing and grantability could disagree about whether an op was still offered. Also cut the comments the review flagged as narration or as duplicating the note now carried in server/DESIGN.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ownership probe added in the previous commit only protected marks that predated a worker's announcement, which leaves a reachable hole: on a hot deploy `restartWorkers` awaits `loadRootComponents()` before it begins draining the old workers (`server/threads/manageThreads.js`), so a `startOnMainThread` component can register an operation the retiring worker also offered. The worker's exit then revoked the main thread's own mark and role validation started rejecting an operation that was registered and executable. Track mirrored names in a separate set that `validateOperations` unions instead of sharing one. The two threads can now register the same name independently and neither can revoke the other, which removes the ownership question rather than narrowing it — the probe and its bookkeeping set are gone. Found by the round-2 cross-model review, which also supplied this approach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The planning review returned better-alternative-exists on the previous
approach, and it was right: a name-level mirror set cannot express which
worker declared the operation grantable, so it did not enforce the invariant
the design claimed ("admissible iff a live worker declares it grantable").
Concretely, a rolling deploy whose new generation keeps an operation but drops
`requiresSuperUser` left the name admissible with no live declarer: the
routing set stayed non-empty via the new workers, so the mark was never
revoked, and `add_role` accepted a grant whose execution then failed closed
with operation-not-found.
Track claims as name -> Set<declaring threadId> and re-derive the mirrored
mark from live claims, so grantability is retracted when a thread withdraws it
or exits even while other workers keep routing the name. Also ignore an
announcement from a thread already reported dead: exit notification is
deduplicated for the process lifetime, so such an entry could never be
cleaned up afterwards.
Extract the thread-exit cleanup and export a test seam for it, which is what
finally lets the revocation paths be tested at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two real gaps found by the gemini and cursor-composer legs, corroborating each other on the second one. Arm the main-thread listeners at module load instead of on first use. `attachMainListeners` ran lazily from the registration handler, but thread-exit notification fires once per thread and is dropped outright when no listener is attached yet — so a worker that died before its first announcement was processed left a registration nothing could ever clean up, and the exited-thread guard never learned about it. serverUtilities imports this module during its own load, before any worker exists, so arming at load is well ordered. Retract grantability when a failed send prunes a dead originator. `executeRemoteOperation` dropped the routing entry but left the claim, so a dead worker could keep a name admissible while a surviving worker that never declared a permission kept routing it — the same false-admissible case this change exists to close. Also guard the ITC payload destructure. A malformed OPERATION_REGISTERED with no `message` would have thrown on the main thread; the envelope is trusted and in-process, but three review rounds have now flagged it and the guard is one expression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Synthetic thread ids in the new tests were small positive integers inserted permanently into the module-global tombstone set, which the `after` hook cannot clear — a later suite starting a real worker in the same process could have been assigned one of those ids and had its legitimate announcement ignored. Use ids the runtime will never assign. Cover the failed-send retraction through its production trigger rather than only the exit seam: a forward whose `sendToThread` reports a dead port must retract the claim, not just the route. Also drop comments the review flagged as narrating the line beneath them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both suggestions from the gemini review, and both behaviour-preserving: a grantability claim implies a registration, since claims are only recorded alongside one, so an operation the dead thread was not registered for can have no claim to retract either. `handleThreadExit` now continues when the id was not in the routing set, and `setWorkerGrantable` only re-derives the mirrored mark when a claim was actually removed, instead of unregistering a name it never held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`add_oidc_trust` is the third main-thread caller of `validateOperations`, and until this change its own source carried a `Known limitation` note saying a component-registered operation "is NOT recognized here and a policy naming one is rejected", pointing at this bridge as where the fix belonged. That note is now false, so remove it rather than leave a comment describing behaviour the code no longer has. The operation was absent from the checkout when this branch started, which is why the earlier rounds could only cover add_role/alter_role and impersonation. Add the two cases that were always wanted: a trust policy naming a component-registered operation is accepted, and one naming an unregistered operation is still rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment on "accepts an operation registered in this process" said a component's operation "is NOT recognized here in production" and named this bridge as where the fix belonged. The test itself is unchanged and still correct — it asserts the delegation to validateOperations — but its explanation described behaviour this branch removes. Surfaced by the review leg grepping for leftovers after the source comment came out, which is the half-true remnant that sweep was looking for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-model review found a real authorization divergence this branch introduced. Registering an operation with `requiresSuperUser` installs a `requiredPermissions` entry keyed by the operation name; re-registering the same name without the flag left that entry in place. Before this branch that was inert, because the operation could never have been granted in the first place. Now main retracts the mirrored grantable mark on the re-announcement while the worker keeps honouring a role grant persisted earlier, so the declaration and the enforcement disagree — reachable whenever the handler's own `.name` matches the operation name, which is a natural way to write one. Retract the entry alongside the mark, tracking the names this API installed so a component re-declaring a built-in's name cannot strip the built-in's permission. Tests cover both directions: a persisted grant stops being honoured once the declaration is dropped, and an entry registered by anyone else survives. Also finishes the limitation cleanup the review caught mid-flight and trims the comments it flagged as narration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment overstated the guard: a bare flagless registration cannot clear a built-in's entry, but declaring that name first puts it in declaredPermissionNames, so a later flagless registration can. The declaring call already overwrote the built-in entry at that point, so this only follows it — but the comment claimed a guarantee the code does not make. Also drops the two comments the review flagged as restating the line beneath. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dawsontoth
force-pushed
the
fix/grantable-component-ops-cross-thread
branch
from
August 24, 2026 18:15
2169d46 to
b7d33a0
Compare
dawsontoth
marked this pull request as ready for review
August 24, 2026 18:38
kriszyp
approved these changes
Aug 24, 2026
kriszyp
left a comment
Member
There was a problem hiding this comment.
Nice catch, good work!
🤖 Reviewed with Codex
Review pointed out that jobs launch a fresh `autoRestart: false` worker per job (server/jobs/jobRunner.ts), so a per-thread tombstone here grows with every completed job on a long-lived node — not once per worker restart, which is what the comment claimed. `manageThreads` already records exactly this in `notifiedDeadThreadIds`, and records it before firing exit listeners, so the local set was duplicating state that was already there and already correct. Expose a sync `hasThreadExited` from `manageThreads` and read that instead. `isThreadRunning` cannot serve: it is async because it awaits process-group confirmation, and this runs on a synchronous announcement path. Export `notifyThreadExit` too, which lets the lifecycle tests drive the real exit path and removes `notifyThreadExitedForTest` from production entirely. The tests now exercise the `onThreadExit` wiring they previously bypassed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It placed the dedupe "above" the export when notifyThreadExit is defined far below it, and an export list is not where that rationale belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Author
|
ah, this needs 1 re-approval |
kriszyp
approved these changes
Aug 25, 2026
The test comment still said exitedThreadIds, which no longer exists. Phrased without naming the holding set so it stays true regardless of which module owns it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dawsontoth
marked this pull request as draft
August 25, 2026 23:24
Reverts the shared-registry reuse from d97a5cc. CI bisects a Windows regression to that commit: `Integration Tests 2/6 (Windows)` fails with it on two independent builds (375s, 389s, then 465s on a fresh build) and passes without it (259s), matching main at 262-280s. `manageThreads.js` is untouched again as a result. I do not have a root cause. Reading `notifiedDeadThreadIds` instead of an equivalent local Set should not cost minutes of HTTP-worker readiness, and it does not reproduce on macOS, so the revert is on evidence rather than understanding. Kris's underlying point stands and is answered in the comment instead: the set holds one integer per dead thread, which is the same growth profile manageThreads already accepts for notifiedDeadThreadIds a few lines from where it records them. Narrowing it to threads that already hold a registration was tried and rejected — it defeats the guard's purpose, because the case it exists for is a thread whose FIRST announcement is in flight when it dies, and such a thread holds no registration at exit time. A unit test covers that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts 9d66638, which was made on a conclusion I have since retracted. I had bisected a Windows failure to the shared-registry change and reverted it on that basis. The bisect was noise: the same test fails at 350s with the change reverted, and ranges 259-465s across builds with identical code, so ~200s of variance was being read as a 110s signal. Windows is red here for #2273 (risk-query and describe_all: npm work, then restart_service, then route readiness — open and reproducing on main) and #2313 (set_configuration), neither reachable from this diff. So this restores the better shape, which is also what review asked for: no second dead-thread registry beside the one manageThreads already maintains, and notifyThreadExitedForTest is out of production surface again, with the lifecycle tests driving the real onThreadExit event instead of a module-local stand-in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dawsontoth
marked this pull request as ready for review
August 26, 2026 14:04
cb1kenobi
added a commit
that referenced
this pull request
Sep 3, 2026
…ted principal chooseOperation handed verifyPerms the caller-supplied search_operation, making both halves of the permission question — principal and tables — body-controlled. Rebased onto main, which independently landed the SQL operations-allowlist check, apiOperation token-scope threading (#2176, #2260), and the processAST denial fix (#2202). This layers the principal/subject hardening on top and closes the gaps the original PR had deferred: - Principal comes from authentication: a nested hdb_user is overwritten, never backfilled. - search_operation stands in as the permission subject only for the export operations that consume it, must be an object, and must name a supported export operation (search_by_value/hash/conditions/sql) — a primitive, {}, or an unsupported op is a request-time 400. - One verifyPerms call cannot authorize both the outer export and its nested query: the outer op is authorized first, then the nested search is authorized additively against its real search handler and the authenticated principal, so a role granted export_local but not the underlying read is denied (previously granted at verifyPerms gate 2 before any table check). - SQL export is routed through verifyPerms too, so its requires_su gate is enforced exactly as on the non-SQL path — SQL is no longer a way around it. (main's SQL branch checked only the allowlist + AST, so a non-super user could export via a SQL search_operation.) Regression cover in integrationTests/security/choose-operation-authz.test.ts: NESTED-NOSQL now asserts denial, NESTED-OP covers empty/invalid nested operations, POSITIVE-NOSQL proves a permitted non-SQL export still completes; and the northwind SQL-export cases now assert the requires_su denial. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cb1kenobi
added a commit
that referenced
this pull request
Sep 3, 2026
…ted principal chooseOperation handed verifyPerms the caller-supplied search_operation, making both halves of the permission question — principal and tables — body-controlled. Rebased onto main, which independently landed the SQL operations-allowlist check, apiOperation token-scope threading (#2176, #2260), and the processAST denial fix (#2202). This layers the principal/subject hardening on top and closes the gaps the original PR had deferred: - Principal comes from authentication: a nested hdb_user is overwritten, never backfilled. - search_operation stands in as the permission subject only for the export operations that consume it, must be an object, and must name a supported export operation (search_by_value/hash/conditions/sql) — a primitive, {}, or an unsupported op is a request-time 400. - One verifyPerms call cannot authorize both the outer export and its nested query: the outer op is authorized first, then the nested search is authorized additively against its real search handler and the authenticated principal, so a role granted export_local but not the underlying read is denied (previously granted at verifyPerms gate 2 before any table check). - SQL export is routed through verifyPerms too, so its requires_su gate is enforced exactly as on the non-SQL path — SQL is no longer a way around it. (main's SQL branch checked only the allowlist + AST, so a non-super user could export via a SQL search_operation.) Regression cover in integrationTests/security/choose-operation-authz.test.ts: NESTED-NOSQL now asserts denial, NESTED-OP covers empty/invalid nested operations, POSITIVE-NOSQL proves a permitted non-SQL export still completes; and the northwind SQL-export cases now assert the requires_su denial. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cb1kenobi
added a commit
that referenced
this pull request
Sep 4, 2026
…ted principal chooseOperation handed verifyPerms the caller-supplied search_operation, making both halves of the permission question — principal and tables — body-controlled. Rebased onto main, which independently landed the SQL operations-allowlist check, apiOperation token-scope threading (#2176, #2260), and the processAST denial fix (#2202). This layers the principal/subject hardening on top and closes the gaps the original PR had deferred: - Principal comes from authentication: a nested hdb_user is overwritten, never backfilled. - search_operation stands in as the permission subject only for the export operations that consume it, must be an object, and must name a supported export operation (search_by_value/hash/conditions/sql) — a primitive, {}, or an unsupported op is a request-time 400. - One verifyPerms call cannot authorize both the outer export and its nested query: the outer op is authorized first, then the nested search is authorized additively against its real search handler and the authenticated principal, so a role granted export_local but not the underlying read is denied (previously granted at verifyPerms gate 2 before any table check). - SQL export is routed through verifyPerms too, so its requires_su gate is enforced exactly as on the non-SQL path — SQL is no longer a way around it. (main's SQL branch checked only the allowlist + AST, so a non-super user could export via a SQL search_operation.) Regression cover in integrationTests/security/choose-operation-authz.test.ts: NESTED-NOSQL now asserts denial, NESTED-OP covers empty/invalid nested operations, POSITIVE-NOSQL proves a permitted non-SQL export still completes; and the northwind SQL-export cases now assert the requires_su denial. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An operation a component registers with
requiresSuperUseris meant to be grantable in a role'soperationsallowlist, but naming it inadd_role,alter_role, or an impersonation payload was rejected as "not a valid operation name or group" — the grantable mark was made in the worker that registered the operation, while the validation that reads it runs on the main thread. The registration announcement now carries that fact across the thread boundary, so a scoped role can actually be granted a component operation.Enforcement is unchanged: the main thread only decides whether a name is admissible in an allowlist, and the operation still runs through the registering worker's own
chooseOperation.One finding is disclosed rather than fixed, and is inherited from the
registerOperationbridge (#1736) rather than introduced here — the announcement is fire-and-forget, so anadd_rolenaming a component operation can still lose a race against component load at startup. It fails closed (a rejected role), and the existing execution path has the same window.For the human reviewer
The step-6 framing gate did not clear. The planning review returned
Framing-Verdict: better-alternative-exists, and it was right — I adopted its recommendation rather than defending mine, which is why the history carries superseded approaches (squash on merge). The entries below lead with the parts I got wrong, because those are the ones worth your scepticism:requiresSuperUser, the routing set stays non-empty via the new workers, so the mark is never revoked —add_roleaccepts a grant whose execution then fails closed with operation-not-found. Grantability is now tracked per declaring thread (name -> Set<threadId>) and the mirrored mark is re-derived from live claims. What to check: thatsetWorkerGrantablere-derives on every path, including retraction, and thathandleThreadExitretracts one thread's claim while preserving routing for survivors.server.registerOperation()to become async. The reviewer pointed out that the loader could instead collect synchronous registrations and await one batched acknowledgement before declaring the worker ready — no API change, and it would also close the fire-and-forget startup window disclosed above. I did not do it: it introduces a worker-readiness protocol in the component loader, which is a materially larger change than the one this bug needs. This is the entry most worth overruling. If you want the startup window closed rather than documented, that is the design to ask for, and this PR is the wrong shape for it.requiresSuperUser, a role grant is accepted while both generations are live, and calls then alternate between succeeding on the declaring worker and returning a permission error on the other. Routing ignoresgrantableByWorkerdeliberately: selecting a worker by authorization metadata would require the main thread to know why a caller is authorized (super_user vs. anoperationsgrant), which is the authorization determination this module documents that main must not make — server.registerOperation() calls in a component's resources.js are unreachable via the ops API #1736 split it as main routes, worker enforces. It fails closed (a permission error, never a wrongful success) and it is transient: once the old generation is gone this change retracts grantability and the grant is correctly rejected at write time. If you would rather routing became authorization-aware, that is a design call, and this is where to make it.grantableis derived asrequiresSuperUser !== undefinedrather than being a new field onOperationDefinition. It reuses the existing tri-state that already decides whetherregisterOperationPermissionis called at all, so the two cannot drift; the cost is that the wire flag's meaning is implicit in that expression. Note this deliberately treatsrequiresSuperUser: falseas grantable, which matches the tri-state's documented meaning ("grantable AND open to any authenticated user") and keeps worker-registered operations behaving like main-thread ones.attachMainListeners()previously ran only on the first forwarded call. Thread-exit notification fires once per thread and is dropped when no listener is attached, so a worker that registered and died before that first call leaked its entries permanently. That is a pre-existing bug in the routing map — I fixed it here because the new mark would otherwise inherit it, but it is separable if you would rather see it on its own.Also flagged by the planning review and not addressed: it recommended lifecycle tests driven through a real hot restart/deploy, and integration coverage of a mixed old/new generation. The new unit tests drive the exit seam directly instead, so they prove the state machine but not that a real
restart_serviceproduces those transitions.Adjacent bug found while checking symmetry, filed rather than fixed here: #2203 (P2, under [Epic] Component authoring & packaging DX) — a component's own
roles.yamlstill cannot grant an operation its ownresources.jsregisters, becauseDEFAULT_CONFIGorders therolesplugin beforejsResourceandcomponentLoader.ts:542iterates config keys in order. Same-thread ordering, different mechanism, survives this fix.This invalidates documentation of the limitation in two places that are still in flight, neither on a
mainbranch, so there is no companion docs PR to open yet: theKnown limitationcomment onassertOperationsAreKnowninsecurity/authn/oidc/trustPolicyOperations.tson #2173's branch, and the "that registry is process-local, so a policy naming one is rejected" paragraph inreference/operations-api/operations.mdon an unpushedHarperFast/documentationbranch. Whichever of the two PRs lands second should drop both. Nothing currently on documentationmainis made false by this change.Windows CI is red for reasons outside this PR (#2273, #2313), and the repo owner has accepted that for this merge.
Integration Tests 2/6failsComponent: risk-queryanddescribe_all metadata upgrade, both npm work →restart_service→ route readiness, which is #2273 (deploy_component (restart: true)hangs after npm pack) — open and reproducing onmain.Integration Tests 6/6failsset_configuration, which is #2313 (Windows config-write rename retry blocking the thread), with fixes in flight in #2339 and #2191. Neither is reachable from this diff.For the record, since an earlier version of this description said otherwise: I bisected the 2/6 failure to a commit here and reverted it on that basis. That was wrong. The same test fails at 350s with the commit reverted and ranges 259–465s across builds with identical code, so ~200s of variance was being read as a 110s signal. The revert has been undone in
9ac125c45and the variance data is on #2273 so the next person doesn't repeat it.Verification
Route (a), extended existing integration test —
integrationTests/components/registered-operation.test.tsgains six cases in a nested suite coveringadd_role,alter_role, impersonation, an end-to-end grant (a non-super_user with the grant executes the operation on a worker), and two negative controls: an unregistered name is still rejected, and a non-super_user without the grant still gets 403. No new CI entry.Unit — seven new cases in
unitTests/server/serverHelpers/serverUtilities.test.js, covering the rolling-deploy retraction, withdrawal by re-announcement, the exited-thread announcement guard, main/worker mark independence, and the failed-send retraction through its real trigger (sendToThreadreporting a dead port).npx mocha "unitTests/server/serverHelpers/*.test.js"→ 295 passing, 1 failing. That failure is pre-existing (uwsServer.test.js, "rejects a body over maxBodyBytes with 413"); confirmed by stashing this branch's changes and re-running. Run as the whole suite rather than the changed file alone, because these tests mutate the process-global grantable registries. Alsonpx mocha unitTests/utility/operationPermissions.test.js unitTests/security/impersonation.test.js unitTests/validation/role_validation.test.js→ 133 passing, 0 failing.fails-on-base — through a detached worktree at the merge-base with
origin/main, with only the test changes applied: the new unit cases fail there and reach their intended assertions rather than a setup or compile error; the 4 positive integration cases fail there while both negative controls pass. Everything passes on the branch.Rebased onto current
origin/main(374408e) before review — the branch had fallen 67 commits behind. No conflicts, andorigin/mainhad touched none of these 7 files.Not run locally:
test:unit:mainandtest:unit:resourcesabort at load on this machine — another process holds~/harper/database/data/LOCK, andHDB_ROOT/ROOTPATHoverrides do not reroute the path that opens it. Worth knowing separately: mocha exits 0 when it dies this way, so an exit code is not evidence those suites ran.test:integration:all(160 files) was not run locally either: without the loopback address pool configured it can only run sequentially against127.0.0.1. Both are left to this PR's Actions run.The coverage field below pins the final round, which was a comment-only delta. The substantive round on this code (
b1b267ecd) was classified high-risk / security-auth and ran three outside lenses — codex, gemini and cursor-grok — at high effort; it is the round that found the re-registration authorization divergence fixed in5daaa6d96. Harper domain adjudication failed or was pruned on every round, so no round was filtered by the leg that drops false positives, and I triaged findings myself.npm run lint:requiredclean;npm run format:writeproduced no changes;npm run buildclean; TypeStrip/tabs/node:-prefix/assert-style greps clean overorigin/main...HEAD.Complexity: complicated
Review-Coverage: authored=claude; ran=codex; blocked=gemini(quota); declined=cursor-grok,cursor-composer,domain; rounds=11 @ 9ac125c
Human-Review-Need: 4 @ 9ac125c