Skip to content

Remove Team Token PR1: User session, live authorization, and aggregate all-team access - #334

Merged
alfredolopez80 merged 564 commits into
devfrom
feat/user-centric-access-foundation
Nov 7, 2026
Merged

alfredolopez80 merged 564 commits into
devfrom
feat/user-centric-access-foundation

Conversation

@josue-tech-94

@josue-tech-94 josue-tech-94 commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

PR1 — user-session and live-access foundation

PR1 of the Task 106 plan to remove the selected team id ("Team Token") from user sessions. It installs the
backend foundation for user sessions that do not depend on the selected team, and checks team membership live
on the server instead of trusting the team and role in the token. Everything that changes what a client sees
is installed off by default; supported V1 / team-switch clients keep working as they do on dev.

Stack

Merge gate

This PR must not merge into dev before keyper-labs/evenfire-infra#154 (the change for
keyper-labs/evenfire-infra#143) is on the infra repository's main and a dev deploy log shows its
EDGE_PHASE apply and EDGE_PHASE settled lines: External REST and Profile UI Ready, with no pod of an older
ReplicaSet left, before control-api rolls out. See Deploy and rollback. #154 merged on 2026-11-06. The lines are in
the automatic dev deploys since then, in the required order (jwt-check, apply, settled, validators):
keyper-labs/evenfire-infra runs 42144889952 (second attempt), 42257859403 and 42292068908. Those deploys did
not change the edge images. A2 in #154 checks the same lines on the first deploy of this PR, where images must
list external-rest-api, profile-ui and control-api.

What this PR installs

  • User sessions independent of the selected team. V2 session acceptance is on; V2 issuance is off
    (CONTROL_API_USER_ACCESS_SESSION_V2_ISSUANCE=false) and cannot be turned on before PR3 (Task 106: fix before activating the access catalog or V2 session issuance #1222). V1
    issuance, acceptance and the legacy team switch stay on
    (CONTROL_API_USER_ACCESS_LEGACY_LIFECYCLE=issue_and_accept,
    CONTROL_API_USER_ACCESS_LEGACY_SWITCH_ENDPOINT=true).
  • Live team-membership checks on team, run, approval, SharedFileSystem, GFS and rpc/token routes:
    control-api reads the current team_members row on each request. No revision tables, triggers or
    SECURITY DEFINER functions are installed.
  • Session lifecycle and revocation: logout revokes the V1 session server-side
    (external_v1_session_revocations, append-only for the runtime role); a password change moves a
    security epoch that invalidates earlier sessions.
  • External REST error relay: External REST relays control-api's status and body, a bounded
    Retry-After, and on a 503 control-api's Cache-Control: no-store. On dev the edge kept a control-api
    4xx status but replaced the body with the internal error message, and turned any 5xx into a 500 carrying
    that message (security(external-rest-api): do not expose upstream error details in public 500 responses #190). An edge-side 4xx keeps dev's message; any other unhandled error now answers a
    sanitized 500 envelope.
  • Control-api path guard in External REST: buildUrl rejects dot segments, ? and # in the path,
    and caller-supplied path parameters are encoded, so a crafted segment cannot reach another
    control-api route.
  • Browser mutation guard in External REST: a mutation without a bearer token that carries a cookie, an
    Origin or a Sec-Fetch-Site header must come from an origin listed in
    EXTERNAL_REST_API_CORS_ORIGIN (behaviour change 8).
  • Desktop session handling changes in appService.ts, main.ts, preload.ts, renderer.d.ts and
    useAuthController.ts (behaviour change 7). The GFS grant error parser is unchanged from dev.

Earlier heads of this PR also installed revision tables with triggers, an aggregate access catalog
(/me/access/catalog, CONTROL_API_USER_ACCESS_CATALOG_MODE), a live access-path resolver and an operational
index. They were removed in f0e8a49ad, 64e27bfac, eb1f52f71 and 522049e07; the variables are ignored
(see the deployment note).

Behaviour changes compared with dev

These are intended and approved by the owner:

  1. Direct-grant V1 workflow runs carry no team attribution (team_id / usage_team_id stay NULL).
    A direct path has direct attribution under Task 106 (AD-06, "Direct path cannot inherit UI team").
    Consequence: team-scoped budgets no longer cover direct-grant V1 runs. Usage is still recorded and
    budgeted by user, recipe, Host and LLM secret.
  2. Invitations and passwords:
    • Setting the password after an invitation or a reset no longer creates a session; the user signs
      in with the new password (guided re-authentication).
    • POST /api/v1/invitations/password sets only the first password; with a password already set it
      answers 409 password_already_set.
    • Repeating the acceptance of an invitation the user already accepted succeeds without writing and
      without creating a session. The admin Desktop-invitation completion does not overwrite a password
      when its acceptance was a replay.
    • Accepting an invitation never renames an existing account.
  3. Logout revokes the V1 session token server-side instead of only clearing the cookie. When the
    Profile UI gets no answer to logout, it fences the session in the browser; if the browser cannot
    store that fence, the page now says the sign-out is incomplete and offers to retry.
    Logout revokes only the token it receives. A V1 token issued before a team switch stays valid until its
    own expiry (at most 7 days), as on dev; to end it, sign out with that token or change the password.
    A logout that commits while /external/rpc/token is already past session validation (during its live
    membership, agent and recipe-access reads) does not stop that mint; the RPC token it returns is valid
    for its TTL (300 s by default, CONTROL_API_RPC_TOKEN_TTL_SECONDS). On dev, logout revoked nothing.
    A revocation read just before the mint would narrow this window but not close it, since the logout can
    still commit after the read. The GFS mint reads the revocation in its mint statement (R2-L20).
  4. External auth error responses (control-api /external/auth/* and /external/rpc/token):
    • Minting a team session (session-token, used by team switch) requires a current user session;
      a revoked, retired or stale session gets 401 instead of a new token.
    • rpc/token checks the live team membership and role instead of trusting the JWT's team and
      role claims.
    • When the authority or session database is unavailable, these routes, the team routes that check
      live membership and the SharedFileSystem routes answer a typed 503 with Retry-After and
      Cache-Control: no-store instead of a 500 that exposed the upstream message. Other errors stay a
      sanitized 500.
    • On session-token, verify and rpc/token (its body sessionToken), a session token, or a token
      or claim field in the request body, that is not a string gets the same answer as an empty value
      instead of a 500. google-login keeps dev's handling: an idToken that cannot be converted to a
      string still answers the sanitized 500.
    • Issuing a V1 session is refused with 503 session_issuance_temporarily_unavailable when the
      user's security epoch would reject the new token at once.
    • The minimum-client 426 responses are installed but cannot be turned on before V2 issuance
      (PR3); the edge does not forward a client version yet (PR4).
      The verify body and the session-token role check are the same as on dev.
  5. Google login verifies the ID token once, in control-api; the edge no longer verifies it a
    second time. A Google account whose email is missing or not verified is refused with
    401 {"error":"Unauthorized"}, the same answer as a retired account (on dev it signed in).
    Invalid or expired tokens still answer 500, now with the sanitized envelope instead of the
    verifier's message and token claims, and the logs no longer contain the token or its decoded
    identity.
  6. Leaving a team (DELETE /external/teams/:teamId/members/:userId) requires the user id in
    canonical form; any other form gets the managed route's 400 invalid_target.
  7. Desktop session handling (4e30c600e, 43fc6e2fd, 1ec1fa8d7): when /me rejects the stored
    session (401, 403 or 410, as on dev) Desktop removes the stored token and tells the window ("Your
    session ended", new auth:sessionCleared IPC); Desktop asks /me after a 401 from a team call in
    listWorkflows, when the entity-changes stream expires and in the team-context lookup. On dev the
    401/403/410 rule covered only the startup restore; here it also covers these three probes. No External
    REST route sends 403 or 410 on GET /me, so only an intermediary (a WAF or CDN) could end the session
    that way; accepted. A workflow listing fails as a whole when a
    team call answers 401 or the session changes during the listing (stale_auth_epoch) instead of
    skipping that team; a failed deliberate team switch is reported to the window.
  8. Browser mutations through External REST must carry an Origin listed in
    EXTERNAL_REST_API_CORS_ORIGIN, and a Sec-Fetch-Site, when present, of same-origin or same-site.
    Profile UI reaches External REST through a same-origin rewrite, so that variable must list the public
    Profile UI origin; otherwise Profile UI login, logout, password reset, invitation accept and
    set-password answer 403. This is a deploy precondition (see Deploy and rollback).
  9. Hardening approved by the owner: directory search returns no channels, orders by display name,
    escapes %/_ and answers 400 above 100 characters; /external/invitations/pending lists the caller's
    own invitations and ignores ?email=; an inviter who is not an admin cannot invite an admin (403) and
    the response no longer carries the invitation token; renaming a team needs a live admin membership; a
    browser team switch sets the session cookie and does not return the token in the body; the
    workflow-recipes runtime access verifier checks every table and column privilege (7 -> 106 table rows,
    0 -> 6 column rows; each file also has one header comment line).
  10. Google sign-in with only a pending invitation no longer gives a session for the invitation's team
    (7045c3fd4, 2ca0f5371): the session has no team until the invitation is accepted.
  11. GET /me when control-api answers a bare 401 answers dev's 404 {"error":"User not found"}.
    The old control-api sends that bare 401 for an ended session, during the rollout window and after a
    rollback. This PR's control-api answers an ended session with the invalid_session envelope, which
    External REST relays as 401. A rejected External REST service credential (for example a misconfigured service secret) also reaches
    the edge as a bare 401, so GET /me answers the same 404 and writes no log, as on dev, where every
    control-api error on /me became a 404. The two bare 401s cannot be told apart at the edge, and
    External REST has no structured logger to record it.
  12. An ended session on the edge team routes (GET /team, team rename, member role change, member
    removal and POST /me/switch-team) answers control-api's 401 instead of dev's 200 {"team":null},
    404 or 403 {"error":"You are not a member of this team"} (3a7465283). The sessions this PR can
    end while the JWT is still valid (logout revocation, password change) would otherwise be reported as
    "no team" or "not a member". A refused External REST service credential also reaches these five routes
    as a bare 401 and now answers 401; GET /me keeps its 404 (behaviour change 11).

Everything else is meant to match dev. Rate limits are the same as on dev for every route that exists
there; each new route with a dev equivalent carries that limit. session/renew, session/logout and
sessions/revoke-all have no dev equivalent and no frequency limit (#1200).

Migrations

Migrations are listed by name. Each version string keeps a numeric prefix because schema_migrations
stores it, but the prefix is assigned when a PR merges and says nothing about which PR comes next:
later PRs take whatever prefix is free when they merge, not the next number after this one.

Base. Every dev migration up to and including 0136_llm_catalog_dismissed_backfill runs in
initDb's single transaction, as on dev, with #1197's rollout setting and synchronous_commit. On dev
the #1197 admission fence waited with a 60 s lock timeout under the pool's 15 s statement timeout; here
the migration session sets its own 15 s bound, so the fence transaction raises both to 60 s.

Applied by this PR, after that base, as 0137_user_access_foundation and
0138_legacy_password_security_epoch_backfill:

Migration What it does
user_access_foundation Creates external_user_sessions, external_user_session_security_epochs and external_v1_session_revocations, and grants their runtime privileges. Its five indexes on tables that already exist (team_members, user_contexts, team_contexts, user_agents, team_agents) are built online, before the migration transaction opens. An existing index is accepted only if pg_get_indexdef matches the expected definition exactly.
legacy_password_security_epoch_backfill Writes one security epoch per user who has a password_set_at, so sessions issued before the last password change stop being accepted.

Neither installs triggers or revision functions, and neither takes AccessExclusiveLock on an existing
table.

Removed from this PR and not carried by any later PR. Earlier heads of this PR also had
invitation_delivery_commands, catalog_utf8_ordering and composable_catalog_revisions. The catalog and the
invitation delivery command table were taken out of this PR, and the revision tables were removed from
every PR. The migration Job stops before 0137 on a database that recorded any version written by an
earlier head of this PR, or that still has its authorization_bump_* / authorization_prelock_* functions,
catalog_utf8_bytes or its revision and catalog tables, and its error names them. The deployment note
gives the check query and the cleanup script
(docs/deploy/sql/user-access-foundation-earlier-head-cleanup.sql).

Left for the next PR (#594). #594 carries its own migrations: workflow_authority_bindings,
gfs_upload_authority_bindings, pr2_readiness_evidence, pr2_runtime_privileges,
workflow_recipe_authority_entity, workflow_run_failure_reason, r31_runtime_behavior_sources and
workflow_authorization_pause. It still lists this PR's earlier migration set; when it is reconciled
with this head it drops the three removed migrations and keeps the two above as they are here.

Execution.

  • Migration policy: 10 s lock timeout, 15 s statement and idle timeouts, 120 s per online index build,
    300 s per Job, 360 s client wait. --timeout now sets only the client wait; the Job deadline is the
    fixed 300 s policy. The policy file is copied next to deploy/scripts/run-control-api-db-migration.sh,
    because the infra repository copies only deploy/ and scripts/.
  • The migration session sets client_connection_check_interval, so a killed Job pod does not leave its
    backend holding the advisory lock (PostgreSQL 14 or later).
  • The migration Job is owned by UID: only the caller that created it deletes it, with a UID
    precondition; an attached caller waits for the Job's deadline. A terminal Job left by an earlier run is
    also deleted with the UID the script just read; a Job another caller created under the same name stops
    the script instead of being deleted (Migration script deletes a "terminal" Job by name, which can hit a newer running Job #1226).

Adjustments made on 2026-10-27/28, and why

A five-model review of this PR and a set of parity probes against dev found places where the PR
replaced working dev behaviour with new routes, flows or limits that the team-id removal does not
need. The owner set one rule for this PR: match dev wherever dev already works, and add new
mechanisms only where removing the selected team id requires them. The changes:

  • Rate limits returned to dev's values on the 57 routes where they differed (gaps that dev already
    has are tracked in control-api / rpc-proxy: no per-subject frequency limit on Host messages and artifact reads #1199, control-api: no frequency limit on external Google login, session verify, session-token and RPC-token issuance #1200 and control-api: no per-user limit on mutations that send email (invitations, approval-medium challenges) #1201).
  • Host access uses dev's parent path again; the /message-resolution and /artifact-read sub-paths
    and their version fence were removed.
  • Workflow, capabilities and resolve routes validate the session once per request.
  • Run reads follow dev: the author always sees their own runs; other users' runs need live authority.
    V1 approvals use dev's caller key, so approvals created on dev replay unchanged.
  • Directory and invitation responses match dev (self-target handling on legacy team routes, the
    retired-invitee 401, no internal assignment_count, the shared draft-activation helper).
  • Direct password login on control-api returns dev's invalid_credentials body again; the
    verify 401 body and the session-token role check also match dev. The external auth routes
    answer 503 only for a real database outage, and every such 503 carries Cache-Control: no-store and
    Retry-After, which the edge relays.
  • CI checks pinned real-PostgreSQL suites from the JSON report only, and CodeQL uses dev's
    configuration.
  • Unused schema was removed. (The revision tables, catalog families and their grants that this bullet and the
    next described were later removed entirely, in 522049e07 and f0e8a49ad.)
  • The branch-profile harness sets External REST's browser origin from the profile's Profile UI URL, so
    the Profile UI login can be tested on a branch profile.

Second review (2026-10-29/30), and what changed

A second blind multi-model review of head eecc120 found 23 defects (0 blocker, 0 critical, 0 high;
8 medium, 15 low), all introduced by this PR. Fixed here:

  • The revision-row lock order, revision bumps, catalog item filtering, live Host reader and live resolver fixes
    made in this round applied to code that was removed later (64e27bfac, f0e8a49ad, 522049e07); they are
    kept here as history only. This PR has no revision rows, catalog or resolver.
  • Secrets in logs. Google verifier errors no longer carry the token into logs (see behaviour
    change 5).
  • Error classification. The live team-membership check answers 503 only for a database outage
    (see behaviour change 4).
  • Input hardening. A non-string session token gets 401 instead of crashing control-api; leaving a
    team needs the canonical user id.
  • Grants. The runtime role can only read and append the session revocation table.
  • Profile UI sign-out reports an incomplete sign-out when it cannot store the local fence.
  • Directory search is open to active team members again, as on dev.
  • A new edge test covers an old native V1 client switching team A → B → A: each returned token is used
    for /me and /me/teams, and both match the requested team.

The other findings are tracked: the dormant catalog and indexer defects in #1222
(comment), and ten
low-severity follow-ups in #1251.

Third review (2026-11-05), and what changed

A third review (zach88, round 2) of head 9235eeb46 found 27 items and 6 more from a blast-radius pass.
Fixed here, in d79a23f4b..9edcd358f:

  • Earlier-head databases are refused before 0137, with the versions, functions and tables named and
    a cleanup script (R2-M2; see Migrations).
  • Online index check. It compares the exact PostgreSQL definition of each of the five indexes; the
    ~300-line DDL normalizer, which served the removed catalog indexes, is gone (R2-L14).
  • Dead code. canCallerReadWorkflowRun, which had no caller here or on dev, was removed (9d32a660b
    had restored it as a no-op; R2-L14). legacySecurityCutoffRejectsSql lost its last caller in
    64e27bfac and was removed too. The unused details parameter of both publicApiError helpers was
    removed.
  • Lock order. Retiring a managed user now locks both users' team_members rows in one statement
    ordered by (team_id, user_id), the order member removal and demotion use, so two admins acting on each
    other no longer deadlock. A two-client real-PostgreSQL suite covers remove, demote and two shared teams
    (R2-M1, R2-L9).
  • GET /me answers dev's 404 for a bare control-api 401 (R2-M6; behaviour change 11).
  • Desktop ends a stored session on 401/403/410 from /me, as on dev, and the GFS error parser is
    back to dev (R2-M4, R2-L7; Desktop GFS public-envelope test does not detect a missing reason #1225).
  • Non-string tokens. On session-token, verify and rpc/token, a non-string token or claim field
    is refused instead of answering 500; the tests send it through the real routers (R2-L1, R2-L2).
    google-login is unchanged from dev (R3-L5).
  • 503s. Handler-level 503s carry Retry-After and no-store and write one structured warning. The
    SharedFileSystem live check answers 503 only for a database outage, rethrows anything else to the
    sanitized 500, and no longer uses console.warn (R2-L4, R2-L5). The bodies keep three shapes: dev's
    {"error":"session_backend_unavailable","retryAfterSeconds":…} from the session middleware, flat
    {"error":"authority_unavailable"} (or context_reconciliation_unavailable) from the team middleware
    and the SharedFileSystem routes, and the public error envelope from /external/auth/*. Desktop and
    Profile UI read only the status, so this is accepted here; unifying the bodies is tracked in Task 106 PR1 follow-ups: ten low-severity defects from the second review of PR #334 #1251.
  • V2 issuance readiness is unavailable, so control-api rejects
    CONTROL_API_USER_ACCESS_SESSION_V2_ISSUANCE=true (session_v2_issuance_unavailable) until PR3 (R2-L6).
  • Profile UI. Setting the password of an already accepted invitation behind an unconfirmed sign-out
    ends at the sign-in and reset links instead of showing the form again (R2-L8).
  • Google login. The edge /auth/google handler no longer has a branch for a 403 that control-api never
    sends on that route; a retired account gets control-api's own 401 (R2-L11).
  • Migration Job. The migration session detects a killed client (R2-L15); a terminal Job is deleted by
    UID (R2-L18, Migration script deletes a "terminal" Job by name, which can hit a newer running Job #1226); the fence comment states what dev did (R2-L17).
  • GFS. A V1 logout also stops a GFS token mint that already passed session validation: the mint reads
    the revocation in the same statement (R2-L20).
  • Hygiene. control-ui/next-env.d.ts and profile-ui/next-env.d.ts are tracked again, and
    .gitignore matches dev (R2-L21). Tracking them exposed minikube T2 dirties the worktree in T0 and then refuses to certify because it is dirty #733, open on dev since 2026-09-22: this
    PR changes control-ui and profile-ui, so T0 runs next build for both, the build rewrites the two
    tracked files, and make minikube-t2 then refuses to certify the dirty tree. Without a fix this PR
    cannot get a T2 verdict. 1a24817fb fixes it in the harness only (scripts/minikube/t0.sh,
    scripts/minikube/t2-common.sh and their scenario test; no product code): T0 restores the two
    generated files to HEAD after the build, and the T2 dirty-tree check fails closed when git status
    fails.

Documented here and in the deployment note instead of code: this description (R2-M3), the Profile UI
origin precondition (R2-M5), the logout scope (R2-L3, behaviour change 3), the hardening list (R2-L10,
behaviour change 9), the 10 s lock wait and the 60 s fence (R2-L16, R2-L17), the --timeout contract
(R2-L19), the rollout order, the grant contract, the 0130 password-check gap, the lock on users, the
Google pending-invitation change and the team budget change. The two dormant V2 items (R2-L12
/auth/session/renew, R2-L13 the two 426 shapes) are added to #1222.

Where this round differs from the review:

  • R2-M2 (numbers as they were at that review; the two migrations are now 0137 and 0138): a version rule
    of "anything after 0130" misses earlier heads that used prefixes 0101..0129 and the same-name 0131; the guard uses the migration names plus a probe for the objects.
  • R2-M6: dev's 404 did not sign Profile UI or Desktop out either; the defect was the retryable 503 label.
  • R2-L8: letting the password call through the fence would let another user's fenced cookie authorize it;
    the terminal screen is the safe fix.
  • R2-L20: the window was smaller than dev's, where V1 logout revoked nothing; it was fixed anyway.
  • R2-L18: pre-existing on dev, fixed here because it is small.
  • R2-M1: the lock-order fix does not close Task 106 PR1 follow-ups: ten low-severity defects from the second review of PR #334 #1251 item 3.

Fourth review (2026-11-06), and what changed

A fourth review (zach88, round 3) of head 6ca5fd67d found 0 blocking items and 24 optional ones
(1 Medium, 23 Low; 6 carried from earlier rounds).

Fixed here:

  • Earlier-head receipts. Earlier heads also recorded hex-prefixed migration names (010a_..010e_,
    012a_, 012b_). The guard, the cleanup SQL and the check query now match ^[0-9a-f]{4}_, and the
    real-PostgreSQL test inserts a hex receipt and asserts that no earlier-head receipt is left after the
    cleanup (R3-L1, R3-M1; b21a3aa14).
  • Ended session on edge team calls. switchTeam and the four team calls rethrow control-api's 401,
    so a revoked session is answered 401 instead of "not a member" or "no team" (R3-L3, behaviour change
    12; 3a7465283). Correction: 61249463a ("forward session end and live denial from team routes")
    made the four team calls rethrow control-api's invalid_session envelope, but control-api answers an
    ended session on /external/teams and /external/users with a bare 401
    (requireValidExternalSessionToken), so those rethrows never fired against the real control-api;
    their tests mocked the envelope. The new tests use the bare 401 that control-api actually sends.
  • Deployment note (b21a3aa14, 79d220b92): the long-transaction note now says a long transaction makes the
    online build fail after the 10 s lock_timeout, not after 120 s (R2-L16); the
    client_connection_check_interval requirement names the supported platforms (R3-L2); the
    earlier-head guard states when it can be removed (R3-L13).
  • Smaller items (79d220b92): the SharedFileSystem proxy relays no-store on a 503 (R3-L4); the two
    503 warnings carry the correlation id (R3-L7); a duplicate test now pins the 403 answer (R3-L10); comments
    say what the edge emits (R3-L14); sessionTokenFromRequest is inlined (R3-L15); a fixture uses a policy
    shape that exists (R3-L16). Internal hostnames were removed from the repository's deployment note.

Documented here instead of code: the scope of the non-string rule (R3-L5), the rpc/token mint and a concurrent logout (R3-L6, behaviour change
3), /me and a rejected service credential (R3-L9, behaviour change 11), Desktop's /me probes
(R3-L11, behaviour change 7), why 1a24817fb is here (R3-L12), and the three 503 bodies (R2-L4). The
revocation purge (R3-L8) is #1266. The R3-L17 mount-time check and the V1 half of R1-L10 are added to
#1251; the dormant V2 items R1-L8 to R1-L11 to #1222.

Where this round differs from the review:

  • R3-L2: PostgreSQL 15 and later support client_connection_check_interval on macOS and the BSDs, not
    only on Linux; the local real-PostgreSQL runs use native macOS PostgreSQL 16 and pass. dev already
    sets the same variable unconditionally. Tolerating the error would hide a missing cancellation, so the
    fix is the doc wording only.
  • R3-L3: the proposed isControlApiInvalidSession rethrow would not fire, because the memberships route
    sends a bare 401, not the envelope; the fix keys on the status.
  • R3-M1: the fixture is extended rather than built from a pinned earlier head in CI. That head
    (20be2fbe4) is not on dev, one pin covers one of several earlier schemas, and the check query returned
    no rows on clerum-dev and production on 2026-11-06.

A focused blind review of 68d020a1e..7befa6ede found no product or security defect and four Low items, fixed in
aac5388a9: a second copy of the old wait in the LLM secret editor test (#1263, first fixed in 7befa6ede), and
three inaccurate comments.

Fifth review (2026-11-07), and what changed

After dev was merged up to 31abc80d8, a focused review of the merge resolutions and of the commits since
a3f017650 found no production defect in the migration wiring and no High or Medium security finding. It found
three red CI checks and two Medium items. Everything below is on this head except where stated.

  • Migration tests follow dev. The latestDev producer now pins dev's tip and moves at each merge of
    dev; hand-written receipt counts are gone, and the upgrade delta is derived from the code. observedDev
    stays, because it covers the step through 0130. One bound (LAST_DEV_MIGRATION_VERSION) now separates the
    dev batch from this PR's runner. Rejection of the old 0131/0132 versions is pinned.
  • Sign-in text. Desktop classifies a failed sign-in from a marker the credential call writes at the end of
    the message, so a response body cannot imitate it. A 401 keeps "Email or password is incorrect." A 429 says
    "Sign-in is paused for now. Your password was not checked. Please try again {wait}." A 5xx or a failure with no
    HTTP answer says "Sign-in isn't available right now. Please try again in a moment." The invitation checks no
    longer match 404 or 410 anywhere in the message. Other failures keep the existing text (Desktop sign-in: "Check your email and password" shown for 426, other 4xx and local failures #1382).
  • No-team notice removed. Desktop shows no notice about the session's team.
  • Plugin image preview closes when the signed-in principal changes.
  • MODELS_DEV_API_URL with credentials is refused, and the raw value is not echoed in the error. The variable
    is not set in the GCP dev or production configuration, checked read-only.
  • Least privilege. control_api_runtime no longer has DELETE on external_user_session_security_epochs
    (upsert profile). This changes 0137, which no shared database has recorded.
  • E2E harness. The session-team lane needs E2E_SESSION_TEAM_UX=1, an explicit E2E_K8S_CONTEXT on a
    branch-owned profile, and an explicit EXTERNAL_REST_API_BASE_URL. It restores memberships exactly. The E2E
    helpers now add the member in a second call, because POST /admin/teams ignores userId.
  • CodeQL. Alerts in code this PR changed are closed, including two older ones in E2E helpers.

Deploy and rollback

The deployment note is docs/deploy/user-access-foundation-upgrade.md. For this release it replaces
step 3 of the general order:

  1. Run the control-api migration Job and wait for it to finish. It also applies dev's migrations through
    0136 on an environment that has not reached them yet.
  2. Roll out External REST and Profile UI together; wait for kubectl rollout status on both and check
    that no pod of an older ReplicaSet is left.
  3. Roll out control-api and every other workload.

Rollback goes the other way: control-api first, then External REST and Profile UI. The mixed window
(new External REST against old control-api) is pinned by
external-rest-api/test/mixedVersion.baseControlApi.test.ts; the note lists what works in it.

Preconditions before step 1 (read-only checks are in the deployment note):

  • EXTERNAL_REST_API_CORS_ORIGIN lists the public Profile UI origin in the target environment (behaviour
    change 8). Checked read-only on 2026-11-05: clerum-dev and production each list the
    environment's Profile UI origin; re-check before each deploy.
  • The database has no version or object from an earlier head of this PR. Checked read-only on 2026-11-06:
    no rows on clerum-dev or production.
  • keyper-labs/evenfire-infra#154 implements this order and merged on 2026-11-06. A deploy through an infra
    revision without it must run the three steps by hand. With a single apply,
    old External REST pods meet the new control-api and team create, team switch and password reset fail.
  • Ship the new Desktop build after the server deploy.

Locks and size:

  • user_access_foundation builds five indexes online on team_members, user_contexts, team_contexts, user_agents
    and team_agents (CREATE INDEX CONCURRENTLY, ShareUpdateExclusiveLock; 0.1–0.3 s each at measured
    sizes), then creates the session tables and their indexes in its bounded transaction.
  • legacy_password_security_epoch_backfill copies password_set_at into the password epochs in one INSERT … SELECT over users under
    AccessShareLock. Measured at about 6 µs per row on one laptop, it reaches the 15 s statement timeout
    near 2.4 million users and then fails without a partial write.
  • A Job that hits the 300 s deadline can be re-run: the next Job skips applied migrations and valid
    indexes.

Rolling back to the dev image:

  • Logout behaves as on dev (cookie cleared, V1 token valid until it expires, at most 7 days), because
    the dev image does not read the revocation table.
  • A password changed while the dev image runs does not revoke earlier sessions after re-deploying
    this PR; those sessions end at their TTL.

Pre-release builds read control-api variables that this release ignores (indexer, catalog and V2
action-context flags); the deployment note lists them so they can be removed. Before the first deploy,
the owner reads one stored migration Job on the development and production clusters to confirm the Job
identity check matches what the apiserver stores.

Validation

Validation on 4d990b023, with dev at 31abc80d8:

  • Full minikube lane (make minikube-t2, new branch profile, full-bootstrap): MINIKUBE_T2_PASS, T2_EXIT=0.
    T0, T1 with 1064 tests in 132/132 real-PostgreSQL files (0 pending), NP-08, port-forwards and health all pass.
  • T0 (scripts/minikube/t0.sh): T0_EXIT=0, tree clean afterwards.
  • CI: all 60 checks pass. The CodeQL check reports no new alert in code changed by this PR; older open alerts
    in code this PR does not change stay as they are on dev.
  • Session-team lane (session-team-ux), three tests, all passed on this head but not in one run: tests 1 and 2
    in one run, test 3 in the next. Two failures were seen and are recorded here:
    • Once, Desktop showed "Email or password is incorrect." for test2@clerum.io right after an API login with
      the same password returned 200. Classified as a test defect (E2E harness: trigger lane cannot sign in, launchAndLogin reads the URL too early and can take external keystrokes #1385): the lane window takes the foreground, and
      keys typed for another app land in the password field after fill. Reproduced with a witness (field length 9,
      then 11, status 401). For this failure the field length is measured (10 dots) and the server's answer is
      inferred, because its logs are gone. No server password code changed, and dev has the same test order.
    • Once, workflowUi.ts:589 read page.url() as empty before the window navigated, before the test reached
      anything it checks. That line is on dev.

Not covered on this head:

  • scripts/e2e/e2e-workflow-triggers.sh did not get past its login phase, so it did not reach the changed team
    helper. It signs in two users with no pause, which the 7.5 s password pace on dev rejects, and its seed gives
    the outsider user no password. Its helper is covered by test-e2e-workflow-triggers-team-helper.sh, and
    seed-e2e-data.sh makes the same two calls on the live profile.
  • The workflow-approval quadrant lane, which uses the changed recipes.ts, did not run.
  • A real upgrade start at 0129 and a rollback before 0130 are hand-measured or untested.
  • The locks 0130 holds during the LLM DDL (pre-existing) have no measurement on a production-sized copy. Measure
    them before deploying.

Results on earlier heads, kept for reference (6ca5fd67d and 9235eeb46):

Follow-up issues

Task 106 activation gates (must be fixed before the matching switch is turned on in a later PR):

Issue Gate Later PR
#1222 V2 session issuance (the access catalog it also covered was removed in f0e8a49ad); also the dormant /auth/session/renew and 426 shapes PR3
#1223 Live access-path resolver / action context (the resolver was removed from this PR in 64e27bfac; the issue applies to the PR that adds it back) PR2
#1224 Disabling the V1 legacy session lifecycle PR4

These cover code that this PR installs turned off; none of them affects the default configuration.

Introduced by this PR, outside the team-id retirement itself:

Issue What
#1225 Fixed here (R2-L7): the Desktop envelope reader is back to dev (it had no producer); to close when this PR merges.
#1251 Ten low-severity defects from the second review (error codes the edge drops or relabels, the 2 s action budget, directory cursor, Profile UI invitation dead-ends, trigger-installation check, CORS sync target, one test).
#1266 Expired V1 session revocation rows are never purged; the purge needs an owner.
#1382 Desktop sign-in still says "Check your email and password" for 426, other 4xx and local failures after a good password. For the front-end group to word.

Obsolete after 522049e07 (no revision rows exist): #1264, #1265 — proposed to close.

Pre-existing in dev, not related to Task 106 (found by the review, not caused by this PR):

Issue What
#1219 Google login restores a demoted member's role from an old accepted invitation (High).
#1220 A pending invitation can reactivate a member who was removed from the team.
#1221 The control-api runtime access contract is not run in CI.
#1226 The migration script deleted a "terminal" Job by name, which could hit a newer running Job. Fixed here (R2-L18); to close when this PR merges.
#1261 Approval decision and approved-run creation take the approval row and the run advisory lock in opposite orders (not reachable with the shipped mcp-host).
#1262 Dead legacy branch in workflow-recipes recipe-deletion cleanup.
#1263 control-ui LLM secret edit page tests can assert before the secret has loaded.
#1381 seed-e2e-data.sh: a rerun after a partial failure creates a duplicate team.

Also related: the rate-limit gaps that dev already has, kept at parity in this PR, are tracked in
#1199, #1200 and #1201. #190 (public External REST 500 detail exposure) remains separate. Known test
flakes on dev seen while validating: #1212, #1228, #1249.

Next steps

  1. Human review of the exact head.
  2. After the merge, check the EDGE_PHASE lines and the images list on the first dev deploy (A2 in keyper-labs/evenfire-infra#154).
    Measure the 0130 locks on a production-sized copy before the production deploy.
  3. A dev deploy can still stop at sync-auth-key on the race in sync-auth-key: gfsc-reader wait returns before the restarted revision is Ready #1363 (fix in fix(deploy): make the gfsc-reader wait prove the rollout finished #1374, still open); the attempt that hit it on 2026-11-06 passed when re-run.
  4. Reconcile PR2 Remove Team Token PR2: Runtime actions use validated access paths #594 with this head (migration numbering and the earlier PR1 versions, rate limits)
    before it is rebased.
  5. PR3 activation and PR4 legacy retirement remain separate phases.

🤖 Generated with Claude Code

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Comment thread control-api/src/routes/external/auth.ts Dismissed
Comment thread control-api/src/routes/external/auth.ts Dismissed
@josue-tech-94 josue-tech-94 changed the title User session v2, live authorization, and aggregate all-team access User session, live authorization, and aggregate all-team access Aug 14, 2026
@josue-tech-94
josue-tech-94 marked this pull request as ready for review August 15, 2026 01:11
@josue-tech-94
josue-tech-94 marked this pull request as draft August 15, 2026 01:11
@josue-tech-94
josue-tech-94 marked this pull request as ready for review August 15, 2026 01:13
@josue-tech-94
josue-tech-94 marked this pull request as draft August 15, 2026 01:13
@josue-tech-94
josue-tech-94 requested a review from zach88 August 17, 2026 20:21
@josue-tech-94
josue-tech-94 marked this pull request as ready for review August 18, 2026 14:23
@zach88

zach88 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Took a deep pass through this one, split across the access-authorization core, the catalog/pagination subsystem, persistence/SQL, auth/session + edge, directory/routes/rate-limit/CodeQL, a full security sweep, and a contract-consistency trace. The security envelope is genuinely well built (fail-closed and deny-by-default hold up consistently), so the interesting stuff is concentrated in the new aggregate-catalog subsystem and one migration side effect. Most of the blocking items are cheap to fix and worth doing now precisely because PRs 2-4 build directly on this foundation.

Verdict

🔴 Not ready to approve

  • Must resolve before approval: 4 (1 Blocker + 3 High)
  • Optional / non-blocking: 16 (4 Medium + 12 Low)

Heads up: this is my read from the review, not a formal GH approval.

Must resolve before approval

  1. [R1-B1] Catalog ordering is inconsistent across three layers → 500 on valid data (control-api/src/services/access/catalogContracts.ts:127). compareCatalogKey orders with JSON.stringify(key).localeCompare(...), but the producers and SQL order/resume by code-unit (catalogProducerHead.ts:116/161, catalogProducerSql.ts:32 ORDER BY + > $n resume filters). localeCompare diverges from code-unit on case and on -/_, and host/context/workflow_recipe logicalIds embed user-chosen names. Reproduced: a user with hosts ns/Zeta and ns/alpha gets a valid code-unit stream [ns/Zeta, ns/alpha] from the producer, which mergeCatalogCandidateStreams then rejects with stream_not_strictly_ordered → catalog_invariant_failed → 500. Under a non-C DB collation the producer throws keys_not_strictly_ordered first; NFC/NFD collisions surface as canonical_id_conflict. The /access/catalog endpoint is gated off in PR1 (503), so this isn't reachable in the default runtime yet, but it's a unit-level repro on the foundation everything else stacks on. Fix: one canonical byte-wise order everywhere (</> in compareCatalogKey, COLLATE "C" in the ORDER BY / resume comparators for text-logicalId families), and align teamGfsTopK (R1-L5) at the same time.

  2. [R1-H2] The property tests meant to catch R1-B1 are circular (control-api/test/accessCatalogProperties.test.ts:83,211). They build the input streams by ordering them with compareCatalogKey (the function under test) and only generate lowercase ids (id-00000), so they never feed the merge a stream that's code-unit-ordered but not localeCompare-ordered, which is exactly what the real producer emits. Result: 29 green tests that certify the wrong contract instead of catching B1. Fix alongside B1: derive the streams from the real producer order (JS < on logicalId), generate ids with mixed case and -/_, and add a regression case that fails against the current head.

  3. [R1-H1] effectiveCapabilities is the union of all candidate paths, not the selected one (control-api/src/services/access/liveAuthorizationResolver.ts:332). normalizeAccessCapabilities(paths.flatMap(p => p.behavior.capabilities)) ignores the path the caller anchored. Reproduced: with a direct grant [gfs.read, gfs.write] and a team share [gfs.read] on the same resource, anchoring the read-only share returns selectedPath.capabilities = ['gfs.read'] but effectiveCapabilities = ['gfs.read','gfs.write']. It's a latent fail-open: no enforcement consumer exists in PR1 (actionContext: unavailable), but it activates the moment PR2 wires a consumer that trusts the field. Fix: derive effectiveCapabilities from selectedPath.behavior.capabilities, or rename it (e.g. resourceCapabilitiesUnion) and document that it must not be used to authorize. Add the assertion in R1-M3 next to the fix.

  4. [R1-H3] Unconditional per-statement revision trigger serializes writes on 18 tables (control-api/src/services/access/userAccessFoundationSchema.ts:326,513). Migration 0100 creates the authorization_bump_catalog_revision FOR EACH STATEMENT trigger unconditionally on 18 tables, including hot-write ones (workflow_runs, notification_deliveries, gfs_grants, gfs_shares, team_members). Every INSERT/UPDATE/DELETE runs UPDATE authorization_catalog_revision SET revision = revision + 1 WHERE singleton = TRUE, taking the lock on that single row until commit, so concurrent writers serialize on it and it bloats. The operationalAccessIndexerEnabled flag (default off) only gates the worker, not the trigger creation, so unlike B1/H1 this is reachable in production in PR1 as soon as the migration applies. Fix: gate the trigger behind the same rollout control, or replace the single-row counter with a non-serializing mechanism (per-table sequence / advisory / a bump off the write path). Your call whether this gates the merge or ships with a tracked follow-up.

Optional (non-blocking)

The PR can merge without these; they're follow-ups or author's-call. The two cross-PR notes (L11, L12) are really for PR2 to build against.

  1. [R1-M1] 14 catch {} blocks swallow the error and return an opaque 503 (control-api/src/middleware/externalSessionAuth.ts:126,180-193,248-261 + routes/external/auth.ts). The base did catch (error) { next(error) }; the diff drops the error entirely and returns 503 authority_unavailable/401 with no req.log. A DB outage or a programming bug in the authority path leaves no trace. Also trips the pino-over-console rule. Fix: catch (err) { req.log?.error({ err }, 'session authority error'); ... }.

  2. [R1-M2] New console.warn logs raw err/reason in a request path (control-api/src/routes/external/users.ts:371,401). These go through console, not pino, so they skip token/secret redaction, and an upstream error carrying sensitive data prints in the clear. Fix: req.log.warn({ err, teamId, source }, '...').

  3. [R1-M3] No test pins effectiveCapabilities === selectedPath or direct-over-team precedence (tied to R1-H1). Add one (ideally real-Postgres, deriving grant + share from inserts).

  4. [R1-M4] Contract constants duplicated with no compile-time link (control-api/src/services/access/userAccessPolicy.ts:5 vs catalogContracts.ts:8 for CATALOG_FAMILIES; the source-dependency rule lives in three places). Identical today, but a future PR adding a 13th family/source to one copy silently desyncs the advertised-families intersection, the cursor's strict producer-count check (accessCatalogCursor.ts:92), and the serve/shadow gate, with no build error. Relevant because PRs 2-4 extend families. Fix: single source of truth + a length/set assertion.

  5. [R1-L1] control-api/src/http/errorHandler.ts:252-268: on /external, 4xx use the public envelope but the final 500 still returns the legacy { error: 'Internal Server Error' }. Inconsistent 4xx/5xx contract at the edge (mitigated: external-rest re-wraps).

  6. [R1-L2] .github/codeql/queries/test/MissingRateLimiting/.../composition.ts: fixture calls rateLimitMiddleware({ type, max }) but the real signature is { bucketType, maxPerMinute, getBucketKey, onLimited }. The query only models the import edge, so detection still works, but the fixture doesn't reflect the producer shape.

  7. [R1-L3] control-api/src/services/directory/membership.ts:1252: the expiry check became unconditional, so an accepted member_invitation past the 48h TTL now returns expired. Reasonable hardening, but no test for the accepted-but-expired case. Confirm onboarding sets the password inside the TTL.

  8. [R1-L4] control-api/src/services/access/accessCatalogShadow.ts:119,124: return skipped_unavailable without calling record(...), so those two skip paths emit no comparison metric.

  9. [R1-L5] control-api/src/services/access/teamGfsTopK.ts:79,258: same localeCompare class as B1; harmless today (gfs logicalId is a uuid), latent. Align when fixing B1.

  10. [R1-L6] control-api/src/services/access/operationTarget.ts:115: team.member.invite requires role for resend/revoke too. Hardening, not fail-open, but a possible false-negative. Confirm it's intentional.

  11. [R1-L7] control-api/src/services/access/accessCatalogCursor.ts:131 + exactOperationalAuthorization.ts:88: reuse config.sessionJwtPrivateKey as the HMAC secret for cursors/fingerprints. Not exploitable (server-side, strong key), but a dedicated CONTROL_API_ACCESS_HMAC_KEY isolates the blast radius. Probably a follow-up issue.

  12. [R1-L8] control-api/src/services/auth/userSessionService.ts:180-189: reuse-detection runs on every protected request with a 10s prior-jti overlap; an in-flight request arriving >10s after rotation triggers representation_reuse and revokes the whole session. Only exercised with issueV2 (off in PR1). Confirm the client token swap closes the window before enabling issuance.

  13. [R1-L9] real-Postgres coverage is partial: reuse-detection and "revoked token no longer authorizes" are only tested against a hand-mocked query; the critical DML does run against Postgres. A real-Postgres case for reuse + session_version_mismatch would close it.

  14. [R1-L10] control-api/src/services/auth/externalSessionAuthentication.ts:210-212: dead branch (renewUserSession never returns valid without a token). Simplifiable.

  15. [R1-L11] (for PR2) control-api/src/routes/external/auth.ts:520-598: the legacy RPC endpoint derives team scope from claims.teamId, but a v2 session forces teamId: null, so a v2 desktop hitting this path silently loses team-scoped RPC. Gated off (rpcDelegationV2). Flag so PR2 builds the v2 path rather than inheriting this one.

  16. [R1-L12] (for PR2) control-api/src/routes/external/access.ts: the catalog→resolve accessPathId replay invariant is load-bearing, but the two ids are computed by fully independent paths and no cross-path property test proves them equal per family. Worth a property test before PR2 depends on replaying catalog-issued ids at resolve.

What checked out clean

  • Security: no gating findings. Token v2 is RS256 only (none rejected, aud/iss/exp + a claim allowlist), revocation is effective (live session_version bump under FOR UPDATE, no IDOR on :sid/revoke/logout), authorization is fail-closed (only the terminal all-checks-pass branch returns allowed; /access/resolve maps only allowed → 200), cursors are HMAC-SHA256 signed + timingSafeEqual + revision-rechecked, the rate-limit subject is server-derived and fails closed if the store errors, Google login now requires email_verified (with a repro test), and the v2 feature flags are closed by default (503, not open).
  • Directory / edge: rate-limit key isn't spoofable, the invitation email validator is linear (no ReDoS) with matching 320-char bounds across control-api / external-rest / profile-ui, legacy team ops revalidate membership/role under FOR UPDATE, memberships upsert (never delete), and the invitation token is stripped from list responses (asserted in a test).
  • Persistence: no SQL injection (all $n params; the only interpolation is a closed literal alias), DDL is idempotent and non-destructive, staging→promote is transactional, and the operational index fails closed so it never acts as effective authority.
  • Contract invariants confirmed: the v2 token carries no team/role/grant (allowlist + teamId: null, no consumer reads .teamId/.role), V1 legacy re-authorizes live via getLiveTeamMembership, a stale V1 team → 403, all 12 resource families have producer + resolver backing, and revision-binding fails closed. Two potential High incoherences (source-array ordering, source-state value normalization) were chased and retired as harmless.
  • Suites: tsc --noEmit clean; ~340 directory tests, 93 auth tests, and 5 persistence files green. The catalog unit suite is green too, but see R1-H2 for why that's not meaningful for ordering yet. The *.realPostgres.integration.test.ts suites weren't run here (they need Postgres) and were read instead.

@alfredolopez80 alfredolopez80 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Security scope consolidation — F1 and F3

This PR appears to address both F1 (stale team-admin authorization) and F3 (password changes or resets not invalidating legacy sessions).

I would keep both findings in this PR's acceptance criteria and verify them with end-to-end regression coverage before treating them as resolved. The inline comments below document the required evidence for the fix.

@josue-tech-94

Copy link
Copy Markdown
Contributor Author

@zach88 We’ve finished the correction/synchronization rounds from your review and would appreciate a final pass on the current exact head:

eec63bd1f915976063f71b5332a04d17277a150d

Your original four must-resolve findings have been addressed:

  • Catalog ordering now uses one exact UTF-8 byte order across JS, PostgreSQL, producer validation/merge, resume, and Team-GFS, with independent mixed-case/punctuation/Unicode + real-Postgres continuation proof.
  • The circular catalog-order property tests were replaced with independent-oracle coverage and prior-head regression proof.
  • effectiveCapabilities is now derived from the selected access path rather than the union of candidate paths.
  • The singleton catalog revision hot row was replaced with scoped/composable revision components, including the complete GFS resource/grant/share writer mapping and forward migration coverage.

The subsequent review rounds also closed the legacy V1/admin/password-race findings, final lifecycle revalidation, structured logging/public error findings, release-coordinate issue, and the latest T2 public-boundary CI regression.

Current exact-head ordinary CI and CodeQL are green, including Real PostgreSQL and the T2 contract lane. The PR body has also been updated to current exact-head truth.

Could you please do a final review of the current head and call out anything you still consider merge-/PR2-base-blocking? The PR remains intentionally an integration base and must not merge to dev until PRs 2–4 and final stack reconciliation are complete.

The optional/non-blocking items from your earlier review remain tracked separately rather than being silently discarded.

@evenfire-ai evenfire-ai deleted a comment from claude Bot Aug 25, 2026

@alfredolopez80 alfredolopez80 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Supplemental security review — F1/F3 remain partially resolved

@josue-tech-94, thank you for the exact-head F1/F3 coverage. The additional security fan-out found the residual paths documented inline below. The existing F1/F3 test comments remain valid, but the green CI result does not cover these migration, final-authority, or derived-credential interleavings. I recommend retaining High/P0 until these paths are either fenced or explicitly covered by cross-service PostgreSQL tests.

Additional residuals not attached inline because GitHub could not resolve their source lines in the current diff: downstream GFS credentials are not bound to the password/session security epoch, and sibling pending password-reset invitations are not explicitly superseded. Please cover those two capabilities with cross-service/contract tests as well.

Comment thread control-api/src/routes/external/auth.ts Resolved
@alfredolopez80

Copy link
Copy Markdown
Contributor

@josue-tech-94 — Non-blocking follow-up observations

The items below are separate hardening and regression-coverage follow-ups. They are intentionally non-blocking for the current F1/F3 review and should not be read as additional merge blockers:

  • Compute the revokeAllUserSessions cutoff after acquiring the user lock; otherwise, a concurrent session exchange can issue a replacement after the cutoff.
  • Bind derived GFS/RPC credentials to the same password/session security epoch so parent password-reset or revoke-all invalidation propagates consistently.
  • Preserve source expiry and lineage constraints when issuing V1 replacement tokens.
  • Revalidate authority at final GFS grant, workflow, and approval mutations instead of relying only on request-admission checks.
  • Add explicit regression coverage for V2 authority-free session compatibility and the same-second valid_after boundary.
  • Re-check the parent session lifecycle before redeeming approval-medium link sessions out of band.

Recommended follow-up: track these as separate hardening items and cover them with focused PostgreSQL interleaving or contract tests.

@josue-tech-94

Copy link
Copy Markdown
Contributor Author

@claude Please do one final independent review of the FULL current PR before we continue with the stacked PR2 work:

#334

Review only — do not make code changes.

Current expected state:

  • base: b2b7c9a416acb79b9142232b2c060563b693bfea
  • head: c7735cd5b6e43ee8348877e08b4d7463c9870c44
  • PR1 of a four-PR migration
  • this PR is an integration base, NOT yet eligible to merge into dev

If the live head/base have moved, review the actual current state and report that rather than relying on the SHAs above.

Please review the complete PR diff for:

  • correctness and regressions;
  • security/authentication/authorization boundaries;
  • cross-service and persistence contracts;
  • concurrency/transaction boundaries;
  • test quality and whether the tests prove observable behavior;
  • PR-description accuracy;
  • any remaining issue that should block using this exact PR1 head as the base for PR2.

Please also independently verify the dispositions of the four currently
unresolved Alfredo threads rather than assuming our replies are correct:

  1. control-api/src/services/access/userAccessFoundationSchema.ts
    — "Backfill the legacy password-revocation epoch"
    Expected disposition: fixed by migration 0106 plus Real PostgreSQL proof.

  2. control-api/src/routes/external/auth.ts
    — "Atomically fence derived RPC issuance"
    Expected disposition: intentionally a PR2 acceptance criterion under the current stacked design, not a PR1 must-fix.

  3. control-api/src/services/directory/teams.ts
    — "Revalidate operator lifecycle in the final mutation transaction"
    Expected disposition: fixed for team rename, member-role mutation, and member removal with final source-session/principal/lifecycle revalidation and Real PostgreSQL race coverage.

  4. control-api/src/services/directory/membership.ts
    — "Supersede older password-reset capabilities"
    Expected disposition: pre-existing at the merge base and neither introduced nor widened by PR Remove Team Token PR1: User session, live authorization, and aggregate all-team access #334, therefore non-blocking for this PR.

Please challenge any of those classifications if the current code/base evidence does not support them.

For any changed paths under control-ui/**, profile-ui/**, or desktop-app/**, first discover and read the applicable live repository guidance from the exact PR checkout before reviewing those implementation details. Do not use remembered conventions. Keep renderer-only Desktop guidance out of desktop-app/** paths outside ui/** unless the live repository scope says otherwise.

Please distinguish clearly:

  • PR-introduced/widened must-fix findings;
  • pre-existing issues;
  • PR2/later-stack acceptance criteria;
  • optional follow-ups.

Do not turn later-stack work into a PR1 blocker unless PR1 itself violates the contract it claims to establish.

At the end, give a clear answer to both:

  1. Are there any PR1 must-fix findings remaining?
  2. Is the current exact PR1 head safe to use as the PR2 stack base?

Also reiterate that "safe as PR2 base" does NOT mean PR #334 is ready to merge into dev; PRs 2–4 and final stack reconciliation are still required.

@claude

claude Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Claude finished @josue-tech-94's task in 4m 31s —— View job


Final independent review — PR #334 (PR1 of 4)

I reviewed the actual current state, not just the quoted SHAs. Live head/base match the expected values: head c7735cd5b6e43ee8348877e08b4d7463c9870c44, base b2b7c9a416acb79b9142232b2c060563b693bfea (= origin/dev). This is a genuine independent pass (byte-order equivalence checked at the SQL level, tests read for observable behavior, dispositions traced to code rather than trusting the replies).


1. The four original blockers — all independently CONFIRMED closed

Finding Status Key evidence
R1-B1 catalog ordering ✅ CLOSED compareCatalogKey is now Buffer.compare UTF-8 byte order (catalogContracts.ts:126-136); PostgreSQL side orders/resumes on catalog_utf8_bytes() = convert_to(value,'UTF8')::bytea (userAccessFoundationSchema.ts:271-279, catalogProducerArms.ts:11-17), which is unsigned byte-for-byte equivalent to COLLATE "C" and is index-backed. Team-GFS (former L5) now uses compareCatalogText, no localeCompare left on the pagination boundary.
R1-H2 circular property test ✅ CLOSED accessCatalogProperties.test.ts now uses an independent TextEncoder-based oracle (:50-66), draws mixed-case + -/_/.// + combining/emoji Unicode (:68-76), and asserts sign(compareCatalogKey) === sign(oracle) over 1000 runs (:207-221). Cross-layer JS↔real-PG order is covered in catalogProducers.realPostgres.integration.test.ts. Minor: no single pinned deterministic regression example (coverage is randomized only) — cosmetic, non-blocking.
R1-H1 effectiveCapabilities ✅ CLOSED Now Object.freeze([...selectedPath.behavior.capabilities]) (liveAuthorizationResolver.ts:328); auto-selection only returns a path when all candidates are capability-identical (accessPath.ts:104-138), so no blending. Pinned by a real test that selects the read-only team path and asserts gfs.write does not leak (accessLiveAuthorization.test.ts:257-307).
R1-H3 revision hot-row ✅ CLOSED Forward migrations 0104/0105 drop the singleton authorization_catalog_revision table + all 18 FOR EACH STATEMENT triggers (userAccessFoundationSchema.ts:629-686) and replace them with per-user/per-team/per-resource scoped revision rows, incl. the full GFS resource/grant/share writer mapping + backfill (:562-627, :710-718). Hot tables no longer serialize on one row. Idempotency + drop asserted in tests.

2. The four Alfredo threads — independently checked

① userAccessFoundationSchema.ts — legacy password-epoch backfill → ✅ disposition holds (CLOSED).
Migration 0106_legacy_password_security_epoch_backfill (:985-1007, registered db.ts:5967) idempotently seeds external_user_session_security_epochs from users.password_set_at with ON CONFLICT DO UPDATE SET valid_after = GREATEST(...) (monotonic-forward). The real-PG test reproduces the pre-fix fail-open, re-runs the migration, and proves the before-cutoff token flips to revoked/security_event while a post-cutoff token stays valid (legacyPasswordEpochBackfill.realPostgres.integration.test.ts:73-188).
Residual (non-blocking, out of the stated scope): validateLegacyUserSession (userSessionService.ts:582) is still structurally fail-open when valid_after IS NULL, so a legacy user with password_set_at IS NULL (SSO/passwordless) gets no fence. The backfill closes the "password changed before migration" hole Alfredo raised; the null-password case is a separate follow-up, not a PR1 regression.

② auth.ts — atomically fence derived RPC issuance → ✅ correctly a PR2 criterion, not a PR1 must-fix.
The /external/rpc/token handler (auth.ts:529-621) already re-derives team scope from getLiveTeamMembership live, not from stale token claims (an improvement over base), and fails closed 503 if authority is unavailable. It does perform separate reads (user agents / team agents / sandbox grant) before issueRpcAccessToken, so the read-then-sign race Alfredo describes is real — but rpcDelegationV2 is off, and the PR body explicitly scopes "bind derived credentials to the live selected access path (RPC/delegation)" to PR2. PR1 does not claim to establish that final-issuance transaction, and does not regress base behavior. Disposition is defensible. PR2 must retain the final-issuance revalidation + deterministic interleaving proof as acceptance criteria (as the author already committed).

③ teams.ts — revalidate operator lifecycle in the final mutation transaction → ✅ disposition holds (CLOSED) for all three classes.
A single primitive validateExternalSessionAuthorityContext (userSessionService.ts:425-528) now runs inside each mutation transaction and does SELECT ... FROM users WHERE id=$1 FOR UPDATE, rejecting on lifecycle_state != 'active' or lifecycle_version != authGeneration, plus source-session/epoch/revocation revalidation — i.e. it locks the user row, not just team_members. Wired for rename (teams.ts:113-118), role change (membership.ts:2042-2047), removal (membership.ts:2119-2123). Real-PG interleaving test parametrizes all three classes and proves "retirement wins after admission → 403, no write" (routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts:330-380).
Minor notes (non-blocking): the v2 branch checks lifecycle_state but not lifecycle_version (mitigated by FOR UPDATE + session-row revalidation); revalidation is guarded by if (authority) which is always populated on the external routes in scope.

④ membership.ts — supersede older password-reset capabilities → ✅ pre-existing at base, non-blocking for PR1.
Confirmed against origin/dev: the base already consumes only the single selected reset row (WHERE id=$1 AND status='pending') with no one-active-reset constraint and no sibling supersession (base membership.ts:1116,1200). Current head keeps the same single-row consume (:1387-1398). PR #334 reorganized the surrounding password flow but did not introduce or widen the multi-reset-sibling behavior, so it is correctly out of a diff-scoped PR1's must-fix set. I'd still recommend tracking it as follow-up, since the user-centric session model raises the stakes on legacy reset capabilities — but that's a judgment call, not a contract violation.


3. Other observations (all non-blocking)

  • Logging (former R1-M1) still open, minor: three bare catch {} in externalSessionAuth.ts:122,176,244 swallow the error and emit no req.log, so a DB outage/authority bug leaves no trace. Behavior is fail-closed (503/401), so this is an observability nit under the pino standard, not a security issue. R1-M2 (console.warn in users.ts) is fixed (now routes through req.log/rootLogger). Fix this →
  • Discovery-only capability union: accessCatalogCoordinator.ts:200 still unions candidate capabilities, but that field is catalog/discovery metadata and never feeds the allowed authorization decision (which flows only through resolveLiveAuthorization with an explicit path). Not a reopening of H1; worth a doc comment so PR2 doesn't mistake it for authority.
  • PR-description accuracy: verified — v2 capabilities are closed by default (503, not open) and operationalAccessIndexerEnabled defaults false (config.ts), consistent with the "additive / disabled-by-default" claims.

Answers to your two questions

1. Are there any PR1 must-fix findings remaining?
No. All four original blockers (B1/H1/H2/H3) are genuinely closed at this head, and none of the four Alfredo threads produces a PR1-introduced/widened contract violation. Threads ①/③ are fixed with real-PG proof; ② is legitimately PR2 scope (PR1 doesn't regress and doesn't claim that contract); ④ is pre-existing at the merge base. The remaining items (bare-catch logging, null-password_set_at fence, the discovery-only union, and the sibling-reset supersession) are optional follow-ups / later-stack work, not foundation-contract breaks.

2. Is the current exact PR1 head safe to use as the PR2 stack base?
Yes. c7735cd5… is a sound integration base: the authorization is fail-closed, the v2 surface is disabled by default, the live-authority and aggregate-discovery contracts PR2 depends on are coherent and test-backed, and the concurrency/persistence hot spots are resolved. PR2 should carry forward two explicit acceptance criteria surfaced here: (a) the final derived-RPC/GFS issuance fencing + interleaving proof (thread ②), and (b) a decision on legacy reset-sibling supersession (thread ④).

⚠️ Reminder: "safe as PR2 base" does not mean PR #334 is ready to merge into dev. Per the PR's own merge constraint, PRs 2, 3, and 4 must be integrated into this branch and final stack reconciliation must pass before this merges to dev.


Review only — no code changes were made. This is my analysis, not a formal GitHub approval.

@alfredolopez80

Copy link
Copy Markdown
Contributor

Independent review — PR #334 @ c7735cd5 (Opus 5, 8-agent parallel fan-out)

Reviewed in a dedicated read-only worktree pinned to the exact head. Baseline established before anything else: git merge-base b2b7c9a4 c7735cd5 = b2b7c9a4 and git ls-remote origin dev = b2b7c9a4, so the declared base is current origin/dev — no drift. No commits landed after the previous @claude review, so this is a directly comparable second pass on the same state.

One correction to the framing up front: the request asked to verify "the four currently unresolved Alfredo threads". On GitHub today only one is unresolved — membership.ts:1410 — and it is the one correctly classified as pre-existing. The other three are marked resolved. Resolution status is not evidence of a fix, so I verified all four against the code regardless.

Answers to the two questions, up front:

  1. Are there PR1 must-fix findings remaining? — Yes, two. Both introduced by this PR, both on the external public surface rather than the foundation contract.
  2. Is this head safe as the PR2 stack base? — Yes. The contracts PR2 depends on are coherent and test-backed. "Safe as PR2 base" still does not mean ready to merge into dev; PRs 2–4 and stack reconciliation remain required.

PR1-introduced must-fix

1. [High] The public error sanitizer collapses five propagated statuses to internal_error — and both guard tests were rewritten to bless it

external-rest-api/src/http/publicApiError.ts:5-19 · tests external-rest-api/test/routes.gfs.test.ts:599-604, 632-647

PROPAGATED in routes/gfs.ts:37 includes 408, 411, 413, 425, 507. PUBLIC_CODE_BY_STATUS in the new sanitizer has no entry for any of them, so fallbackCode maps all five to internal_error, and retryable = [429,502,503,504] strips retry-after.

Reproduced by executing the shipped sanitizer directly:

413 payload_too_large    -> internal_error, retry-after undefined
408 request_timeout      -> internal_error, retry-after undefined
425 too_early            -> internal_error, retry-after undefined
507 insufficient_storage -> internal_error, retry-after undefined
411 length_required      -> internal_error, retry-after undefined

Concretely: Desktop uploads over the product limit, control-api returns 413 {code:'payload_too_large', message:'upload exceeds the 200 MiB product limit'}, and the public relay returns 413 {code:'internal_error', message:'The request could not be completed.'} with upload-length and retry-after stripped. The client can neither explain the failure nor learn the limit. For 408/425 the lost retry-after removes the backoff signal; 507 becomes indistinguishable from a bug.

The test half is the part that needs attention. Both guard tests kept their names and comments and had only their assertions changed:

  • it('preserves a public 413 payload_too_large response and its safe transport headers') now asserts expect(res.body.error.code).toBe('internal_error') and expect(res.headers['upload-length']).toBeUndefined(). The name promises preservation; the assertion certifies deletion.
  • The sibling test still carries its own in-file comment — "507 is terminal for Desktop/Control UI, while 408/425 and the transient 5xx family are retryable. The public relay must not turn any of them into a generic 500 before the client can apply that policy." — while the assertion three lines below expects internal_error for exactly 408/425/500/507. The comment and the assertion contradict each other inside the same block.

Verified against git show b2b7c9a4 — the pre-PR assertions matched the test names and the stated requirement. Under verify-test-expectations, the original expectation was the correct one.

Fix: add 408:'request_timeout', 411:'length_required', 413:'payload_too_large', 425:'too_early', 507:'insufficient_storage' to PUBLIC_CODE_BY_STATUS + ALLOWED_CODES_BY_STATUS; add 408/425 to retryable; allow upload-length through for 413 — then restore both tests to their pre-PR assertions.

2. [Medium→High] An accepted invitation past the 48h TTL is now permanently unrecoverable

control-api/src/services/directory/membership.ts:1256

Verified base vs head:

  • BASE: the expiry check lived inside the password_reset branch only.
  • HEAD: the expiry check moved before the purpose branch, so it now also applies to member_invitation / admin_desktop_access in state accepted.

Failure flow: invitation created at T (expires_at = T+48h). User accepts at T+47h55m → status='accepted', session token issued. User picks a password 10 minutes later via POST /external/invitations/password → expired → HTTP 410. The invitation is no longer pending, so neither resendManagedInvitationForUser (requires status='pending' AND expires_at > NOW()) nor revokeManagedInvitationForUser can touch it. The user is left with password_hash IS NULL and no way to set one — recovery requires createPasswordSetupInvitationForUser, exposed only on the admin plane (routes/admin/users.ts:172).

This was seen in the earlier review as R1-L3 "reasonable hardening, no test for the accepted-but-expired case". Tracing the onboarding flow shows it is a permanent lockout with no self-service recovery.

Fix: keep the check for password_reset and for member_invitation while pending; skip it when status === 'accepted'. If the hardening is intentional, provide auto-recovery (allow resend on accepted invitations with a pending password).


Pre-existing High that this PR now builds on

3. The rate limiter fails open on a Postgres error — including the new password-login brute-force policy

control-api/src/services/rateLimiterService.ts:89-120 (blame 5d663a49, byte-identical at base)

checkAndIncrementWithQuery returns {allowed: true} on any DB error and when the query returns no row. PR1 declares "explicit bounded rate-limit policies with trusted server-derived subjects" on sensitive auth operations and wires nine new consumers onto this primitive. During a Postgres blip the nominal 5/min on password-login is void and brute-force against bcrypt proceeds unthrottled — precisely when the DB is already degraded.

Notably the data needed to fix it already exists: checkAndIncrementWithQuery returns backendAvailable: false, and no caller reads it — rateLimitMiddleware checks only result.allowed. A failClosed option on the middleware for authentication_attempt / session_lifecycle / rpc_token would close it without new plumbing.

4. V1 replacement tokens get a fresh 12h TTL with no lineage, and V1 logout does not kill replacements already issued

externalSessionIssuance.ts:87-94, 148-159 · externalSessionAuthToken.ts:15 · routes/external/auth.ts:450

exchangeLegacyExternalUserSession signs the replacement without passing ttlSeconds, taking the 12h default and a fresh iat, propagating neither the source exp nor any reference to it. /external/auth/session/logout revokes V1 by token hash, not by lineage or epoch — so a replacement minted from token A survives A's logout and can be re-exchanged indefinitely. Reachable with default flags (legacyLifecycle='issue_and_accept', legacySwitchEndpoint=true). V2 has an absolute lifetime cap; V1 has none.

This confirms Alfredo's non-blocking follow-up #3 with a concrete one-line fix: ttlSeconds = min(12h, claims.exp - now), which closes both halves at once.


Corrections to the previous review round

These matter because three of Alfredo's threads were resolved partly on the strength of the last pass.

Prior claim Verdict
Thread ② is mitigated because "rpcDelegationV2 is off" Incorrect. That flag appears only in userAccessPolicy.ts; the legacy /external/rpc/token handler (auth.ts:530) never consults it. The read-then-sign window is live in PR1 today, not dormant. The deferral to PR2 is still coherent — PR1 claims live authorization, not atomic issuance binding — but the comfort offered alongside it does not hold.
valid_after IS NULL is a structural fail-open for SSO/passwordless users Literally true, operationally inert. revokeAllUserSessions writes the epoch row via INSERT … ON CONFLICT DO UPDATE GREATEST(…) in the same transaction that sets password_set_at = NOW(), across all five password writers. NULL therefore means "this user has never had a security event", not "the fence was skipped". Three independent confirmations. I would not carry this forward as an open item.
"No pinned deterministic ordering regression — randomized coverage only" Incorrect. catalogProducers.realPostgres.integration.test.ts:98-100 pins an explicit value set (alpha, Zeta, a_b, a-b, é, é, Ω, 😀) with fixed seed 106_026.
The v2 branch omits lifecycle_version True but unreachable. lifecycle_version is incremented in exactly one statement repo-wide (users.ts:575), always together with lifecycle_state='retired'; no reactivation path exists, so the state check subsumes the version check. Worth pinning as an invariant comment — the day someone adds reactivation with a version bump, the v2 branch opens silently.

Medium — worth tracking, not merge-blocking

  1. 502/504 diverge between the two sanitizers in this same PR. app.ts:142-154 special-cases only 503, so 502/504 fall through to 500 internal_error retryable:false, while gfs.ts:149-156 returns 502 upstream_unavailable retryable:true for the identical error. A client honouring retryable will not retry a transient outage on the access/members/auth surface.
  2. Verify production CodeQL source scope asserts 3 of 14 services. git ls-files '*/src/main.ts' returns 14; the step checks control-api, external-rest-api, desktop-app. The workflow's own header names rpc-proxy's JWT chain, webhook-gateway's HMAC verification and HCC's NetworkPolicy generation as the reason the gate exists — none of the three is asserted. Deriving the list from git ls-files would close it.
  3. The PR body's CodeQL claim is incomplete. It says the modeling recognises the rate-limit middleware "while preserving the stock security-and-quality suite". In fact the suite sets disable-default-queries: true and excludes two stock queries — js/missing-rate-limiting and js/user-controlled-bypass — replacing both with custom queries that reuse the stock @id. Consequence worth knowing: the Security tab cannot distinguish stock-produced from custom-produced alerts, and the 18 dismissals dated 2026-08-12 now attach to alerts evaluated by different logic. The queries themselves look sound; this is a disclosure gap, and PR-description accuracy was explicitly in scope.
  4. Deploy window. 35 CREATE INDEX with zero CONCURRENTLY (24 of them on hot pre-existing tables: workflow_runs, notification_deliveries, team_members, gfs_grants, gfs_shares, the workflow-trigger tables), plus 36 trigger operations at ACCESS EXCLUSIVE and a full-table backfill — all inside initDb's single BEGIN…COMMIT, so locks accumulate until one final commit. The transactional runner is pre-existing (verified at base), but the volume inside it is introduced here, and CONCURRENTLY is illegal in a transaction block, so this cannot be fixed without a nonTransactional migration path. At minimum, measure index build time against a production dump and document a maintenance window.
  5. R1-H3's hot row narrows rather than disappears. authorization_team_revisions(team_id) is touched by every workflow phase transition, every notification delivery and every GFS grant/share write for that team, via INSERT … ON CONFLICT DO UPDATE SET revision = revision + 1 holding a row lock to commit. On a mono-tenant or large-shared-team install this is functionally the original problem at a different granularity. Also :469-470 bumps team_id and usage_team_id without dedup — two updates to the same row in one statement when they are equal.
  6. Latent deadlock. authorization_bump_gfs_resource_subjects (userAccessFoundationSchema.ts:587-607) iterates a UNION with no ORDER BY while taking row locks on the revision tables. Two concurrent GFS resource updates with overlapping subjects in opposite plan order can deadlock. One-line fix: ORDER BY 1, 2.
  7. Silent authority-fingerprint freeze. environment_id is pinned once at migration time but recomputed live at runtime, and canonicalEnvironmentId() falls back silently to development / local-cluster when the tracing env vars are absent. If they later diverge, the LEFT JOIN never matches and COALESCE(revision, 1) returns a constant — the fingerprint that should detect an authority change freezes, with no metric and no error. Comparing the pinned value against the runtime value at assertDbReady and failing loudly would close it.
  8. Fabricated payload crossing a service boundary. external-rest-api/src/routes/members.ts:165-174 replaces an over-long name with 'x'.repeat(121) (and over-long teams with 51 nulls) and forwards that to control-api to provoke invalid_name / too_many_teams. It works only because both limits are 120/50 today; raise control-api's limit and an invitation gets persisted with a destroyed name and a 201 where a 400 was intended. Blame: 00469ac5e, 89361daa1 — both commits in this PR.
  9. x-request-id echo lost on the grants path. The 429 test was relaxed from toBe(REQUEST_ID) to toMatch(UUID_RE) because correlationId falls back to randomUUID(); the /me/gfs/shares test ~80 lines later still asserts the echo. Two adjacent endpoints, two contracts, both pinned green — and UUID_RE can no longer detect the loss.
  10. Final-transaction revalidation covers 6 of 10 privileged mutations. Missing: the four invitation mutations and — most notably — DELETE /external/members/:userId (full user retirement). Those four functions do not accept the authority parameter at all. Mitigating: they do lock and revalidate operator lifecycle via getManagerRolesForTeams(..., lock=true); what they skip is session state (revocations, security epoch, revoked_at). Mechanical to wire.
  11. The CodeQL model certifies as rate-limited six routes whose limiter fails open. rateLimitMiddleware allows the request uncounted when getBucketKey returns null; the query constrains only the callee, never the key. Sites: decisions.routes.ts:64,103, users.ts:129,376, notifications.routes.ts:114,364. Latent — all six currently sit behind auth middleware. Worth noting that PR1's own new helper does this correctly, returning a ':missing' sentinel that is deliberately counted; the six legacy sites keep the null pattern while CodeQL is taught to trust all of them identically.
  12. deploy/base/control-plane/configmaps.yaml:21 sets CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'true' while config.ts:1012 defaults it false. The key did not exist at base. This is not a prose violation — the indexer is deliberately absent from the disabled-by-default list and the configmap comment says so — but it means the watch-driven reconcile loop starts in every deployed environment on merge, with new cluster-wide watch RBAC. Worth stating explicitly, since a reader checking config.ts alone would conclude the opposite.

Low

  • Three bare catch {} in externalSessionAuth.ts:122,176,244 (R1-M1, still open). Behaviour is fail-closed, but the 503 carries retryable: true, so a deterministic programming bug becomes a client retry loop with zero telemetry — the file emits no req.log at all.
  • R1-M4 still open: CATALOG_FAMILIES duplicated verbatim in userAccessPolicy.ts:5 and catalogContracts.ts:8, each with its own type. A 13th family added to one desyncs silently.
  • R1-L7 still open: config.sessionJwtPrivateKey reused as the HMAC secret in four places.
  • R1-L4 partially open: two of three skip paths in accessCatalogShadow.ts:119,124 still emit no comparison metric.
  • R1-L1 confirmed but low impact: errorHandler.ts:252-268 still returns the legacy envelope on the terminal 500 for /api/v1/external. Verified no public client observes it — grep -rn 'api/v1/external' across desktop-app, mcp-host, profile-ui and rpc-proxy returns nothing, and external-rest-api re-wraps it.
  • isCurrentExternalSession / assertCurrentExternalSession (externalSessionAuth.ts:36-57) are now caller-less after PR1 replaced their use, and they check less than the canonical boundary (no epoch, no revocation). A future route adopting them by name would accept logged-out tokens. Safe to delete.
  • A vacuous assertion in the F1 suite: expect(staleAdminToken).not.toBe('same-valid-v1-token-minted-while-admin') compares a signed JWT against prose — it cannot fail.
  • codeql/javascript-queries pinned at 2.4.3 with no dependabot/renovate entry, which defeats the weekly cron's stated purpose of letting newly published stock queries reach the default branch.
  • The POST /external/teams/:teamId/invitations route does not use normalizeInvitationEmail, unlike its sibling — pre-existing, but inconsistent with the bounded validator this PR introduces.

What I verified as genuinely sound

Stated with evidence, not assumed:

  • All four original blockers are closed, and the fixes are load-bearing. compareCatalogKey compares fields sequentially rather than a serialization, so it is structurally equivalent to a multi-column SQL ORDER BY — the field-boundary counterexample cannot exist, and 200,000 sampled pairs produced zero divergences against an independent oracle. The R1-H2 property test was mutation-tested: reverting compareCatalogText to the original localeCompare diverges on 1.59% of generated pairs, giving a ~1e-7 chance the 1000-run test misses it. That test is real coverage, not decoration. H1 and H3 likewise verified, including that no equivalent global hot row was reintroduced (the residual per-team row is item 5 above, a different granularity).
  • No localeCompare survives on the pagination boundary. I checked the residual bare .sort() calls in accessCatalogMerge.ts:124,203 and catalogProducerHead.ts:168 specifically — they sort validUntil (ISO-8601, ASCII), not logicalId, which uses compareCatalogText. Not a finding.
  • The CodeQL recognition predicate is handler-level, not file-level. It matches a resolved import path (unspoofable by a local shadow), and the safety property is a universal quantifier: not exists(RouteSetup | covered and not hasLimiter). Adding one rate-limited route does not silence its unprotected siblings — and the incompleteParentRouter fixture pins exactly that scenario as still flagged. paths-ignore is a single glob matching 12 files, all fixtures, zero production paths. Detection survives the model change in both directions, and codeql test run is now a CI step, so drift either way fails the build.
  • 27 alerts → 0 open. Only two results are still produced at this head, both defensible: the js/insufficient-password-hash alert at operationalAccessProjection.ts:143 is a server-keyed HMAC change-detection fingerprint (createHmac('sha256', key)), not password storage; the other is on a test file. The ReDoS and unused-variable alerts are resolved by code — neither query is touched by the custom suite, so that attribution is sound by construction.
  • Tests execute and prove what is claimed. tsc --noEmit clean on both services (exit 0). control-api: 4856 passed, 182 skipped (DSN-gated), real exit 0 — captured without a pipe. All four headline realPostgres suites are collected by the control-api-migration lane (CI job 98594577414: 34 files / 181 tests / 0 skipped) and use real routers, real auth middleware and real Postgres, with deterministic pg_blocking_pids() transaction barriers rather than sleeps. A specific production line was identified whose revert would turn each one RED.
  • Zero-tests-is-never-success is properly guarded — the lane asserts Test Files [1-9]…passed and Tests [1-9]…passed and fails on any skipped count, which is stricter than most.
  • Thread ④ is pre-existing and not widened — verified four ways: the function body is character-identical at base, the same two call sites, the same 48h TTL, the same requester guard. The one new AND purpose <> 'password_reset' is a narrowing. Leaving it open is the right call.
  • Alfredo's follow-up fix+docs: onboarding dry-run blockers, README rewrite, and Diátaxis docs tree #1 is already fixed — revokeAllUserSessions takes SELECT id FROM users … FOR UPDATE before computing the cutoff, and createUserSession takes the same lock, so a concurrent exchange serializes.
  • F1 is structurally satisfied — requireExternalRole reads req.externalTeamAuth.role, populated only by getLiveTeamMembership; at base it read the token claim. grep confirms exactly three requireExternalRole(['admin']) routes exist and all three are covered by the regression suite. (The suite is a hand-maintained list rather than derived from router.stack, so it has no drift resistance — a follow-up, not a gap today.)
  • No SQL injection, correct transaction boundaries throughout (no pool.query mixed into a client transaction), statement accounting not bypassable on the catalog path, and the operational index cannot become effective authority — requireCondition(readiness.snapshot === 'current') throws rather than degrading permissively.
  • The v2 no-authority claim holds — 14 forbidden claims rejected at the external boundary, and every consumer of .teamId/.role re-verifies against live membership.
  • The public boundary gate cannot produce a false green — test-minikube-t2-public-boundary.sh runs under set -euo pipefail with no || true, no masking pipe, no silent skip, and is mutation-verified by test-minikube-t2-contract.sh, which asserts it fails on an unresolved base, on a secret in an untracked file, and on materialized credentials.
  • No dependency changes — the only manifest/lock edits are version bumps; nothing added, no new postinstall.

Method and limits

Eight agents in parallel over a dedicated worktree at the exact head: access core, auth/session, directory/teams, persistence/migrations, cross-service contract, CodeQL/CI, test quality, and an adversarial disposition auditor whose only job was to try to break the consensus. Findings that two agents reached independently by different routes are marked as such above (the sanitizer, reached once via contract reading and once via execution).

Honest limits: CodeQL was not executed locally, so the "fails on a fresh violation" half of the guard standard is established structurally from the checked-in .expected fixtures rather than by a run. No Postgres 16 was available, so the 34 realPostgres suites were judged from source plus the real CI job log — no mutation testing was performed on them, and the named revert lines are traced, not executed. Lock levels and the UNION deadlock are derived from the SQL plus documented Postgres semantics, not reproduced. Five of the seven "disabled by default" flags were not independently re-read by anyone in this round.

Review only — no code changes, and this is analysis rather than a formal GitHub approval.

@alfredolopez80

Copy link
Copy Markdown
Contributor

Addendum to the review above — one Critical finding, reproduced

The access-core pass on services/access/** finished after I posted, and it produced a finding I have now verified line by line myself. It is the most serious thing in this review and it inverts one of my "verified clean" statements, so it needs its own comment rather than a quiet edit.

[Critical] The gfs_resource producer throws unconditionally on the production route for any user with a team membership

control-api/src/services/access/teamGfsTopK.ts:229-234, reached from routes/external/access.ts:43

const memberships = input.context.principal.memberships
if (memberships.length === 0) return { candidates: Object.freeze([]), hasMore: false }
const admission = input.context.budget.teamGfsMembershipAdmissionLimit
if (admission === null || memberships.length > admission) {
  throw new AccessBudgetExceededError('teamGfsMembershipAdmission', true)
}

teamCandidates treats a null admission limit as "budget exhausted". But null is the default, not an exhausted state:

// accessExecutionBudget.ts:248-251
const teamGfsMembershipAdmissionLimit =
  options.teamGfsMembershipAdmissionLimit === undefined
    ? null
    : options.teamGfsMembershipAdmissionLimit

I checked every production caller of AccessExecutionBudget.create — there are eight, and not one passes the option:

routes/external/access.ts:43                  create(kind)
services/access/accessCatalogCoordinator.ts:369  create('catalog', {…})
services/access/liveAuthorizationResolver.ts:346 create('action')
services/access/operationalAccessIndexer.ts:145  create('catalog', { parentSignal })
services/access/operationalAccessIndexer.ts:220  create('action', { parentSignal })
services/access/operationalAccessIndexer.ts:290  create('action', { parentSignal: signal })
services/access/userAccessRuntimePolicy.ts:161   create('action')
services/access/accessCatalogShadow.ts:127       create('catalog')

So on the real route the limit is always null, and the throw is unconditional for any principal with at least one active membership. A user with zero memberships returns early at :230 and is fine — meaning the aggregate-team case, which is the entire purpose of this PR, is the only one that fails.

Reproduced. The agent compiled the PR's real modules with tsc --noCheck, stubbed only the DB boundary module, and constructed the budget exactly as attachAccessExecutionBudget does:

budget.teamGfsMembershipAdmissionLimit = null
THROWN: AccessBudgetExceededError: Access execution budget exhausted:
        teamGfsMembershipAdmission (limit=teamGfsMembershipAdmission, authorityRequired=true)

accessCatalogCoordinator.ts:393-405 does not map this error class, so it propagates to sendCatalogError → 503 authority_unavailable on every request to GET /external/access/catalog once CONTROL_API_USER_ACCESS_CATALOG_MODE=serve.

The second blast radius is the worse one, because it is silent. The same null propagates through AccessExecutionBudget.child() (accessExecutionBudget.ts:396) into the shadow lane, where compareAccessCatalogShadow catches AccessBudgetExceededError and records skipped_capacity (accessCatalogShadow.ts:160-165). gfs_resource shadow is live-wired at routes/external/gfs.ts:867. So under catalogMode=shadow, every GFS list by a team member records skipped_capacity and compares nothing — the rollout-validation mechanism is a no-op for exactly the population it exists to de-risk, and it self-reports as capacity pressure rather than as an error. Compounding it: two of the three shadow skip paths (accessCatalogShadow.ts:118,122) return without calling record(...), so "shadow disabled", "shadow failed" and "no traffic" are indistinguishable in the aggregate metric.

Why no test caught it: every test that reaches this code injects the limit — accessCatalogPerformance.realPostgres.integration.test.ts:878,910,930; catalogDistinctPagination.realPostgres.integration.test.ts:191,827; accessCatalogFamilies.realPostgres.integration.test.ts:376. No test constructs the budget the way the route does. This is the mirror image of the R1-H2 problem zach88 caught: the tests certify a configuration that production never produces.

Mitigating: catalogMode defaults to 'off' (userAccessPolicy.ts:311-316), so this is dormant today. But it breaks on day one of enablement, and it breaks the shadow lane that is supposed to precede enablement.

Fix: give teamGfsMembershipAdmission a non-null default in ACCESS_EXECUTION_LIMIT_CLAMPS (e.g. 64), or have attachAccessExecutionBudget pass one. Add a route-level regression that builds the budget via AccessExecutionBudget.create('catalog') — no injected limit — and asserts 200 for a principal with one membership.


[High] Per-arm LIMIT window exhaustion is reported as source exhaustion — a truncated page returned as complete

catalogProducerArms.ts:60-64 with catalogProducerHead.ts:128-135, 139-144, 189-191

For any arm without duplicateCapable: true, the generated SQL emits the literal FALSE for source_saturated, so state.exhausted = !saturated becomes true after the first query regardless of whether the arm's LIMIT $4 window was full. If that window's rows collapse to fewer than take+1 distinct logical_ids under the adjacent-duplicate merge, the producer stops and the coordinator emits nextCursor: null with complete: true.

This is the direct inverse of PR1's stated contract — "exact bounded catalog pagination … fail-closed work exhaustion". Here, hitting the work bound and reaching the end of the source are indistinguishable in the return value, and correctness rests entirely on a hand-maintained per-arm boolean asserting a SQL cardinality invariant that nothing in the types, the SQL or the tests verifies.

RED/GREEN repro against the compiled unmodified producer (60 resources, limit 50, 2 rows per canonical id):

RUN A — as shipped (source_saturated = literal FALSE)
  page 1: candidates=26 hasMore=false exhausted=true
  distinct in DB: 60 | served: 26 | LOST (declared complete): 34

RUN B — control, identical data, duplicateCapable:true semantics
  page 1: candidates=51 hasMore=true  exhausted=false
  page 2: candidates=9  hasMore=false exhausted=true
  distinct in DB: 60 | served: 60 | LOST: 0

Honest reachability note: all 24 arms in catalogProducerSql.ts were audited against the real primary keys and no live duplicate on an unflagged arm was found — user_workflow_triggers(user_id,ns,name), team_workflow_triggers(team_id,ns,name), team_members(team_id,user_id), user_agents(user_id,agent_name), user_contexts(user_id,context_id) and operational_resource_index(environment_id,resource_type,logical_id) make every unflagged arm 1:1 today. The closest call is the two sandbox_app arms: operational_resource_relationships permits N rows per target_id and the arm has no DISTINCT, but the only writer (operationalAccessProjection.ts:400-435) sets appId = rootId, making it 1:1 in practice. So this is a latent fail-open, not live data loss. It is still worth fixing now because the failure mode is silent, unbounded, and guarded only by an unwritten invariant.

Fix: make the bound observable instead of inferred — emit source_saturated as (SELECT COUNT(*) FROM source_N) >= $4 for every arm and delete the duplicateCapable flag. A genuinely 1:1 arm that fills its window is then correctly re-queried once and reports zero new rows.


[Medium] Revision canonicalization still uses localeCompare, and that layer feeds the cursor and the access-path HMAC

authorizationRevision.ts:46, 70, 97, 112; also operationalAccessProjection.ts:137, exactOperationalAuthorization.ts:38

This corrects my own statement above. I wrote that no localeCompare survives on the pagination boundary. That remains true and I verified it again — but it is incomplete: localeCompare survives in the revision canonicalization layer that the cursor and the ap1_ access-path HMAC bind to.

localeCompare returns 0 for distinct strings differing only by collation-ignorable characters, so Array.sort falls back to input order — i.e. DB row order, which PostgreSQL does not guarantee without ORDER BY. The functions named "canonical" are therefore not canonical:

["ab","a<ZWSP>b"]  localeCompare=0 | Buffer.compare=-1
["ab","a<ZWJ>b"]   localeCompare=0 | Buffer.compare=-1
["ab","a<WJ>b"]    localeCompare=0 | Buffer.compare=-1
canonicalAuthorizationRelationships is order-independent? false
  (same two inputs, two orders, two different outputs)

A plan flip alone suffices to produce a different authorizationRevision for identical state, which surfaces as: a previously issued accessPathId returning 409 access_path_stale; an in-flight cursor returning 409 stale_cursor; or two hydration fragments of one resource disagreeing → authorization_revision_conflict → catalog_invariant_failed → 503.

Honest limit: validateLogicalResourceId (resourceIdentity.ts:52-75) constrains logical ids to UUID or [a-z0-9.-], so ignorable characters cannot enter targetResourceId today. The nearest unconstrained input is gfs_grants.drive (TEXT, no CHECK) reaching filesystemScope via accessAuthorityStore.ts:812, and no external write path was traced to it. Comparator defect proven; exploit path not proven.

Fix: replace those localeCompare call sites with compareCatalogText — the same one-line substitution that closed R1-B1, applied to the layer the cursor actually binds to. Also resourceIdentity.ts:110-115 exports compareResourceIdentity, a localeCompare-based comparator on resource identity with zero callers; any future use reintroduces R1-B1 in the identity module itself. Delete it.


Two further items

  • [Low] The catalog cursor has no explicit principal binding. Cross-user replay is blocked today, but only derivatively: the cursor carries no userId and catalogAuthorizationRevision omits principalUserId, deriving uniqueness solely from sessionRevision. Note the asymmetry — the resolver's own authorizationRevision() (authorizationRevision.ts:65) does include principalUserId. Any future stabilisation of sessionRevision (for example dropping jti so cursors stop dying on token rotation, a very plausible PR2 request) silently opens cross-user cursor reuse. Put principalUserId in the payload and assert it in assertAccessCatalogCursorCurrent.
  • [Low] Policy resolution runs before the rate limiter on /catalog and /resolve (routes/external/access.ts:203-221, 247-265): requireEffectiveV2Contract does a DB policy read and is registered before rateLimitMiddleware, so that work is unmetered. /capabilities gets the ordering right at :180-181.

Corrections this pass makes to my own comment above

  • My "no localeCompare on the pagination boundary" holds for the pagination boundary specifically, but I should have said the revision-canonicalization layer still uses it — see the Medium above.
  • The previous round's claim that there is "no pinned deterministic ordering regression" is wrong in a second place as well: besides the seeded real-Postgres case I cited, control-api/test/catalogOrdering.test.ts is pinned and non-randomized, asserting ns/Zeta vs ns/alpha — exactly where localeCompare diverges from byte order — plus NFC/NFD non-equivalence.
  • Q3 comes back clean, and more strongly than I stated: a repo-wide grep confirms the capability union at accessCatalogCoordinator.ts:199-201 has no consumer that could treat it as authority — only the JSON response and accessCatalogShadow.ts, which never reads item.capabilities. Worth renaming it to discoveryCapabilities now, while there are zero consumers, since it sits as a sibling to accessPaths[].capabilities and a PR2 client reading it would see a set no single path grants.
  • On the configmap point: enabling the indexer everywhere does not affect the capability union. What it makes live is the operational index as an authorization input — liveAuthorizationResolver.ts:170-220 passes the graph into loadResourceAuthority. The design appears to be index-narrows-then-Kubernetes-confirms: the graph can only produce not_found/unavailable/deny, and the final gate before allowed is validateExactOperationalBindings, which does a live exact K8s GET and re-verifies providerUid plus every relationship fingerprint. So the index cannot manufacture an allow. But operationalAccessReader.ts / operationalAccessIndex.ts internals were not audited, so that claim is not certified end to end. Given the indexer now ships enabled in every environment, that is the single highest-value remaining follow-up.

Effect on my two answers

Question 1 (must-fix remaining): now three, with the gfs_resource producer as the most serious. It does not break a contract PR1 claims to have delivered — the catalog is off by default — but it means the aggregate cross-team discovery path, the central capability of this PR, has never executed successfully outside tests that inject a value production never supplies.

Question 2 (safe as PR2 base): still yes, with a sharper condition than I gave before. The foundation contracts PR2 depends on are sound. But the shadow lane cannot be used as rollout evidence until the Critical is fixed, because for team members it currently compares nothing and reports skipped_capacity while doing so. Fix the admission default before enabling shadow, not after.

Repro scripts were written outside the review worktree; the worktree was not modified. tsc --noEmit was clean and control-api's suite ran 4856 passed / exit 0, as reported above — but note that neither would have caught this, which is the point.

@josue-tech-94

Copy link
Copy Markdown
Contributor Author

@alfredolopez80 done!

The F3/D3 stop was handled, and the correction set is now published at exact head d3ba05b:

  • Public GFS error contract — R11-H1: closed in 921efe1. The 408/411/413/425/507 responses now retain their safe typed public semantics, with only bounded approved retry/upload metadata and no raw upstream detail.
  • Accepted onboarding expiry — R5-L3: closed in 61aaa8d. Pending/reset capabilities still expire, while onboarding accepted before expiry can finish authenticated password setup after the original TTL without reopening acceptance or replay.
  • Team-GFS production admission — R11-B1: closed in 82de142. Shadow/serve now require an explicit positive operator-owned admission value; no numeric production default was introduced, missing configuration fails before rollout traffic, and actual over-limit membership remains fail-closed.

The correction review also found one same-round regression, R11-B2, where the real External REST client discarded the approved 413 metadata before sanitization. That is separately closed in d3ba05b with a status-aware safe extraction boundary.

Fresh exact-head validation is green:

The repeated collateral pass is clean, and the fresh formal round-11 review returned No objection with no PR1 must-fix findings remaining.

Exact head d3ba05b is now accepted as safe as the PR2 stack base.

As before, that does not mean PR #334 is ready to merge into dev; it remains the integration base until PRs 2–4 and final stack reconciliation are complete.

@alfredolopez80

Copy link
Copy Markdown
Contributor

Independent review — PR #334 @ d3ba05b0 (round 2)

Reviewed in three dedicated worktrees off the exact head (d3ba05b0, merge-base 9b8bcc92 = current dev, so the 253-file diff is all PR content): one read-only tree for analysis, two isolated mutable trees so mutation testing could not cross-contaminate. 11 parallel agents. 92 raw findings: 0 Blocker · 0 Critical · 17 High · 30 Medium · 44 Low.


1. The four claimed closures hold — verified by mutation, not by reading

@josue-tech-94, all four are genuinely closed. I re-introduced each defect surgically and required the tests to go red.

Closure Commit Verdict
R11-H1 — public GFS error contract 921efe14 ✅ CLOSED. All five statuses now produce correct typed public codes (408→request_timeout, 411→length_required, 413→payload_too_large, 425→too_early, 507→insufficient_storage), no upstream detail leaks, and an upstream-spoofed code is replaced by the canonical one in all five. 5 of 6 mutations turn real tests red.
R11-B2 — REST client regression d3ba05b0 ✅ CLOSED END-TO-END. The boundary is 413-specific, but retry-after already crosses unconditionally via SAFE_RATE_LIMIT_RESPONSE_HEADERS (controlApiClient.ts:34-39), and 411/507 carry no approved metadata. Verified by driving the real client against simulated responses for all five statuses.
R11-B1 — team-GFS production admission 82de1421 ✅ CLOSED. The 3 production callers that need it propagate the limit; the 4th (indexer) is legitimately exempt — its budget only covers k8s object paging. No path leaves null read as "budget exhausted". 4 of 5 mutations red. And "fails before rollout traffic" is literal: it is a module-load fail-fast, not a first-request error.
R5-L3 — accepted onboarding after expiry 61aaa8d8 ✅ CLOSED AND BOUNDED. Not "never expires": it requires four simultaneous conditions (accepted + accepted_at <= expires_at + accepted_user_id === userId + null password_hash, which makes it single-use) plus an authenticated session belonging to the user. The token path keeps its unconditional expiry check.

The guardian tests that previously blessed the bug were restored: routes.gfs.test.ts:600-610 went from expect(...).toBe('internal_error') to toEqual({code:'payload_too_large', ...}).

Mutation table for R11-H1 (each restored between runs, working tree verified clean):

Mutation Exit Red tests
Remove the 5 statuses from PUBLIC_CODE_BY_STATUS 1 1
Revert retryable to [429,502,503,504] 1 3
Remove the status === 413 branch from the sanitizer 1 3
Remove the 413 block from controlApiClient.ts 1 3
Emit retry-after on every status 1 1
Remove the 5 entries from ALLOWED_CODES_BY_STATUS 0 0 ← see Low findings

2. Corrections to my own previous review

Three things I said in the c7735cd5 round need fixing, and they are mine to fix:

  1. I overstated the severity of R5-L3. I claimed "user with no self-recovery". That is wrong: POST /external/auth/password-reset/request is unauthenticated and issues a password_reset for any active user without requiring a prior password_hash (membership.ts:1017-1043). That predates the fix. The fix is a genuine UX improvement, not the closing of an absolute dead end.
  2. My reasoning about valid_after IS NULL was wrong, even though the conclusion was right. It is not inert because revokeAllUserSessions writes the epoch in the same transaction as password_set_at — revokeAllUserSessions (userSessionService.ts:398-409) does not look at password_set_at at all. It is inert because the epoch is only ever born from a revocation event, so NULL means "no event yet".
  3. Three fresh suspicions of mine this round turned out to be false, and I am recording them rather than publishing them as findings: the 26-second external-rest-api CI job does run all 215 tests (36/36 files, 0 skipped); the deployment does not break without the new env var (default is off); and the *.realPostgres.integration.test.ts files are not orphans — they run unconditionally in ci-public.yml:150 under a triple fail-loud guard that also fails if any skipped appears.

3. Highest-value new finding: part of the green CI is manufactured by retries

control-api/vitest.config.ts:14 — REPRODUCED

retry: process.env.CONTROL_API_REAL_PG_ADMIN_URL ? 0 : 2

On the untouched tree:

npm test                        → EXIT=0   4871 passed
npx vitest run --retry=0        → EXIT=1   2 failed | 4865 passed
npm test with a real PG DSN     → EXIT=1   2 failed | 5050 passed

The CI matrix lane runs npm test, so CI never sees these two reds.

One of them is a regression introduced by this PR. test/routes.profile.test.ts:768 is byte-identical to the merge-base, but faa41635 migrated console.warn → requestLogger(req).warn in src/routes/external/users.ts:318 while leaving the twin warn in src/routes/external/teams.ts:139,182 untouched (0 lines changed there). The spy counts 1 instead of 2. The false-green mechanism is worth naming: the assertion fails before warn.mockRestore(), so the spy survives into attempt 2 with accumulated history — 1 + 1 = 2 — and passes.

The second (test/routes.externalMembers.test.ts:135, added in 2cd02c9e) is cross-test contamination: vi.clearAllMocks() clears calls but does not drain the mockResolvedValueOnce queue, so the next test consumes a stale value.

Suggested fix: drop retry to 0 on the ordinary lane and fix both tests. A harness that reports green over deterministic reds is indistinguishable from one that was deleted.


4. Other High findings

Derived RPC tokens live 300s with no downstream revocation check — middleware/rpcAccessAuth.ts:21,44 (REPRODUCED). The middleware verifies signature, typ and scopes only; it never consults external_user_sessions, the security epoch, or external_v1_session_revocations. All five exported functions are synchronous, with no pool/db access. TTL is config.rpcTokenTtlSeconds, default 300 (config.ts:713).

This reframes the open P1 thread on auth.ts:608. Atomic fencing removes the race, but the 300 seconds of post-revocation access remain in every case, race or no race. And the stated mitigation does not exist: grep -rn "rpcDelegationV2" control-api/src/routes control-api/src/middleware returns empty — the flag only lives inside userAccessPolicy.ts. If this is deferred to PR2, the accepted exposure should be documented as 300 seconds, not "the race window", and the flag mitigation should be withdrawn.

Final session authority covers 3 of 7 directory mutations — membership.ts:1995,2229,2258,2314 (structural precondition CONFIRMED; exploitation chain READ-ONLY). There are exactly three production call sites of validateExternalSessionAuthorityContext: teams.ts:114, membership.ts:2052, :2129. Meanwhile:

resendManagedInvitationForUser(managerUserId: string, invitationId: string)   ← no authority param
revokeManagedInvitationForUser(managerUserId: string, invitationId: string)   ← no authority param

Invite, resend, revoke-invitation and retire-user cannot revalidate the authority context because they never receive it; they only recheck lifecycle_state through the JOIN users in getManagerRolesForTeams, which revocation does not touch. Under lock contention, a request admitted before a revoke-all would block on the same users row, re-read in READ COMMITTED after the lock is released, see lifecycle_state='active', skip the epoch, and create the invitation — possibly with role admin.

Falsifiable in one line: add the team invitation mutation to mutations() in routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts:213-273. denies … when source-session invalidation wins after admission (:353) should fail. That settles it either way faster than discussion.

Consumed password_reset capabilities do not supersede older ones, and nothing can enumerate them — membership.ts:1035,1389,1396-1408,1123 (READ-ONLY). The endpoint is unauthenticated, 5/min per email, TTL 48h ⇒ ceiling of ~14,400 simultaneously valid tokens against a third-party address, mintable without any credential. The "already has a password" guard is explicitly exempted for reset (if (invitation.purpose !== 'password_reset' && user.password_hash), :1389), so an old link still works after a legitimate recovery, sets the attacker's password, and revokeAllUserSessions then evicts the victim. updateUserPassword does not invalidate them either.

Two asymmetries inside this repo suggest defect rather than design: the control-admin flow does supersede (adminAuthService.ts:461-467, plus a partial unique index at db.ts:2047-2049), and all three admin listing surfaces filter purpose = 'member_invitation' (:1107, :1123, :1944), so an incident responder cannot even list a live reset link — only wait 48h.

On the "pre-existing relative to the merge base" defence: correct on the strict point — requestProfilePasswordReset is byte-identical to the merge base — but not correct about the function. This PR substantially rewrites setInvitationPasswordForEmail (adds a mandatory invitationToken, switches to getInvitationRecordByToken(…, {lock:true}), replaces the unconditional UPDATE with a status CAS, adds revokeAllUserSessions). If the argument rests on "I did not touch that function", it does not hold.

The new error envelope breaks profile-ui — external-rest-api/src/app.ts:127-154 ↔ profile-ui/lib/api.ts:130 (REPRODUCED, found independently by two agents). The shape changed from {error: "<string>", message: "<string>"} to {error: {code, message}}, but the client does String(parsed.message || parsed.error || text), which yields "[object Object]":

BASE 500 | detail = "Control API POST /external/members/invitations failed (503): member_registration_unavailable"  | friendly message fired: YES
HEAD 500 | detail = "[object Object]"                                                                               | friendly message fired: no

Both detail.includes(...) recovery messages at lib/api.ts:138,143 become unreachable, and the comment at :134-137 documenting the contract they depend on is now stale. profile-ui/lib/api.ts is not touched by this PR.

The sanitizer rewrites control-api's code and always discards its message — publicApiError.ts:157-159,223 + routes/access.ts:10 (REPRODUCED). ALLOWED_CODES_BY_STATUS[409] omits invalid_request, so Catalog access changed; refresh from the first page. reaches the client as conflict / The request conflicts with current state. — the actionable recovery instruction is erased, on the surface this PR introduces.

/rpc/token stops relaying desktop_requires_team, and the guardian test was rewritten to bless it — routes/rpc.ts:6,26 ↔ publicApiError.ts:77-84. The reason is absent from SAFE_DOMAIN_REASONS, so it collapses to forbidden. The deleted comment said the relay existed precisely so "the desktop app and logs can distinguish 'needs a team' from a genuine auth failure". The test that protected it was renamed from "relays the control-api denial reason instead of a generic 403" to "returns a bounded public envelope without reflecting the control-api denial body", with its fixture changed from desktop_requires_team — a legitimate, allowlistable reason — to secret-internal-reason, which reads like a leak. This is the same pattern that was a must-fix as R11-H1, in a different endpoint. Suggested fix: add desktop_requires_team and no_permitted_scopes to SAFE_DOMAIN_REASONS, and restore a test asserting that reason does reach the client, alongside the no-leak one.

426 is unexpressible, and its trigger has no producer (two agents converging from different angles): ACCESS_PUBLIC_STATUSES (routes/access.ts:10) omits 426, so a deliberate upgrade_required degrades to invalid_request (rg -n "426" external-rest-api/src → zero hits). Separately, x-evenfire-client-version has four readers in control-api and zero producers anywhere in the repo, so enabling CONTROL_API_USER_ACCESS_MINIMUM_CLIENT_ENFORCEMENT would return 426 to every external user regardless of their actual version.

members/invite fabricates a sentinel payload — external-rest-api/src/routes/members.ts:166-173 (REPRODUCED). Instead of returning 400 at the edge, it replaces the user's name with 'x'.repeat(121) and their team list with 51 nulls and forwards that upstream so control-api rejects it:

input name         : "Ana Ana Ana …"  len=123
SENT upstream name : "xxxxxxxx…"      len=121
SENT upstream teams: 51 entries, all = null
user payload survives? false

git blame: 00469ac5e (name) and 89361daa1 (teams), both inside this PR. This is a placeholder shipped as the final implementation, and production code shaped to provoke the response a test expects. It also burns the user's member_mutation bucket for an error the edge already rejected for free, and if the two limits ever diverge (both 120/50 today) the invitation is created with the fabricated name.

Enabling the catalog rollout with one variable kills control-api at import — userAccessPolicy.ts:332,389 (REPRODUCED). configuredUserAccessIntent is a module-level const, so setting CONTROL_API_USER_ACCESS_CATALOG_MODE: 'shadow' without its partner CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT throws during module load → CrashLoopBackOff. grep -rn 'USER_ACCESS|CATALOG_MODE|ADMISSION' deploy/ returns nothing, and the PR touches no docs/ file at all. To be clear: today's deployment does not break (default is off); the defect is an undeclared knob with a one-step trap.

Three defects make serve/shadow impractical today: everyCatalogFamilyReady (userAccessPolicy.ts:183) demands all 12 families at once, so a single non-current K8s source — a routine watch close — returns 503 across all of /external/access, including the six families that need no K8s source at all (and makes the whole partialErrors machinery unreachable in those modes); the indexer has a hard, non-configurable ceiling of 1,000 objects per family (operationalAccessIndexer.ts:145); and it starts in every replica with no leader election (main.ts:117), producing mutual relist livelock. In fairness on that last one: the fencing itself is correct — beginRelist is atomic and stageRelistPage/promoteRelist verify generation under FOR UPDATE. The defect is multi-writer, not corruption.


5. Verdicts on the open inline threads

Thread Your reply Verdict
userAccessFoundationSchema.ts:44 — legacy epoch backfill "closed by additive migration 0106" ✅ Correct. The backfill is wired (db.ts:5968). A real gap remains for legacy users (password_set_at is ADD COLUMN IF NOT EXISTS, and the backfill filters IS NOT NULL), but it is inert because of the 12h v1 TTL, not because of the backfill. Worth stating that way.
auth.ts:608 — atomic RPC fencing "intentionally accepted risk for PR2" ⚠️ Acceptable only if what is accepted is rewritten. It is 300 seconds of post-revocation access, not a race window — and the flag mitigation does not exist.
teams.ts:119 — revalidate operator lifecycle "closed under R9-H2" ✅ Real closure, not nominal. Same transaction, same db client, SELECT … FOR UPDATE on both users and team_members, serialised against revokeAllUserSessions, which takes the same lock. Real concurrency test in CI. What remains open is scope, not mechanism — see above.
membership.ts:1419 — supersede older resets "pre-existing relative to the merge base" ⚠️ Correct on the strict point, not about the function — see above.

6. What I verified and found clean

  • Authorization core: decision precedence is correct; no fail-open anywhere in the new code (full inventory of catch in the decision chain — all deny with 503/400 or rethrow); multi-tenant isolation correct across all 43 queries reviewed; stable ordering in the producer merge (REPRODUCED). The only production ?? true is fail-closed (event.authorityRequired ?? true).
  • This PR removes four pre-existing silent fallbacks in teamService.ts (catch { return null } → typed 403/404 handling with throw for everything else). Net improvement, worth recording.
  • Session token v2: explicit algorithms:['RS256'], key passed as a public KeyObject rather than a PEM string (so the classic algorithm-confusion vector is unavailable even if the allowlist were relaxed), strict claim allowlist, disjoint audiences. No v1→v2 downgrade path exists.
  • Revocation epoch: the iat > valid_after bound is strict and fail-closed (a token stamped at the exact revocation instant dies), identical across all four readers.
  • Rate limiting: the limiter key is not client-controllable (x-evenfire-client-ip is honoured only after the internal-service token is validated); login, reset and invitation are all covered; and it fails closed if the store is down.
  • CodeQL is not weakened. disable-default-queries: true re-imports the full standard suite; two ids are excluded but replaced by in-repo variants with the same @id and severity, and the custom bypass query re-widens coverage with three predicates that report cases the stock early-abort guard hid. paths-ignore only excludes the deliberately-vulnerable test fixtures, and a new step fails if those fixtures enter the production database.
  • Zero placeholders, zero unrequested fallbacks, zero logging-standard violations in control-api (the four console.* are in main.ts, bootstrap with no logger available — the case CLAUDE.md explicitly permits).
  • The "test rewritten to bless the bug" pattern does not repeat in control-api: systematic sweep of 781 blocks, 19 candidates, all 19 false positives.
  • No new dependencies and no third-party version bumps — all four manifests only change their own version field.
  • utils/auth/googleAuth.ts now rejects an id-token when payload.email_verified !== true. New relative to the merge base, and a real security improvement.

7. Recommendation

This is not ready to merge into dev, which matches your own position that #334 stays the stack integration base until PRs 2–4 land. With that framing:

Before enabling CATALOG_MODE anywhere: declare both paired variables in the ConfigMap with a comment recording the pairing, gate serveCatalog per family, separate indexer limits from HTTP-request limits, and add leader election.

Worth fixing in this PR (live regressions, independent of the rollout): the retry: 2 masking plus the two red tests, the profile-ui envelope break, desktop_requires_team and its rewritten guardian test, and the members/invite sentinel payload.

Your call, not mine: whether the 300-second post-revocation window is accepted for PR2 now that it is quantified; and whether the public vocabulary gap (426, invalid_request on 409, upstream message propagation) is widened here or in PR2. For the session-authority scope finding, I would ask for the one-line test extension before debating severity — it confirms or refutes it in a single commit.

Full consolidated report with per-agent detail, reproduction commands and their output is kept locally; happy to paste any specific section into a thread.

@alfredolopez80

Copy link
Copy Markdown
Contributor

@claude Please perform a complete independent re-review of the FULL current PR. Review only — do not make code changes.

#334

Current expected state:

  • base: 4862d9c2ff306e7727f35539ae696bc2c465b24a (origin/dev)
  • head: 8ffc021b1a39bca0e4666c01d7b6737b67c43c6e
  • PR1 of a four-PR migration
  • this PR is an integration base and is not eligible to merge into dev until PRs 2–4 and final stack reconciliation are complete

If the live head/base have moved, review the actual current state and report the live SHAs rather than relying on the values above.

This is not a delta-only pass and not a recap of prior reviews. The last @claude review was at c7735cd5. The last @alfredolopez80 review was at d3ba05b0. Multiple R12/R13 correction commits and a dev sync have landed since those reviews. Re-audit the entire current diff against live origin/dev.


Required deliverable 1 — historical-findings closure ledger

Build an exhaustive ledger of every finding, residual, inline thread, review observation, and claimed closure that appears anywhere on this PR: conversation comments, review bodies, inline review threads, author replies, and any @claude job output.

For each item report:

Field Required content
ID / short title Stable ID if one exists (R1-B1, F1, R11-H1, …) or a precise coined title
Origin reviewer Who raised it
Original severity Blocker / Critical / High / Medium / Low / note
Current disposition CLOSED / OPEN / PARTIAL / DEFERRED_TO_PR2 / PRE_EXISTING / NOT_FOUND_IN_PR
Live-head evidence File:line, test name, migration, or explicit absence
Claimed vs verified Independently confirm or refute the author's claimed closure
Residual What remains, if anything

Rules for this ledger:

  • Do not trust author replies, “resolved” GitHub threads, prior @claude conclusions, or commit-message claims. Re-verify against the live head.
  • A resolved thread is not evidence of a fix.
  • If a reviewer left no findings on Remove Team Token PR1: User session, live authorization, and aggregate all-team access #334 after a full search, say so explicitly as NOT_FOUND_IN_PR. Do not skip the search.
  • Include optional/non-blocking items, not only must-fix items.
  • If a previously CLOSED item has reopened after the later R12/R13 commits or the dev sync, mark it REOPENED and explain why.

Reviewers whose findings must be inventoried

  1. @claude — previous independent review at c7735cd5 (comment 5442769222 / job on this PR) and any other Claude comments or review jobs on Remove Team Token PR1: User session, live authorization, and aggregate all-team access #334.
  2. @zach88 — original deep review (comment 5331462023), including all must-resolve and optional items.
  3. @alfredolopez80 — every round on this PR:
    • F1 / F3 security-scope review and inline threads
    • supplemental P1 threads (legacy epoch backfill, atomic RPC fencing, operator-lifecycle revalidation, sibling password-reset supersession)
    • non-blocking follow-ups in comment 5415388190
    • independent review at c7735cd5 (comment 5443347582)
    • Critical addendum (comment 5443432875, team-GFS production admission / R11-B1)
    • round 2 at d3ba05b0 (comment 5445483375)
  4. @jozer-rami — search the full PR conversation, reviews, inline threads, and mentions. If this reviewer left no findings on Remove Team Token PR1: User session, live authorization, and aggregate all-team access #334, report NOT_FOUND_IN_PR after the search.
  5. Josué Palmera / @josue-tech-94 — every author disposition, claimed closure, deferred-to-PR2 statement, and inline reply on this PR (including comments 5414826001, 5442765586, 5445027822 and all inline replies). Independently confirm or refute each claimed closure.

Known IDs that must appear in the ledger

This list is a minimum checklist, not the complete set. Add any other finding you discover on the PR.

Zach (@zach88):

  • R1-B1 catalog ordering
  • R1-H1 effectiveCapabilities union
  • R1-H2 circular catalog-order property tests
  • R1-H3 singleton catalog-revision hot row
  • R1-M1 bare catch {} / missing req.log
  • R1-M2 console.warn on request path
  • R1-M3 missing effectiveCapabilities === selectedPath pin
  • R1-M4 duplicated CATALOG_FAMILIES / contract constants
  • R1-L1 through R1-L12 (error envelope, CodeQL fixture, accepted-expired invitation, shadow metrics, Team-GFS localeCompare, invite role, HMAC key reuse, reuse-detection window, real-PG reuse coverage, dead branch, v2 RPC team scope, catalog→resolve accessPathId replay)

Alfredo / later rounds (@alfredolopez80):

  • F1 live team-admin revocation
  • F3 password-change/reset invalidates legacy sessions
  • R5-L3 accepted onboarding after expiry
  • R9-H1 legacy password-security epoch backfill
  • R9-H2 final mutation transaction revalidates operator lifecycle
  • R11-H1 public GFS error contract / sanitizer rewritten guardians
  • R11-B1 team-GFS production admission (gfs_resource producer throw)
  • R11-B2 REST client regression for public GFS statuses
  • retry-masked CI greens (vitest retry: 2) and the two deterministic reds
  • derived RPC tokens live 300s with no downstream revocation check
  • final session authority covers only a subset of directory mutations
  • consumed password_reset capabilities do not supersede older ones
  • new error envelope breaks profile-ui
  • sanitizer rewrites control-api code and discards message
  • /rpc/token stops relaying desktop_requires_team and guardian test rewritten
  • 426 unexpressible / x-evenfire-client-version has no producer
  • members/invite fabricated sentinel payload
  • enabling catalog rollout with one variable kills control-api at import
  • serve/shadow impracticalities (everyCatalogFamilyReady, indexer ceiling, multi-replica indexer)
  • non-blocking follow-ups: revoke-all cutoff-after-lock, bind derived GFS/RPC credentials to security epoch, preserve V1 replacement expiry/lineage, revalidate authority at GFS/workflow/approval mutations, V2 authority-free compatibility + same-second valid_after, approval-medium link-session re-check
  • later R12/R13 commit series on this head, including at least: R12-B1, R12-B3, R12-B4, R12-H1, R12-H2, R12-H3, R12-H4, R13-B1, R13-B2 — verify each claimed fix independently

Claude prior review:

  • the four original blockers (R1-B1 / R1-H1 / R1-H2 / R1-H3)
  • the four Alfredo threads at c7735cd5
  • residual logging R1-M1
  • discovery-only capability union
  • null password_set_at / valid_after IS NULL fence
  • “safe as PR2 base” conclusion — re-evaluate on the current head

Required deliverable 2 — full independent audit of the current PR

Review the complete current PR diff, not only historical items. Cover at least:

  • correctness and regressions
  • security, authentication, and authorization boundaries
  • session revocation, security epochs, live membership, F1/F3
  • catalog/pagination contracts, ordering, resume, and producer merge
  • public error envelopes across control-api, external-rest-api, profile-ui, Desktop, and rpc-proxy
  • cross-service and persistence contracts
  • concurrency, transaction, and lock boundaries
  • migrations and backfills
  • test quality: circular tests, rewritten guardians, retry-masked greens, missing real-Postgres interleavings
  • rollout flags and fail-closed defaults
  • logging / redaction contract

Report:

  • new findings with severity (Blocker / Critical / High / Medium / Low) and exact evidence
  • whether any previously CLOSED item has reopened on the current head
  • remaining PR1 must-fix vs acceptable deferred to PR2 vs pre-existing / out of diff scope
  • whether the current exact head is still a sound PR2 integration base
  • an explicit reminder that “safe as PR2 base” does not mean ready to merge into dev

Be as detailed as possible. Trace dispositions to code and tests, not to comments. Prefer observable-behavior or mutation-style evidence over narrative. Read tests for what they actually assert.

Review only — no code changes. This is analysis, not a formal GitHub approval.

@claude

claude Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Claude finished @alfredolopez80's task in 3m 32s —— View job


Independent re-review — PR #334 @ 8ffc021b (full re-audit, not a delta pass)

Live state verified first. git rev-parse HEAD = 8ffc021b1a39bca0e4666c01d7b6737b67c43c6e; origin/dev = git merge-base = 4862d9c2ff306e7727f35539ae696bc2c465b24a. The declared base is current origin/dev — no drift. The full R12/R13 correction series and the dev sync are present since the last @alfredolopez80 pass at d3ba05b0.

Method. Five parallel independent traces over the exact head (access-core/catalog, auth/session+directory, public-error envelopes, test-quality/CI, persistence/rollout/logging) plus first-hand inspection of every R12/R13 commit diff and the key comparators/flags. Dispositions below are traced to code and to what the tests actually assert — not to author replies or resolved-thread state. Where a claim rests on a test, I read the assertion.


Deliverable 1 — historical-findings closure ledger

Legend: ✅ CLOSED · ⚠️ PARTIAL/residual · 🟡 OPEN (non-blocking) · 🔵 DEFERRED-TO-PR2 · ⬜ PRE-EXISTING/out-of-scope · ✔ verified-independently

zach88 (R1 round)

ID Sev Disposition Live-head evidence (claimed vs verified)
R1-B1 catalog ordering Blocker ✅✔ compareCatalogKey → Buffer.compare(utf8,utf8) (catalogContracts.ts:127); SQL orders/resumes on catalog_utf8_bytes(). No localeCompare on the pagination boundary.
R1-H1 effectiveCapabilities union High ✅✔ Derived from selectedPath only (liveAuthorizationResolver.ts:328); read-only-path test asserts gfs.write does not leak.
R1-H2 circular property test High ✅✔ accessCatalogProperties.test.ts uses an independent TextEncoder UTF-8 oracle (not the SUT), mixed-case + -_./ + combining/emoji. Not circular.
R1-H3 revision hot row High ✅✔ Singleton table + 18 statement triggers dropped (0104); replaced with FOR-EACH-ROW scoped per-user/team/resource bumps. See NF-5 for the narrowed residual.
R1-M1 bare catch{} / no req.log Med 🟡✔ Still open. externalSessionAuth.ts:122,176,244 swallow the error, no req.log. Fail-closed (503/401), so observability nit.
R1-M2 console.warn on request path Med ⚠️✔ users.ts migrated to req.log ?? rootLogger. But teams.ts:139,182 still use console.warn — partial migration (NF-6).
R1-M3 pin effectiveCapabilities===selectedPath Med ✅✔ Pinned in accessLiveAuthorization.test.ts:257-307.
R1-M4 duplicated CATALOG_FAMILIES Med 🟡✔ Still two literal copies (userAccessPolicy.ts:5, catalogContracts.ts:8), no compile-time link.
R1-L1 terminal-500 legacy envelope Low 🟡✔ errorHandler.ts still returns {error:'Internal Server Error'} on the terminal 500 for /api/v1/external; 4xx use the public envelope. Low impact (re-wrapped downstream).
R1-L3 accepted-expired invitation Low ✅✔ See R5-L3.
R1-L5 Team-GFS localeCompare Low ✅✔ Team-GFS uses compareCatalogText (byte order).
R1-L7 HMAC key reuse Low 🟡✔ config.sessionJwtPrivateKey reused as cursor-HMAC + fingerprint key (accessCatalogCursor.ts:131, exactOperationalAuthorization.ts:88). Not exploitable; blast-radius isolation follow-up.
R1-L2/L4/L6/L8–L12 Low 🟡/🔵 Tracked; L11/L12 are PR2 build-against notes.

alfredolopez80 / later rounds + Claude prior

ID Sev Disposition Live-head evidence (claimed vs verified)
F1 live team-admin revocation High ✅✔ externalSessionAuth.ts:233-242 re-derives role from getLiveTeamMembership; real-PG routes.externalTeamsLegacyAdminRevocation asserts stale admin V1 → 403 on rename/role/removal.
F3 password change/reset invalidates legacy High ✅✔ validateLegacyUserSession fenced by epoch valid_after (userSessionService.ts:582); both flows call revokeAllUserSessions.
R5-L3 accepted onboarding after TTL Med ✅✔ Authenticated onboarding may finish past 48h (accepted_at ≤ expires_at, single-use, null-password); token path keeps unconditional expiry. Bounded.
R9-H1 legacy epoch backfill High ✅⚠️✔ Migration 0106 idempotent, GREATEST monotonic; real-PG proof. Residual: fence is fail-open for SSO/passwordless (password_set_at NULL → valid_after NULL, skipped). Bounded (no password to invalidate).
R9-H2 operator-lifecycle revalidation High ✅✔ validateExternalSessionAuthorityContext runs inside each mutation txn with SELECT users FOR UPDATE (lifecycle_state/version + epoch + revocation).
R11-H1 public GFS error contract Blocker ✅✔ 408/411/413/425/507 → typed public codes; guardian tests un-blessed (921efe14 asserted internal_error) and restored to assert typed codes.
R11-B1 team-GFS admission throw Critical ✅✔ Now a config fail-fast: userAccessPolicy.ts:332 throws if catalogMode≠off without a positive admission limit; teamGfsTopK.ts:232 null-throw unreachable when serving. Route-level regression accessCatalogConfiguredAdmission.test.ts. Over-limit still fail-closed.
R11-B2 REST client 413 regression Blocker ✅✔ controlApiClient.ts preserves upload-length (413) + retry-after.
retry-masked CI greens High ✅✔ vitest.config.ts:15 retry: 0 unconditional (R12-B4); both deterministic reds fixed at source (R12-B1: stale mockResolvedValueOnce removed; console-warn spy → structured req.log assert).
final authority covers subset of mutations High ✅✔ R12-H1 (58b62e95) wires authority into invite/resend/revoke-invitation/retire; real-PG matrix now parametrizes 8 mutation classes × 3 interleavings (was 3).
/rpc/token drops desktop_requires_team High ✅✔ R12-H3 (61cf72c9) restores desktop_requires_team+no_permitted_scopes to SAFE_DOMAIN_REASONS; test asserts the reason reaches the client and secret-internal-reason collapses to forbidden.
new error envelope breaks profile-ui High ✅✔ R12-H2 (1e8536ff) — profile-ui/lib/api.ts reads structured publicError.code; sanitizer preserves member_registration_* on 503. No [object Object].
members/invite sentinel payload High ✅✔ R12-H4 (8dd209aa) removes the 'x'.repeat(121) / 51-null fabrication; real name/teams forwarded, invalid_name/too_many_teams mapped at the edge.
x-request-id / correlation on 503 High ✅⚠️✔ R13-B1/B2 (858ec766/8ffc021b) — app-level handler preserves request-derived correlation (validated, bounded) before randomUUID; test asserts echo toBe(requestCorrelationId), not relaxed. Residual: route forwarders (gfs.ts/access.ts/rpc.ts) still call the sanitizer without the fallback arg (NF-4).
derived RPC tokens live 300s, no revocation check High 🔵✔ OPEN, PR2-scope. rpcAccessAuth.ts verifies sig/typ/scope only, no session/epoch/revocation DB read; TTL default 300s. The "rpcDelegationV2 mitigates" claim is false — that flag is never consulted by /rpc/token. PR1 doesn't regress base; credential-binding is PR2's contract.
consumed password_reset siblings not superseded High ⬜✔ PRE-EXISTING. No one-active-reset constraint; sibling resets survive. PR hardened the flow (FOR UPDATE, token↔id binding, revoke-all) but did not change sibling semantics. Not widened.
V1 replacement TTL/lineage note 🟡✔ OPEN (minor). exchangeLegacyExternalUserSession signs with no ttl arg → fresh 12h, no min(12h, source.exp); single logout doesn't kill lineage (epoch/revocation still fence each use).
revoke-all cutoff after lock note ✅✔ SELECT users FOR UPDATE precedes cutoff computation (userSessionService.ts:394-396).
discovery-only capability union note 🟡✔ accessCatalogCoordinator.ts:200 still unions candidate caps; no authority consumer reads it (only the route response + shadow parity). Rename to discoveryCapabilities before PR2.
revision-canonicalization localeCompare Med 🟡✔ Still present — authorizationRevision.ts:46,70,97,112 feeds the ar1_ revision hash → cursor freshness + ap1_ access-path HMAC; resourceIdentity.ts:114 compareResourceIdentity is a dead export. Latent (ids constrained to UUID/[a-z0-9.-]); determinism/availability, not a bypass. See NF-1.
per-arm LIMIT window exhaustion High 🟡✔ catalogProducerArms.ts:61-64 emits literal FALSE source_saturated for 16 unflagged arms (9 now duplicateCapable:true). Guarded-safe today (a truncated non-dup window still forces ≥take+1 candidates → hasMore truthful); becomes live only if an arm is mislabeled. See NF-2.
426 unexpressible / no x-evenfire-client-version producer Med 🔵✔ 426 omitted from ACCESS_PUBLIC_STATUSES; minimum-client enforcement off by default. PR2 concern.
access sanitizer rewrites code, discards message High 🟡✔ 409 invalid_request→conflict + canned message erases "Catalog access changed; refresh from the first page." Reachable only when catalog served (default off). See NF-3.
everyCatalogFamilyReady / 1000 ceiling / no leader election Med 🔵✔ All confirmed; serve/shadow-only, default off. PR2 rollout hardening.
CodeQL scope / rate-limiter fail-open Med 🟡✔ Custom suite swaps 2 stock queries reusing their @id; paths-ignore fixtures-only; scope check asserts 3 of ~14 services; rateLimiterService fails open on DB error and CodeQL certifies such routes as limited.

Reviewer inventory completeness


Deliverable 2 — fresh full-diff audit (standing findings on this head)

No Blocker/Critical survives on this head. All four original blockers and both R11 blockers are genuinely closed (mutation-relevant tests read, not just green). Independent access-core scan also found no fail-open catch in the new decision chain, no SQL injection (all $n params; dynamic SQL is developer-authored arm literals only), and sound multi-tenant isolation (every arm filters by principal userId / active membership). Standing items, none a PR1 must-fix:

  • NF-1 (Med, latent): authorizationRevision.ts:46,70,97,112 localeCompare in the "canonical" functions that bind the ar1_ revision hash → cursor freshness and the ap1_ access-path HMAC. localeCompare returns 0 for distinct collation-ignorable strings → Array.sort falls back to DB row order → a plan flip / Node-ICU upgrade can yield a different revision for identical state → spurious access_path_stale/stale_cursor/authorization_revision_conflict 409s. Determinism/availability, not a bypass; exploit path not reachable (ids constrained today). Fix: use compareCatalogText; delete dead compareResourceIdentity (resourceIdentity.ts:110). Fix →
  • NF-2 (Low→Med, latent trap): per-arm source_saturated = FALSE on 16 unflagged arms makes "hit the work bound" indistinguishable from "reached source end". Safe today — a non-dup LIMIT take+1 window holds take+1 strictly-distinct rows, so truncation still yields ≥take+1 candidates and hasMore is truthful. Becomes a silent-drop only if a non-dup arm is ever mislabeled (starts emitting intra-arm duplicate logical_ids). Fix: emit COUNT(*) >= $limit for every arm and drop the duplicateCapable flag.
  • NF-3 (Med, gated): access sanitizer 409 invalid_request→conflict + always-canned message erases the actionable guidance; 426 unexpressible. Reachable only under catalogMode=serve/shadow (default off). PR2 should widen the public vocabulary before enabling.
  • NF-4 (Low): R13 correlation fix is app-level only; gfs.ts/access.ts/rpc.ts route forwarders still randomUUID the body correlationId (gfs response header survives).
  • NF-5 (Med, ops): deploy-window risk — 35 CREATE INDEX (0 CONCURRENTLY, illegal in-tx) + dozens of trigger ops + 2 full-table backfills all inside initDb's single BEGIN…COMMIT, so locks on users/team_members/workflow_runs/notification_deliveries/gfs_* accumulate until one final commit. Measure against a prod-size dump and document a window. Plus a UNION-without-ORDER-BY row-lock ordering in authorization_bump_gfs_resource_subjects (:595-602) → deadlock-prone (ORDER BY subject_type, subject_id fixes it), and a same-row double-bump when team_id = usage_team_id.
  • NF-6 (Low, standing): R1-M1 bare catch{} (no req.log) and teams.ts console.warn violate the pino logging standard; fail-closed, no secret leak.
  • NF-7 (Med, rollout trap): enabling CONTROL_API_USER_ACCESS_CATALOG_MODE without its paired …TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT throws at module load → CrashLoopBackOff, and the pairing is undocumented (no docs/, no configmap comment). This is the intended fail-before-traffic behavior (R11-B1) but a one-step foot-gun. Also configmaps.yaml:21 ships OPERATIONAL_ACCESS_INDEXER_ENABLED: 'true' (code default false), so the indexer + its cluster-wide watch RBAC start in every deployed environment on merge — worth stating explicitly since reading config.ts alone implies the opposite.
  • NF-8 (Low, defense-in-depth): the catalog cursor payload carries no userId; cross-user replay is blocked only indirectly (revision equality + producer re-filtering by session userId). Bind userId into the cursor payload/HMAC before any PR2 stabilization of sessionRevision.

Reopened items: none. No previously-CLOSED finding reopened after the R12/R13 commits or the dev sync. The delta commits are additive corrections; the byte-order/H1/H3 fixes are untouched.

PR-description accuracy: consistent — v2 surfaces are 503-closed by default; operationalAccessIndexerEnabled defaults false in code (note the configmap override in NF-7). The CodeQL prose slightly under-discloses that the custom suite reuses stock @ids (disclosure gap, not a security gap).


Answers

1. Are there any PR1 must-fix findings remaining?
No. Every item that breaks a contract PR1 claims to establish is closed and test-backed on this head. What remains is (a) latent defects gated behind default-off rollout flags (NF-1/2/3, 426, everyCatalogFamilyReady, indexer ceiling/leader-election), (b) legitimately PR2-scope credential-binding (RPC 300s window — real, quantified, not a PR1 regression), (c) pre-existing/out-of-diff (sibling password-reset supersession), and (d) hygiene/ops follow-ups (NF-4/5/6/7/8, R1-M1/M4/L7, V1 replacement TTL, SSO null-epoch fence). None is a foundation-contract break.

2. Is the current exact head safe as the PR2 stack base?
Yes. Authorization is fail-closed (no fail-open in the new decision chain), v2 surfaces are disabled by default, and the live-authority + aggregate-discovery + byte-order + revision-scoping contracts PR2 depends on are coherent and real-PG-backed. The R12/R13 series fixed the d3ba05b0 regressions at source (verified, not masked). PR2 should carry these as explicit acceptance criteria: (a) final derived-RPC/GFS issuance fencing + the 300s post-revocation window; (b) the public-vocabulary/sanitizer gaps (NF-3, 426); (c) the rollout hardening (NF-7, everyCatalogFamilyReady, leader election) and the NF-1/NF-2 comparator/saturation fixes before enabling CATALOG_MODE/shadow anywhere — the shadow lane is not usable as rollout evidence until then.

⚠️ Reminder: "safe as PR2 base" does not mean PR #334 is ready to merge into dev. Per the PR's own merge constraint, PRs 2, 3, and 4 must be integrated into this branch and final stack reconciliation must pass first.


Review only — no code changes. This is analysis, not a formal GitHub approval. Dispositions traced to code and test assertions at 8ffc021b across five independent subsystem traces; suites were not executed here (npm sandboxed), so pass/fail is read-verified and the CI-lane fail-loud guards were inspected instead.

@alfredolopez80 alfredolopez80 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Independent review — exact head 8ffc021b

Read-only review against dev 4862d9c2 (live origin/dev). Claude's ledger at #334 (comment) was used as a checklist only. Every disposition below was re-found in head (and compared to base when claiming introduced_by_pr).

This is not a GitHub Approve. Suites were not re-executed locally (control-api has no vitest binary here). Ordinary CI 33198807151 and CodeQL 33198807026 are green on this head.

Legend: ✅ CLOSED · ⚠️ PARTIAL · 🟡 OPEN · 🔵 DEFERRED-TO-PR2 · ⬜ PRE-EXISTING · ✔ independently verified


Deliverable 1 — historical-findings closure ledger

ID Sev Claude This review Live-head evidence
R1-B1 catalog ordering Blocker ✅ ✅✔ compareCatalogKey → Buffer.compare UTF-8. SQL catalog_utf8_bytes(). Team-GFS uses compareCatalogText. Independent TextEncoder oracle.
R1-H1 effectiveCapabilities High ✅ ✅✔ liveAuthorizationResolver.ts:328 copies selectedPath.behavior.capabilities only.
R1-H2 circular tests High ✅ ✅✔ Streams sorted by a standalone oracle, not the SUT.
R1-H3 revision hot row High ✅ ✅✔ 0104/0105 drop singleton + statement triggers. Residual is per-subject locks (RP-005), different granularity.
R1-M1 bare catch{} Med 🟡 🟡✔ externalSessionAuth.ts:122,176,244. Fail-closed 503/401. No req.log. RP-006. Combined with RP-020, a 503 can be invisible.
R1-M2 console.warn Med ⚠️ ⚠️✔ users.ts migrated. teams.ts:139,182 still console.warn — pre-existing on base.
R1-M4 CATALOG_FAMILIES Med 🟡 🟡✔ Two identical literals. RP-012.
F1 live admin revocation High ✅ ✅✔ Role from getLiveTeamMembership, not token claims.
F3 password invalidates V1 High ✅ ✅✔ valid_after fence + users FOR UPDATE before epoch write.
R9-H1 epoch backfill High ✅⚠️ ⚠️✔ valid_after NULL skips the fence (SSO/passwordless residual).
R9-H2 / R12-H1 final authority High ✅ ⚠️✔ Eight HTTP classes + real-PG matrix are wired. Invite/resend activation does not re-check the session epoch (RP-013). Helper fail-open if authority omitted (RP-011).
R11-H1 / B1 / B2 Blocker ✅ ✅✔ GFS public codes, admission fail-fast, 413 upload-length.
R12-H2..H4 + retry: 0 High ✅ ✅✔ profile-ui structured error.code; invite sentinel gone; desktop_requires_team allowlisted; retry: 0.
R13 correlation High ✅⚠️ ⚠️✔ App-level fallback exists. Route forwarders still two-arg → randomUUID (RP-004).
RPC 300s High 🔵 🔵⬜✔ Pre-existing. rpcAccessAuth is signature/typ/scope only. TTL 300s. RP-009.
sibling password-reset High ⬜ ⬜✔ Pre-existing. Not widened.
jozer-rami — NOT_FOUND ✔ No comments/reviews/inline threads on #334.
josue-palmera — author is josue-tech-94 ✔ No GitHub login josue-palmera.

Reopened previously-CLOSED items: none, except R12-H1 is PARTIAL (not a full reopen of the eight-class HTTP wiring — the post-commit invite/resend gap is new relative to Claude's CLOSED).


Deliverable 2 — PR-introduced findings on this head

Count: 0 Blocker · 0 Critical · 2 High introduced · 10 Medium introduced · 6 Low introduced · 5 pre-existing · 1 evidence gap (local Vitest).

Claude's last pass reported no PR1 must-fix. This review disagrees on RP-013 (live invite activation after revoke-all). The extra Medium vs the earlier “9” is RP-020 (silent sendPublicApiError), found in the logging lane.

Inline comments are attached on the 2 High and 10 Medium introduced by this PR.

Introduced High

ID Sev File Defect Gate Cluster
RP-013 High membership.ts:800-872 Two-phase invite: txn 1 checks epoch, email is sent outside the lock, txn 2 only re-checks team role + lifecycle_state='active'. revokeAllUserSessions does not change those columns, so draft → pending still happens and the emailed token works. Accept/getInvitationByToken require pending (draft/revoked are hidden) — the hole is activation, not a dead draft. Real-PG matrix only contends the first users FOR UPDATE. Retirement does fail txn 2. Resend is weaker (already pending). Live today (email I/O window). C3
RP-019 High liveAuthorizationResolver.ts:227-248 Catalog ap1_ HMACs an ar1_ over all hydrated paths. Live rebuilds ar1_ from capability-filtered capable only. Heterogeneous grants + gfs.write + catalog handle → permanent access_path_stale. Not an extra-capability grant. Live unit test uses gfs.read and live-issued ids. Catalog mode default off. C1

Introduced Medium

ID Sev File Defect Gate Cluster
RP-001 Med authorizationRevision.ts:46 localeCompare (and normalizeAccessCapabilities .sort() UTF-16) still bind ar1_ / ap1_ after pagination moved to UTF-8 bytes. Spurious stale/409 across ICU/locale. UUID/ASCII ids make a concrete reorder hard today. Catalog serve C1
RP-002 Med catalogProducerArms.ts:61-64 Non-duplicateCapable arms emit literal FALSE for source_saturated. Silent drop if a future arm is mislabeled. Safe today if arms stay 1:1 C1/C5
RP-003 Med external-rest-api/.../publicApiError.ts:57 409 invalid_request not allowlisted → rewritten to canned conflict. Catalog refresh guidance erased. Catalog serve C2
RP-005 Med userAccessFoundationSchema.ts:595 In-tx CREATE INDEX (no CONCURRENTLY) + unordered GFS subject UNION locks + double bump when team_id = usage_team_id. Deploy window C6
RP-007 Med configmaps.yaml:21 Catalog-without-admission throws at module load (CrashLoop). Deploy sets indexer 'true' (code default false) with no leader election, 1000-object clamp. Live on merge. Merge / catalog enable C5
RP-011 Med membership.ts:1901 hasValidManagedMutationAuthority: if (!authority) return true. HTTP eight-class routes do pass it. Future/internal caller skips the fence. Not current HTTP C3
RP-015 Med external-rest-api/.../access.ts:10 No 426 anywhere in sanitizer tables / PROPAGATED_STATUSES. Global handler maps other 4xx to invalid_request. Minimum-client gate becomes inert for public clients. Enforcement default false C2/C5
RP-016 Med external-rest-api/.../publicApiError.ts:256 publicCorrelationId echoes raw x-correlation-id (sanitizer validates; this helper does not). Live on 4xx fallback C2
RP-017 Med codeql-config.yml + local .ql disable-default-queries: true; stock js/missing-rate-limiting and js/user-controlled-bypass excluded and replaced by local queries reusing those @ids. Bypass model only treats 5-arg sendPublicApiError. paths-ignore is fixtures-only. SAST only —
RP-020 Med control-api/.../publicApiError.ts:39 sendPublicApiError never logs. Direct /external 401/403/503 have no audit line. clerumErrorHandler still logs before calling it on next(error). Fail-closed. Live observability C2

Introduced Low (no inline)

ID File Defect
RP-004 gfs.ts / access.ts / rpc.ts Route sanitizers omit fallbackCorrelationId.
RP-006 externalSessionAuth.ts:122,176,244 Bare catch {}.
RP-008 accessCatalogCursor.ts Cursor payload has no userId (HMAC + session revision still bind).
RP-012 CATALOG_FAMILIES ×2 + REQUIRED_SOURCES ×2 Dual source of truth; order is a wire contract.
RP-018 externalSessionIssuance.ts:148 V1 exchange mints a fresh 12h TTL, not min(12h, source.exp).
RP-021 main.ts:122 Indexer .completion.catch uses console.error at runtime.

Pre-existing (not introduced)

ID Sev Defect
RP-009 High RPC bearer valid 300s after parent revoke.
RP-010 High No one-active password-reset constraint.
— — POST /external/teams has no in-txn authority (createTeamForUser). Middleware admits without users FOR UPDATE.
— — teams.ts / sharedFilesystems.ts console.warn. Rate-limiter DB fail-open.

Root-cause clusters

Cluster Violated invariant Fix in this PR vs PR2
C1 One UTF-8 byte order everywhere that binds pagination, ar1_, ap1_ Before catalog serve (RP-001/012/019)
C2 One public {status, code, message, correlation} table; every egress logs Before minimum-client / catalog (RP-003/004/015/016/020)
C3 Every privileged directory write revalidates the session inside the txn that makes the capability live RP-013 now (invite/resend). RP-011 fail-closed primitive.
C4 Derived credentials die with the source epoch PR2 (RP-009 300s; GFS authGeneration is v1-only)
C5 Flags, deploy, and fail-fast are one contract; no single env CrashLoops a replica Before merge / catalog enable (RP-007)
C6 Production DDL does not hold write locks for the entire init txn; subject bumps lock in stable order Before first prod migrate (RP-005)

Answers

1. Merge into dev?
No. The PR's own constraint forbids landing PR1 before PRs 2–4. Merge would also turn the operational access indexer on in every replica (RP-007) and leave RP-013 live.

2. Any PR1 must-fix remaining?
Yes — RP-013. A revoked session must not authorize invitation activation after the authorizing txn commits. Claude marked R12-H1 CLOSED; the eight HTTP classes are wired, but the two-phase path is not epoch-fenced.

3. Safe as PR2 integration base?
Yes, with explicit acceptance items: RP-013 first; RP-019 + C1 before CATALOG_MODE=serve/shadow; RP-015 before MINIMUM_CLIENT_ENFORCEMENT; RP-007 before treating indexer-on as intentional; RP-009 quantified as 300s until PR2 binds derived RPC/GFS to the epoch (v2 sid/sv, not only v1 authGeneration).

4. What this review did not promote
CSRF dummy-Bearer: extractAuthToken prefers Bearer and fails 401 (no cookie fallback). Old-client /rpc/token 403 shape: in-tree Desktop already reads error.details.reason. resolveLiveAuthorization having one caller is PR1 stated scope. Advertised-but-unconsumed flags (rpcDelegationV2, etc.) are PR2–4 work. session_expired is declared and never emitted.


Review only — no code changes and no formal approval. Evidence is inspected-code at 8ffc021b vs 4862d9c2. Inline threads cover the 2 High and 10 Medium introduced by this PR.

Comment thread external-rest-api/src/routes/access.ts Resolved
Comment thread .github/codeql/codeql-config.yml Resolved
Comment thread control-api/src/http/publicApiError.ts Resolved
@alfredolopez80

Copy link
Copy Markdown
Contributor

Independent review — PR #334 @ 8d541772 (18-agent parallel fan-out)

Reviewed in a dedicated read-only worktree pinned to the exact head. 8d541772 contains
origin/dev 24d749d0 in full (0 commits behind, 210 ahead). Tree stayed clean throughout;
no tracked file was modified. control-api and external-rest-api dependencies installed
locally so suites and mutation checks could actually run.

5 Blockers · 18 High · 31 Medium · 24 Low. Every finding is labelled REPRODUCED
(command + output) or READ-ONLY (code trace). Where two agents disagreed, the
disagreement is resolved in-line and the losing reading is stated.


The two structural findings

Most individual findings below are instances of one of these two. If only two things are
taken from this review, take these.

1. The rollout path has never been executed end to end

Four of the five Blockers live behind CONTROL_API_USER_ACCESS_CATALOG_MODE, which defaults
to 'off' (userAccessPolicy.ts:322-327) and appears in no file under deploy/
(grep -rn 'CONTROL_API_USER_ACCESS' deploy/ → empty; none of the 7 rollout flags reach a
manifest). They are not "latent until day 1" — day 1 has never been rehearsed:

  • turning on shadow puts a cold start into CrashLoopBackOff (B4, reproduced);
  • serve cannot start at all, in any configuration (B4, reproduced);
  • with the catalog on, gfs_resource can never be compared (B3, arithmetic);
  • and the aggregate producer for sandbox_app silently drops canonical resources (B2).

This also explains why 18 review rounds did not surface them: the reviewed configuration is
not the configuration the feature needs.

There is no observability surface either:
grep -rn 'aggregate_access_shadow|catalogParityAccepted|catalog_serving_parity' over
*.yaml *.yml *.json *.md *.tsv → empty. No dashboard, alert or runbook renders the
shadow signal that catalogParityAccepted gates on.

2. Three separate findings were closed by widening the detector

Commit / file Guard What was done
eec63bd1 (r9-b1) T2 public boundary Widened the DSN matcher by keyword instead of an exact allowlist
1045b570 T2 public boundary Moved private runtime URL to return True for source fixtures and widened the fixture-path regex
EvenfireMissingRateLimiting.ql (new) CodeQL Relaxed "the limiter dominates the expensive node" to "a limiter is reachable later on the route"

1045b570 is labelled "preserve … after latest dev" but restores nothing — verified
that merge 3cc9fa25 lost no lines under scripts/. It adds a synthetic literal and
widens the escape hatch in the same commit, and its body claims validation from one
result. Accepting a guard change needs three: it passes on the tree, it fails on a
freshly introduced violation, and its escape hatch silences an annotated case. The second is
demonstrated nowhere.


Blockers

B1 — Canonical rate limiting runs after runtime policy work (R18-B1, author-declared, now measured)

control-api/src/routes/external/access.ts:209-212 (catalog) and :253-256 (resolve).
requireEffectiveV2Contract is composed before rateLimitMiddleware, so the 409 branch
is 100% unmetered.

REPRODUCED. Temporary test, run and deleted, with the bucket already exhausted
(checkAndIncrement → allowed:false):

npx vitest run test/zz-audit-r18b1-repro.test.ts   → EXIT=1, Tests 2 failed (2)
  "limiterCalls": 0,
  "policyCalls": 2,
  "sessionValidations": 1,
  "status": 409,

limiterCalls: 0 with an exhausted bucket proves the limiter is never reached.
policyCalls: 2 quantifies that the policy is resolved twice and the first result discarded.

Honest cost, both configurations (measured statement-by-statement, not estimated):

Configuration Statements before the limiter
Today (catalogMode='off') 5 statements / 1 transaction. v2 is read_write with SELECT … FOR UPDATE OF s + UPDATE external_user_sessions; v1 is read_only with a plain SELECT, no row lock. The two policy resolutions short-circuit in userAccessRuntimePolicy.ts:147-149 without touching the DB.
Rollout target (catalogMode on) 15 statements / 3 pool checkouts, 1 of them a write holding a row lock.

Requires a validly signed session. Measured: a garbage token costs
{ status: 401, statements: 0, poolCheckouts: 0 } — verifyUserSessionV2Token and
verifyExternalSessionToken are pure jwt.verify. Any report claiming "an unauthenticated
caller forces expensive DB work"
is inaccurate for the shipped configuration. That
becomes true only once the catalog is on, because the policy resolution runs before token
verification.

The correct pattern is 20 lines above, in the same file.
/external/access/capabilities (:184-187) composes
requireAuthenticatedExternalUserRateLimitContext + rateLimitMiddleware before the handler,
and has a real guard test (test/routes.externalAccess.test.ts:186-206). Ten other external
route files mount createExternalClientRateLimiters pre-auth;
/external/access is the only external family with no per-IP ceiling at all.

Why it was never closed: 8e4b1d21 "r3-h8 route external policy through limiter
middleware"
changed exactly one line of access.ts — the /capabilities one. History
confirms no commit ever moved the policy work behind the limiter.

Fix: move the limiter ahead of requireEffectiveV2Contract; reuse the policy already
resolved during authentication instead of re-resolving it (one line — see H-group 4); and
mount the pre-auth per-IP limiter this family is missing.

B2 — The sandbox_app producer drops canonical resources and reports the page as complete

catalogProducerSql.ts:370-420 (both arms lack duplicateCapable) ·
catalogProducerArms.ts:60-64 (SQL emits literal FALSE) ·
catalogProducerHead.ts:139-144.

source_saturated is generated as constant FALSE for any arm without duplicateCapable,
and listBoundedProducerKeys translates that into state.exhausted = true after the first
refill. But sandbox_app arms can return repeated logical_ids: the PK of
operational_resource_relationships includes source_id and relationship_instance_id
(userAccessFoundationSchema.ts:154-157) and the SELECT has no DISTINCT.

Failure: GET /external/access/catalog?families=sandbox_app&limit=1 for a user with two
recipes exposing app-a and one exposing app-b → app-b disappears permanently and the
response is complete: true, partialErrors: [], nextCursor: null. That is the exact
inverse of the PR's stated invariant.

node repro-minimal.mjs → EXIT=0
source rows: 3 · distinct sandbox_apps: app-a, app-b
candidates returned: sandbox_app:app-a
hasMore: false · continuation.exhausted: true

Evidence scope, stated honestly: the loss mechanism is reproduced by executing a literal
copy of listBoundedProducerKeys. The input premise — that the SQL yields duplicates with
source_saturated=FALSE — is read from the code, not executed against Postgres.

The coverage hole that let it through:
catalogDistinctPagination.realPostgres.integration.test.ts:420 is named "preserves distinct
keys for every duplicate-capable producer arm class"
and exercises host, context,
workflow_recipe, mcp_server, shared_filesystem and gfs_resource — it omits
sandbox_app
, the one duplicate-capable family without the flag.

Fix: add duplicateCapable: true to both arms and extend that test. Independently, make
source_saturated always (SELECT COUNT(*) FROM arm) >= $4; the flag should save the COUNT,
never be able to produce a false "not saturated".

B3 — The shadow lane can never compare gfs_resource, by arithmetic

accessCatalogShadow.ts:131-140 reserves a child budget of databaseStatements: 8. The
minimum non-empty path for gfs_resource needs 9:

# Step File:line Acc.
1 withAccessDatabaseTransaction → BEGIN accessCatalogCoordinator.ts:398 1
2 SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY accessAuthorityTransaction.ts:9-15 2
3 SELECT set_config('statement_timeout', …) accessAuthorityTransaction.ts:16-22 3
4 principal + memberships catalogAuthorityContext.ts:83-168 4
5 operational_catalog_source_state catalogAuthorityContext.ts:185-194 5
6 refill(null) — direct arm catalogProducerHead.ts:147 6
7 readHeads — team arms teamGfsTopK.ts:200 → :168 7
8 hydrateCanonicalKeys catalogProducers.ts:564-569 8
9 COMMIT accessDatabaseQuery.ts:202 9 → throws

runAccessDatabaseQuery charges databaseStatements unconditionally
(accessDatabaseQuery.ts:78). Condition, in terms of the variables: with M = active
memberships and R = visible GFS resources, step 7 fires ⟺ M ≥ 1 (even if those teams own
nothing) and step 8 ⟺ R ≥ 1. So M ≥ 1 ∧ R ≥ 1 ⟹ 9 statements ⟹ skipped_capacity, always.

Why this matters more than it looks: the shadow lane exists to de-risk the rollout. For
any user with a membership and a GFS resource it records skipped_capacity and compares
nothing — and the failure is silent, because skipped_capacity is the same bucket as
genuine load shedding. The metric cannot distinguish "we were busy" from "this family is
structurally impossible to compare".

Fix: size the child budget from the real per-family cost, and separate structural
exhaustion from transient exhaustion in the metric.

B4 — Every readiness degradation ends in throw: shadow crash-loops on cold start, serve can never start

userAccessRuntimePolicy.ts:64-71,147-158,190-197 · userAccessPolicy.ts:129,205,210,362-387
· main.ts:63,116.

resolveEffectiveUserAccessPolicy carefully degrades to unavailableReadiness() in its two
failure branches — but that readiness carries snapshot: 'unavailable' and
compileUserAccessPolicy rejects it on its first line
(requireCondition(readiness.snapshot === 'current', 'readiness_snapshot_unavailable')),
before it ever looks at catalogMode. Both degradation branches are dead ends that always
throw. Separately, catalogParityAccepted is a constant false that no readiness constructor
overrides, so serve fails unconditionally.

  1. Cold start with catalogMode=shadow → CrashLoopBackOff, bootstrap deadlock.
    main.ts:63 resolves the policy before the indexer starts at :116. Without fresh rows
    in operational_catalog_source_state, runtimeReadiness admits only the 6 DB-only
    families → catalog_shadow_families_incomplete → process.exit(1). The indexer that would
    populate those rows never runs. Escapable only if another replica keeps the rows fresh; a
    cold start of the whole Deployment is permanently unstartable.
  2. serve / serve_and_shadow → catalog_serving_parity_not_accepted, even with perfect
    operational readiness.
  3. Hot path, already running. If an operational source moves to relisting — the routine
    watch expiry — userAccessRuntimePolicy.ts:196 explicitly re-throws, and
    externalSessionAuth.ts:176 turns it into 503 authority_unavailable on every
    /external/* route
    : profile, teams, members, invitations, GFS. The blast radius far
    exceeds the feature the flag protects, and shadow is precisely the mode meant to de-risk.

REPRODUCED, with the red validated first:

npx vitest run test/zzTempReviewPolicyRepro.test.ts
  1st run (deliberately wrong expectation): EXIT=1
    AssertionError: expected 'readiness_snapshot_unavailable' to be '<no-throw>'
  2nd run (corrected):                      EXIT=0, Tests 4 passed (4)

The four assertions pin the exact codes: [off + unavailableReadiness] → readiness_snapshot_unavailable,
[shadow + unavailableReadiness] → readiness_snapshot_unavailable,
[shadow + cold start] → catalog_shadow_families_incomplete,
[serve + perfect readiness] → catalog_serving_parity_not_accepted.

Fix: have compileUserAccessPolicy return a degraded policy (everything v2 off, v1
alive) when readiness falls short, and reserve throw for invalid configuration validated at
startup, not on the hot authentication path. Move the main.ts:63 call after the indexer
starts, or give it a bootstrap grace window.

B5 — Deployment: the migration Job locks 12 hot tables in one transaction with no lock_timeout

userAccessFoundationSchema.ts:201-306 and :629-686; runner db.ts:6246-6291; Job
deploy/scripts/run-control-api-db-migration.sh.

Measured on the tree: 14 new tables, 37 indexes, 24 of them on pre-existing tables, CONCURRENTLY: 0,
plus 36 DROP/CREATE TRIGGER statements — all inside a single BEGIN … COMMIT, with no
SET LOCAL lock_timeout.

To be precise about the actor: the pod does not migrate. main.ts calls assertDbReady(),
which only verifies and throws if a migration is missing — correct fail-fast. Migrations run in
the separate migrate.ts → initDb() entrypoint, i.e. the deploy Job. The risk is that Job
holding ACCESS EXCLUSIVE (which blocks reads too) on users, teams, team_members,
workflow_runs, workflow_approval_requests, notification_deliveries, gfs_grants,
gfs_shares, gfs_resources while already-serving pods keep taking traffic. Table volume
decides how long the index build takes, not whether it blocks: the trigger DDL blocks
users and team_members even if it takes 1 ms, and no lock is released until the batch
commits.

The repo already learned this lesson. Migration 0090 (db.ts:2350, registered at
:5899) sets SET LOCAL lock_timeout = '60s' with 13 lines of comment stating exactly this
reasoning — and the Job's backoffLimit: 2 / restartPolicy: Never was added in that same
PR to accompany it
. The 6 new migrations inherit the Job but not the lock_timeout: they
inherit the retry without the fail-fast that made it useful. With lock_timeout = 0 there is
no abort to retry, only an unbounded wait.

A failing deploy does not end the incident. On kubectl wait --timeout=300s expiry the
script exit 1s without deleting the Job. The migration pod stays alive, its transaction
open and its ACCESS EXCLUSIVE request queued — and in Postgres a queued lock request
blocks everyone behind it, readers included. There is no activeDeadlineSeconds, no
statement_timeout and no idle_in_transaction_session_timeout on this path.

Reversibility, precisely — two different questions, two different answers:

  • Schema rollback: SAFE. Additive only — 11 CREATE TABLE IF NOT EXISTS, zero
    ALTER TABLE
    , no new NOT NULL column on an existing table; old code keeps inserting
    unchanged. Job failing mid-way is also safe: one transaction, schema untouched, and old code
    starts because its CONTROL_API_MIGRATIONS doesn't include 0107–010c.
  • Write-cost rollback: NOT REVERSIBLE. If the Job completes and the app rollout then
    fails, reverting the image leaves 8+ FOR EACH ROW triggers active on the hottest
    tables. There is no down migration (DbMigration has no reversal field) and no feature
    flag switches them off
    — they don't depend on catalogMode or sessionV2Issuance.

Fix: SET LOCAL lock_timeout = '60s' at the head of each of the 6 new apply bodies,
mirroring 0090; activeDeadlineSeconds on the Job so a hang cannot outlive the script's
exit 1; and move the 24 indexes on pre-existing tables to an online migration outside the
startup transaction.


High — grouped by theme

Group 1 — Guards that do not guard

G1-a · The T2 public-boundary guard now lets internal DSNs through any test file.
scripts/tests/test-minikube-t2-public-boundary.sh:145 (function), :155-164 (the branch),
:161 (the keyword regex), :227 (call site). The new exemption accepts any credential-less
Postgres DSN in a test path when the line contains one of six words:
(?i)(CONTROL_API_REAL_PG_ADMIN_URL|adminUrl|sentinel|fixture|example|test).

REPRODUCED with a before/after control and two negatives, same fixture and same base:

CASE 1  PR guard, internal DSN in a test file      → PUBLIC_BOUNDARY_PASS      EXIT=0
CASE 2  origin/dev guard, same fixture             → PUBLIC_BOUNDARY_REJECTED  EXIT=1
        - control-api/test/leak.realPostgres.integration.test.ts: private PostgreSQL URL
CASE 3  PR guard, same DSN under control-api/src/  → REJECTED                  EXIT=1
CASE 4  PR guard, DSN *with* credentials in a test → REJECTED                  EXIT=1

Cases 3 and 4 bound the hole: it does not apply under src/, and credentialed DSNs are still
rejected. The hole needs three simultaneous conditions, so please don't read it as "any DSN in
any test".

What makes the fix trivial: eec63bd1 is +156/−0 over the two scripts and deleted no
string. The strings that motivated it are synthetic — one literal, 11 times:
grep -hoE "postgres(ql)?://[^\"' ]+" test/*.realPostgres.integration.test.ts | sort | uniq -c
→ 11 postgresql://postgres@127.0.0.1/postgres. Loopback, user postgres, no password. The
fixtures are legitimate; the exemption mechanism is the defect. For credential assignment
the same commit used an exact literal allowlist — correct and airtight, verified. Apply
that same treatment to DSNs and drop the keyword branch.

G1-b · The new CodeQL model is, by design, blind to B1's defect class.
.github/codeql/queries/EvenfireMissingRateLimiting.ql:143-168. The queries are all new
(git diff --diff-filter=A). The first disjunct uses stock isGuardedByNode (limiter
dominates); the three added disjuncts relax it to "a limiter is reached later on the route",
and the fourth marks a parent router.use as guarded when all its HTTP children rate-limit —
without requiring the context handler itself to be cheap. access.ts:182 fits that shape
exactly, so CodeQL cannot report the blocker this PR has open.

Two more measurements make this affirmable without running CodeQL:

  • The fixture never exercises the real shape. asyncHandler appears 0 times in
    .github/codeql/queries/test/ and 6 times in control-api/src/routes/external/access.ts.
    Either branch is bad: if CodeQL models asyncHandler as resuming dispatch, access.ts:182 is
    suppressed and B1 is invisible; if it does not, the 45-line fourth disjunct is dead code in
    production and only ever runs against the synthetic fixture.
  • The recognition of "canonical middleware" is a hand-written allowlist of two file paths and
    three function names
    , with sameSourceFile required in five predicates.
    requireAuthenticatedExternalUserRateLimitContext — the guard this PR introduces, used
    15+ times including access.ts:186 — is in neither list, because it lives in a third file.
    The query also replaces stock js/missing-rate-limiting in the suite.

G1-c · The .qls verifier can be bypassed by exclusion syntax. REPRODUCED end-to-end with a
codeql stub against the real guard: excluding js/sql-injection by id → EXIT=1
(correct); excluding the same query by tags contain → the query disappears from the resolved
suite just the same and the guard returns EXIT=0. The guard is not inert — it satisfies
all three required results — but what it watches is syntactic, not semantic.

Group 2 — Tests that exist but protect the wrong thing

G2-a · The only ordering test on these routes certifies the blocker as correct.
test/routes.externalAccess.test.ts:163-173. The it.each is literally named
'keeps %s %s disabled before rate-limited work' — its name enshrines the policy gate
running before the limiter — and asserts only 409 + invalid_request. The assertion that
would catch it exists in the same file, at :186
(expect(mocks.resolvePolicy).toHaveBeenCalledTimes(1)) — applied to /capabilities, the one
route that already has the right order. And the two new policies have zero coverage:
grep -rn 'external_access_catalog\|external_access_resolve' → 4 hits, all in
src/routes/external/access.ts; 0 in control-api/test
.

Practical consequence: whoever fixes B1 by swapping the order will see this test go red and
think they broke something. The bug is protected by its own guardian.

G2-b · Mutation testing: 9 mutations, each reverted with a verified-clean tree between them.

# Mutation EXIT Tests that go red
1 retry: 0 → retry: 2 (vitest.config.ts:15) 0 NONE
2 PUBLIC_CODE_BY_STATUS[413] → 'internal_error' 0 NONE
3 PUBLIC_CODE_BY_STATUS[422] → 'internal_error' 0 NONE
4 PUBLIC_CODE_BY_STATUS[429] → 'internal_error' (contrast control) 1 3
5 ALLOWED_CODES_BY_STATUS[413] → new Set([]) 0 NONE
6 both 413 tables broken together 1 3
7 both 422 tables broken together 0 NONE
8 r15-h1 reverted (membership.ts:856-863) — NONE in the non-Postgres lane
9 r15-b4 reverted 1 1

The 429 contrast control is what makes this table mean something: without it, "I found no test
protecting this" is indistinguishable from "I failed to find them". The suite can catch this
class of regression — it does so for 429, including the fallback branch.

The 413 defect is double, and rows 2/5/6 separate the two halves. The pass-through path is
tested (routes.gfs.test.ts:571-611), but those tests never reach the mapping:

// external-rest-api/src/http/publicApiError.ts:177-180
const fallbackCode = PUBLIC_CODE_BY_STATUS[error.status] || 'internal_error'
const code = ALLOWED_CODES_BY_STATUS[error.status]?.has(suppliedCode) ? suppliedCode : fallbackCode
  1. No test walks the fallback branch for 413 (row 2: the fallback table can say anything and
    nobody notices). No test emulates a 413 whose upstream supplies no valid code — which is
    exactly where the historical regression lived.
  2. Neither table is independently verifiable, because for 413 both carry the same string and
    code falls silently from one to the other (row 5: emptying the allowlist is also green, since
    fallbackCode is 'payload_too_large' too). Only breaking both at once turns 3 tests red.

So: the three 413 tests do not verify the mapping, they verify the final value — they cannot
tell which table produced it, and therefore protect neither.

422 is worse: coverage is zero, not masked. Row 7 breaks both 422 tables simultaneously and
the suite still reports EXIT=0, 232 passed (232). Not one test asserts its public code.

Fix: a table-driven test over all 18 statuses exercising both branches per status —
upstream supplying a valid code, and upstream supplying none. That pattern already exists and
works for 429 (publicErrorContract.test.ts > preserves only a bounded retry delay from legacy rate-limit bodies, which emulates a legacy body with no supplied code). It just needs generalising.

G2-c · r15-h1 is covered only by a Postgres-gated suite. Its sole guard,
routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts, is describe.skip on
any run without a DSN. Reverting the authority revalidation passes 5,515 tests green.

G2-d · retry: 0 has nothing pinning it. grep -rln "vitest.config" over scripts/,
.github/, control-api/test/ and Makefile returns exactly one file,
scripts/tests/test-gfs-real-pg-minikube-gate.sh:50, which checks setupFiles, not retry.
One grep -Fq 'retry: 0' in that same script closes it.

Group 3 — The public error contract is implemented three times and already diverges

REPRODUCED by importing the real modules at 8d541772: 11 of 19 statuses disagree
between external-rest-api/src/http/publicApiError.ts (the propagated table) and the ternary
chain in external-rest-api/src/app.ts:117-159 (72 lines, new in this PR), within the same
package
. 408, 409, 410, 411, 412, 413, 425, 426, 502, 504, 507 all differ, and retryable
differs for 408/425/502/504.

  • Two codes control-api emits are not recognised by the edge: 426 upgrade_required
    (9 emitters) collapses to internal_error, and 409 invalid_request collapses to conflict
    — so access.ts:117's "This access contract is not enabled for the current session" reaches
    the client as a generic conflict message.
  • upgrade_required has no public representation at all, so minimum-client enforcement
    is inert end to end
    : a stale Desktop can never be told to upgrade.
  • 502/504 collapse to 500 internal_error with retryable:false outside GFS, so a
    transient proxy failure is presented to Desktop as permanent and is not retried. The
    aggravating detail: controlApiClient.ts:141-147 synthesises that 502 deliberately, and
    the comment right above it (:132-136) says why — "instead of propagating a raw SyntaxError
    through Express' default error handler (which masks it as an opaque 500)"
    . The handler this
    PR adds turns it back into an opaque 500. The documented intent is undone by the PR's own
    code.
  • forwardControlApiError is duplicated 8 times; 7 copies drop the sanitizer's headers.
    Latent today — those routes next(error) and the global handler does emit headers — so it is
    an armed trap, not an active failure: it fires the day someone adds 429 or 413 to a local
    status set.
  • The envelope changed with no version negotiation, breaking both directions: dev's
    profile-ui does String(parsed.message || parsed.error || text) → "[object Object]" for
    every error, and its member_registration_* friendly-message branches stop firing; a new
    profile-ui against an old edge shows the raw internal string
    "Control API … failed (503): …". During a rolling update the same user sees both, alternating
    by pod. Suggested mitigation with zero deployment coordination: emit the new envelope and
    keep a flat message at the root during the V1 window.
  • The PR canonicalises ~80% of the surface and leaves the rest incompatible: 5 new
    res.status(400).json({error:'<string>'}) in routes/members.ts:159-182, and
    middleware/rateLimit.ts:44-51 still emits 429 {"error":"<string>", retryAfterSeconds} on
    password reset (5/min), invitation lookup/accept and team directory. A client implementing the
    contract this PR defines reads body.error.code → undefined and loses its backoff exactly
    where the limit exists to slow repetition
    .

Good news on the same axis, verified end to end: the 429 + Retry-After chain on
/me/access/* is intact across all six links, with a test pinning it
(test/routes.access.test.ts:133-171). If B1 is fixed, clients will know to retry — no extra
work needed there.

Group 4 — Session and authority

G4-a · The policy is resolved 2× per request and the first result is thrown away.
authenticateExternalUserSession already resolves it and returns it in authentication.policy
(externalSessionAuthentication.ts:139-140), but validateExternalSessionToken stores only
claims and authorityContext (externalSessionAuth.ts:173-174) and drops policy. The
request field that carries it (externalSessionAuthentication) already exists in the type and
is populated by the sibling middleware 55 lines earlier. No memo, no cache. Since
validateExternalSessionToken guards all of /external/*, turning on catalogMode=shadow
— a de-risking step, not a serving step — adds a pool checkout and 5 statements to every
authenticated external request
. One line (req.externalSessionAuthentication = authentication)
fixes this and removes B1's pre-limiter DB work at the same time.

G4-b · hasValidManagedMutationAuthority returns true when authority is absent.
membership.ts:1915 → if (!authority) return true, with authority? optional in all
mutation signatures. Not exploitable today — verified that all 9 production call sites pass
req.externalSessionAuthority, and the previously reported gap (resend/revoke missing it)
is closed: all 9 mutations now receive and use it, inside the same transaction as the
mutation. The cost is the next one: a mutation added in PR 2-4 that forgets the argument
compiles, passes the tests and authorises. git blame -L 1910,1918 → 58b62e953, Josué,
2026-08-28. Make the parameter required, or return false.

G4-c · Session validity is decided against two different clocks.
userSessionService.ts:170-177 compares against the Node process clock and, on the expired
branch, calls revokeLoadedSession → session_version + 1. The same two columns are evaluated
with Postgres NOW() in accessAuthorityStore.ts:116-117 and catalogAuthorityContext.ts:103
(read-only, no mutation). A pod with a fast clock evicts sessions Postgres considers perfectly
alive, and the +1 is terminal and self-unrepairing: even after clocks resync, the session
stays revoked. The skew is baked in at creation too — createUserSession:215-216 computes
expires_at with the Node clock for values later compared against NOW(), so two pods
disagreeing with each other is enough. Fix: derive now from Postgres in the same transaction.

G4-d · Every authenticated v2 request takes a row lock and writes.
validateUserSessionClaims runs with touch = true on the normal authentication path:
SELECT … FOR UPDATE OF s plus an UPDATE of last_used_at/idle_expires_at, per request.
N parallel requests on one session serialise on that row; with the action budget,
statement_timeout resolves to 1750 ms, so queued requests time out → 57014 →
AccessBudgetExceededError('deadline') → 503 authority_unavailable on legitimate traffic,
labelled as an authority outage when it was lock contention on the user's own session. Latent
today (sessionV2Issuance: false) but armed the moment issuance is enabled, because
sessionV2Acceptance is already true. Separate the read from the touch.

G4-e · password_reset capabilities are never invalidated, and no surface enumerates them.
Each request inserts a new 48 h invitation without revoking siblings, and completing a reset
marks only the row used. The 5/min limit is per email (not per IP) and the counter is a
Postgres token bucket shared across replicas — so 5 × 60 × 48 = 14,400 exactly, not an
optimistic bound. All four admin views filter purpose = 'member_invitation', so no admin can
see or revoke these
. The related dead-end from an earlier round is closed, verified across
all five flows.

Group 5 — Rate limiting coverage beyond B1

G5-a · A dev merge silently deleted 11 rate-limit compositions. Verified commit by commit:

1efacc63 -> 11   (branch: d28-gfs adds them, 2026-08-13)
cf6f86af -> 11   (branch side of the merge)
5e20e560 -> 0    (dev side)
41078180 -> 0    (the merge: dev wins)
origin/dev -> 0  ·  HEAD -> 0

gfs_read/gfs_mutation survive in POLICIES as dead configuration, plus a table test that
blesses them (externalUserRateLimitPolicy.test.ts:31-32) — green forever, with or without
routes composing them. GFS is not unprotected: it keeps externalGfsIngressRateLimit and
friends at router level from dev, and its order (gfs.ts:408 limiter → :412 auth) is the
correct counterexample to access.ts. The defect is dead canonical configuration plus a test
that certifies it. Note: a two-dot git diff origin/dev...HEAD shows 0 added / 0 removed here —
net-zero precisely because the merge restored dev's file. Auditing intent requires walking
commits.

G5-b · /external/workflows reads and runs have no rate limit at all — 6 GET routes
(read.routes.ts:21,67,96, runs.routes.ts:49,84,105), each performing a Kubernetes LIST per
request
via getAuthorizedRecipeResources. It is the most expensive external family and the
only one with no ceiling. Its sibling POST .../trigger does have one, but with authentication
before the limiter — the same inversion as B1. Also: authorisation runs inside each
handler, while the composable middleware already exists unused
(routes/workflows/shared/auth.ts:115).

G5-c · The edge applies no limiter to any of the three new /me/access/* routes, while
/me/gfs/*, /invitations/*, /me/teams and /auth/password-reset/request all do. No comment,
test or config documents the decision; the repo's own pattern contradicts it.

G5-d · The fail-open in rateLimitMiddleware was patched at the call site, not in the
mechanism.
rateLimitMiddleware.ts:38-43 still does if (!key) { next(); return }. The PR
opened that file and did not touch the branch; instead the rule was implemented twice at call
sites (the 'missing' sentinel and an optional guard). 50 sites inherit the mechanism.

Group 6 — Path injection at the edge (pre-existing, amplified here)

memberManagementService.ts:57,94,101,130,138 interpolate req.params.* into the upstream path
without encodeURIComponent. REPRODUCED: a JSON body {"teamId":"../../auth/sessions/revoke-all"}
reaches .../external/users/auth/sessions/revoke-all with the service token attached, and
GET /api/v1/members/..%2F..%2F..%2Fhealth escapes above the /api/v1 prefix to /api/health.
Applies to GET, DELETE, PATCH and POST — the trailing path suffix does not protect, because ?
swallows it.

Bounded honestly. Requires a valid session (any account, no admin role) → High, not Critical.
Enumerated all reachable DELETEs: every one is already exposed by the edge with its own route and
authorises against the attacker's own x-user-session-token, so there is no privilege
escalation
; the worst non-self-inflicted case checked (member retirement) additionally requires
reason and Idempotency-Key, which traversal cannot supply → 400. What the attacker gains is:
bypass of edge-only rate limiters, and an enumeration oracle over the internal surface, including
above /api/v1. Independently confirmed against control-api's route set: of 5 /external/*
routes with no public counterpart, none offers a non-self-inflicted escalation, because every
control-api route revalidates session, role and team on its own.

git blame → 5d663a49, Jose Ramirez, 2026-07-14 — pre-existing, not introduced here. Two
reasons it still belongs in this review:

  1. This PR amplifies it. meService.ts:86-88 changed catch { return null } to re-throw
    anything that is not a 404. The hardening is correct in itself, but it converts a blind
    primitive — everything used to collapse to a generic 403 — into a response oracle: the
    attacker now distinguishes 200/403/401/429/503/500 per probed path.
  2. The asymmetry proves it is an oversight, not design. In the same file,
    deleteManagedUser (:117) does call encodeURIComponent on the very same
    req.params.userId that four sibling functions leave raw.

Medium and Low

Full detail is in the consolidated review; one line each here.

Medium (31). Shadow lane not certified on the path production actually takes (the only
caller passes no options; all 3 suites inject all 3) · live authorization judges index freshness
with a laxer criterion than policy does, and its SELECT doesn't even fetch last_success_at —
an unification left half-done, strict introduced a day after lax · budget exhaustion inside
exact validation is reported as an operational-store outage, leaving dependencyForError
inert · two catch blocks in the same /resolve flow treat budget exhaustion in opposite ways ·
deterministic conditions advertised to clients as retryable: true, so a client that honours the
flag retries forever · the resolver's final catch is mute and the file imports no logger, so a
TypeError is indistinguishable from a Postgres outage and the correlationId returned to the
user is never written to any log · CATALOG_FAMILIES declared twice with no type crossing — a
13th family would fail open at the serving readiness gate · REQUIRED_SOURCES duplicated,
which aggravates the above · withAccessDatabaseTransaction re-implements withTransaction's
state machine and breaks the "single blessed point" invariant of #375 M3 · the correlation-id
validator at the public edge is more permissive than the internal one, whose comment
explicitly says it rejects everything else "to avoid propagating attacker-controlled arbitrary
strings" · catalogProducers.ts keeps 6 per-family mappings and only 1 is exhaustive, so a new
family silently falls through to the GFS hydration SQL · the family→capabilities table is written
twice (verified not diverging today; capabilitiesForTeamRole already exists and the catalog
re-implements it by hand) · the 15 new realPostgres suites are outside CI's anti-rename
allowlist, whose own comment explains why that allowlist exists · the shadow scheduler's capacity
test never runs a deferred callback nor asserts shadowInFlight is released · let shadowInFlight
is process-wide module state reusing a per-request concurrency constant, and 2 of 3
skipped_unavailable returns don't record a metric · cursor validUntil produced with the DB
clock and judged with the app clock · /members/invite stopped bounding teams and name at the
edge and forwards the payload unbounded · the CSRF guard is mounted after the JSON parser ·
the edge normalises invalid catalog query params instead of rejecting them, turning a 400 into an
unfiltered first page · three different shapes for the same access-path handle across three
responses · the edge drops sessionContract and absoluteExpiresAt from renew · the ConfigMap
turns on a worker the code comments say to keep opt-in, depending on RBAC applied out-of-band ·
canCallerReadWorkflowRun has zero production callers and its db parameter exists only for its
tests · external_v1_session_revocations grows with no purge while the purge pattern exists twice
in the repo · formatApiError loses server detail for bodies without error/message · CodeQL
suite pinned to an exact stock version with no drift detection · codeql test run can pass having
run zero tests · isEvenfireFailClosedGuardNode doesn't require the return to leave the same
function as the sensitive action · ~85 sequential SQL round-trips per catalog page · a re-sorted
membership list per catalog item · two response serialisations per catalog response · four
canonicalAccessPathSeeds passes per item · up to 4 sequential Kubernetes GETs per /resolve.

Low (24). Dead code (compareResourceIdentity with localeCompare and zero callers,
catalogCoordinatorContractRevision, registeredCatalogFamilies, AuthorizationRequestMemo
never instantiated in production so memoEntries/memoBytes are dead budget) · 7 residual
localeCompare in the access core, each verified inert today · AccessSemaphore(4) never exceeds
1 in flight · a new file documented as a "compatibility facade" for imports that don't exist ·
mergeCandidates discards the direct candidate's validUntil · the coordinator drops the
producer's opaqueState · authorityRequired is an error field nobody reads, with three
contradicting defaults · fast-check properties without a fixed seed in the unit lane while the
realPostgres lane fixes one · the catalog performance test asserts no threshold · the
?? 'unknown' bucket key (verified unreachable, kept for contract consistency) · a
console.error for a runtime indexer death where rootLogger was available, while the twin site
was migrated in this same PR · Math.random() correlation id in control-api where the edge uses
randomUUID() · the invitation email is sent before authority is revalidated · createUserSession
locks the user row without lifecycle_state = 'active' · authGeneration weakened from required
to optional · run history disappears when a team loses a recipe grant, which contradicts the
aggregate-discovery pillar and needs a product decision · r15-b3's guard has only incidental
regression coverage · an intra-round flip-flop in the public correlation-id selector · six review
rounds and two finding IDs with no closing commit · the CodeQL source-scope check runs after
analyze, i.e. after SARIF upload · a silent boolean parser inconsistent with the fail-loud one
introduced in this same PR · profile-ui rollout skew, cosmetic.


What is clean — verified, not assumed

This carries the same weight as the findings above.

  • retry: 2 → retry: 0 with an explicit rationale. That was the mechanism painting two
    deterministic reds green.
  • The round-1 Critical is closed in the right shape: userAccessPolicy.ts:332-336 throws at
    startup
    if the catalog is enabled without its admission limit — a silent misconfiguration
    turned into an impossible-to-ignore one. Covered by accessCatalogConfiguredAdmission.test.ts.
  • Opaque access paths are genuinely signed, and better than that: HMAC-SHA256 over material
    that includes authorizationRevision — session contract and revision, user revision, all
    active memberships sorted, resource/source-state/relationship revisions and the canonical
    candidate set — compared with timingSafeEqual. Stronger still: the server does not verify
    the MAC of the id it receives; it recomputes the principal's current path set in-transaction and
    looks the requested one up, so a perfectly signed id with no remaining authority is rejected
    anyway. principalUserId is inside the signed value, so A's id never appears in B's set.
  • Fail-closed budget: BudgetCounter.take never partially consumes, charge throws rather
    than truncating, child() rolls back every reservation if any counter fails, hydrateSelected
    throws rather than emitting an incomplete page, and Postgres 57014 becomes
    AccessBudgetExceededError('deadline') with the backend cancelled over a separate connection.
  • Cancellation is real, not declarative: the AbortSignal reaches pg_cancel_backend, and
    statement_timeout is derived from the remaining deadline minus 250 ms.
  • Zero placeholders across 16,207 added production lines: no TODO/FIXME as implementation,
    no not implemented, no foo/bar/example.com, no empty catch {}.
  • Zero production code shaped to pass a test: no NODE_ENV === 'test', no testMode, no
    hardcoded value matching an assertion. The three NODE_ENV hits are log level and tracing.
  • Zero failure-masking in tests: no .only, xit, xdescribe, continue-on-error,
    || true, set +e, or --passWithNoTests anywhere in the repo.
  • The test-first protocol is real, and it changes how "fix without test" should be read:
    146 of 173 commits carry a Repro-test: line naming the test file and, in most cases, the
    exact SHA where it is red. Across 94 closed finding IDs, exactly one fix lacks its own
    regression test (r15-b3, Low). A naive count of "fix commits with no test file in the diff"
    would badly misrepresent this.
  • The merges did not lose work, with one exception. Two independent detectors over 37 merges,
    16 candidate files, each audited by symbol against HEAD: 15 restored, re-implemented or
    deliberately renumbered. One merge (41078180) concentrates 12 of the 16; the other 36 lost 4
    benign lines between them. safeForwardableResponseHeaders came back stricter than what was
    deleted, and it is branch work, not dev's.
  • Migration design is one of the strongest parts of this PR: additive, idempotent, fail-fast,
    with legacyVersions so a database from earlier review rounds converges without re-applying.
  • The realPostgres CI lane is exemplary: set -euo pipefail inside the run (so the | tee
    cannot mask the exit), then it requires Test Files ≥1 passed and Tests ≥1 passed, and
    fails if any skipped appears. That is zero-tests-is-never-success done right. The
    describe.skip on the 48 realPostgres suites is not a fail-open: realPostgres.requirement.ts
    is a global setupFiles that throws when CONTROL_API_REAL_PG_REQUIRED=1 without a DSN.
  • CodeQL's public/private boundary is genuinely restored (R16-B1/R16-B2 close for real):
    dev's global exclude: js/system-prompt-injection disappears and is not replaced; no
    lgtm[...]/codeql[...] suppressions; and CI asserts the stock query resolves by id. Both
    fixtures contain real negative controls, so neither query is a no-op.
  • Typecheck is clean and that was proven, not assumed: npx tsc --noEmit → EXIT=0 for both
    services, validated with a positive control (a deliberate type error → EXIT=2 with a
    diagnostic), so the empty log means a clean pass and not a command that never ran.
  • No SQL injection on the new surface: every ${} in access-layer SQL is a server-generated
    constant; the client's families goes through an allowlist against CATALOG_FAMILIES; every
    arm carries LIMIT $4.
  • The v2 token carries no authority claims at all — normalisation forces teamId: null,
    role: 'member'. RS256 with the algorithm pinned, public key derived from the private one
    (HS256 confusion impossible), issuer/audience validated twice, strict claim allowlist.
  • Mass revocation is atomic with no issuance race: revokeAllUserSessions takes
    SELECT … FOR UPDATE on users before writing the epoch, the same lock createUserSession
    takes, so they serialise. Credential changes revoke sessions on all five paths.
  • Google login requires a verified email and cannot be bypassed: one call site, audience
    pinned, throws when email_verified !== true.
  • No N+1 over memberships anywhere on the hot path: the GFS team fan-out groups 2·M streams
    into one statement via jsonb_to_recordset + CROSS JOIN LATERAL; the principal snapshot
    resolves session liveness, all memberships and all revisions in one statement; hydration is
    one statement per family, keyed by arrays. Zero Kubernetes calls on the catalog path.
  • No V1↔V2 copy-paste: a 9-line duplicate-block detector over all 268 changed files found only
    5 distinct file pairs, all investigated. The briefing's suspicion is not confirmed.
  • No new dependencies: manifest and lockfile diffs are version bumps only. No new package, no
    new post-install script.
  • No secrets anywhere in the diff or in deploy/; the only hits are inside the synthetic
    fixture that codeql-config.yml excludes.
  • compareCanonicalUtf8Text is the PR's positive counterexample: 9 consumers across 8 files,
    zero inline re-implementations, and its Postgres equivalent (catalog_utf8_bytes) is correctly
    IMMUTABLE STRICT PARALLEL SAFE.
  • Branch naming compliant; the Electron rule untouched; the minikube T0/T1/T2 contract untouched
    (new fixtures use throwaway repos with their own git init, never touching the developer's HEAD).

Verdict on the PR body's own claims

Claim Verdict
"fail-closed work exhaustion" HOLDS — though in the shadow lane that same fail-closed is what produces skipped_capacity
"no skipped/duplicated canonical resources" DOES NOT HOLD — B2
"stable ordering / private request-local producer state" HOLDS
"bounded database/Kubernetes work, deadlines, cancellation" HOLDS, with a reservation — the mechanism is real; two paths don't receive the budget
"physical SQL statement accounting" DOES NOT HOLD (partial) — liveTeamAuthorization.ts:14 queries pool directly: unaccounted, no statement_timeout, no cancellation, and it runs on every :teamId route
"producer-backed evidence across all twelve families" DOES NOT HOLD — gfs_resource is always skipped_capacity, no test runs a real producer under the child budget, and no producer has ever run in a deployed environment (B4)
"catalog presence is discovery only; actions authorize again" HOLDS
"legacy V1 compatibility remains live-authorized" HOLDS
"explicit bounded rate-limit policies, canonical public retry/error behaviour" DOES NOT HOLD — B1, G5, Group 3
"later-stage capabilities remain disabled by default" LITERALLY TRUE, SUBSTANTIVELY MISLEADING. The 7 flags are false/off — but none of them reaches deploy/, they exist only as code defaults; enabling shadow crash-loops a cold start and serve cannot start at all. It is not "disabled by default", it is un-enableable. And the claim covers only the read plane: 17 row triggers across 15 hot product tables are active from the first deploy regardless of any flag, and the one access-family setting that does reach a manifest is CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'true', against a code default of 'false'
"admission/work limits remain activation inputs" NOT VERIFIABLE — a claim about the provenance of future values. What is checkable does fit; but the hard clamps are constants chosen with no documented measurement, and one of them is exactly what breaks the validation lane
"exact pushed head 1ccf7f0e" STALE, impact bounded. Real head is 8d541772; the 5 intervening commits are all dev merges (20 files, none owned by this PR), so CI 33409641472 and CodeQL 33409641558 did not run on 8d541772 — although the PR's own diff is identical between the two SHAs. The companion claim about the merge base doesn't hold either: git merge-base origin/dev HEAD = 24d749d0

Suite status

Lane Result
control-api run 1 1 failed / 5,273 passed / 241 skipped — EXIT=1
control-api run 2 3 failed / 5,271 passed / 241 skipped — EXIT=1
control-api run 3 0 failed / 5,274 passed / 241 skipped — EXIT=0
control-api run 4 2 failed / 241 skipped — EXIT=1
external-rest-api 38 files, 232 tests, green and stable
tsc --noEmit, both services EXIT=0, validated with a positive control
PR CI 34/34 green on this exact head

Four identical runs on the same head, six different failing tests, none repeated. Symptoms
(Parse Error: Expected HTTP/, 404 where a route exists, crossed 400/201) are the signature
of an ephemeral supertest port being recycled — the request lands on a server that is no longer
that test's. routes.adminWorkflows.test.ts passes 112/112 in isolation. The comment at
vitest.config.ts:7-9 claims fileParallelism: false prevents "nondeterministic HTTP parse
failures"; it does not.

Stated as an observation, not a finding, with two caveats: the machine was under 17
concurrent agents, and PR CI is green on this head. Of the 6 flaky files, this PR touches one
(workflows.shared.rateLimit.test.ts, which issues 60 sequential supertest requests per
parameter). One of the six is a genuine pre-existing test bug rather than contention:
oauth.brokerJwt.test.ts:59 does token.slice(0,-2) + 'XX' on an RS256 signature whose last
base64url character carries only 2 significant bits, so the "tampered" token is byte-identical
1 in 200 times — measured over 5,000 samples, after refuting a first hypothesis over 200,000.
git blame → 5d663a49, pre-existing; it was masked by retry: 2 (0.5%³ ≈ 1 in 8 million).

Removing retry: 2 was the right call — it exposed this rather than causing it. The two
populations need opposite fixes and should not be treated as one issue: population A is fixed
in the tests' transport (Connection: close, or one app.listen per file instead of an ephemeral
server per request), and nothing in production needs changing; population B is fixed in that one
test and nowhere else.

The 241 skipped tests reconcile exactly, and nothing vanishes silently — measured, not
inferred. 47 Postgres files run alone give 2 passed | 238 skipped (240);
network.gateway.intent + network.k8s.integration give 41 passed | 3 skipped (44).
238 + 3 = 241 and 46 + 1 = 47 skipped files, identical to the full run. The 3 non-Postgres
ones are gated on CONTROL_API_K8S_INTEGRATION and on a deploy/scripts/gcp-detect-k8s-api-ip.sh
that isn't present in the worktree. Because the totals reconcile test-by-test, no other file
contributes a single skipped test.

One extra Low from the same lane: external-rest-api/vitest.config.ts does not set
fileParallelism; the sequencing guarantee lives only in the npm script. npx vitest run invoked
directly → EXIT=1 with three failures; the same run with --no-file-parallelism → EXIT=0,
232/232. Move the setting into the config, as control-api already does.


Corrections this review made to itself

Listed because a review that doesn't declare its own errors isn't auditable.

  1. main.ts:471 → main.ts:123. The number came from diff-hunk numbering, not the file (214
    lines). The substance stands.
  2. B5's actor is the migration Job, not pod startup. The mechanism is unchanged; the wrong
    actor would have let the whole finding be dismissed in one line.
  3. B1 has two figures, not one: 15 statements is the rollout target; today it is 5. One agent
    first said 17 and corrected itself by measuring.
  4. B1 is authenticated abuse today. One agent inferred an unauthenticated primitive; another
    measured a garbage token at 0 statements and corrected it. Publishing the stronger claim would
    have been refutable in one sentence.
  5. /capabilities resolves the policy 2×, not 3× — an agent retracted its own figure after
    confusing two similarly named middlewares, and also withdrew a timer-leak claim after verifying
    the budget is always closed in a finally and its timer is unref()ed.
  6. The edge path injection was downgraded from "SSRF with escalation" to "path injection with
    rate-limit bypass and enumeration" after the real damage ceiling was measured.
  7. The duplicated capabilities table was downgraded from functional to maintainability after a
    set-by-set comparison proved the two tables do not diverge today.
  8. The 413/429 mutation results and the 413 coverage reports were reconciled, not averaged:
    both were right about different code paths.
  9. That reconciliation was then itself corrected. The first explanation — "the fallback table
    has no coverage" — was only half of it. A 2×2 truth table showed the second half: for 413 both
    tables carry the same string, so breaking either one alone is invisible. The proposed
    "decisive" mutation (emptying the allowlist) came back green, which is what surfaced it.
  10. A merge-loss finding was nearly discarded on a bad measurement of mine — in zsh,
    "$c:control-api/…" applies the :c modifier to the variable, so HEAD resolved to
    /usr/bin/HEAD. Re-run with correct quoting, the finding held. A second agent's own
    measurement (git diff origin/dev...HEAD → 0 added / 0 removed) was also correct and
    compatible: net-zero precisely because the merge restored dev's file.
  11. Discarded after verification: routes.externalAccess.test.ts:186 does not bless a bug;
    browserMutationGuard.ts:12's catch { return new Set() } is fail-closed; a suspected
    missing CREATE TRIGGER on team_members was false (they are created in a DO $$ block);
    the client-IP forwarding hypothesis did not survive checking all five routes.

Declared limitations

  • CodeQL was never executed (no CLI available). G1-b rests on the model's design and on the
    author's own certified fixture, not on a run.
  • Real Postgres was never executed: the 48 *.realPostgres.integration.test.ts suites (15 new
    here) were skipped. B2 and B3 depend on premises read from SQL that an integration test would
    close.
  • Two risks flagged but NOT raised to findings, both needing real Postgres: the relationship
    set in catalogHydrationSql.ts:163-198 doesn't check the target type while
    operationalAccessReader.ts:200-219 does — a logical_id collision across types would make
    every catalog accessPathId permanently access_path_stale, an infinite client loop; and the
    relationship CTE LIMITs have different scopes (global in the catalog, per-resource in the
    resolver), so revisions would diverge under truncation.
  • One agent noted it did not read four large files end to end
    (userAccessFoundationSchema.ts, accessAuthorityStore.ts, membership.ts,
    catalogHydrationSql.ts) and said exactly which parts it did read.

@alfredolopez80

Copy link
Copy Markdown
Contributor

@zach88 thanks for the round-3 review. Status of every item at the new head (each was checked against the code first; where the premise did not hold we say so):

Fixed in code

  • R3-L1 and R3-M1, hex receipts. Fixed in b21a3aa14. The guard (migrationRunner.ts), the cleanup SQL and the check query match ^[0-9a-f]{4}_, so the 11 receipts of a 20be2fbe4 database (010a_..010e_, 012a_, 012b_) are detected and deleted, and the doc says four-character prefixes. The real-PostgreSQL test now inserts a hex receipt and asserts that no earlier-head receipt is left after the cleanup; it fails with the old regex. We extended the fixture instead of adding a pinned earlier-head producer to CI: 20be2fbe4 is not on dev, and one pin covers one of several earlier schemas. The shared clusters never ran an earlier head: the check query returned no rows on clerum-dev and production on 2026-11-06.
  • R3-L3, switchTeam. Fixed in 3a7465283, with a different mechanism. The membership lookup (GET /external/users/:id/memberships/:team) sits behind requireValidExternalSessionToken (control-api/src/routes/external/users.ts:109), which answers a revoked or expired session with a bare 401 {"error":"Unauthorized"} (control-api/src/middleware/externalSessionAuth.ts:158), not the invalid_session envelope. isControlApiInvalidSession needs error.code === 'invalid_session' (external-rest-api/src/controlApiClient.ts:51-62), so the proposed rethrow would not fire; your repro's stub sent an envelope control-api does not send on that route. switchTeam now rethrows control-api's 401 (and 503); a 404 still answers "not a member". The new test drives the real app with the bare 401 control-api sends.
  • Correction to our own 61249463a. The same applies to /external/teams (control-api/src/routes/external/teams.ts:53): the four teamService rethrows that commit added keyed on the envelope and never fired against the real control-api, because their tests mocked it. They now key on the 401 too (3a7465283), and the description lists the result as behaviour change 12.
  • R2-L16, long transactions. Fixed in b21a3aa14: the note says CIC's wait for older transactions is a lock wait, so a long transaction makes the build fail after the 10 s lock_timeout on every attempt; the 120 s bounds only the build itself.
  • R3-L4, streaming proxy. Fixed in 79d220b92: the SharedFileSystem proxy relays Cache-Control: no-store on a 503 only.
  • R3-L7, correlation id. Fixed in 79d220b92: both helpers now log through req.log ?? rootLogger.
  • R3-L10, duplicate test. Fixed in 79d220b92: the duplicate now pins that a control-api 403 membership_not_found is answered 403.
  • R3-L13. 79d220b92 adds the retirement condition to the guard's comment.
  • R3-L14, R3-L15, R3-L16. Fixed in 79d220b92: the comments say what the edge emits, sessionTokenFromRequest is inlined, and the fixture sets issueV2: false.

Corrected premise

  • R3-L2, client_connection_check_interval. The "must be set to 0 on this platform" error applies to PG 14 off Linux and to Windows. Since PG 15 the check uses socket-closed events on Linux, macOS, illumos and the BSDs. Our local real-PostgreSQL runs use native macOS PostgreSQL 16 and pass, initDb included, and dev already sets the same variable unconditionally. Tolerating the error would turn a missing cancellation into a silent one, so the code stays; b21a3aa14 corrects the doc and the code comment to name the supported platforms.

Documented in the description (accepted in writing)

  • R3-L5: the non-string sentence is scoped to session-token, verify and rpc/token; google-login is as on dev.
  • R3-L6: a logout that commits during an rpc/token mint does not stop it; the token lives at most its 300 s TTL. On dev logout revoked nothing. A read before the mint would narrow the window, not close it.
  • R3-L9: behaviour change 11 now covers the rejected service credential. External REST has no structured logger, and the logging standard rules out adding console.*, so no warning was added.
  • R3-L11: behaviour change 7 now says the 401/403/410 rule covers the three new /me probes; no edge route sends 403 or 410 on /me.
  • R3-L12: the description says why 1a24817fb is here. Re-tracking next-env.d.ts (R2-L21) made T0's next build dirty the tree, and T2 then refused to certify, so this PR could not get a T2 verdict without it. Harness only, no product code.
  • R2-L4: the three 503 bodies are listed; clients read only the status. Unifying them stays in Task 106 PR1 follow-ups: ten low-severity defects from the second review of PR #334 #1251.

Moved to issues

Later commits: 5912f19fc and 568194a53 record the CodeQL dispositions and the shared-cluster check, 7befa6ede and aac5388a9 fix the LLM secret editor test wait (#1263) and three comments, and a3f017650 merges dev (#1210).

Validation at head a3f017650: MINIKUBE_T2_PASS, T2_EXIT=0 (T0, T1 with 970 tests in 123/123 real-PostgreSQL files, NP-08, port-forwards, health). All 60 CI checks pass; Build / test / typecheck (mcp-host) failed once on a 5 s test timeout in a file identical to dev and passed when re-run with no code change. The merge gate's dev deploy log with the EDGE_PHASE lines is still open.

alfredolopez80 and others added 15 commits November 6, 2026 22:09
…ss-foundation

Dev now ends at 0136_llm_catalog_dismissed_backfill (#1285), which took slots
0131 and 0132. No shared environment has applied the PR1 migrations, so they
move above dev's last version with their names unchanged, as the procedure in
migrationRunner.ts requires:

- 0137_user_access_foundation
- 0138_legacy_password_security_epoch_backfill

The dev batch bound in initDb gets its own constant, LAST_DEV_BATCH_MIGRATION.
PASSWORD_SUCCESS_ADMISSION_MIGRATION keeps identifying the password rollout.

A branch profile that recorded the old 0131/0132 receipts is an earlier head:
the migration runner refuses it until it is recreated or cleaned with
docs/deploy/sql/user-access-foundation-earlier-head-cleanup.sql.

Other conflicts:
- ci-public.yml and password-recovery-suites.json keep both suite lists.
- The runtime access contract keeps the peer-derived check and adds dev's two
  new relations as sentinels. workflow-recipes gets those relations as `none`,
  because the contract requires the same relation names in both profiles.
- The upgrade guide states the new numbers and that a database below 0130 now
  applies 0129 through 0136 in the same transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge of dev moved the foundation migration to 0137, so the literal
0131 version in the upgrade guide no longer names a registered migration and
migrationReferences.test.ts rejects it. The sentence keeps the same fact with
the prefix and the migration name written separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The banner told a signed-in member "this session has no active team". This
change starts retiring the team id from sessions, and a session without a team
works in every section, so the banner reports a normal state as a problem.

The E2E seed also created users without a membership: POST /admin/teams ignores
userId and creates only the team. It now adds the member with a second call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 429 from password login showed "Login failed. Check your email and password."
The server did not verify the password on that path, so the text sent people to
fix a password that may be correct, and it offered no wait.

authClient appends the transport's status and Retry-After to the error message,
because only the message crosses IPC. The sign-in screen says that there were too
many attempts, that the password was not checked, and how long to wait.

The check runs before the invitation checks, which match 404 and 410 anywhere in
the message: a wait of 404 or 410 seconds would otherwise read as an invitation
failure. A rate-limited login also skips the backend switch hint, because the
backend answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dedicated lane runs against a seeded branch profile. It stops control-api, so it
stays out of the default suite and refuses any other context:

- a member without a team and a team member see no team notice in any section;
- an outage keeps the session, and a real session end returns to sign-in;
- a rate-limited sign-in explains the wait and does not blame the password.

The shared password pace moves from 8 s to 13 s. The source limit allows five
attempts per clock minute and counts correct passwords, so 8 s between attempts
could reach it. The helper comments now state the limits from #1197 instead of
the retired five-per-identity budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ss-foundation

Dev brings no new migration, so the PR1 versions stay at 0137 and 0138. The only
conflict is in desktop-app/package.json, which keeps both new scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bounds script grepped the old 0131_ prefix: grep -c exits 1 on no match
and set -e killed it with an empty log, so CI skipped every later step of
that job. It now counts the plan's migrationVersion entries with awk; the
version itself is enforced by the literal type in pr1OnlineIndexPlan.ts.

LAST_DEV_BATCH_MIGRATION in db.ts and LAST_DEV_MIGRATION_VERSION in
migrationRunner.ts stated one fact; if they disagreed the Job would exit 0
with migrations missing. db.ts now imports the runner's constant. Comments
say that a dev merge moves the bound and the latestDev producer.

The policy test compares the full version instead of the 0137 prefix, the
shape the merge sweep missed. The earlier-head comment no longer calls the
old user_access_foundation prefix "the same 0131 version".

Orphans checked: LAST_DEV_BATCH_MIGRATION had one reader, db.ts's batch loop,
which now reads the shared constant; nothing else referenced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…delta

The upgrade tests hand-wrote the receipts between an immutable start and a
moving head, so dev's six migrations made them expect 137 receipts against
143. They now derive the pending versions from CONTROL_API_MIGRATIONS and
assert the apply order equals them, with no hand-written count.

The latestDev producer moves to origin/dev's tip, 31abc80, because that is
where the migration starts. Its test fails with "latestDev is stale" when a
dev merge brings migrations and the pin is left behind, and asserts that the
PR1 migrations commit in separate transactions. observedDev stays pinned: it
is the only real producer that reaches the password-login fence.

Earlier-head receipts under 0131 and 0132 now have exact-message tests, with
dev's own migrations under those numbers as negatives.

The upgrade doc drops the relation row count and adds the 0130-0135 start.
The runbook states what each pin means and how to re-point an existing
latest-dev worktree. The runner's retirement clause names these tests.

Not covered by a real run: a start at production's recorded 0129 and a
rollback with a pre-0130 migrator.

Orphans checked: DEV_POST_0106_THROUGH_0129, OBSERVED_DEV_RECEIPT_COUNT and
LATEST_DEV_RECEIPT_COUNT had no reader outside the removed assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A MODELS_DEV_API_URL with userinfo passed the guard. fetch refuses such a URL
with a message containing it, and the client logged that message as the
reason, so the credential reached the log and the catalog stayed on vendored
data. The guard now rejects userinfo before any fetch, which also stops boot,
and a malformed URL is no longer echoed in the exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Desktop password seed cached on a SHA-256 of a password that was already
in memory. The cache now stores the password per user and email, so a
different password still reseeds and no password reaches a hash.

waitForNewRun built its poll message once, before the callback could record a
transient error, so that suffix could never show. The callback now returns
what it saw and the matcher compares it, so the last state appears in the
poll's own failure output.

Also removes four unused symbols from test files.

Orphans checked: createHash had no other user in workflowUi.ts;
lastTransientError, rootLogger, config, SIDEBAR_COLLAPSED_KEY and the one
resolveCount had no other reader in their scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arker

The sign-in screen read a marker that a response body could imitate, called
every 429 "too many sign-in attempts", told the user to check the password
during an outage, and matched 404 and 410 anywhere in a message.

AuthClient.passwordLogin now removes any marker from the message of every
failure and appends the transport's own: httpStatus plus, for a 429, a
strictly parsed Retry-After, or signInTransport=noResponse when there was no
HTTP answer. The renderer reads it anchored at the end of the message, so body
text cannot be in that position.

The texts: 401 stays "Email or password is incorrect."; a 429 says sign-in is
paused and the password was not checked, which holds for all its reasons; a
5xx or no answer says sign-in isn't available and keeps the backend probe;
everything else keeps today's text and the probe, including a failure after a
good password, which has no marker and can no longer read as incorrect. The
429 and outage toasts use the default error duration, like the generic one.

Desktop setup recognizes only the invitation_config_not_found code main raises.

Orphans checked: isInvitationExpiredError and isUnauthorizedError had no other
caller. Their texts and checks are gone from the password path; no route on it
returns invitation_expired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hanges

App outlives a session and only the close handler cleared the plugin GFS image
preview, so it survived a session end, reappeared for the next user and
refetched the previous user's file under that session. It now resets in the
effect that resets the workspace tabs, on sign-out or another principal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… profile

The lane stops control-api, yet it defaulted to and accepted the shared
clerum-test context, had no opt-in, and its fresh config skipped global setup,
so nothing validated the URLs. The config now extends the base one and requires
E2E_SESSION_TEAM_UX=1, an explicit E2E_K8S_CONTEXT that is a branch-owned local
profile, and an explicit EXTERNAL_REST_API_BASE_URL, the one variable the spec
reads. The base config is imported first because global-setup reads
E2E_PROD_CONTEXT from the canonical env at load.

Memberships were deleted and rebuilt by a naming convention, which lost roles
and timestamps on a non-unique team name. The lane now snapshots the full rows,
restores them in one transaction and asserts equality. The password restore
runs even if closing the app throws, and the outage restores the replica count
it read.

Orphans checked: setMembership had no other caller; its convention inserts at
the start of two tests are gone, and the witnesses that follow them fail on an
unseeded user. isLocalPortForwardContext stays, used by global-setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /admin/teams reads only the name, yet two E2E helpers also sent userId, so
those teams had no member and a later switch-team would get 403. Both helpers
now send the name alone and make the membership call, as the seed script does,
and the script helper's test runs in CI.

Because the membership now exists, the two approval-bound tests remove the team
in their finally blocks, and cleanupRecipe failing no longer skips that. The
cleanup moved from the figure-b test to the shared recipes module. It deletes
the membership in its own call, so a failure deleting the team cannot roll the
membership back onto the shared E2E user.

Whether those lanes pass is still decided by their own run.

Orphans checked: the local cleanupTeam in figure-b had one caller, which now
imports the shared one; sqlLiteral and runProfilesSql stay used there. The other
cleanupTeam in sandbox-ui-apps-catalog-authz is a separate helper, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing in the runtime deletes from external_user_session_security_epochs, and
the user-deletion cascade runs as the table owner. The grant and the access
profile move it to upsert now, before this migration has a receipt on a shared
cluster; afterwards the change would need a new migration. The sessions table
keeps DELETE, which the session service uses.

The byte-for-byte pin on the migration body moves to the new file hash.

A branch profile that already ran this 0137 fails the access check and needs a
fresh profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alfredolopez80 and others added 2 commits November 7, 2026 16:05
The public boundary scanner rejected two literals that look like
credentials and do not say they are synthetic. The job reached them only
after the migration bounds step stopped failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The quadrant recipe wrote its instruction into a double-quoted YAML scalar
with only quotes escaped, so a backslash or newline changed or broke the
value. JSON.stringify writes a valid YAML double-quoted scalar for any
string. The global setup's outer loginBody held a value nothing read.

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

Copy link
Copy Markdown
Contributor

@claude Please do the closing review of this PR before it merges, and end with a clear verdict: APPROVE or CHANGES REQUIRED. Your workflow token cannot submit a GitHub approval, so state the verdict in your comment.

Pinned state

Scope

Focus on what changed after 6ca5fd67d, excluding code that came from dev:

  • The PR's own 22 commits: git log --no-merges 6ca5fd67d..4d990b023 --not 31abc80d8
  • The resolutions of the 4 merges of dev: 68d020a1e, a3f017650, 67985dd74, 1c9979c62

The checkout has depth 1, so fetch these objects first. If you cannot obtain them, say so and state what you did not review; do not approve on a partial read.

Phase context

Judge this PR as a phase, not as the end state. The plan removes the selected team id from user sessions:

  • PR1 (this PR): installs the backend foundation and live membership checks. Everything that changes what a client sees is off by default; V1 and team-switch clients keep working as on dev.
  • PR2 (Remove Team Token PR2: Runtime actions use validated access paths #594): runtime access paths. It builds on this PR and is reconciled after it.
  • PR3: activates the Desktop and V2 path.
  • PR4: adopts Profile and retires the legacy path.

Code installed turned off is covered by activation gates #1222, #1223 and #1224. A gap that only matters at a later activation belongs there unless this PR makes it harder to close.

What to check

  1. Safe to merge into dev and later promote to production with no real impact on running services. A dev merge triggers an unattended deploy.
  2. Migrations. 0137 and 0138 follow dev's 0136. Check the order from a database at dev's tip, the single bound between the dev batch and this PR's runner, and the refusal of databases from earlier heads. No environment holds a 0137 receipt yet.
  3. Rollout order. Migration, then External REST and Profile UI, then control-api. The opposite pairing is not supported. See docs/deploy/user-access-foundation-upgrade.md.
  4. Sign-in failure classification (desktop-app/src/authClient.ts, desktop-app/ui/src/lib/signInFailure.ts, desktop-app/ui/src/hooks/domain/useAuthController.ts), including whether a response body can imitate the marker.
  5. Grants. The runtime DELETE removed on external_user_session_security_epochs, and whether anything still needs it.
  6. MODELS_DEV_API_URL refusal of credentials.
  7. Removals. What did the removals in this range leave without a caller?
  8. Tests. Whether the changed tests would fail if the behaviour broke, and any assertion weakened in this range.

Validation already run on this head, for you to weigh

T0 pass; T2 MINIKUBE_T2_PASS on a new profile (T1 1064 of 1064 tests in 132 files, NP-08, port-forwards and health); 60 of 60 CI checks. The PR body's Validation section lists what is not covered.

Already tracked, no need to report again

#1381, #1382, #1385, #1363 (fix in #1374), #1251, #1266.

Output

For each finding: severity, path:line, a concrete failure scenario, and whether it is new in this range or pre-existing. Separate what blocks the merge from follow-ups. Verify each claim against the code at the pinned head. If you find nothing blocking, say so and state what you checked.

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @alfredolopez80's task in 11m 19s —— View job


Closing review — PR #334 @ 4d990b023 (base dev 31abc80d8)

Reviewed the post-approval range against the pinned head. All range objects were fetchable (6ca5fd67d, 4d990b023, 31abc80d8, and the four merges 68d020a1e, a3f017650, 67985dd74, 1c9979c62). 31abc80d8 == origin/dev; the mergeable diff 31abc80d8..4d990b023 is 428 files, +46,527 / −4,101 (line totals match the PR metadata). The LLM-catalog / egress / HCC commits that appear in the merge topology are not in the net diff — their content is already on dev, reconciled by the merges — so this merge introduces only the team-token foundation plus the small post-approval hardening below.

Scope note on reconstruction: the literal 6ca5fd67d..4d990b023 --not 31abc80d8 set resolves to ~120 commits here, not 22, because the dev-sync merges pull in commits whose SHAs aren't ancestors of the final base but whose content already landed on dev. I therefore reviewed by the net content diff and the identifiable PR-own commits — equivalent for correctness. Nothing in the net diff falls outside Task 106 + the listed fixes.


Findings by requested check (none blocking)

1. Safe to merge + unattended deploy — OK (with the PR's own precondition).
V2 issuance off, catalog mode off, minimum-client off, indexer off; every client-visible change is gated or restricted to sessions this PR can end. Deploy safety relies on the infra ordering (keyper-labs/evenfire-infra#154), which the PR body reports merged on infra main (2026-11-06) with the required EDGE_PHASE lines; and on EXTERNAL_REST_API_CORS_ORIGIN listing the Profile UI origin (checked read-only per the body). These are documented preconditions, not code defects — re-confirm both before the deploy completes. (pre-existing gate, satisfied)

2. Migrations 0137/0138 — OK. control-api/src/db.ts:6786-6943, control-api/src/migrations/migrationRunner.ts.

  • Order from a dev-tip DB: dev batch runs through LAST_DEV_MIGRATION_VERSION='0136_llm_catalog_dismissed_backfill' in the single initDb transaction (db.ts:6790-6803), commits, then applyPendingPr1Migrations applies 0137 then 0138, each in its own bounded transaction, with the online CREATE INDEX CONCURRENTLY prep run outside the txn (pr1OnlineIndexPlan.ts:142-165, migrationRunner.ts:195-216).
  • Single bound is real: any migration > 0136… not in PR1_MIGRATION_VERSIONS throws Unclassified post-dev migrations (migrationRunner.ts:171-178). The 67985dd74 merge correctly renumbered PR1 0131/0132 → 0137/0138 and moved the bound 0130 → 0136 when dev brought 0131–0136.
  • Earlier-head refusal (assertNoEarlierPr1Head, migrationRunner.ts:118-165) matches both decimal and hex prefixes (^[0-9a-f]{4}_) by migration name, plus an object probe for leftover authorization_bump_*/authorization_prelock_*/catalog_utf8_bytes and the earlier revision/catalog tables. 0137/0138 are excluded (registered), and this PR's schema creates none of the probed objects, so a clean post-0138 DB passes. The real-PG test now pins exact rejection messages incl. 0131_user_access_foundation/0132_… with dev's own 0131/0132 as negatives.

3. Rollout order — OK. docs/deploy/user-access-foundation-upgrade.md:12-32 states migration → External REST + Profile UI → control-api; rollback reverse. The mixed window (new edge vs old control-api) is pinned by external-rest-api/test/mixedVersion.baseControlApi.test.ts, and the "single apply" hazard (team create/switch/reset fail) is called out and gated on infra#154.

4. Sign-in failure classification — OK, marker is not spoofable. desktop-app/src/authClient.ts:329-361, desktop-app/ui/src/lib/signInFailure.ts, desktop-app/ui/src/lib/gfsGrantErrors.ts:173-178.
markedPasswordLoginFailure first strips any httpStatus=/retryAfterSeconds=/signInTransport= token from the body message (UNVETTED_SIGN_IN_MARKERS, global), then appends the transport's own marker at the end. The renderer reads NO_RESPONSE_MARKER/RATE_LIMIT_WAIT_MARKER anchored with $, and parseHttpStatus uses an end-anchored lookahead, so a body-embedded marker (even one that survives the strip by lacking a leading space) can never occupy the authoritative end position. A failure after a correct password carries no marker and no longer reads as "incorrect". (new in range; this is a hardening — the old code could be imitated.)

5. Runtime DELETE grant on external_user_session_security_epochs — correctly removed. control-api/src/services/access/userAccessFoundationSchema.ts:99-101, deploy/scripts/control-api-runtime-access-profiles.tsv:20.
Grep confirms the runtime only does INSERT … ON CONFLICT/SELECT on that table — no DELETE. User removal cascades via user_id … REFERENCES users(id) ON DELETE CASCADE (userAccessFoundationSchema.ts:40), which runs as the referential action (no child-table DELETE privilege required). The 0137 body is edited in place, acceptable because no shared cluster holds a 0137 receipt yet; the migration-body hash pin and the runtime-access contract test were updated in the same commit. (new in range; low risk.)

6. MODELS_DEV_API_URL credential refusal — OK. control-api/src/services/modelsDevCatalogUrl.ts:107-160.
assertSafeCatalogUrl rejects username/password ("must not contain credentials"), requires https:, blocks internal/loopback/RFC1918/link-local/metadata hosts, and never echoes the raw value (invalid-URL and credential errors print no URL). Invoked at config boot (config.ts:624) and before every fetch (modelsDevClient.ts:408). (new in range; hardening.)

7. Removals left no dangling callers. Verified no remaining references to isInvitationExpiredError, isUnauthorizedError (only an intentional test string), DEV_POST_0106_THROUGH_0129, OBSERVED_DEV_RECEIPT_COUNT, LATEST_DEV_RECEIPT_COUNT, canCallerReadWorkflowRun (removed earlier), or the modelsDevApiUrl config field. StatusBanner (4 uses) and currentTeamId (253 uses) survive the no-active-team banner removal. The plugin-preview fix (App.tsx:3450, setPluginGfsPreview(null) on principal change) closes a real cross-user leak and uses existing symbols.

8. Test quality — assertions hold; one strengthened, none weakened. migrationExecutionPolicy.realPostgres.integration.test.ts dropped brittle hand-written receipt counts but now derives the pending set from CONTROL_API_MIGRATIONS, asserts applyOrder === pending, witnesses the tail equals PR1_MIGRATION_VERSIONS, and adds a real-PG check that the two PR1 migrations commit in distinct transactions (xmin) — a genuine new guarantee. The earlier-head rejection test asserts the exact recorded versions […]; functions []; tables [] message. The sign-in and plugin-preview changes ship behavior-pinning tests that fail on their parents. No weakened assertion found in range.


Non-blocking follow-ups (do not gate this merge)


Verdict

APPROVE — for merging this phase (PR1) into dev.

I found nothing blocking in the post-approval range. The migration ordering/bound/earlier-head refusal are correct and real-PG-verified; the sign-in marker is strip-then-append and end-anchored (unspoofable); the epoch DELETE-grant removal matches actual runtime use and the FK cascade; the MODELS_DEV_API_URL guard refuses credentials without echoing; removals left no dangling callers; and no assertion was weakened (the migration suite was strengthened). Everything client-visible remains off by default or confined to sessions this PR can already end, consistent with the phase contract. (My workflow token cannot submit a GitHub approval — this comment is the verdict.)

@alfredolopez80
alfredolopez80 merged commit d02fcb9 into dev Nov 7, 2026
60 checks passed
@alfredolopez80
alfredolopez80 deleted the feat/user-centric-access-foundation branch November 7, 2026 19:03
alfredolopez80 added a commit that referenced this pull request Nov 12, 2026
Merge dev at d02fcb9, which carries PR #334, into this branch. PR #334
removed the access catalog, the operational index and indexer, the live
resolver and the authorization revision counters, which this branch
builds on.

Does not compile: restored by the next commit. 19 production and 28 test
files in control-api import modules that dev deleted; the next commit
takes over the action authorization modules from PR1.

Resolutions:
- dev wins on what PR #334 changed: migration runner and its guard,
  online index plan, user access policy and runtime policy, CI producer
  pins, external-rest-api error forwarding (M-10), migration tests;
- the 25 files dev deleted and this branch modified are removed here
  and restored where the plan needs them in later commits;
- this branch keeps its PR2 readiness evidence, workflow authority
  bindings and approval authority checks; the operational access
  indexer, the catalog shadow hook and the authorization prelocks go;
- versions above both parents for control-api, external-rest-api,
  host-context-controller, mcp-host and workflow-recipes;
- password recovery suite manifest: dev's suites plus PR2's five.

Removed: scripts/tests/task106-r61-b1-producer-checkout-contract.cjs,
which checked the literal producer pins that dev replaced with
certification inputs.

Orphans checked: scheduleAccessCatalogShadow, OperationalAccessIndexer,
startOperationalAccessIndexer, authorization_prelock_,
sanitizeControlApiPublicError, task106-r61-b1-producer-checkout-contract.
Removed: all except sanitizeControlApiPublicError in
external-rest-api/src/routes/rpcDelegations.ts, which the next commit
replaces with the M-10 forward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

4 participants