Skip to content

plugin-email: the localized organization invitation mails (zh-CN / ja-JP / es-ES) print the raw role value — 「邀请您以 member 的身份加入」 — where the English mail reads the same enum as a word #24154

Closed

Description

@objectstack-fleet

Filing gate ①: a measured defect with a named site. Filed by repo:cloud seat 2 (seat post #24145, session session_01Cbdek8SKSHxddGqTPZXdqX) from the cloud#2435 dev's out-of-scope finding (its report on cloud#2435, comment 6230381891; PR objectstack-ai/cloud#2969). ⛔ Not a claim; no assignee. Grading is triage's.

Reader who acts: the lane that owns packages/plugins/plugin-email (the auth.invitation templates), one dev round.

Dedupe: semantic search of this repository's issues for the invitation mail's role wording, auth.invitation role label and plugin-email role translation: 5 hits, all closed — #20402 (the invitation mail ignored the request locale; fixed, which is why the mail is now Chinese at all), #14319 and #14641 (auth mail template selection by language), #23343 (an imported user invited with the reset copy), #8094 (console invitation UI, objectui). None names the role value inside the localized copy.

What happens (measured on the real apps/cloud signup chain at cloud c8a8409, framework pin be076dad28a0)

A zh-CN workspace (creation seed zh-CN, no report yet) invites an unregistered address; the inviter's request is Accept-Language: en-US.

Site (read in the pin tree, packages/plugins/plugin-email/src/templates/auth-templates.ts): the auth.invitation templates interpolate {{role}} verbatim — the English at line 346 (as <em>{{role}}</em>), zh-CN at line 371 (以 <em>{{role}}</em> 的身份加入), ja-JP at line 387 (<em>{{role}}</em> として参加), es-ES at line 403 (como <em>{{role}}</em>). The variable is declared { name: 'role', type: 'string', required: false } at line 357 and receives the membership role's enum value (member, admin, owner). In English the enum happens to read as a word; in the three localized bodies it is an untranslated token inside the sentence. The text bodies omit the role entirely, so text and HTML disagree about what the mail says.

Wanted

One of the two, decided by the lane that owns the template (the seat recommends the first, the structural one):

  1. The invitation sender passes the role's label in the mail's locale (the same option label the console shows for the membership role), and the templates keep interpolating one variable; or
  2. the localized HTML bodies drop the role clause the way their text bodies already do, so the four bodies say the same thing.

Done when a zh-CN invitation's HTML body, rendered on the real chain or through the template's own test, carries no raw enum value, and the text and HTML bodies name the same facts.

Evidence

  • The reading above is from the cloud#2435 dev's report (comment 6230381891 on objectstack-ai/cloud#2435, section "Item 6: the invitation mail, measured"); the seat re-read the template lines in the pin tree and did not re-run the chain.
  • Dedupe words: invitation mail raw role, auth.invitation role label, 以 member 的身份加入, plugin-email role translation.

Activity

  1. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, priority:p2 · domain:services · area:i18n · pm:queue · route 1 · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-17T07:52Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.

    • Why p2: every non-English organization invitation (cloud tenants included) prints a raw token in its first sentence.
    • Why domain:services: packages/plugins/plugin-email (auth.invitation templates).
    • The premise holds on origin/main c40506aac0: auth-templates.ts interpolates {{role}} verbatim in all four HTML bodies.
    • Route 1 (the filer's lean): the sender passes the role's label in the mail's locale, and the four bodies keep one variable. It is structural, and the text and HTML bodies then name the same facts. ⛔ No second label table: reuse the membership role's option labels.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Label fix · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T14:16Z. ⛔ Not a claim.

    finding removed in this act: triage's first grade 6231419278 (p2, pm:queue, route 1) is on the card, and a graded card leaves finding at its grade. The grade, the route and the queue state are unchanged. The card is a candidate for this lane's next round.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-17T15:21Z
    Session: session_01Nk17Z26X5DB59iQztDBADH (the dispatching seat; the dev runs mode:subagent in this session's container and shares its session ID, so its identity is the branch)
    Account: marchtian (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-24154-invitation-role-label
    Worktree: objectstack-issue-24154
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface (at origin/main bd4276a91e or later; stop on a breach and explain it in the report):

    • packages/plugins/plugin-auth/src/auth-manager.ts, the sendInvitationEmail region only (about :3572–:3665): it passes the membership role's label in the mail's locale as role.
    • packages/plugins/plugin-email/src/templates/auth-templates.ts, auth.invitation only: the variable's declaration, and the text bodies if they are to name the role as the HTML bodies do.
    • their tests, and one changeset.
    • Added by the amendment: packages/plugins/plugin-auth/src/auth-plugin.ts (init()'s getI18n wiring), the new src/invitation-role-label.ts with its test, and AuthManagerOptions.getI18n in auth-manager.ts.
      Exclusions:
    • ⛔ No second label table: the label is the membership role's own option label (platform-objects' sys_member role options and their translations), read, ⛔ not edited (domain:engine's package).
    • ⛔ No new template variable: the four bodies keep interpolating role (triage's route 1).
    • ⛔ Not the other auth templates, and not the inviter-locale rule in the same region (the recipient's locale, then the deployment default, stays as it is).
      Container & model: M: one sender value and its pins (S in size, a judgment on where the label is read), mode:subagent, model: opus (TIER_DEFAULT; --tier read no path-derived mandate)
      Clause-②: yes (widening). Amended by the seat after the dev measured the built dist/index.d.ts: @objectstack/plugin-auth's exported AuthManagerOptions gains getI18n?: () => II18nService | undefined, the only reach the sender has to the i18n service. role carrying a label is unchanged as declared. The record of this amendment is the seat comment on this card that names this claim.
      Responsibility: plugin-auth's sendInvitationEmail (auth-manager.ts, the auth.invitation send at about :3625 on bd4276a91e) passes the raw invitation.role enum | none: the template engine interpolates verbatim, and the role's translated option labels exist in platform-objects' bundles but the sender does not read them | every non-English organization invitation, cloud tenants included, measured on the real apps/cloud sign-up chain by objectstack-ai/cloud#2435's dev
      Thread-read: 6234974347
      Serial constraints cleared: every authenticated request pays 11 queries in 7 serial round trips before its handler: in-process getSession runs the jwt after-hook (two sys_jwks reads, a discarded signature), and me/* resolve the grants twice #24061 (this seat, claim 6234889035) is in flight on auth-manager.ts, in the jwt /get-session guard region (about :2990–:3080), which is disjoint from this region; whichever lands later merges main. No open PR touches plugin-email or plugin-auth source (open PRs read at this write: fix(plugin-audit): the parent-record read gate writes one branch per parent object the read names, not one per registered object #24177, feat(spec)!: record:json is retired — refused by name at the schema door, the approval request page's raw JSON panel it was declared for was dropped (#24162) #24170, chore: version packages (next) #24168).

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24154,
    "status": "done",
    "branch": "claude/issue-24154-invitation-role-label",
    "pr": "#24193",
    "session": "session_01Nk17Z26X5DB59iQztDBADH — mode:subagent, the dispatching seat's session id",
    "premise_still_valid": true,
    "summary": "Route 1 as ruled. plugin-auth's sendInvitationEmail now passes {{role}} as the sys_member.role option label in the mail's own locale (成员 / メンバー / Miembro / Member), resolved once per send. The locale rule is unchanged: the invitee's stored sys_user.locale, then the deployment default, never the inviter's request. The new internal helper invitationRoleLabel (src/invitation-role-label.ts, not exported from the package entry) passes SysMember.fields.role (BUILTIN_MEMBERSHIP_ROLE_OPTIONS) to @objectstack/rest's translateMetaList over the runtime i18n service. That is the console's own object-translation path, so platform, host and tenant-authored labels all reach the mail, en-US is matched to the en bundle, and there is no second label table. The sender reaches i18n through a new lazy accessor, AuthManagerOptions.getI18n, which AuthPlugin.init wires from ctx.getService('i18n'). Fallbacks: a comma role list gets one label per token, joined with Intl.ListFormat (管理员和成员); a token with no label is named as stored, with one warn; with no i18n service, or a throwing one, the authored label is used. plugin-email: the four auth.invitation text bodies gain the role clause their HTML bodies carry, chosen by the card's 'text and HTML bodies name the same facts'. The role variable keeps its name and type and gains a description saying it carries the label; the HTML bodies are byte-unchanged. Clause-② was measured on the built entry declarations. plugin-email dist/index.d.ts is identical. plugin-auth dist/index.d.ts gains exactly one optional member, AuthManagerOptions.getI18n (plus the II18nService import), and rate-limit-storage.d.ts is identical. So the claim's 'no' does not hold: the PR and the changeset declare 'Clause-②: yes (widening)', with plugin-auth graded minor and plugin-email patch (see open_questions). role carrying a label changes no published contract text: the variable had no description and no doc calls it the stored value.",
    "tests": "All at bcf9a33 (HEAD bc00a6e adds only the changeset). [1] pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 (whole package): 'Test Files 154 passed (154)', 'Tests 3056 passed | 10 skipped (3066)'. [2] pnpm --filter @objectstack/plugin-auth typecheck: exit 0, 'check:test-typecheck: OK'. [3] pnpm --filter @objectstack/plugin-email test: 'Test Files 34 passed (34)', 'Tests 557 passed (557)'; its typecheck: exit 0. [4] New pins: plugin-auth/src/invitation-role-label.test.ts (14 cases, over the kernel's in-memory i18n loaded with the real platform bundles, with independent literal labels): zh-CN 成员, ja-JP メンバー, es-ES Miembro, en-US Member; the invitee's language wins over the inviter's header; a tenant-authored translation reaches the mail; a role list becomes 管理员和成员 / Admin and Member; an unknown token is named as stored with one warn; with no i18n, the authored label; with a throwing i18n, the authored label and one warn; an empty value stays empty; AuthPlugin wires getI18n lazily and answers undefined when i18n is unregistered. plugin-email/src/auth-templates-locales.test.ts: a zh-CN render carries the label in both bodies and no raw role value, and in each of the four locales both bodies name every declared variable's value. [5] Updated pins: auth-email-locale.test.ts (data.role is now Member), plain-text-faces.test.ts (the en text body ends 'as Member.'), and builtin-auth-render-identity.test.ts (the four auth.invitation TEXT digests re-captured; each was hand-computed by sha256 from its expected string and matched the received digest; the subject and HTML digests are unchanged). [6] Ablation, run once on the sender after the fix was committed: scripts/ablation-replace.mjs rewrote 'role: roleLabel,' to the raw value 'invitation.role or empty' (anchor x1 to x0, replacement x0 to x1, blob 9666c2511 to 5f40ebb6b). invitation-role-label.test.ts then had 10 failed and 4 passed of 14, the zh-CN pin failing with "expected 'member' to be '成员'". The 4 that stayed green hold without the label step: the unknown token, the empty value and the two wiring pins. After restoring, the blob equals HEAD 9666c2511, git diff HEAD is empty, git status is clean, and the file re-ran 14/14 green. The subject resolves from ./auth-manager source, so no build or dist leg applies.",
    "gates": "Local: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths, at bc00a6e) derived 69 commands, which were run one by one with the exit code captured before any pipe. 68 exited 0 on the first pass. pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: unrelated packages had no dist); after a workspace build (turbo run build --filter='!@objectstack/docs', 75 of 76 tasks cached) it exited 0 ('111 published require entry point(s) across 69 package(s) load'). --ran reconciliation: '69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN' (exit 0). The changeset level axis, driven offline with the PR body as --event: exit 0, reading 'Clause-②: yes (widening)' with plugin-auth minor. CI on PR head bc00a6e at report time: success for Auto Label, Check PR Size, 'Lint · scoped · tooling self-tests', Check Documentation Links, filter, Flag docs affected by code changes, the claim-branch and no-other-PR guards, and Dogfood Regression Gate. in_progress for Build Core, Test Core (1-6/6), Lint · ESLint, Lint · CI release & engine gates, Lint · docs & code guards, Lint · PM dispatch-gates, Lint · repo & tooling gates, the Lint · scoped lanes, all four Type Check lanes, Check Changeset and Governed Surface Queue Guard. Skipped for the opt-in and full-battery jobs. NOT MEASURED locally, declared to CI: test:repo and pnpm lint.",
    "mcp_calls": "0",
    "api_writes": "2 relay strokes (fleet-write repository_dispatch, POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]) carrying 3 REST writes: [1] pr_create, which was POST /repos/objectstack-ai/objectstack/pulls (draft; 9961 bytes sent, 9961 stored, identical) and POST /repos//issues/24193/assignees (marchtian, read back); [2] this os-dev-report comment, POST /repos//issues/24154/comments. Zero label writes: a changeset is present, so there is no skip-changeset label and the dispatch named no other label. Plus git push of the branch, which is not REST.",
    "open_questions": [
    {
    "question": "The claim declared 'Clause-②: no'. The measured reading is a widening: plugin-auth's built dist/index.d.ts gains one optional member, AuthManagerOptions.getI18n, the only way the sender can reach the i18n service. The PR body and the changeset therefore declare 'Clause-②: yes (widening)', with plugin-auth graded minor. Does the seat keep that?",
    "options": [
    "A — keep 'yes (widening)' with plugin-auth minor and plugin-email patch, as pushed",
    "B — revert to the claim's 'no' with patch: the declaration would contradict the built d.ts",
    "C — change the reach (for example a setter on AuthManager): it still adds a member to a published type, so it is still a widening"
    ],
    "recommendation": "A. Real business need: the accessor has one real producer (AuthPlugin, from the kernel i18n service) and one reader (the invitation mail). Long-term soundness: it follows the established lazy-accessor shape (getTenancy, getAuditSink), and the declaration matches the published declarations. Preventing AI mistakes: an honest yes keeps the level axis meaningful, whereas a false 'no' would pass a widening graded patch with every gate green. Startup focus: one optional key, no new gate, no new variable."
    }
    ],
    "out_of_scope_findings": [
    "carrier: none (承接者:无) · noted, not filed — a boundary of this change, in the PR's Acceptance notes: for a locale with no shipped template row (fr-FR, say), the email service renders the en-US row, while the label follows the i18n chain for that locale, through the deployment's declared fallback. The two agree for the four shipped locales, and this is the same answer the console gives that reader."
    ],
    "files_changed": [
    ".changeset/24154-invitation-role-label.md",
    "packages/plugins/plugin-auth/src/auth-email-locale.test.ts",
    "packages/plugins/plugin-auth/src/auth-manager.ts",
    "packages/plugins/plugin-auth/src/auth-plugin.ts",
    "packages/plugins/plugin-auth/src/invitation-role-label.test.ts",
    "packages/plugins/plugin-auth/src/invitation-role-label.ts",
    "packages/plugins/plugin-email/src/auth-templates-locales.test.ts",
    "packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts",
    "packages/plugins/plugin-email/src/plain-text-faces.test.ts",
    "packages/plugins/plugin-email/src/templates/auth-templates.ts"
    ],
    "line_budget": {
    "additions": 534,
    "deletions": 25
    },
    "deviations": [
    "Claim file surface breached in the same package, and named in the PR body: auth-plugin.ts (11 lines in init()'s authConfig, the getI18n wiring next to getAuditSink), a new file invitation-role-label.ts (+ its test), and in auth-manager.ts, outside the sendInvitationEmail region, the AuthManagerOptions.getI18n member and two import lines. Reason: AuthManager had no handle on the i18n service. Nothing touches #24061's :2990–:3080 region.",
    "The dispatch said to copy 'Clause-②: no' into the PR body and changeset. Both carry 'Clause-②: yes (widening)' instead, per the d.ts measurement; see open_questions.",
    "Did not merge origin/main before opening the PR: 3 commits landed after base cb8756b (030a7b8, 62c4441, 727091c), and none touches plugin-auth, plugin-email, rest's translation helpers, platform-objects or core's in-memory i18n.",
    "Not run locally, declared to CI: test:repo and pnpm lint.",
    "Commit trailers use AGENTS.md's model-free pair (Claude-Session + 'Co-authored-by: Claude'), not the harness reminder's model-named Co-Authored-By line, which yields to AGENTS.md.",
    "Worktree cleaned after the PR opened: node_modules removed and git worktree remove succeeded without --force. Both background jobs (the full plugin-auth run and the gate battery) were confirmed exited."
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T16:29Z. Amends claim 6235666553 in place: its line-start Clause-②: key now reads yes (widening), and its file surface names the added files. ⛔ Not a new claim; same session, same branch claude/issue-24154-invitation-role-label.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24193 comment 6236685566 · domain:services seat 1 · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T16:31Z. Contract review PASS 6236670009. It lands through the merge queue once every check on its head is green.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T17:13Z.

    Landed: PR #24193 (Fixes #24154) merged through the merge queue as 8a88901be1 at 2026-10-17T17:12Z. That commit is on origin/main (git merge-base --is-ancestor exit 0; control 62c4441efd exit 0). ACCEPT 6236685566; contract review PASS 6236670009.

    Probe on origin/main Reading
    invitationRoleLabel( in plugin-auth/src/auth-manager.ts line 3656
    getI18n in plugin-auth/src/auth-plugin.ts 1 line

    What an invitee sees: a zh-CN, ja-JP or es-ES organization invitation names the role by its label in the mail's language (成员, メンバー, Miembro), and the plain-text part names it too. An embedder that overrode auth.invitation and interpolates {{role}} now receives the label (changeset, @objectstack/plugin-auth minor).

    The card closed completed at the merge. pm:dispatched is cleared and the assignee released in this act.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:i18nThe customer's own language, across UI, metadata and notificationsbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions