Skip to content

fix(auth): a sign-in email is proven, unique, and written in one place (ent#720) - #3085

Merged
vybe merged 13 commits into
devfrom
fix/ent720-email-binding
Sep 30, 2026
Merged

vybe merged 13 commits into
devfrom
fix/ent720-email-binding

Conversation

@dolho

@dolho dolho commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every sign-in path resolves an account by email alone, so whoever holds an address on a users row holds that identity. This closes the remaining ways an address could be taken or duplicated:

  1. Mailbox proof to bind. PUT /api/users/me/email now requires a 6-digit code sent to the new address (POST /api/users/me/email/code). The code has its own purpose (email_bind:<user id>) and can never be redeemed as a sign-in code.
    • On an install with the console email provider, only an admin can bind without a code, and the address is recorded as unverified.
    • A non-admin gets a named 409 email_verification_unavailable at both steps.
    • Agent and MCP keys stay refused (403).
    • Wrong guesses are capped like email sign-in: 5 wrong codes in 10 minutes and the next attempt, even with the right code, gets 429 too_many_attempts. The counter is bind-scoped (otp_attempts:bind:{user_id}:{email}), so wrong bind guesses never lock the address's owner out of email sign-in. A successful bind clears it.
  2. Unique addresses. A unique index on lower(email), covering rows where email IS NOT NULL. A lost race on the index surfaces as EmailInUseError (409 email_in_use), never a 500.
  3. One checked writer. create_user, update_user and the password upsert all go through the same duplicate check.
  4. Reclaimed username. When a re-bound account still has username == <old email>, the next email sign-in for that address creates a suffixed account instead of a 500. The account lookup checks the address's holder first.
  5. Suspended accounts. The channel redeemers (Telegram, WhatsApp, message router) and the MCP inline code redeem now refuse suspended accounts.

Migration (both tracks)

SQLite ent720_email_identity + Alembic 0084_ent720_email_identity (down_revision 0083_execution_conversation_key, single head; renumbered after #3076 and #3110 landed on dev):

  • adds email_login_codes.purpose;
  • turns blank emails into NULL;
  • resolves existing duplicates: the earliest-created account keeps the address and the others are set to NULL;
  • adds the unique index.

The log names affected accounts by username only, never by address. Both tracks share one decision function, and a test pins the two copies identical.

Operator note — duplicate addresses. If more than one account holds the same sign-in email (case-insensitive), the migration keeps it on the earliest-created account and clears it on every other one. The next email sign-in for that address goes to the earliest account, and the owners of the cleared accounts lose email sign-in to that address until they bind another one. Check the migration log after upgrading: each cleared account is named by username ([ent#720] duplicate sign-in email: cleared on account '<username>').

Docs

  • docs/memory/feature-flows/email-authentication.md — new "Binding a Sign-In Email" section: both routes, code purpose, the console-provider admin bypass, the attempt cap, and every refusal code.

Test plan

  • tests/unit/test_ent720_email_binding.py (32 tests, incl. the guess cap: 5 wrong → the right code is refused; sign-in counter untouched; per-account counters) + channel gate suites, on pytest-randomly seeds 12345 and 99999. Mutation: removing the cap check, or keying it on the bare address, turns the cap tests red. The tests use a real SQLite database built by init_schema and the real /api/users routes.
  • test_1160_migration_atomicity, test_lint_sys_modules (a reduced users table shape is tolerated; no new sys.modules violations).
  • Frontend emailBindProof.spec.js (mounted).
  • PG: Alembic upgrade on a simulated pre-fix database (duplicates resolved, index created).
  • verify-local: the image builds, import main works, the stack boots and is healthy, and the integration stage passes.
  • Live against the verify stack (console provider), 14/14:
    • admin console bypass (unverified);
    • code round trip: wrong code refused, right code binds verified, reused code refused;
    • a bind code refused as a sign-in code (401);
    • non-admin 409 at both steps;
    • an unclaimed address can't be bound without its mailbox;
    • a held address in any case → 409;
    • a raw duplicate write refused by the index;
    • an MCP key refused (403);
    • sign-in after the username was reclaimed → suffixed account;
    • a suspended account refused by channel access and the MCP inline redeem.

Fixes abilityai/trinity-enterprise#720

🤖 Generated with Claude Code

dolho and others added 2 commits September 29, 2026 13:45
Abilityai/trinity-enterprise#720)

Every sign-in path resolves the account by email alone, so whoever holds an
address on a users row holds that identity. These are the residual doors
after #711:

1. Mailbox proof to bind. POST /api/users/me/email/code sends a 6-digit code
   to the NEW address (3 per 10 min). PUT /api/users/me/email requires
   {email, code}. Codes carry a purpose (email_login_codes.purpose):
   `email_bind:<user id>` completes only that account's bind; a bind code
   never signs in and a sign-in code never binds. Both routes are
   interactive-only. The one no-proof bind is the #82 transition on an install
   that cannot deliver mail (provider `console`): an interactive admin, audited
   as email_bind_unverified. Anyone else there gets 409
   email_verification_unavailable. The onboarding step and the Settings card
   gain the code step (auth store `bindOwnEmail`).
2. Unique. idx_users_email_unique ON users(lower(email)) WHERE email IS NOT
   NULL, on both tracks (SQLite `ent720_email_identity`, Alembic
   `0081_ent720_email_identity`). Existing duplicates are resolved first: blank
   becomes NULL, and per address the earliest-created account keeps it while
   the rest become NULL, logged by username only.
3. One writer. create_user, update_user, the password upsert and email sign-in
   creation all write through `_insert_user` / `_update_user_row`. These refuse
   a held address (EmailInUseError → 409 email_in_use) and map a lost race on
   the index to the same refusal. A writer-census test enumerates them.
   get_user_by_email is case-insensitive.
4. A reclaimed username is not a 500. Email sign-in whose `username = email`
   is taken creates a suffixed username. Auth0 sign-in resolves the HOLDER of
   the address first and never hands it back to an account that re-bound away.
5. Redeemers honour suspension. Telegram and WhatsApp redemption, the MCP
   inline redeemer, email_has_agent_access and the per-message channel gate
   (open_access included) refuse a suspended account, through
   db.is_email_account_suspended.

Second factor on the channel redeemers is out of scope: they cannot present
a challenge. It is recorded in FR-4 as a follow-up.

Tests:
- test_ent720_email_binding (28 tests);
- suspended cases in the Telegram, WhatsApp and router suites;
- emailBindProof.spec.js (mounted).

Mutations: bind-without-proof, no unique index, no username suffix, and
suspension ignored each turn tests red. The related backend suites (67 files)
pass on seeds 12345 and 99999 (1571 passed). Frontend: 181 files and 3728
tests pass, and the build is OK.

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

The duplicate-email sweep read username/created_at unconditionally, which
crashed the #1160 concurrent-boot fixture's minimal users table. Read those
columns only when present. The unit tests now resolve EmailInUseError and
UserOperations from the module the live db singleton uses (another test may
re-import db.users), and stub alembic.op via monkeypatch.setitem so the
sys.modules lint ratchet stays at zero.

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

@obasilakis obasilakis 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.

This PR requires the following changes before merge:

  • Fix the red regression diff: four TestUniqueAndOneWriter tests fail. The failing tests are test_create_user_refuses_a_held_address_any_case, test_update_user_refuses_a_held_address, test_the_password_upsert_cannot_insert_a_duplicate and test_a_lost_race_on_the_index_is_the_same_refusal. In each one, pytest.raises misses the db.users.EmailInUseError that the code raises. _users_mod() resolves the class through sys.modules[type(db._user_ops).__module__]. After an earlier test evicts and re-imports db.users, that name points at the new module, but the db singleton still raises the old class. Reproduced locally with a probe test that imports database and then does del sys.modules["db.users"]; import db.users, which gives exactly these 4 failures. Reading the namespace the raising code actually uses makes them pass, both with the probe (29 pass) and alone (28 pass):

    def _users_mod(db):
        import types
        return types.SimpleNamespace(**type(db._user_ops)._insert_user.__globals__)
  • Cap wrong guesses on the bind code. PUT /api/users/me/email checks the code with verify_login_code and never counts failures. Email sign-in (routers/auth.py) and the portal (client_portal/router.py) both stop a code after OTP_MAX_ATTEMPTS = 5 wrong tries. Here, any signed-in human can request up to 3 live codes per 10 minutes to an unclaimed address and then guess without limit. A guess matches any of the live codes. This reopens the "bind an unclaimed address" door that this PR closes. Suggested fix: reuse check_otp_rate_limit / record_otp_attempt under a bind-scoped key such as otp_attempts:bind:{user_id}:{email}. Use the bind-scoped key, not the bare email: a bare-email key would let wrong bind guesses lock the address owner out of platform sign-in, the cross-surface lockout the portal key prefix avoids. Please add a test that 5 wrong codes lock out the 6th attempt, including the right code.

Non-blocking:

  • The Alembic revision 0081 is one of six open PRs that parent onto 0080_agent_skill_sets (#3076, #3036/#3035, #3022/#3021, #2984). Whichever lands second re-parents onto the first. None of the others touch users or email_login_codes, so the re-parent is mechanical.
  • The migration clears the address on every duplicate account except the earliest-created one. The next email sign-in for that address then goes to the earliest account, and the cleared account's owner loses email access to it. Please state this in the PR description so operators know to check the migration log.
  • Refs abilityai/trinity-enterprise#720 carries no closing keyword. The issue lives in the enterprise tracker, so it needs a manual status-in-dev after merge either way.
  • There is no feature-flow doc for POST /me/email/code or the bind flow.

The rest checks out: both migration tracks share one pinned decision function, every users.email write goes through a single checked writer with an AST census, a lost race on the unique index maps to 409, a bind code cannot be used as a sign-in code, agent and MCP keys are refused, and the tests run against a real schema and the real routes.

Please address these items and request re-review.

@obasilakis obasilakis added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Alembic head check clear — merging this PR into dev leaves one head (0084_agent_loops_chain_depth).

Previously flagged; resolved.

Advisory — this check does not block merge. · head_sha: 8a864b3ce73f6ce1d1ef8a49cc77db8e35afc734 · run

dolho and others added 4 commits September 29, 2026 16:55
…est helper

PUT /api/users/me/email verified the bind code without counting failures,
so a caller could mint 3 live codes per window and guess without limit.
Reuse the sign-in OTP limiter (OTP_MAX_ATTEMPTS=5 per 10 min) under a
bind-scoped key `otp_attempts:bind:{user_id}:{email}` — never the bare
address, which would let wrong bind guesses lock the owner out of email
sign-in. Past the cap even the right code is refused (429
too_many_attempts); a successful bind clears the counter, as sign-in does.

_users_mod now reads the raising function's own globals, so the tests hold
after another suite evicts and re-imports db.users.

Refs Abilityai/trinity-enterprise#720

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

dev added 0081_portal_messages_unread_idx (#3076) off the same parent.
Rename the Alembic revision to 0082_ent720_email_identity, parented on
0081_portal_messages_unread_idx, and order the SQLite entry after dev's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sibling suite (test_telegram_login_gate) parks a bare `routers.auth` stub
in sys.modules at collection time. The bind route now reaches the OTP
limiter through that module, so the `api` fixture re-imports the real one
when a stub holds the slot and always backs its counter with an in-memory
Redis.

Refs Abilityai/trinity-enterprise#720

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/users/me/email/code and PUT /api/users/me/email: code purpose,
console-provider admin bypass, the bind-scoped attempt cap, refusal codes,
and what the duplicate-resolving migration does.

Refs Abilityai/trinity-enterprise#720

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

dolho commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@obasilakis thanks. Item by item:

1. Red regression diff (4 TestUniqueAndOneWriter tests): fixed in 4873c43. _users_mod now reads type(db._user_ops)._insert_user.__globals__, your version. I reproduced it first with your probe (import database, then del sys.modules["db.users"]; import db.users, run before the file): 4 failed / 25 passed before, 29 passed after.

2. Cap wrong guesses on the bind code: fixed in 4873c43, and the tests were hardened in 05e16d5. PUT /me/email reuses check_otp_rate_limit / record_otp_attempt (OTP_MAX_ATTEMPTS = 5, 10 min) under otp_attempts:bind:{user_id}:{email}, never the bare address. Past the cap, even the right code gets 429 too_many_attempts. A successful bind clears the counter, as sign-in does. New tests go through the real route:

  • 5 wrong codes, then the 6th attempt with the right code is refused, and nothing is bound.
  • The sign-in counter otp_attempts:{email} for that address is untouched, and check_otp_rate_limit(email) still passes.
  • A different account's counter is independent: that account can still bind with its own code.
  • Success clears the counter.

Mutation:

  • Removing the check_otp_rate_limit call turns test_five_wrong_codes_lock_out_the_right_one red.
  • Keying the counter on the bare address turns the sign-in-isolation and per-account tests red.

05e16d5 is needed because test_telegram_login_gate parks a bare routers.auth stub at collection time, and that stub already breaks test_ent311_* / test_2381_* on dev under some orders. The api fixture now loads the real module when a stub holds the slot, and it always uses an in-memory Redis.

Non-blocking

  • Alembic: merged origin/dev in e3f7d67 and renumbered to 0082_ent720_email_identity, with down_revision 0081_portal_messages_unread_idx. The SQLite entry is ordered after dev's. check_alembic_heads.py reports 1 head, and check_alembic_parity.py origin/dev HEAD passes.
  • Duplicate resolution: the PR description now has an operator note. It says which account keeps the address, which accounts lose email sign-in to it, and where the migration log names them by username.
  • Refs abilityai/trinity-enterprise#720: kept. status-in-dev will be set by hand after merge.
  • Feature flow: added in 2724298, as a "Binding a Sign-In Email" section in docs/memory/feature-flows/email-authentication.md.

Tests: I ran the ent720 file, the channel gates, test_1160, test_lint_sys_modules, the Alembic guards, and the email-auth / OTP / portal-OTP suites on seeds 12345 and 99999. Each seed gave 430 passed, with 17 failed and 4 errors. Those 17 + 4 are the same test_2381_* / test_ent311_* failures that clean origin/dev shows on the same files and seeds (the telegram stub above), so this branch adds none. test_ent720_email_binding.py alone passes 32/32 on both seeds.

🤖 Generated with Claude Code

@github-actions github-actions Bot removed the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

@vybe

vybe commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

merge-train: not on this train — rides the next one once fixed. The review items from 13:15 are addressed (cap + _users_mod both verified).

  • Conflict with dev in src/backend/routers/users.py (against fix(auth): agent-config and self-service identity routes are human-only, with a route census (#2996) #3044): import style + the update_my_email docstring / request: Request param. Keeping your side resolves it, but the merged tree then fails tests/unit/test_2996_self_service_session_only.py::test_a_session_binds_a_new_email (400 code_required) — that dev test binds with no code, which ent#720 now refuses. Whether it moves to the code round-trip or the console-provider admin bypass is your call, hence the ejection.
  • Alembic fork: feat(sync-health): divergence age is the signal — red and frozen at >24h, persisted on the row (trinity-enterprise#706) #3035 (on this train) also adds 0082_* off 0081_portal_messages_unread_idx. After it lands, re-parent to 0083_ent720_email_identity ← 0082_agent_sync_state_divergence.
  • Worth a look: bind-code requests share the per-address sign-in counter (db/email_auth.py:249, no purpose filter), so any signed-in user can burn an unclaimed address's 3-per-10-min allowance and suppress the real owner's sign-in codes — the same cross-surface lockout class as the guess cap.
  • Minor: db/tables.py lacks idx_users_email_unique; body still says Refs (use Fixes if the MFA door is out of scope for ent#720).

dolho and others added 2 commits September 30, 2026 10:31
…sion to 0083

dev gained 0082_agent_sync_state_divergence (#3035), forking the Alembic
head. Chain 0083_ent720_email_identity off it and order the SQLite entry
after agent_sync_state_divergence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ent#720 requires the mailed code for PUT /me/email, except an admin on a
console-provider install. #2996's session-path test binds without a code,
so it now states that exception explicitly.

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

vybe commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

merge-train: not on this train. It rides the next one once fixed. The earlier review items are verified fixed at c5127c47: _users_mod, the bind-guess cap, the #3044 conflict, and the 09-29 nightly regressions.

Needs your call:

  • Bind codes and sign-in codes share one per-address counter. db/email_auth.py:249 count_recent_code_requests has no purpose filter, and both sign-in (routers/auth.py:572) and the MCP code request (services/mcp_auth_service.py:122) count bind rows. So any signed-in user can call routers/users.py:105 for someone else's address three times per 10 minutes. That blocks the owner's sign-in codes indefinitely and floods their inbox. Filtering purpose IS NULL in the sign-in counters is the mechanical half. A per-caller limit on bind requests is the design half.

Mechanical items, needed before merge:

  • Alembic fork, again. dev now has 0083_execution_conversation_key (feat(pull): interactive turns first, one turn per conversation (#2842, #2843) #3110), with the same parent as your 0083_ent720_email_identity. The two touch disjoint tables. The fix:

    1. Merge dev, keeping both SQLite list entries.
    2. Rename the revision to 0084_ent720_email_identity with down_revision = "0083_execution_conversation_key".
    3. Update tests/unit/test_ent720_email_binding.py:245 (the filename) and :256 (the down_revision assertion). Without this, test_the_alembic_copy_of_the_rule_is_the_sqlite_rule fails after the rename.
    4. Update the migrations.py docstring and the PR body, which still says 0082.

    With all of that applied locally, check_alembic_heads.py reports one head and check_alembic_parity.py passes.

  • Missing index. db/tables.py lacks idx_users_email_unique. Collapse schema.py + migrations.py into single source of truth (follow-up to #713) #746 autogenerates from that MetaData.

  • Closing keyword. The body says Refs. Every acceptance criterion of ent#720 is met, so use Fixes abilityai/trinity-enterprise#720. MFA on the redeemers is a follow-up.

Lower priority:

  • The migration clears more than the index needs. resolve_duplicate_emails groups on Python .strip().lower(), but the index is plain lower(email), so whitespace and non-ASCII case variants get cleared. Probably acceptable, but say so.
  • Dead code. stores/auth.js::resendEmailBindCode has no caller.
  • No mounted test for Settings.vue. Its bind path has no mounted test.

Not your fault: the journey-smoke failure is the 09-29/30 infra break that #3107 fixes.

obasilakis's CHANGES_REQUESTED review still stands, so the PR needs a re-review after the push.

@vybe vybe added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Sep 30, 2026
trinity-ability and others added 3 commits September 30, 2026 14:55
…il_identity (#3085)

dev gained 0083_execution_conversation_key (#3110) off the same parent as this
PR's 0083_ent720_email_identity — a two-head fork (#2068). The two touch
disjoint tables, so the unmerged revision is re-parented: renamed to
0084_ent720_email_identity with down_revision 0083_execution_conversation_key.
SQLite list keeps both entries, dev's first. Test path + down_revision pin,
migrations.py docstring and the feature flow follow the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
schema.py and both migration tracks create the index; tables.py MetaData did
not, so #746 autogenerate would propose dropping it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ance (#3085)

count_recent_code_requests had no purpose filter, so sign-in (routers/auth.py)
and the MCP code request counted bind rows: any signed-in user could request
bind codes for someone else's address three times per 10 minutes and keep the
owner's sign-in codes suppressed while flooding their inbox.

- Sign-in counters count sign-in codes only (purpose IS NULL), the same
  exact-match rule verify_login_code applies.
- The bind route limits its CALLER across every address
  (count_recent_codes_for_purpose('email_bind:<id>')), 3 per 10 minutes, so no
  account can spend another's allowance.

Tests drive the real route: another account's bind requests leave the sign-in
counter at 0; the cap is per caller across addresses; one account cannot spend
another's; sign-in codes do not spend a bind allowance.

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

vybe commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

merge-train: this PR is on today's train with #3122, #3119 and #3120. I pushed three commits to your branch; each one covers a single change:

  1. 85b4cbfb3: merged dev and re-parented the Alembic revision.
    • The migrations.py list conflict is resolved by keeping both entries, with dev's execution_conversation_key first.
    • The revision is renamed to 0084_ent720_email_identity, with down_revision = "0083_execution_conversation_key".
    • The test path and down_revision pin, the migrations.py docstring and the feature-flow doc follow the rename.
    • check_alembic_heads.py reports 1 head, and check_alembic_parity.py passes.
    • A local DB stamped 0083_ent720_email_identity needs alembic downgrade 0082_agent_sync_state_divergence && alembic upgrade head.
  2. 35182d866: added idx_users_email_unique to db/tables.py, with the same lower(email) WHERE email IS NOT NULL shape as schema.py and both migration tracks.
  3. efe3ac603: the shared-counter item from the 12:40 comment.
    • Mechanical half: count_recent_code_requests now counts sign-in codes only (purpose IS NULL, the same exact-match rule as verify_login_code). Sign-in (routers/auth.py) and the MCP code request no longer count bind rows.
    • Design half: the bind route's 3-per-10-minutes limit now belongs to the caller, across every address. It counts through the new count_recent_codes_for_purpose('email_bind:<id>'). No account can use up another's allowance, and one account can't send codes to many inboxes.
    • This is a design decision that was made on your branch. If you'd rather have a different shape, say so here.
    • 4 new tests drive the real route, and all 4 failed before the change.

I ran the ent720 file plus the related suites (186, 2381, 2996 ×2, admin email login, ent311, OTP rate limiting, schema parity, 1160, verification email, sharing null email) on seeds 12345 and 99999: 228 passed, with no new failures against dev.

The PR body now says Fixes abilityai/trinity-enterprise#720 and names 0084.

Two things still to come:

…url.path (#3085)

test_3102_host_header_fences (#3108, now on dev) forbids request.url.path in
backend code: request.url is rebuilt from the Host header. The two ent#720
audit calls in routers/users.py predate the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5_ent720_email_identity (#3085)

#3120 landed 0084_agent_loops_chain_depth off the same parent as this PR's
0084_ent720_email_identity. Disjoint tables, so the revision is re-parented:
0085_ent720_email_identity <- 0084_agent_loops_chain_depth. SQLite list keeps
both entries, dev's first — the same resolution train #3126 was gated on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vybe
vybe dismissed obasilakis’s stale review September 30, 2026 15:00

merge-train: all items addressed (_users_mod fix + bind-guess cap by the author; re-parent, index, counter by the train). Re-review welcome post-merge.

@vybe vybe 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.

merge-train: batch validated on train/20260930-1429 (#3126, all gates green); re-parented to 0085 after #3120

@vybe
vybe merged commit b09d454 into dev Sep 30, 2026
29 checks passed
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