Skip to content

Integrate overlay, kernel bridge security and encryption fixes - #651

Merged
kshivang merged 5 commits into
mainfrom
dev
Sep 14, 2026
Merged

kshivang merged 5 commits into
mainfrom
dev

Conversation

@kshivang

Copy link
Copy Markdown
Contributor

Integrates reviewed PRs #477, #635, #645 and #647: correct overlay placement for RTL/fractional coordinates, verified caller identity for the kernel Git and Log bridges, and randomized IVs for secret encryption with legacy compatibility. Original contributor attribution remains in the constituent PRs and commits.

Validation on rebased dev a3f4b7f: 38 targeted desktop tests passed (overlay/inset layout and Git/Log authorization, including revocation). A disposable local database applied all migrations and passed 826 assertions across 34 suites, including legacy encryption upgrade and key rotation. Production was not modified.

Remaining manual check: native menus/popovers near window edges, narrow layouts, focus mode, RTL and different display scaling. The encryption migration must be deployed separately after integration; the PR merge alone does not deploy it.

sgoel2be24-cyber and others added 4 commits September 14, 2026 01:43
* Stop anon reaching schema public by inheritance, and scope identity to real orgs

The BOSS Supabase anon key is compiled into this repo, which is public. Anything
`anon` can execute is therefore executable by anyone. Verified on 2026-09-08:

  * get_encryption_key() returned the Vault master encryption key, unauthenticated.
    decrypt_text() was anon-callable beside it, making a decryption oracle over
    everything encrypt_text protects - user secrets and the QBO/Google broker
    credentials.
  * find_user_by_email() confirmed any address and returned its uuid.
  * list_shareable_recipients() returned 152 users WITH full email addresses to
    any self-registered account, and 82 @risalabs.ai addresses on a search.
  * arcade_leaderboard() and arcade_bs_standings() published the roster of
    everyone who had opened the Arcade, unauthenticated.

None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on
every new function, PUBLIC includes anon, and this project's default privileges
add anon on top - so a SECURITY DEFINER function is internet-callable from the
moment it is created. Both revokes are traps in mirror image: `from public`
leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and
ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses
the entry and new functions still get `=X`).

  20260908000000  event trigger: revoke PUBLIC+anon on every function created in
                  schema public. An explicit `grant ... to anon` after the create
                  still wins - anon access must be stated, never inherited.
  20260908010000  org_visible_users / org_is_vetted / user_display_name: ONE
                  definition of "may this account learn who that account is",
                  shared by the Arcade and poker rather than copied. Vetted means
                  a human approved the join, which excludes the catch-all `boss`
                  org every account joins on signup (153 members, 20 domains).
  20260908020000  route list_shareable_recipients through both. It was already
                  org-scoped, which is why it looked right; it accepted ANY
                  shared org.
  20260908030000  one-time sweep of the ~30 functions that already had the
                  inherited grant. The RBAC mutators fail closed
                  (is_user_admin(NULL) is false), so this is defence in depth,
                  not a patched escalation.

The rule is a SET, not a per-target predicate: as a per-row qual it is pushed
below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score
rows for 45 players, against 37ms.

supabase/audit/identity_disclosure_audit.sql is the standing check, because "we
fixed the leak" is not a durable claim. All four checks report HEALTHY.

Left deliberately anon-callable, each documented where it is kept: the plugin
store browse API, the three helpers called from RLS policies on anon-readable
tables, and custom_access_token_hook. Verified after the sweep that anonymous
plugin-store browse and signed-in RBAC reads both still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add the master-key rotation procedure, and rotate

get_encryption_key() was anon-callable, so the Vault master_encryption_key was
retrievable by anyone holding the project anon key - which ships compiled into
this public repo. Revoking access does not un-disclose a key, so it was rotated
on 2026-09-09 and the 184 rows encrypted under it re-encrypted.

Scope of the original exposure, stated precisely because the capability was
worse than the reach: decrypt_text() was anon-callable beside it, so an attacker
holding ciphertext from ANY channel - a backup, an export, a screenshot - could
decrypt it through the API without even needing the key. But no bulk read path
to that ciphertext existed: qbo_token_state and google_token_state have no
client grants at all, and all 178 secrets rows have org_id NULL, so the secrets
policy reduces to owner-only. This was a serious latent vulnerability - one
org-shared secret, one RLS slip, or anyone with backup access would have had
plaintext - rather than a confirmed bulk disclosure.

The script is kept because rotation is not a one-off. It is atomic (one DO
block), self-verifying (md5 of every row's plaintext captured before, re-derived
after through decrypt_text so the check covers both the swap and the data), and
a mismatch or row-count drift raises and rolls back, so a broken rotation cannot
commit. Verified after the run: the live key differs from the disclosed one,
184/184 rows decrypt under it, and the disclosed key opens 0 rows.

The old key is retained in the Vault under an explicit name. Do NOT delete it
while pre-rotation backups exist - they are encrypted under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(db): close signed-in crypto access and verify visibility boundaries

* test(db): exercise repeated key rotation and exact anonymous ACLs

* test(db): grant fixture role membership on Supabase Postgres

* test(db): inline rotation source for isolated pgTAP mounts

* test(db): keep generated rotation SQL out of source control

* fix(db): reserve identity-taking store mutators for the edge service

* fix(db): serialize rotations without requiring direct Vault writes

* fix(db): address review gaps in system-org and rotation coverage

* test(db): verify core secrets across hex and broker-free rotations

* test(db): execute standing audit and missing-signature regression

* fix(db): enforce revoke postconditions and harden rotation verification

* test(db): enable recovery metadata in the authenticated fixture

* test(db): use the schema-supported authenticator type

* Address remaining database review quality findings

* Fix invoker view audit boolean parsing and test timestamp bounds

* Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift

Addresses the handoff's database-integration item and the substantiated findings
from the current-head review. Everything below was executed against the PR's own
Supabase preview branch, not asserted from reading.

**The blocker: rotation refused to run once #417 landed.** The guard was a
blanket "TOTP is installed, refuse everything". It is now an adapter. The stored
form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing
differs - so the column map gained an envelope prefix and rotates like the rest
once the prefix is stripped and re-applied. Three things this needed that reading
the diff would not have shown:

  * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a
    storage envelope'), so the re-encryption update is rejected outright. The
    reviewed trigger is now disabled for the update and restored after, inside
    the same transaction.
  * safe_decrypt_recovery_codes returns jsonb, not text, so verification through
    the real read path needed a cast. Both fingerprints now go through each
    column's OWN application read path rather than decrypt_text, which also
    removes the step-1/step-5 asymmetry raised as U5.
  * a step 0 pre-check reports rows that are already unreadable, instead of
    letting a safe_decrypt_* NULL surface at the end as "a row was missed".

The rejection contract is preserved, not removed: an unknown envelope version,
an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped
table each still refuse. All three are now regression-tested, along with the
recovery-code column that was previously never exercised (its fixture was
vacuous - create_secret writes no secret_metadata row without p_twofa_enabled).
Verified: three consecutive rotations, TOTP/recovery/password all intact, v1
envelope preserved, trigger re-enabled and still enforcing its own contract.

**CREATE EXTENSION was impossible.** The fail-closed guard aborted the first
routine of any extension installed into public: `create extension pgtap` failed
with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces
operators to disable it in order to install an extension is a guard that ends up
disabled. Extension-owned routines are now exempted with a warning, and the
exposure stays visible as advisory CHECK 1x rather than vanishing - it reports
pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers
are called from column DEFAULTs and CHECK constraints, which are evaluated with
the DML role's privileges. No extension owns a function in public on a clean
database, so no deployed behaviour changes.

**The repo was weaker than production.** find_user_by_email, get_session_status
and update_api_key_last_used were locked down on the live project on 2026-09-08
but never captured as a migration, so a fresh deploy re-opened them.
find_user_by_email is a user-enumeration oracle over auth.users and signup is
open. The standing audit caught this itself on the preview - CHECK 2 named it -
which is the drift that check exists for. Both remaining callers use
SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps
EXECUTE and neither path changes.

Also: the guard no longer hard-fails on a database without the Supabase roles (a
plain restore), and the vault fixtures no longer assume the key is absent (U3) -
both suites failed outright on any database that already had one. Retained-key
assertions measure this run's delta instead of an absolute count.

Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all
clean; the standing audit reports no failing gated check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Exercise the anonymous read path, not just the grant

The keep-list assertions only checked has_function_privilege for the seven
allowlisted signatures. That proves the grant, not the path: an RLS policy
expression is evaluated as the QUERYING role, so revoking a policy helper turns
an anonymous SELECT into 'permission denied for function ...' rather than an
empty result, and no privilege assertion can see it.

The seeding is the part that matters. A policy expression runs PER ROW, so on
an empty table it is never evaluated - and plugins and user_roles are both empty
on a fresh database, which made the first version of this probe pass no matter
what had been revoked. Caught by trying to make it fail.

Sensitivity is stated from measurement, not assumption: with a row present,
revoking can_view_plugin_row from anon does fail the probe and revoking
authorize does not, because permissive policies are ORed and short-circuit. A
helper in a policy that never has to be evaluated cannot be detected this way by
any test, so the comment says so rather than implying broader coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make the gated audit checks and the test generator provable

Both halves of this are the same failure: a suite that is green while proving
nothing. I hit that twice writing the previous commits - a recovery-code fixture
over a secret_metadata row create_secret never wrote, and an anonymous-read
probe over an empty table whose RLS policy therefore never evaluated - so these
are not hypothetical.

Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY,
while every advisory check already had a fixture-based detection test. An
inverted `not exists`, a wrong role literal or a typo in the evtenabled test
would have left the gate permanently green. Each is now broken deliberately and
asserted to report it: a routine granted to anon AFTER creation (which is how
one gets past the event trigger), a revoked deliberate anonymous grant, a
disabled event trigger, and a crypto routine exposed to authenticated. They have
teeth by construction - the assertion is that the finding appears, so a broken
audit query fails them.

Finding 8. The generator gained --check, which regenerates in memory and
compares without writing, and orphan removal for a generated suite whose .sql.in
has been renamed or deleted - that file otherwise keeps being executed forever
with stale content. Orphans are identified by a provenance banner rather than a
hard-coded list, so a hand-written suite is never touched; verified that
explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by
making it fire: a hand edit and a planted orphan are both reported by --check,
and generate removes the orphan.

CI now runs generate and --check as their own step, so a preparation failure is
visible in the log instead of being attributed to the test run.

Preview: rotation 16/16, db access audit 15/15, visibility 56/56.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Announce every project change, not only the plugin-initiated ones

Panels could come up empty and stay empty, and the reason was that the
startup restore told no one.

ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject,
which is the path a PLUGIN takes. The two callers that matter most bypass
it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject
directly when it restores the workspace, and BossTopBar's picker goes
through selectProjectInWindow. Neither published anything.

The ordering that produced the empty panel: DefaultPlugin is constructed in
a DisposableEffect keyed on registries and window state, not on
selectedProject, and it kicks off the plugin scan asynchronously while
BossAppScaffold composes and the sidebar builds panels immediately. The
restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON
reads. When those reads are slow the panel is built first, reads the ""
that WindowProjectState seeds itself with, and renders its no-project state.
The restore then lands, silently, and nothing remounts the cached component.

Publishing from the state itself rather than from one caller catches all
three. The bus is MutableSharedFlow(replay = 0), so a publish that never
happens cannot be recovered by a later subscriber - which is why this could
not stay per-caller.

The publish is removed from selectProject rather than added alongside the
collector; keeping both would double-fire on the plugin path.

previousPath is seeded from the current value so the StateFlow's replay of
it is not announced as a change. That seed is "" at startup, and telling
every plugin the project just became "" moments before the real restore
arrives is precisely the clear-yourself signal worth avoiding.

One collector per window: projectDataProvider is `by lazy` on DefaultPlugin,
which is per-window, so this cannot stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Publish from the state's own callback, not from a lazily-built provider

The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl`
put the fix behind two `by lazy` initializers. `projectDataProvider` and
`applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only
host path that creates the bus at all. So the collector started only if some plugin
touched both before the workspace JSON reads finished - and "the reads are slow" is
the premise of the bug. Touched after the restore, the collector seeds to the
restored path and announces nothing; never touched, no caller is announced at all.

The announcement now hangs off the `ProjectSelectionCallback` that
`WindowProjectStateRegistry` installs when it builds the window's state, which
`BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is
the sole mutator of the selection and invokes that callback synchronously, so every
caller is covered with no coroutine, no scope and nothing lazy in the path. Being
synchronous also makes `previousProjectPath` a faithful history rather than
best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C.

The two registry entry points carried a copy of the wiring each; they now share one
`newState`, so the announcement cannot be installed on some windows and not others.

Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a
no-op whenever the bus had not been created, so on a build where no installed plugin
had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`,
not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered.
It now creates the bus instead of dropping. `getInstance` also re-checks the registry
on every call rather than only at creation, so an instance that exists while the
registry is empty can no longer strand the host's publisher.

Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and
`DefaultPlugin.dispose()` releases it through a named delegate, alongside
`logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a
`SupervisorJob`, matching `pluginScope`.

Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being
announced, the previous-path chain, the seeding rule, the same-path suppression,
both registry entry points, and the bus being created rather than dropping the event.
Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Round 2: honest bus wording, atomic previousPath, three more tests

Review (1/2) and (2/2).

1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no
   registered publisher means nobody holds the bus, so it has no subscribers and that
   first event still reaches no one. The comment, the test name and the PR body all
   claimed otherwise. Reworded to what it actually buys: the host stops being
   permanently silent while it waits for a plugin to touch `applicationEventBus`.

3. `previousPath` was check-then-act on a plain field, and
   `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to
   Main (verified: `KernelBootstrap` registers it, the bridge calls
   `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read
   and the write are one operation.

5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields
   are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the
   lock is uncontended after the first call and removes the question. The
   bus-before-publisher ordering is now written down, including why a publish landing
   in that window loses nothing.

6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is
   documented as defensive and test-reachable, and defaulted to "".

7. Three tests added:
   - a plugin-initiated selection is announced exactly once (catches both a silent
     regression to zero and an accidentally re-added publish);
   - the registry test now asserts the recent-projects half of the callback ran -
     deleting it from `newState` previously left every test green;
   - `dispose()` stops the recent-projects collector, which is the whole reason
     `projectDataProviderDelegate` became a named lazy.

   `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider
   collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so
   its collector silently never ran. Unconfined also makes the dispose test
   deterministic rather than a sleep.

Smaller: `SupervisorJob` comment describes it as future-proofing (there is one
collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of
disposing the provider is documented on `dispose()`.

Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but
it changes `events()` semantics for every subscriber and every event type on a bus
whose consumers are out of tree. The body now says the race is narrowed rather than
closed, and names the api-side `selectedProject` StateFlow as what closes it.

`:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Round 3: unconditional announcement, ordered publish, drop dead register

Review 3.

2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe"
   paragraph, since this widens *when* a filesystem path reaches every installed
   plugin.

3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only
   the `actual` had been corrected. Fixed, and it now states the part that IS still
   best-effort: replay = 0 means an unsubscribed event is gone either way.

4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw
   there skipped it AND left `previousPath` stale - every later selection reporting a
   previous path one step behind, silently, for the session. Now `try { recents }
   finally { announce }`: order preserved for anyone reading recents off the event,
   the announcement unconditional, the throw still propagating.

5. `getAndSet` closed the read-modify-write but not the publish order, so two
   concurrent selections could emit out of order. `previousPath` and the publish are
   now in one `synchronized` block (the AtomicReference is gone - the lock subsumes
   it). The KDoc says plainly what this still does not settle: `WindowProjectState`
   writes `_selectedProject.value` then calls the callback with no atomicity, so state
   and last event can still disagree. Closing that means locking upstream.

6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value`
   wholesale and could land mid-test, dropping a just-added path - only where
   `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A
   once-per-JVM settle in `@BeforeTest` puts that single file read before any test.
   The test paths stay deliberately non-existent, now with a comment saying why: the
   fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose
   directory is gone, so a real temp directory would leak where these self-heal.

7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`),
   and it overwrote an existing entry - handing back a state with a fresh announcer
   seeded to "" for a window that already had a project. `newState` stays; it is what
   keeps the wiring from drifting.

8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside
   the lock now, and leaving it reads as if a fast path survives.

Also: a test for the actual regression path, `applyWorkspace` announcing the project it
restores, rather than a comment claiming to imitate it.

Answering the grep you could not run: the only `projectChanges()` consumers in
boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign
`_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat
publish as a reload nudge. Recorded in the announcer comment.

Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not
closed"), naming the mirror case and the api-side StateFlow that closes it.

`:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Round 4: split the dispose out, stop tests writing the real ~/.boss

Review 4. Items 1 and 3 were the before-merge ones.

1. Verified: ProjectDataServiceBridge.watchRecentProjects collects
   provider.recentProjects, a StateFlow, so cancelling the provider's scope on
   window close leaves that gRPC stream open and silently frozen for every
   out-of-process plugin. Taking the first option and SPLITTING the dispose out.

   Note this reverses what round 1 asked for, and the justification changed rather
   than the opinion: once the announcement moved to the registry callback,
   ProjectDataProviderImpl went back to being exactly what it was before this PR, so
   the leak is pre-existing and unrelated to the subject. Closing it properly means
   the bridge reading ProjectState directly instead of a per-window provider, which
   is its own change. The reasoning is on the class so it is not re-fixed by accident.

3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict
   real ones from the developer's picker and no cleanup restores them.
   systemProperty("user.home", <build>/test-home) on the Test task. One run with it in
   place shows the existing suite was writing far more than recents to the real home -
   window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json,
   recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all
   land in test-home now. The settle is gone with it: no recent-projects.json in
   test-home means the wholesale reload never happens.

2. publishSystemEvent now refuses the bus != null && systemPublisher == null state
   instead of falling through into a bus that may not be the registry's, with one
   warning rather than one per event. The comment at the fallback is true as written.

4. Both stale comments fixed (one construction path, not two).

5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is
   testable - ProjectState is an object, so a hard-coded call cannot be made to fail.

6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys
   (ordering), what it costs (an inline subscriber runs holding the lock inside
   selectProject), and that the contended path is untested.

7. The serial-execution dependency is recorded in the test class KDoc.

Plus: multi-window isolation, and a test that makes the recents update throw.

One thing this round caused and fixed: removing dispose() left two
ProjectDataProviderImpl collectors alive on the test dispatcher. On a
TestCoroutineScheduler a live coroutine is something every later runTest waits on, and
the git classes timed out at 60s in full-suite runs while passing in isolation.
Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher():
same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a
real argument for the follow-up - without dispose() the provider cannot be constructed
in a test without leaking.

:composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Round 5: reset test-home per run, stop getInstance stealing a publisher

Review 5.

1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting
   there from the previous run. doFirst now deleteRecursively() before mkdirs(), so
   the home is fresh per run rather than merely private. Without it the redirect only
   helped the first run and ProjectState.init's load reintroduced the same race from
   the second. Verified by running the full suite twice back to back.

2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and
   `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest,
   BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests
   both, so getInstance cannot take a publisher away from whoever set it.

3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning -
   partialRegistryWarned is a one-shot process global, so "it warns" is unassertable
   after the first test to trip it. Uses a stub bus so the half-registry state is real.

4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant,
   previousPath advances before the publish), so it is not "fixed" later. The
   lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block.

5. Taken, including the optional ones:
   - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally
     discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching
     Throwable is the contract, and both are rethrown.
   - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main).
     This removes the global setMain AND the round-4 collector leak at the source
     rather than working around them, without reopening the DisposableProvider split.
   - Three more pins: a null window state announces nothing, applyWorkspace
     (restoreProject = false) announces nothing, unregister + getOrCreate restarts the
     chain at "".

Not taken, with reasons: jvmArgumentProviders (the current form works, is
config-cache-serialisable and passed CI on three OSes); widening the user.home redirect
to plugin-path-utils (its test only mkdirs a directory every BOSS user already has).

There is no CHANGELOG in this repo, so the release-notes line for the same-path change
is an action item at release time, not a file change. Flagged in the body.

Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times.
detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in
this round's own code first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address project announcement review feedback

* fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488)

* Batch validation: promote latest reviewed dev changes to main (#463)

* Fix #30: Resolve duplicate context menu label collisions

* Cleanup: Remove dead code for #91 and #93

* chore: Sanitize SubmitResult.Error at construction rather than at one render site

* Fix panel component lifecycle disposal

* fixes

* Guard panel lifecycle destruction failures

* fix(logging): sanitize secret RPC failures and crash-report errors

Three related gaps in what gets logged and shown when something fails:

- SecretService.kt had no BossLogger in any of its ten catch blocks (#145).
  When the organisation migration broke all four secret RPCs (#144), the
  only WARN in the log came from the calling plugin - the code that
  actually failed said nothing. Every catch now logs via
  logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized
  failure for both the log and the returned Result rather than sanitizing
  twice. RoleService and RoleCreationService were checked and already log
  correctly - SecretService was the only silent one.

- LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it
  appeared inside a URL - a bare hostname (exactly what
  UnknownHostException.getMessage() produces, i.e. every DNS failure, and
  what a proxy-connect failure looks like) passed through untouched (#109).
  Added a narrow hostname pattern: lowercase-only labels ending in a short
  explicit TLD/`.internal`/`.local` list, with a negative lookahead so a
  package path that happens to end in a real TLD word mid-FQN
  (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException)
  is not mistaken for one - caught by the existing realistic-stack-trace
  test after the first version of the pattern redacted a live Kotlin
  package name out of it.

- CrashReportService.SubmitResult.Error held a raw exception string,
  sanitized at exactly one render call site in CrashReportDialog (#110).
  The constructor is now private; SubmitResult.Error.of(...) is the only
  way to build one, and it sanitizes before the raw string can reach
  .message - a property of the type now, not something every future
  consumer (a copy button, a toast, a log line) has to remember on its own.
  Both construction sites (the service's two catch blocks, and the one in
  CrashReportDialog the issue names directly) now route through it.

Tests: new coverage for the ten now-logging SecretService paths (via
SupabaseWiringTest's existing sanitization-wiring guard, updated to
recognize the "sanitize once into a local, reuse for log and return" shape
SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for
the hostname fix (including the FQN/version-number false-positive guards),
and a new CrashReportServiceTest for the sanitize-at-construction guarantee.

* test(crash): extract construction tests from #404

Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately.

* chore(ipc): clarify advisory menu ID scope and fix formatting

* chore(ui): finish obsolete toolbar comment cleanup

* test(crash): adapt consolidated coverage and guard factory visibility

Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately.

* refactor(crash): consolidate construction sanitization in #311

Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively.

* test(panels): retain sibling lifecycle isolation coverage from #313

Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work.

* fix(panels): complete lifecycle cleanup across failure and window teardown

Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions.

* docs(ui): remove stale disabled top-bar feature list

* test(ipc): protect advisory menu item ID uniqueness

* fix(panels): keep store registration scoped to its window effect

Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged.

* fix(crash): address consolidated sanitization review notes

Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate.

* fix(logging): keep server failure payloads out of secret RPC logs

Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately.

* ci: build PR batches on dev

* Allow Claude diff reviews after approved fork builds (#413)

* fix(browser): preserve newer clipboard copies during plain-text paste (#316)

* fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205)

* fix(browser): make paste-without-formatting's restore identity-based, not text-based

Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers.

* fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount

* fix(browser): blank lines between FakeTransferable overrides per ktlint

* test(browser): retain clipboard restore scenarios from #408

* fix(browser): track clipboard ownership through AWT wrappers

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>

* fix(plugins): serialize concurrent first loads per class name (#324)

* fix(plugins): prevent duplicate class definitions during concurrent loads

* fix(plugins): make class loading parallel-safe

* test(plugins): pin concurrent loading lifecycle boundaries

Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes.

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412)

* fix: make BrowserHandle JavaScript execution cancellable

the previous synchronous JxBrowser call blocked the Main/EDT thread;
coroutine cancellation could not take effect while that call was blocking;
the implementation now uses JxBrowser’s asynchronous JavaScript callback API;
suspendCancellableCoroutine makes the Kotlin waiting side cancellable;
late callbacks after cancellation are safely ignored;
this does NOT terminate JavaScript already executing inside Chromium.

* fix: add robust native call lifecycle tracking for disposal

* test: verify native operation cleanup on synchronous failure

* fix: linearize browser disposal and pending operation tracking

* fix: resolve detekt violations in browser native tracking

* test: satisfy detekt failure simulation rule

* fix: close native operation disposal race

* fix(browser): drain an in-flight call before closing the browser

Issue #300 reports that executeJavaScript is a synchronous, non-
cancellation-aware native call, and a plugin's own withTimeoutOrNull
around it can only abandon the caller's wait - not the call itself -
which stays running on Main indefinitely and can race a concurrent
handle.dispose(), touching the same native object from two threads at
once.

The severe half of this was already fixed by BoundedBrowserCall: this
class confines every blocking round trip to one dedicated daemon thread
instead of Dispatchers.Main, so a wedged renderer no longer freezes the
app. What was not yet closed is the residual window this issue's core
report is actually about: shutdown() stopped new work but did not wait
for whatever was already running, so a caller could proceed straight to
browser.close() while a call from just before shutdown was still
finishing on the dedicated thread.

shutdown() now waits, bounded, for that in-flight call to drain before
returning. Costs nothing in the common case - awaitTermination returns
immediately once the one worker thread and its queue are both idle,
which is where an instance sits between calls - and only the bound in
the rare case something was genuinely still running.

Known, and disclosed in the KDoc rather than claimed away: this narrows
the race for the common case (a call that was always going to finish
quickly) rather than closing it. It cannot help when the in-flight call
is itself the wedge this class's own deadline exists for - waiting
unboundedly for a genuinely stuck call would reintroduce the exact freeze
BoundedBrowserCall exists to prevent, just moved from a plugin's await
into every caller's teardown path. There is no JxBrowser API on this
version able to interrupt a blocking round trip already inside the
native call, which is the whole reason the call is confined to its own
thread instead of cancelled - closing that window completely needs
JxBrowser's own cooperation, which the issue's own analysis already
concluded is unavailable.

Tests: shutdown waits for a fast in-flight call to finish before
returning, and does not wait past its own drain timeout for a wedged one
- the same before/after pair the rest of this test file already uses for
BoundedBrowserCall's deadline.

* fix(browser): defer native close until admitted calls drain

Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring.

* fix(browser): bound profile waits and clarify deferred cleanup ownership

Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit.

* fix(browser): schedule disposal after early UI teardown failure

* ci: create launcher directory before Claude native installation

* Revert "ci: create launcher directory before Claude native installation"

This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0.

* test: keep warmup registry ordering check on one event loop

* test(browser): normalize CRLF in disposal source wiring guards

---------

Co-authored-by: john k <johnk@johns-MacBook-Pro.local>
Co-authored-by: john k <johnk@syn-172-100-137-112.res.spectrum.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>

* feat(tabs): show a speaker glyph on tabs that are playing audio (#314)

* feat(tabs): show a speaker glyph on tabs that are playing audio (#308)

* fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush

1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister.

* fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures

- BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire.

* fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file

* fix(tabs): publish audio state by browser ownership and keep icon layout stable

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: connect chrome density settings and keyboard controls (#305)

* feat: wire the chrome density scale into settings and small-screen defaults

ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on
main and every bar (BossTitleBar, BossTopBar, the main tab bar,
BossBottomBar) already read its height from BossChrome.dimens - but
LocalChromeDimens was never provided anywhere, so it silently resolved to
its staticCompositionLocalOf default (Comfortable) always. The scale was
built and unreachable.

This closes that gap:

- WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so
  an existing install's settings file - which has never heard of this key
  - decodes to exactly the chrome it already had; no settings-version bump
  needed).
- BossApp.kt now provides LocalChromeDimens from that field, host-only and
  outside BossAppCompositionLocals (plugins never draw host chrome).
- A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious),
  indexed in Settings search.
- A fresh install on a small screen (< 1000dp logical height, clearing the
  13" MacBook Air's ~931-956pt from #239's own measurements) now defaults
  to Compact and starts with the bottom bar off - the one bar this manager
  can still reclaim itself, since the side strips are already off by
  class default. Toolkit.getScreenSize() is read once, wrapped in
  runCatching (HeadlessException off a display must not break a fresh
  install), with a pure defaultDensityFor(screenHeightDp) so the decision
  is unit-testable without a display.

Addresses #239, scoped down from the full issue. Out of scope in this
pass, deliberately:

- The app does not yet report its own chrome budget as a measured
  percentage - the issue's own "state it, don't argue it" ask. Reachable
  later from the same ChromeDimens source, but needs a spot to render it.
- The title bar's 27dp "Boss Console" label row is untouched - collapsing
  or merging it with the tab bar is a separate, riskier layout change than
  a density scale.
- No regression intended to the existing per-bar show/hide switches or
  focus-mode edges: density only changes how much room a bar that is
  already on screen takes, never whether it is shown.

* fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line

Address review feedback on #305:

- Removed showBottomBar = density != ChromeDensity.COMPACT from
  getDefaultSettings(). ChromeMetricsTest already shows the shipped
  macOS defaults reach 93.3% of a 931dp window at Comfortable, so
  #239's >=90% bar is met without touching the status bar - and that
  bar's own KDoc argues it must stay on ("the only always-on readout").
  It was also reachable from the settings-load catch fallback, so a
  transient read error could silently drop the bar and have the next
  save persist the loss. Compact now only changes density, nothing else.

- Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's
  default MaxLineLength (120), which applies here since .editorconfig's
  140 is a ktlint-only setting and this line wasn't in the baseline.

* feat: add chrome density setting

* feat: add small-screen chrome defaults

* feat: add chrome density controls and small-screen defaults

* fix(ui): preserve lean density defaults and test density controls

* style: format multiline screen-height lookup

* fix(ui): separate density recovery and make editor chords opt-in

* style: wrap density default documentation

---------

Co-authored-by: AdityaK-iiita <adityakolate0070@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): stop host resource fallback after unload (#325)

* fix(plugins): stop host resource fallback after unload

* fix(plugins): preserve resource warning for a missing result

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* Handle closed browser frames during navigation callbacks (#400)

* Handle closed browser frames during navigation callbacks

* test(browser): retain closed-event guard after navigation integration

* fix(browser): preserve helpers on PID failure and recognize closed transports

* style(browser): wrap review diagnostics for detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* refactor(window): pin the panel content alignment width (#398)

* fix(window): restore strict width constraints for nested panels

Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation.

* Cover panel content alignment across width changes

* Keep panel layout regression in the existing app test package

* Document panel alignment contract and pin filling content width

* Wrap the panel width assertion to satisfy detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* #320 - Fix quick actions vanishing in short tab rail (#328)

* #320 - Fix quick actions vanishing in short tab rail

* Fix: detekt & ktlint checks

* Fix rail action budgets and reversible fallback wiring

* Fix resize test assertion import and update rail coverage note

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(crash): refine scroll boundary and bound error sanitization (#350)

* fix(crash): refine scroll boundary and bound error sanitization

* Avoid exposing a partial token at the crash message limit

* Document sanitizer input bound and verify expansion ordering

---------

Co-authored-by: Aishwary Anand <aishwary.cd22@bmsce.ac.in>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(logging): redact private hostnames (#346)

* fix(logging): redact private hostnames

* Handle private hostname punctuation and preserve diagnostic ports

* Format hostname punctuation regression assertion

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: Opening an .html file should ask open as a file or webpage (#333)

* feat: Opening an .html file should ask open as a file or webpage

* Fix HTML prompt delivery and persisted preference ordering

* Cover HTML routing and correct review test fixtures

* Honor HTML navigation intent and release pending prompts on failure

* Isolate HTML prompt handling from the general dialog host

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugin-store): parse timestamps consistently in list and detail metadata (#379)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* fix(repository): implement robust ISO timestamp parsing for Supabase payloads

* style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught

* fix(plugin-store): implement plugin metadata timestamp parsing

PluginStoreClient.parseTimestamp was a stub that always returned 0L, so
every plugin fetched from the store showed "Last Updated"/"Published" as
the Unix epoch in the Toolbox UI regardless of what the server actually
sent (#337).

Parses the ISO-8601 timestamp (the shape the store sends, e.g.
2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant,
falling back to 0L for a blank, missing, or malformed string rather than
throwing - matching the field's own empty-string default for a response
that omits it. java.time rather than adding kotlinx-datetime as a new
dependency: this module already uses java.util.* directly in the same
commonMain source set (it has one real target, jvm("desktop")), so nothing
is gained by introducing a second time library for one function.

Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the
convention already established next to it in
PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset
timestamp, a blank string, and a malformed string.

* Fix timestamp normalization edge cases and verify both response mappings

* Use release publication dates and pin timezone-independent fallback

---------

Co-authored-by: Sanjay <sanjaysaini4423@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(remote-ui): authenticate and expose remote UI surfaces (#348)

* feat(remote-ui): authenticate remote UI process identity

* feat(remote-ui): place authenticated remote UI surfaces

* fix(remote-ui): close ownership and placement lifecycle races

* test(remote-ui): expose placement recovery cases to JUnit

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(dashboard): add shared read-only What's New release feed (#381)

* Add What's New release feed to dashboard

* Serialize update settings persistence

* Remove unused update settings import

* fix(updater): retain realtime refreshes during shared release fetches

* style(updater): format shared release fetch regression tests

* fix(updater): coalesce release refresh bursts and preserve visible history

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(language): consolidate host and out-of-process language-id tables (#358)

* fix(language): consolidate host and out-of-process language-id tables

EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor)
each hand-maintained their own extension-to-language-id table because
boss-app-editor - a plain JVM module compiled to a GraalVM native image -
could not depend on anything in composeApp. They disagreed: EditorServiceImpl
named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and
EditorServiceImpl was missing more than forty ids the other table had
(fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75).

Moves the table into a new plugin-platform/plugin-language-types module -
dependency-free by design, so it carries nothing extra into boss-app-editor's
native-image build - and has both EditorLanguages and EditorServiceImpl read
from it. This is the first time anything under modules/ depends on
plugin-platform/; verified compiling and passing tests before committing to
the approach.

EditorLanguages keeps its existing public API unchanged (same values for
every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest
needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry
and "plaintext" fallback as local additions on top of the shared table,
rather than folding them into it: proto isn't part of the boss-file-types.json
default-app-association surface the shared table backs, and "plaintext" is
this service's own gRPC default, not a value composeApp reads.

Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously
no tests existed for this class at all) - the latter pins the actual bug fix,
that detectLanguage("sh") now returns "bash" instead of "shell".

Out of scope, and disclosed rather than silently dropped: two of the five
duplicate tables the issue names - the editor-tab plugin's own
LanguageDetection and BossEditor's lexer registry - live in separate
repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in
plugin-icons is also not consolidated: it keys icon selection off the raw
filename to preserve distinctions a language id can't carry (package.json,
yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different
brand icons) and never computes a language id in the first place, so
routing it through this table would need a separate icon-selection redesign.

* fix(language): keep file-association drift checks on the shared table

* fix(language): apply shared filename rules to editor file opens

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(editor): contain write-local stack overflow and preserve fatal errors (#396)

* Make writeFileContentSafe actually safe: catch the Errors it was named for

`writeFileContentSafe` exists to return false rather than throw. It did not hold
that contract:

    } catch (e: Exception) {
        fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e)
        false
    }

StackOverflowError is an Error, not an Exception, so it went straight through -
out of writeFileContent, out of the editor_write_file MCP handler, and to the
caller as a bare StackOverflowError instead of the handler's own
"Write failed for <path>". The warn that would have named the file never ran
either.

That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two
independent reports of it, on unrelated content, and #27's complaint is precisely
the missing diagnosis: the error "gives no indication of which input caused it".

StackOverflowError and OutOfMemoryError are caught explicitly rather than
widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and
readFileContentSafe in this same file already treats OutOfMemoryError as a
reportable outcome. Throwable would also absorb LinkageError and ThreadDeath,
which are not this function's to swallow - there is a test asserting a
NoClassDefFoundError still propagates.

The failure log now names the path and the character count. Not the content:
these writes carry whatever the user is editing.

**This does not fix the write.** Whatever recurses is upstream of this function,
which is mkdirs plus writeText - I ruled out the plugin's handler, this write,
and the host's MCP argument parsing, and said so on the issue. What changes is
that the failure is reported honestly instead of escaping, which is where a
diagnosis can start.

The body moves to `guardedWrite` with an injectable write, because a test JVM
cannot be made to overflow the stack inside writeText on demand.

Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt.

Refs risa-labs-inc/boss-plugin-editor-tab#18, #27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(editor): clarify write guard scope and prior recursion fix

* fix(editor): preserve fatal heap errors in write guard

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(state): apply version-checked JSON merge patches (#361)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* Feature: Implement JSON Merge Patch for plugin delta state synchronization

* fix(state): validate delta bases and exclude duplicate timestamp scope

* fix(state): reject malformed and deeply nested patches

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363)

Bumps `grpc` from 1.83.1 to 1.84.0.

Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

---
updated-dependencies:
- dependency-name: io.grpc:grpc-netty
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-protobuf
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-stub
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-services
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-util
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364)

Bumps `protobuf` from 4.36.0 to 4.36.1.

Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1

Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Commits](https://github.com/protocolbuffers/protobuf/commits)

---
updated-dependencies:
- dependency-name: com.google.protobuf:protobuf-kotlin
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.protobuf:protobuf-java
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer stale jar cleanup until restart (#340)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Require cleanup intent and retain artifacts with unordered manifest versions

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer fluck-browser hot-reload to a restart (#352)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): defer fluck-browser hot-reload to a restart

Hot-reloading fluck-browser force-unloads the classloader that created its
JxBrowser native views. Every open tab - and every tab opened afterward,
since the factory that would recreate the view is gone too - draws an empty
box, and a second hot-reload does not recover it; only a full restart does.

HotReloadPolicy names plugins that must never be swapped in place, matching
the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at
every path that can force-unload a live plugin to apply an update: the
resetPluginInstances/doReloadPlugin path that actually reproduced this bug,
the menu-driven Reload action, the Toolbox update flow, and the store
installer. For a not-hot-reloadable plugin, the new jar is staged into
installed.json without touching the running instance or its open tabs, and
a status message tells the user to restart to apply it - the next cold
start loads the update with a fresh classloader.

Also fixes two call sites that asked "restart dependent plugins?" before
checking whether an unload was even going to happen, which produced a
confusing prompt for an unload that a deferred update was never going to
do.

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Validate deferred browser artifacts and reject ineffective downgrades

* Require cleanup intent and retain artifacts with unordered manifest versions

* Preserve unloaded-browser recovery and unify safe deferred selection

* Format scoped update cleanup integration

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365)

* Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135)

reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest.

* Serialize plugin spawn registration with reaping and preserve replacement handles

* Fence late process creation without blocking shutdown and cover recovery

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): complete loader cleanup after disposal linkage errors (#302)

* fix: API hot swap during startup

* fix: workflows

* resolved comments by maintainer

* fix CI and buil…
* fix(kernel): require verified caller identity for the Git bridge

GitServiceBridge had no caller-identity check on any of its fourteen RPCs.
Any process able to open a connection to the kernel IPC server - not only the
plugins the host itself loaded - could read the open project's full commit
history and working-tree status, and could discardChanges (destroying
uncommitted work), checkout, cherryPick or revert against the project's real
git repository with no credential at all.

Gate every RPC behind authenticatedCallerOrRefuse(), the same
PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established
for BossConsole#53. The four watch streams (file status, commit log, is-repo,
is-loading) capture identity synchronously before returning and re-check it
on every emission, so a revoked token stops a live stream rather than only
new calls - matching the shape PR #550's review settled on for this bridge's
siblings.

* style(tests): wrap chained builder calls in GitServiceBridgeTest

Real CI's ktlintDesktopTestSourceSetCheck flagged chain-method-continuation
violations (a local run had missed this) - wrap the affected builder chains
onto their own lines, per ktlintFormat. No behavior change.

* test(kernel): cover revoked callers and log stream refusals

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
* fix(kernel): require verified caller identity for the Log bridge

LogServiceBridge had no caller-identity check on any of its nine RPCs. Any
process able to open a connection to the kernel IPC server - not only the
plugins the host itself loaded - could watchLogs to see the live stdout/stderr
stream of the running BOSS process, or call exportLogs to pull its full
history, with no credential at all.

Unlike a bridge whose values are deliberately routed through LogSanitizer at
the call site, nothing about this stream is scrubbed for IPC exposure - it is
whatever every logger.info/warn/error call site across this large and
growing codebase happened to write, which is not a guarantee that none of it
is a stack trace, a file path, or something else nobody meant to expose to
an arbitrary local process.

Gate every RPC behind authenticatedCallerOrRefuse(), the same
PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established
for BossConsole#53. The four watch streams capture identity synchronously
before returning and re-check it on every emission, matching the shape PR
#550's review settled on for this bridge's siblings.

* test(kernel): verify revocation and diagnose stream refusals

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
* fix(security): randomize the IV for encrypt_text/decrypt_text

encrypt_text called the 3-arg extensions.encrypt(plaintext, key, 'aes'),
which pgcrypto documents as using an all-zero IV, so encryption was a
pure function of (plaintext, key) alone: two equal passwords produced
byte-identical ciphertext. Because create_secret encrypts server-side
and RLS lets a user read their own password_encrypted back, any
signed-in user was an encryption oracle - grouping a dump by ciphertext
names every set of users sharing a password, with no key required. The
same defect applied to recovery_codes_encrypted and twofa_secret.

encrypt_text now draws a fresh random 16-byte IV per call and stores
'v2:' || base64(iv || ciphertext); decrypt_text dispatches on that
prefix and falls back to the original zero-IV path for anything not
yet migrated, so this deploys without a flag day. A backfill converts
existing rows in all three columns, guarded by NOT LIKE 'v2:%' so a
partial or repeated run is a no-op.

Deliberately scoped to this one defect (BossConsole#618 names three).
Defect 2 (the encryption key truncated to 32 string characters instead
of 32 bytes) is not touched here: ops/rotate_master_encryption_key.sql
records that a deployed key may be hex or base64, and this migration
cannot tell which is live in any given project's Vault - guessing wrong
would make get_encryption_key()'s result unusable and break encryption
for every row on deploy. Defect 3 (no MAC/AEAD) needs its own
wire-format review. ops/rotate_master_encryption_key.sql itself will
need a follow-up before it can run again, since it manipulates raw
ciphertext bytes assuming the old no-IV, no-prefix layout - both gaps
are documented in the migration's own header.

This could not be run against a real Postgres/pgTAP instance in this
environment (no supabase/docker CLI available here) - the added test
was written and reviewed by hand, and hosted CI's Database (pgTAP)
check is the actual verification gate.

* fix(security): un-qualify substring(... from ...) so it parses

CI's Database (pgTAP) check caught what local review couldn't (no
Postgres available in this sandbox): pg_catalog.substring(x from y)
is a syntax error. substring's SQL-standard FROM/FOR trailing syntax
is recognized by the parser only on the bare function name -
schema-qualifying it makes PostgreSQL treat it as an ordinary call,
where FROM is not a valid argument separator.

20260909000000_encrypt_totp.sql's own decrypt_text already calls
substring unqualified for exactly this reason; this migration's three
call sites (envelope/iv/body extraction in decrypt_text, and the
twofa_secret backfill) now match that convention. Both functions'
search_path already resolves the unqualified name to pg_catalog's
substring, so nothing else about name resolution changes.

* fix(security): make key rotation understand the v2 IV envelope

CI's Database (pgTAP) check caught what local review couldn't (no
Postgres available in this sandbox): the pre-existing
master_key_rotation_test.sql runs ops/rotate_master_encryption_key.sql
against real fixture data, and that script's re-encrypt loop calls
extensions.decrypt/encrypt directly on raw ciphertext bytes, assuming
the old zero-IV, no-prefix layout. The 'v2:' prefix the previous
commit's encrypt_text now writes is not valid base64, so decoding it
raised - exactly the gap this migration's own header comment already
named as a follow-up, just sooner than expected.

Added a pg_temp helper, rotate_ciphertext, that re-encrypts a mapped
column's value under the new key while preserving whichever envelope
it already carries: a 'v2:' row is decrypted with decrypt_iv under the
old key and re-encrypted with encrypt_iv under the new key AND a fresh
IV (a function rather than an inline SQL expression, specifically so
gen_random_bytes(16) is evaluated once and reused for both the
encrypt_iv call and the stored prefix - two calls would produce two
different values and corrupt the row). A legacy row - every column
before this pair of migrations, and still qbo_token_state /
google_token_state going forward, since those are untouched by the IV
migration - stays zero-IV, matching what it already was. Rotation's
contract stays "same shape, new key"; upgrading a row's shape is the
encryption migration's job, not rotation's.

Updated the IV migration's own header comment, which had flagged this
as a still-open follow-up, to point at the fix now landing alongside
it instead.

* fix(security): preserve plaintext bytes during IV upgrade

* fix(security): make IV backfill atomic and diagnose unreadable rows

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
@supabase

supabase Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Updates to Preview Branch (dev) ↗︎

Deployments Status Updated
Database ✅ Mon, 14 Sep 2026 09:36:46 UTC
Services ✅ Mon, 14 Sep 2026 09:36:46 UTC
APIs ✅ Mon, 14 Sep 2026 09:36:46 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations ✅ Mon, 14 Sep 2026 09:36:47 UTC
Migrations ✅ Mon, 14 Sep 2026 09:36:47 UTC
Seeding ✅ Mon, 14 Sep 2026 09:36:47 UTC
Edge Functions ✅ Mon, 14 Sep 2026 09:36:52 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@kshivang

Copy link
Copy Markdown
Contributor Author

@claude Please review this dev-to-main integration PR for substantive correctness, security and regression issues. Focus on RTL/fractional overlay placement (#477), verified caller identity and revocation for Git/Log kernel bridges (#635/#645), and randomized-IV encryption migration (#647): legacy compatibility, populated backfill, transaction safety, TOTP framing and key-rotation compatibility. Check the combined changes against main and distinguish verified issues from speculation. Validation on rebased dev: 38 targeted desktop tests passed and a disposable local database passed 826 assertions across 34 suites. Production was untouched; native overlay checks across window edges, RTL and display scaling remain manual.

@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 10m 49s —— View job


Review: dev → main integration (#477, #635, #645, #647)

I read all 18 files against origin/main, plus the surrounding code each change depends on (ProcessIdentityInterceptor, ProcessTokenRegistry, BossIpcServer, KernelBootstrap, DownloadServiceBridge as the precedent, git.proto/log.proto, the original encrypt_text, safe_decrypt_*, enforce_explicit_anon_grants, rotate_master_encryption_key.sql, prepare-db-access-tests.py). The three fixes are individually sound and the test coverage is unusually good. Below, findings first, then what I verified as correct so you know what was actually checked rather than skimmed.

No blockers on the code. The one thing I would not ship without is finding #1 — and it is in the migration, which you have already said deploys separately.


Findings

1. The backfill has no post-condition check, so a bad conversion commits (migration, high)

supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql:150-198 proves every row was readable before the rewrite, then irreversibly re-encrypts every password, recovery-code set and TOTP seed — and verifies nothing afterwards. Compare supabase/ops/rotate_master_encryption_key.sql, which fingerprints plaintext under the old key (step 1), re-derives those fingerprints through each column's application read path after the swap (step 5), and raises on any mismatch or row-count drift, specifically so that "a broken rotation cannot commit."

This migration performs the same class of operation (bulk in-place re-encryption of credentials) with strictly weaker guarantees. twofa_secret is the worst case: its reader safe_decrypt_twofa_secret returns NULL rather than raising, so a silently mis-framed row surfaces as a user's 2FA quietly not working, with the plaintext gone.

The rotation script's machinery is right next door and this is inside one DO block already, so a failure rolls back cleanly. At minimum, re-read each of the three columns through its real read path (decrypt_text, safe_decrypt_recovery_codes, safe_decrypt_twofa_secret) after the three UPDATEs and raise if any row now reads NULL/differently; ideally reuse the HMAC fingerprint pattern so it compares plaintext rather than just non-nullness.

Fix this →

2. Nothing locks the tables, so the backfill is not guaranteed to be complete (migration, medium)

The DO block never locks public.secrets at all, and only acquires a write lock on secret_metadata at the ALTER TABLE ... DISABLE TRIGGER on line 191 — after both of the preceding UPDATEs. Under READ COMMITTED each statement takes a fresh snapshot, so a create_secret that commits after the UPDATE public.secrets statement but before the migration commits writes a zero-IV row that is never converted. decrypt_text reads it fine, so nothing breaks — but that row is a surviving instance of the exact encryption oracle this migration exists to remove, and it will only be fixed by an unrelated later write to that secret.

It also means the readability pre-check's verdict isn't authoritative over the set the UPDATEs actually touch.

rotate_master_encryption_key.sql:88-92 already does lock table public.%I in access exclusive mode over every mapped table for precisely this reason. Two statements at the top of the DO block close it.

Fix this →

3. encrypt_text also changed plaintext::bytea → convert_to(plaintext,'utf8'), undocumented (migration, medium)

The old function (20251023000005_encryption_functions.sql:134) used plaintext::bytea, which in plpgsql goes through byteain — so it escape-decoded \\ and \nnn and raised on an invalid escape. A password containing a lone backslash could not be stored at all. The new convert_to(..., 'utf8') is the correct fix, and secret_iv_upgrade_test.sql.in:18-19,40-42,59-60 covers it deliberately.

But the header comment — which is otherwise exhaustive, right down to enumerating the two defects it is not fixing — never mentions it. That matters operationally rather than cosmetically: this is the change that can make the migration refuse to run. A legacy row whose stored bytes are not valid UTF-8 (reachable through the old octal-escape path) fails convert_from in the pre-check, and one such row aborts the entire backfill. An operator hitting that message deserves the paragraph explaining why. The .sql.in header hints at it ("independent of the old text-to-bytea cast, which interpreted backslashes"); the migration itself should say it.

4. The pre-check aborts on the first bad column instead of surveying all three (migration, low)

:167-170 raises at the end of each field's loop, so an operator with unreadable rows in secrets.password_encrypted learns nothing about secret_metadata until they have fixed the first column and re-run. Accumulating across all three fields and raising once would make a single failed run diagnostic. Reporting the affected primary keys (never values) would too — the message currently says "recover these rows" without any way to identify them. The rotation script's step 0 has the same shape, so this is a consistency improvement rather than a new defect.

5. RTL drawer slides in from the wrong edge on the lightweight path (#477, low)

VerticalTabBarDrawer.kt:112-113 uses slideInHorizontally(initialOffsetX = { -it }) / slideOutHorizontally(targetOffsetX = { -it }). Those are not layout-direction aware, while the same branch's Alignment.CenterStart (:109) and padding(start = railWidth) (:110, and :78 on the dismiss catcher) are. So in RTL the drawer is correctly anchored on the right and then animates in across the content from the left.

Only reachable via BOSS_RENDERING_MODE=OFF_SCREEN, so purely cosmetic — but it is the one mirroring site the PR left behind, and it is in a file the PR already edited for exactly this.

Fix this →

6. The new layoutDirection parameters default to Ltr (#477, low)

cornerPosition, insetBounds, resolveRegion (OverlayWindowBounds.kt:147,169,199) and besideLeadingRail (VerticalTabBarDrawer.kt:134) all gained layoutDirection: LayoutDirection = LayoutDirection.Ltr. Every production caller now passes it explicitly — only tests rely on the default. This repo's own convention argues for dropping it: AGENTS.md records blockingDependentsOf taking its isDisabled predicate with "no default, so a new call site has to answer." Same reasoning applies here, and the failure mode is identical to the bug #477 just fixed: a future overlay that forgets the parameter silently reverts to LTR, with no signal.

7. ProcessIdentityInterceptor's KDoc is now materially wrong (#635/#645, low)

modules/boss-ipc/.../ProcessIdentityInterceptor.kt:17-24 still reads "thirteen other service bridges as of writing, none of them authenticated today" and names PluginUIServiceBridge, SecretServiceBridge and RoleManagementServiceBridge as "the three that currently make it". DownloadServiceBridge already made it on main, and with Git and Log it is six. That KDoc is the canonical answer to "which kernel bridges are still unauthenticated", and it is the reason a reader trusts the interceptor's fail-open design — so being wrong about the list is load-bearing, not decorative. Worth updating in this PR since it is the change that makes it wrong by two.

8. LogServiceBridge's KDoc summary sentence is the caveat, not the class (#645, nit)

LogServiceBridge.kt:16-18: the revocation note was prepended above "Kernel-side bridge for LogService.", so KDoc's first paragraph — the summary tooling shows — is now "Revocation is checked before each emission…". GitServiceBridge.kt:17-19 places the same sentence correctly, after the summary. One-line move.


Verified correct

Recording what I actually checked, so the absence of a finding means something.

Kernel bridges (#635/#645)

  • Full RPC coverage. Counted the overrides against git.proto (16 RPCs) and log.proto (9). Every one is guarded; nothing is left reachable.
  • The stream pattern matches DownloadServiceBridge exactly — CURRENT_IDENTITY captured synchronously in the method body before the cold flow is returned (so it is not re-read from a Context that may no longer be current on the collecting thread), re-resolved and compared against the captured caller before every emission, StatusException(PERMISSION_DENIED) thrown from inside the flow. The Kotlin smart-cast after currentIdentity?.invoke() ?: refuseIdentity(...) is sound because refuseIdentity returns Nothing.
  • The guard is actually reachable. Both bridges are added in KernelBootstrap.registerPluginServices (:977-978) to the single ipcServer built at :486 as BossIpcServer(kernelAddress!!, tokenRegistry), which is what installs the interceptor. BossIpcServer's own KDoc already warns that hosting one of these on a registry-less server is a silent total outage — worth keeping in mind for any future second kernel server, but correct today.
  • Revocation coverage is honest about its limit. An idle stream with no further emissions is not proactively disconnected, and both KDocs say so. The tests pin the real behaviour (revoke → next StateFlow emission is refused, and received.tryReceive().isFailure proves the post-revocation snapshot never reached the client) rather than just asserting the status code.
  • The fakes recording calls and asserting provider.calls.isEmpty() is the right shape — it proves refusals are silent at the provider, not merely that the RPC returned an error.

Overlay placement (#477)

  • Fractional rounding is fixed on both halves. cornerPosition rounds the size and OwnedCornerDialog.setSize (HeavyweightCorner.kt:276-280) now rounds to match, so the overlay's far edge lands exactly on the region boundary instead of a sub-unit short. setLocation's remaining .toInt() (:274) is exact, since cornerPosition returns Int.
  • Delegating to Alignment.align(content, available, layoutDirection) is the right call: BiasAlignment flips horizontalBias under RTL while AbsoluteAlignment does not, which is exactly the documented contract, and both cases are pinned. Note this rounds half-up, so a centered overlay with odd slack moves one unit versus main (385 vs 384) — intentional, named in a test, and now consistent with how Compose places everything else.
  • insetBounds's RTL branch is right: the end inset comes off the physical left, so the region origin moves and the width shrinks, keeping logical start fixed. coerceIn(0, bounds[2]) cannot throw here because bounds is an AWT content-pane rect (non-negative width), and it also fixes the old behaviour where a negative inset would have grown the region.
  • The reportContentInset → contentInset → HeavyweightCorner.inset → insetBounds chain agrees on "inset.width means logical end", and the Rect.left-vs-root.width - Rect.right switch at FocusModeQuickActions.kt:621 is the only place that decision is made.
  • besideLeadingRail mirrors to the right edge and the narrow-panel degenerate case still collapses to left == right, so resolveRegion's documented inset fallback still applies. Pinned by the test.
  • Every production call site passes the direction (see finding 6 for the residual risk).

IV randomization (#647)

  • Legacy compatibility. 'v2:' cannot collide with a base64 prefix (: is outside the alphabet), the legacy decrypt_text path is byte-for-byte unchanged, and pgcrypto's encrypt and encrypt_iv share the same aes-cbc/pad:pkcs default — so old and new differ only in the IV. The secret_encryption_iv_randomization_test.sql fixture constructs legacy ciphertext with the raw pgcrypto expression rather than through the replaced function, which is the only way that assertion means anything.
  • The oracle is not reopened. CREATE OR REPLACE FUNCTION preserves the ACL, and the enforce_explicit_anon_grants event trigger re-revokes public, anon on every replaced public routine — so redefining encrypt_text/decrypt_text does not undo 20260909120000_close_client_crypto_access.sql. Checked rather than assumed.
  • Nothing downstream depends on ciphertext shape. No index, unique constraint or equality comparison on any of the three columns anywhere in supabase/migrations; no Kotlin or edge-function code reads password_encrypted/recovery_codes_encrypted/twofa_secret. All three are text, so the longer envelope fits. (Postgres encode(...,'base64') wraps at 76 chars and decode ignores newlines — already true of the legacy format, so no change.)
  • TOTP framing. The outer v1: is preserved and only the inner payload re-encrypted, which keeps safe_decrypt_twofa_secret (v1:-only) and the input trigger working. The DISABLE/ENABLE TRIGGER window is inside the same DO block, so a failure restores it transactionally — and the up-front tgenabled = 'O' refusal is a genuinely good touch, since it means the unconditional ENABLE TRIGGER at the end cannot silently downgrade an A/R trigger to O. The test asserting the trigger is still 'O' after a failed run is the assertion I would have asked for.
  • Key-rotation compatibility. pg_temp.rotate_ciphertext preserves each value's own envelope rather than upgrading one to the other, and the stated reason it is a function (evaluating gen_random_bytes(16) exactly once and reusing it for both the encrypt_iv call and the stored prefix) is correct — an inline expression would have produced two different values. Rotation's verify step fingerprints plaintext, so random IVs don't disturb it. The v1:-only guard on twofa_secret still fails closed, and moving the test's negative case from v2: to v9: is right now that v2: is a real inner format. The mixed-format test (legacy client_id_enc alongside v2 client_secret_enc, both surviving one rotation, with a fresh IV asserted) covers the interesting case.
  • Idempotency and fixture plumbing. The NOT LIKE 'v2:%' guards make a replay a no-op, and results_eq over a snapshot proves it byte-identically rather than by inspection. prepare-db-access-tests.py's 3-marker count matches the three \ir sites in the template, with_suffix("") produces the exact path added to .gitignore, and the --check step will catch drift.
  • No em-dashes in any added line (checked against the U+2014 guard).

Manual checks I'd prioritise

Your list is right. Given what the code does, I'd order it: fractional display scaling first (that is the half of #477 a unit test genuinely cannot reach — setSize/setLocation on a real native window at 1.25×/1.5×), then RTL with the vertical tab drawer specifically, then overlays at the right and bottom window edges. Focus mode and narrow layouts exercise the inset path, which is the best-covered part.

One rollout item not in your list: these Git and Log RPCs now require the child to present BOSS_PROCESS_TOKEN via ChildProcessBootstrap. A plugin runtime built against a boss-ipc older than the token interceptor loses Git and Log entirely — PERMISSION_DENIED, not degraded. Same exposure the secret, role and download bridges already took, so it is probably already satisfied, but worth confirming the pinned boss-microkernel-runtime jar before this reaches KERNEL-mode users.

I did not run the build, tests or ktlint — this is a static review of the combined diff.
· branch dev

@github-actions

Copy link
Copy Markdown
Contributor

Claude diff review of a3f4b7f (no tests executed).

Review of PR #651 @ a3f4b7f

Three unrelated changes are bundled here (Compose RTL support for overlay/inset geometry, IPC identity checks on the Git/Log bridges, and a pgcrypto IV randomization migration). Notes below are from the diff text only; I have not run anything.


1. Likely defect: backfill updates secret_metadata while the TOTP input trigger is still enabled

supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql, backfill block — statement order is:

UPDATE public.secrets SET password_encrypted = ...            -- trigger irrelevant
UPDATE public.secret_metadata
   SET recovery_codes_encrypted = public.encrypt_text(...)    -- <-- trigger ENABLED here
...
ALTER TABLE public.secret_metadata DISABLE TRIGGER encrypt_twofa_secret_trigger;
UPDATE public.secret_metadata SET twofa_secret = 'v1:' || ...
ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger;

The recovery_codes_encrypted UPDATE is an UPDATE on secret_metadata, so encrypt_twofa_secret_trigger (a row trigger on that table, per the migration's own comment and the tgenabled precheck) fires with NEW.twofa_secret still holding the stored 'v1:…' envelope. Your own new test asserts that exact input is rejected:

-- supabase/tests/secret_iv_upgrade_test.sql.in
SELECT throws_ok($$UPDATE public.secret_metadata SET twofa_secret='v1:copied-envelope' ...$$,
    '22023', 'TOTP input must be plaintext, not a storage envelope', ...);

Unless the trigger function short-circuits on NEW.twofa_secret IS NOT DISTINCT FROM OLD.twofa_secret (or the trigger has a WHEN clause), this migration will abort on any row that has both recovery codes and a 2FA secret — which is precisely the shape the .in fixture inserts. I cannot see the trigger body in this diff, so I can't confirm which it is; either it is safe and this is a latent hazard, or the new .in test does not pass. Cheap, unconditional fix: move the recovery_codes_encrypted UPDATE inside the DISABLE/ENABLE window (it costs nothing and removes the dependence on trigger internals).

2. Removing ciphertext determinism is a behavioural contract change — confirm nothing depends on it

encrypt_text now returns a fresh value on every call. Anything that compares ciphertext for equality breaks silently:

  • a UNIQUE/index or WHERE password_encrypted = public.encrypt_text($1) style lookup on secrets.password_encrypted, secret_metadata.recovery_codes_encrypted, or twofa_secret;
  • any client/edge-function "same password reused" or dedupe logic;
  • results_eq-style comparisons in existing test suites.

The diff contains no evidence either way. Worth an explicit grep before merge, since a broken equality predicate fails open (returns no rows) rather than erroring.

3. Undocumented plaintext-encoding change in encrypt_text

New body: extensions.encrypt_iv(pg_catalog.convert_to(plaintext,'utf8'), …).
The previous implementation used the text→bytea cast — your own legacy fixture proves it:

-- supabase/tests/secret_encryption_iv_randomization_test.sql
extensions.encrypt('legacy-password'::bytea, public.get_encryption_key()::bytea, 'aes'::text)

text::bytea interprets \\ and \ooo escapes and errors on invalid ones, so this change (a) makes previously-unstorable passwords containing backslashes storable, and (b) means new writes and old writes of the same plaintext are byte-different. The backfill preserves bytes, so existing readers are unaffected, but any out-of-repo re-implementation of the envelope (client, edge function, ops script) that mirrored the cast semantics will now disagree. The migration header documents the IV change in detail but never mentions this; please add it (the .in test comment alludes to it, which is not the same as documenting it in the migration).

4. Backfill operational risk (confirmed from the code)

  • Every candidate row is decrypted once in the precheck loop (PERFORM public.decrypt_text(stored.value)) and again in the UPDATE — 2× AES work over the full tables, row-at-a-time through a cursor, no batching or bound.
  • ALTER TABLE public.secret_metadata DISABLE TRIGGER … takes ACCESS EXCLUSIVE on secret_metadata and holds it until the migration transaction commits, i.e. through the whole twofa_secret rewrite. Logins/2FA reads on that table block for the duration.
  • A single unreadable row raises P0001 and blocks this migration and every migration after it, with no operator override (no GUC/flag). Fail-closed is defensible for an upgrade, but the failure mode should be documented next to the RAISE, with the recovery procedure.
  • The precheck requires tgenabled = 'O'. A trigger set to ENABLE ALWAYS ('A') is enabled and correct, yet the migration refuses. Suggest tgenabled IN ('O','A').
  • The backfill rewrites every row, so other triggers on secrets/secret_metadata (audit, updated_at, history — this repo clearly has audit suites) will fire for all of them. Intentional?

5. Minor but real: base64 newlines in the new envelope

pg_catalog.encode(iv || ciphertext, 'base64') wraps at 76 characters. Adding the 16-byte IV lowers the plaintext threshold at which a newline first appears by 16 bytes, so many short passwords that previously stored as a single line now contain an embedded \n inside the v2: payload. decode() ignores whitespace and LIKE 'v2:%'/substring(… from 4) are unaffected, so this is internally safe — but it will surprise anything doing strict column validation, single-line logging, or regex matching on these columns. Consider stripping newlines on encode.

6. Compose: LTR defaults hide call sites that were not updated

OverlayWindowBounds.kt (resolveRegion, cornerPosition, insetBounds) and VerticalTabBarDrawer.kt (besideLeadingRail) all gained layoutDirection: LayoutDirection = LayoutDirection.Ltr. Only HeavyweightCorner.kt and VerticalTabBarDrawer were updated to pass it. Any other production caller (another overlay host, an Android/lightweight path) keeps LTR behaviour with no compile error, and a future caller will too. Since the new tests mostly pass the argument explicitly anyway, I'd drop the defaults so the compiler enumerates the call sites.

Also note the new RTL branches assume the surrounding chrome physically mirrors (Row reversal): reportContentInset now reads bounds.left in RTL, and besideLeadingRail now carves the right edge. If the rail or the end sidebar is positioned with AbsoluteAlignment/fixed offsets rather than direction-aware layout, these mirror the wrong edge. I can't see those layouts in the diff.

7. cornerPosition rewrite — arithmetic checks out, with two behaviour changes

Delegating to alignment.align(content, available, layoutDirection) is the right call and correctly handles AbsoluteAlignment and custom BiasAlignment. I hand-checked all new assertions in HeavyweightCornerTest against BiasAlignment semantics (x = centerX * (1 + resolvedBias), roundToInt) and against insetBounds/besideLeadingRail; they are consistent (e.g. resolveRegion(parent, DpSize(48,24), null, Rtl) → [148,50,952,776], TopStart RTL → 148 + (952-432) = 668).

Two intentional-looking changes not called out in the KDoc:

  • Center rounding moved from floor (slack / 2) to half-up (roundToInt of centerX) — the new odd center slack test pins 385 where the old code gave 384. Fine, just note it's a 1px shift for odd slack.
  • Size now rounds instead of truncating (roundToInt), both here and in OwnedCornerDialog's dialog.setSize. Consistent, good.

Oversized-content clamping in RTL: offset.x.coerceAtLeast(0) still floors at the region's physical left, so an oversized overlay overhangs physically right — i.e. past the logical start edge in RTL. The KDoc still says "overhangs bottom-right rather than escaping top-left", which is now only half true. Low impact; worth a doc tweak.

Verify: HeavyweightCorner.kt now calls .roundToInt() in OwnedCornerDialog, but the diff's import hunk only adds LocalLayoutDirection. If kotlin.math.roundToInt was not already imported in that file, this does not compile. (OverlayWindowBounds.kt already used roundToInt in insetBounds, so it's fine there.)

8. IPC bridges: design looks right; coverage and duplication are the issues

The capture-then-recheck shape is correct and defensible: CURRENT_IDENTITY.get() is read synchronously at RPC entry, the supplier is invoked before every emit, and refuseIdentity throws StatusException(PERMISSION_DENIED) from inside the flow so it surfaces as an RPC failure rather than a silent completion. Unary handlers check before touching provider, and the tests assert provider.calls.isEmpty() on refusal, which is the right property.

Observations:

  • The documented limitation ("an idle revoked stream is not proactively disconnected") means a revoked caller keeps an open stream indefinitely if the upstream StateFlow never changes. Acceptable, but it is a real residual: revocation is not a disconnect.
  • Identity is checked; authorization is not. Any authenticated process can discardChanges/checkout/revert on the real repo and exportLogs. If per-plugin capability scoping is planned, the KDoc should say so rather than implying the hole is closed.
  • Streams use CURRENT_IDENTITY; unary uses AUTHENTICATED_PROCESS_ID. Two keys with different liveness semantics across one class is easy to get wrong later; a short comment on why unary doesn't need the supplier would help.
  • refuseIdentity / authenticatedCallerOrRefuse / NO_IDENTITY are now duplicated verbatim in GitServiceBridge and LogServiceBridge (and, per the KDoc, a third copy exists from fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges #505). Extract a shared helper so the fail-closed path can't drift per bridge.
  • LogServiceBridge's KDoc leads with the revocation caveat, so the doc summary sentence is the caveat rather than "Kernel-side bridge for LogService" (inconsistent with GitServiceBridge).

Test gaps (bridges)

  • Authenticated happy path is only covered for watchFileStatus, watchCommitLog, getCurrentProjectPath, stage, checkout, discardChanges (Git) and watchLogs, exportLogs, setSearchQuery, clearLogs (Log). A copy-paste slip that refuses authenticated callers on watchIsGitRepo, watchIsLoading, refreshStatus, refreshLog, unstage, stageAll, unstageAll, cherryPick, revert, openFile, watchFilter, watchSearchQuery, watchAutoScroll, setFilter, toggleAutoScroll would pass every new test, since those RPCs appear only in the anonymous-refusal list.
  • The currentIdentity.invoke() != caller branch is only exercised for revocation (non-null → null). The "identity changed to a different non-null process" case is untested.
  • GitServiceBridgeTest never asserts that a revoked stream stops before emitting for watchCommitLog/watchIsGitRepo — only watchFileStatus. Given the check is hand-copied four times per class, one shared parameterised test would be stronger than the current single instance.

Test gaps (SQL)

  • The RAISE EXCEPTION 'IV upgrade requires enabled encrypt_twofa_secret_trigger…' guard is untested. It is a hard deploy-blocking path; worth a throws_ok with the trigger disabled.
  • Unreadable-row abort is tested only for twofa_secret; password_encrypted / recovery_codes_encrypted share the loop but not the coverage.
  • A twofa_secret that is non-NULL but not 'v1:%'-prefixed (pre-fix(security): encrypt TOTP secrets at rest #417 plaintext, or a row written with the trigger off) is skipped by both the precheck and the UPDATE. No test pins that behaviour, and nothing in the migration comments says it is intended.
  • No end-to-end assertion of the actual issue claim — two rows created via create_secret with the same password now have different password_encrypted. The function-level isnt(encrypt_text(x), encrypt_text(x)) is good but doesn't cover the write path.

9. master_key_rotation_test.sql.in: the sentinel swap needs justification

-update public.secret_metadata sm set twofa_secret = 'v2:' || substr(sm.twofa_secret, 4)
+update public.secret_metadata sm set twofa_secret = 'v9:' || substr(sm.twofa_secret, 4)

No corresponding change to the rotation script's prefix-validation loop is visible in the diff. If the guard only special-cases 'v1:', this swap is cosmetic. If 'v2:' is now accepted as an outer envelope for twofa_secret, then a row with inner/outer envelope confusion (v2: at the outer level) is no longer rejected and the old negative test's coverage has been silently lost. Please either show the guard or add an assertion that an outer v2: on twofa_secret still raises.

Otherwise the rotation changes read correctly: pg_temp.rotate_ciphertext is created after the validation loop (so negative cases still raise before it exists), it preserves each row's own envelope shape, generates a fresh IV per v2 row (the reason for a function rather than an inline expression is real — gen_random_bytes must be evaluated once), and the four new plan entries match plan(20) (16 + 4).

10. Nits

  • .gitignore: the new entry /supabase/tests/secret_iv_upgrade_test.sql uses a leading / and a preceding blank line, unlike its four neighbours. Harmless, but inconsistent.
  • ContentInsetLayoutTest's new RTL expectation DpSize(RIGHT_SIDEBAR, BOTTOM_BAR) is identical to the LTR expectation, so the test only discriminates the new code path if LEFT_SIDEBAR != RIGHT_SIDEBAR. Those constants are not in the diff — please confirm they differ, otherwise the test passes against the pre-change implementation.
  • authenticatedCallerOrRefuse returns a String that is discarded at all 20-odd call sites; Unit (or using the value in the warn log) would be clearer.
  • Three unrelated concerns in one PR (Compose RTL, IPC auth, DB encryption) makes bisecting and reverting harder; splitting the migration out would be worth it given §1–§4.

@kshivang

Copy link
Copy Markdown
Contributor Author

Addressed the review findings on dev:

  1. The backfill now snapshots every non-null mapped cell with HMAC-SHA256 under a per-run ephemeral key, then verifies raw plaintext and the application reader result after rewriting. It refuses plaintext changes, missing/replaced rows, and incomplete v2 conversion inside the same atomic DO block. No plaintext, ciphertext or keys appear in diagnostics.
  2. It takes the same advisory transaction lock as key rotation and ACCESS EXCLUSIVE locks on both core tables before preflight, in rotation-compatible order. The header documents blocking secret reads/writes during backfill.
    3–4. Documented literal UTF-8/backslash behavior and legacy invalid-UTF-8 recovery. Preflight now surveys all mapped columns before raising one column/count-only diagnostic. Primary keys are intentionally omitted from logs; no credential values are emitted.
    5–6. RTL drawer animation uses the logical start edge. Removed default LTR arguments from the overlay/rail helpers and made existing tests explicit; production call sites already supplied direction. Added an animation-direction regression test.
    7–8. Updated interceptor documentation to describe per-service enforcement without stale service counts, and restored LogServiceBridge's class summary before its revocation caveat.

Fresh disposable database: all migrations applied and 830 assertions across 34 suites passed. Added real migration replay with a corruption trigger that also verifies both locks, and a row-deletion trigger; both failures roll back data. Existing legacy/Unicode/backslash/TOTP/idempotency/rotation tests remain green. Production was untouched; disposable DB cleaned up.

The native display-scaling/RTL visual check remains manual. The pinned runtime compatibility observation is a rollout consideration, not an assertion that an end-to-end external plugin runtime was exercised by these service tests.

Kotlin validation: 75 targeted tests passed; composeApp and boss-ipc ktlintCheck/detekt passed. Fixture generator --check and git diff --check passed. @claude Please verify the updated migration postconditions/locking and the RTL fixes on this new head.

@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 6m 4s —— View job


Verification of 3e09b57 (new head)

  • Read the delta a3f4b7f..3e09b57 and re-read the whole migration
  • Migration: HMAC snapshot + post-condition verification
  • Migration: advisory lock + ACCESS EXCLUSIVE ordering, preflight survey, header docs
  • RTL: drawer animation, removed LTR defaults, new regression test
  • KDoc fixes (interceptor, LogServiceBridge)
  • Post verification result

All eight findings from the previous round are addressed, and two of them are addressed better than I asked for. Three new observations below, one of which I'd fix before the migration deploys. Note I did not run Gradle, pgTAP or ktlint here — Gradle invocation isn't available to me in this environment, so this is static verification of your reported runs, not a reproduction of them.


Confirmed fixed

1 — post-conditions. Verified against rotate_master_encryption_key.sql's step 1/step 5 shape, and it is stronger than the rotation script in one respect: rotation verifies through the application reader only, while :243-255 fingerprints both the raw decrypt_text plaintext and the reader's result, per row, and compares both. That closes the exact safe_decrypt_twofa_secret hole I raised (reader returns NULL instead of raising) from two directions: a post-rewrite NULL makes hmac(convert_to(NULL,…)) NULL, the f.app_fp = NULL comparison yields NULL, NOT EXISTS fires, mismatched increments. The preflight's app_plain IS NULL guard at :182 is what guarantees no stored app_fp is itself NULL, so that comparison can't be satisfied vacuously.

expected <> actual plus the per-pk EXISTS covers the three ways the set can move — deletion, insertion, and swap-at-equal-count. secret_metadata.secret_id is UNIQUE (20251023000011_triggers_and_constraints.sql:112), so the temp table's PRIMARY KEY (tbl, col, pk) can't collide and silently turn a row into an "unreadable" count.

The NOT LIKE 'v2:%' clause at :247 is doing real work and is easy to miss: it is what makes "incomplete conversion" a verification failure rather than a pass, since an unconverted row still round-trips its fingerprint perfectly.

The fingerprint key is ephemeral (gen_random_bytes(32), :148), lives only in a plpgsql variable, and the temp table is ON COMMIT DROP plus explicitly dropped at :262 — the explicit drop is required, not belt-and-braces, because the pgTAP fixture replays the migration five times inside one transaction. Diagnostics carry column names and counts only.

2 — locking. pg_advisory_xact_lock(423, 1200) is byte-identical to rotate_master_encryption_key.sql:76, so the two genuinely serialize. The table lock order also matches: rotation locks distinct cols[j][1] … order by 1, which for the core tables is secret_metadata then secrets, and :157 lists them in that order. LOCK TABLE processes its list left to right, so the claim in the comment holds rather than just sounding right.

Both locks precede the trigger precheck and the preflight, so the preflight's verdict is now authoritative over the set the UPDATEs touch — the second half of my original finding, and the one that's easy to fix halfway.

3/4 — docs and survey. The encoding paragraph (:27-33) says the three things an operator hitting the abort needs: the cast changed, legacy bytes must decode as UTF-8, and an invalid-UTF-8 row refuses the upgrade. The preflight now accumulates into failures and raises once (:193-200); secret_iv_upgrade_test.sql.in:132-136 pins the multi-column message, which is the assertion that keeps it from regressing to first-column-only.

5 — RTL drawer. drawerSlideOffset (VerticalTabBarDrawer.kt:176-180) is right: in RTL the drawer's resting left edge is region.right - railWidth - width, so +width starts it at region.right - railWidth — entirely outboard of the rail's inner edge, the exact mirror of LTR's -width. LocalLayoutDirection.current is hoisted once at :89 and reused for both the region and the animation, so the two can't disagree. Pinned by ContentInsetLayoutTest:97-101.

6 — defaults removed. All four (cornerPosition, insetBounds, resolveRegion, besideLeadingRail) now require the parameter, and I checked every call site: two production (HeavyweightCorner.kt:120,136,144, VerticalTabBarDrawer.kt:90), one internal (OverlayWindowBounds.kt:149,154), the rest tests. Nothing was relying on the default.

While I was in there: kotlin.math.roundToInt is already imported in HeavyweightCorner.kt:37, so the compile concern raised in the other automated review is unfounded. And ContentInsetLayoutTest's RTL case does discriminate — LEFT_SIDEBAR = 48.dp vs RIGHT_SIDEBAR = 56.dp (:152-154), so the LTR formula applied in RTL would yield 48 and fail.

7/8 — KDoc. LogServiceBridge.kt:16-20 now leads with the class summary. See new finding B on the interceptor.

Also re-confirmed, since the preflight rewrite touched the surrounding logic: encrypt_twofa_secret_trigger is BEFORE INSERT OR UPDATE **OF twofa_secret** (20260909000000_encrypt_totp.sql:96-98) and short-circuits on NEW.twofa_secret IS NOT DISTINCT FROM OLD.twofa_secret (:69-73). The recovery_codes_encrypted UPDATE at :207 therefore cannot fire it, on either ground — the ordering hazard raised in the other automated review does not exist. The new CREATE TEMP TABLE also cannot trip enforce_explicit_anon_grants, which filters on schema_name = 'public' and object_type in ('function','procedure').

secret_iv_upgrade_test.sql.in's three new fault injections are the right ones, and the lock assertion at :90-93 is a genuinely nice touch. One caveat on reading it: for public.secrets it is unambiguously discriminating, but for secret_metadata it depends on ALTER TABLE … DISABLE TRIGGER taking only ShareRowExclusive (true since PG 9.5) — otherwise the fixture's own ALTER TABLE at :31 would supply that lock and the assertion would prove nothing about the migration for that table. Worth a comment so a future reader doesn't weaken it. The \ir count in prepare-db-access-tests.py:30 was correctly bumped to 5 and matches.


New findings on this head

A. The field mapping is written twice in one DO block, and the drift direction fails open (medium)

:169-173 and :230-234 are the same five-column VALUES list, duplicated verbatim ~60 lines apart, and nothing binds either of them to the three hand-written UPDATE statements at :202-225. That is a three-way coupling with no single source.

The asymmetry is what makes it worth fixing rather than just noting. Adding a column to the verification list only fails closed — expected is 0, actual is N, it raises. Adding it to the preflight list only (the natural editing order: you add the readability check, add the UPDATE, and stop) fails open: the column is re-encrypted, fingerprints are taken, and the verification loop never looks at them. You would have built the postcondition and then silently exempted the one column you just added from it.

rotate_master_encryption_key.sql avoids this with a single cols array consumed by every step, and this migration's header already cites that script as the precedent. One CREATE TEMP TABLE iv_upgrade_fields AS SELECT * FROM (VALUES …), or a text[][] declared once in DECLARE, closes it — the same "next to each other so they cannot drift" reasoning AGENTS.md records for blockingDependentsOf/missingFor.

Fix this →

B. The interceptor's new bridge list is already missing one (low)

ProcessIdentityInterceptor.kt:17-21 now reads "Plugin UI, Secret, Role Management, Download, Git and Log bridges enforce caller identity". SupabaseServiceBridge also enforces it — authenticatedCallerOrRefuse at :127, called from both select (:38) and rpc (:95), throwing PERMISSION_DENIED on NO_IDENTITY. So the list is wrong by one on the commit that rewrote it to stop being wrong.

The hedge you added ("Do not treat this list as an authorization policy: inspect each service's guards") is the right instinct, and it argues for finishing the job: delete the enumeration and keep the sentence. A list that is disclaimed as non-authoritative earns nothing by existing, and it is the part a reader quotes.

Fix this →

C. The preflight now refuses rows the previous version skipped, with a message that doesn't describe that case (low)

The old preflight filtered rows by prefix (LIKE 'v1:%' for twofa_secret); the new one iterates every non-null cell. So a twofa_secret that is non-null and not 'v1:'-framed — pre-#417 plaintext, or a row loaded with the trigger off, exactly the data-only-COPY case 20260909000000_encrypt_totp.sql's own header warns about — now decrypts substr(value, 4) of a plaintext seed, fails, and blocks the whole migration.

Failing closed is the right call and it matches rotation, which refuses the same shape outright ('secret_metadata.twofa_secret holds an unrecognised storage envelope'). The problem is only the diagnostic: :199 tells the operator to "recover these rows with the correct key and valid UTF-8", and neither a key nor UTF-8 is the issue for an unframed row. Rotation's wording names the actual condition. Adding the envelope case to that hint — or a separate count for "not in a recognised envelope" — turns a confusing dead end into a one-line fix for whoever hits it.


Operational note, not a finding

The lock window grew with the fix, and the header should probably say by how much. There are now three full passes over both tables inside one ACCESS EXCLUSIVE hold: preflight decrypts every non-null cell twice (raw + reader), the UPDATEs decrypt and re-encrypt every candidate, and verification decrypts every cell twice again. That is roughly 3× the crypto work of the pre-review version, row-at-a-time through cursors, with all secret reads and writes blocked throughout. It is the correct trade — an unverified rewrite of credentials is worse than a longer outage — but "blocking secret reads and writes for its duration" reads very differently at 50 rows and at 500,000. Worth having the operator run select count(*) from public.secrets first and, if the number is large, timing a rehearsal on the disposable DB before the production window.

One residual the locks can't close, for completeness rather than action: a REPEATABLE READ transaction that began before the migration committed and was blocked at the table lock could still resolve the old encrypt_text from its catalog snapshot and write one zero-IV row after the backfill. decrypt_text reads it, so nothing breaks, and READ COMMITTED (the default, and what PostgREST uses) takes a fresh catalog snapshot per command and is unaffected.


Residual manual checks

Unchanged from last round and correctly scoped in your summary: fractional display scaling at 1.25×/1.5× against a real native window is the half of #477 no unit test reaches, then RTL with the vertical tab drawer, then overlays at the right and bottom edges. Your framing of the pinned-runtime point as a rollout consideration rather than a tested assertion is the accurate one — a plugin runtime built against a boss-ipc predating the token interceptor loses Git and Log outright, and nothing in this PR's tests exercises that path end to end.
· branch dev

Sign up for free to 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.

3 participants