Skip to content

feat(spec,plugin-email,service-messaging,plugin-auth): widen SendEmailInput with optional organizationId, threaded from org-holding producers - #11839

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-11741-sendemail-organization-id
Aug 24, 2026
Merged

feat(spec,plugin-email,service-messaging,plugin-auth): widen SendEmailInput with optional organizationId, threaded from org-holding producers#11839
os-warren merged 2 commits into
mainfrom
claude/issue-11741-sendemail-organization-id

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#11741

Clause-②: yes — public contract widening; the PR stays draft, the contract-review chain runs before enqueue.

The ruling this carries out (Decision 2 of #11303)

Maintainer, 2026-08-24, live PM chat: 「其他按照你的建议继续」, recorded on #11303 at 5396411781, Decision 2 verbatim:

sys_email: separate card, ruled to file. Widening SendEmailInput in packages/spec/src/contracts/email-service.ts is Clause-② work with spec ownership: thread from callers that hold an organization (the email channel's delivery.notification.organizationId), absent stays legal where the caller genuinely has none (auth verification/reset mail). The services seat files it with this provenance; ⛔ never smuggled into the merged PR #11698.

Parent ruling (#11303, 5393621706): 「11303 sys_inbox_message/sys_notification/sys_email 应该写 organization_id。」 Backfill posture: forward-stamping only, no backfill of existing org-less rows (maintainer 2026-08-23 precedent 「10950 不考虑存量」). Nothing here touches merged PR #11698.

What changed

  • packages/spec/src/contracts/email-service.tsSendEmailInput.organizationId?: string and SendTemplateInput.organizationId?: string (optional; pass-through only; TSDoc states the no-fabrication rule and the absent-stays-legal rule; the SendTemplateInput doc distinguishes it from the pre-existing org overlay key).
  • packages/plugins/plugin-email/src/email-service.tssend() stamps input.organizationId verbatim onto the persisted sys_email row (organization_id); absent writes nothing. sendTemplate() forwards the key into the SendEmailInput it builds. No in-adapter resolution: the writer runs under a constant SYSTEM context and only passes through what the input carries. The durable paths need no further change — queue/boot-sweep delivery re-reads the row, and the terminal status update never rewrites organization_id.
  • packages/services/service-messaging/src/email-channel.ts — the named producer. Both arms thread delivery.notification.organizationId: the plain send arm and the sendTemplate template arm; the EmailSenderSurface structural mirrors declare the key.
  • packages/plugins/plugin-auth/src/auth-manager.tssendInvitationEmail threads invitation.organizationId (the one auth producer that holds a real organization). All org-less auth mail is untouched.
  • Changeset: @objectstack/spec minor + the three touched packages (launch-window convention: no major; additive, no ADR-0087 disposition owed — node scripts/check-adr-0087-registration.mjs exit 0).

Caller census (the card marked it unmeasured)

Producers that HOLD an organization — now stamp:

ProducerEvidenceValue threaded
service-messaging email channel, plain armpackages/services/service-messaging/src/email-channel.ts:229 (send call); org on channel.ts:32delivery.notification.organizationId
service-messaging email channel, template armpackages/services/service-messaging/src/email-channel.ts:188 (sendTemplate call)delivery.notification.organizationId
plugin-auth sendInvitationEmailpackages/plugins/plugin-auth/src/auth-manager.ts:2672invitation.organizationId
plugin-email sendTemplatesend() (internal producer)packages/plugins/plugin-email/src/email-service.ts:1281forwarded input.organizationId

Producers genuinely WITHOUT one — unchanged, absent stays legal (the ruling's named class):

  • plugin-auth sendResetPassword (auth-manager.ts:1254), sendVerificationEmail (:1311), sendMagicLink (:2833), sendChangeEmailNotice (:3391) — user-scoped auth mail.
  • plugin-email mail-test button (email-plugin.ts:544) — operator test message.
  • plugin-email legacy queue subscriber (email-plugin.ts:755) — raw SendEmailInput pass-through; carries whatever its producer wrote, no change needed.
  • plugin-reports dispatchDue (report-service.ts:742,757) — holds only report.owner_id (a user id); the owner-context resolver is wired undefined (reports-plugin.ts:137, scheduled runs fail closed), so no organization value is in hand at the send site, and deriving one would be the resolution the ruling forbids.
  • REST POST /email/send (rest-server.ts:8334) — spreads the caller's body verbatim, so a body carrying organizationId now passes through with zero code change; whether the route should additionally stamp the execution context's tenant when the body omits one (the existing sentBy symmetry) is recorded as an open question in the dev report, not guessed at here.

Out-of-scope finding filed while sweeping: #11832SendTemplateInput.org ("org-overlay resolution (when supported)") has zero readers in the only IEmailService implementation.

Pins

  • Identity, not counts: each stamping pin asserts the exact org value the specific producer stamped (messaging both arms; plugin-email row organization_id; sendTemplate forwarding; plugin-auth invitation).
  • Over-denial controls: org-less send/sendTemplate/delivery write no organization_id and are NOT refused (plugin-email, messaging both arms, plugin-auth reset mail).
  • Contract pins: packages/spec/src/contracts/email-service.test.ts — the pre-widening shape stays legal byte-identically; the widened shape carries the optional key on both inputs. The pre-existing exact-shape toEqual pins in email-channel.test.ts double as byte-identity evidence for org-less callers.

Reverse verification (pins written first, run against unfixed source)

RED (before the fix, value dropped/never carried — expected undefined to be 'org_apex'):

  • service-messaging email-channel.test.ts: 2 failed (both arms' identity pins) | 15 passed
  • plugin-email email-service.test.ts + send-template.test.ts: 2 failed (row stamp; sendTemplate forwarding) | 43 passed
  • plugin-auth auth-manager.test.ts: 1 failed (invitation threading) | 246 passed
  • Over-denial controls were green on unfixed source in all three packages, as expected — the org-less path was already legal; only the stamping direction was red.

GREEN (after the fix, same commands): messaging 17/17 · plugin-email 45/45 · plugin-auth 247/247.

Verification (all readings at head 5df550961 unless noted)

  • pnpm --filter @objectstack/spec test — 421 files / 11221 passed; pnpm --filter @objectstack/spec typecheck — OK (incl. check:test-typecheck).
  • pnpm --filter @objectstack/plugin-email test — 27 files / 429 passed; typecheck OK.
  • pnpm --filter @objectstack/service-messaging test — 27 files / 279 passed; typecheck OK.
  • pnpm --filter @objectstack/plugin-auth test1519 passed (suite) and 247/247 on the pinned file at head; typecheck OK (after building the package's own dist — the examples tsconfig resolves the package by name).
  • Full workspace typecheck: pnpm exec turbo run typecheck --concurrency=2129/129 tasks successful (covers every downstream consumer of the widened spec surface).
  • pnpm --filter @objectstack/spec check:generated — "All 14 generated artifacts are up to date."
  • node scripts/pm/dispatch-gates.mjs derivation line, quoted: "dispatch-gates: gate list derived from the tree of 'objectstack-ai/objectstack' at commit 5df5509 (/home/user/objectstack-11741)." Every derived family ran locally at that head, all exit 0: changeset-gate-self-tests, cross-package-test-inputs (both spellings), doc-formula-expressions, spec empty-state / liveness / strictness-ledger / variant-docs, merge-driver, objectui-changeset, published-files, slot-lookup, spec-parsed-alias, test-source-alias, type-source-resolution, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs, plugin-teardown-shape, docs-audit affected-docs + drift-comment, release-rehearsal-clone --self-test; convention families: query-options-erasure, type-check-coverage, type-check-debt (--re-measure OK, none above recorded — the first run caught +3 in plugin-auth's frozen TEST_DEBT from this PR's own new tests; fixed by type-clean mock access, re-measured back to the recorded 97), engine-double-contract, where-matcher, check:i18n, check:nul-bytes.
  • A first-run check-dev-prereqs red was the unbuilt fresh worktree (40/67 packages without dist), green after the full packages build — worktree state, not diff state.

Generated by Claude Code

…lInput with optional organizationId and thread it from org-holding producers
Fixes#11741 (Decision 2 of #11303). SendEmailInput/SendTemplateInput gain an
optional organizationId; plugin-email's writer stamps it verbatim onto
sys_email.organization_id (pass-through only — no in-adapter resolution or
fabrication); the messaging email channel threads
delivery.notification.organizationId on both arms; plugin-auth's invitation
mail threads the invitation's own organizationId. Org-less callers (auth
verification / password-reset mail) stay legal and unstamped. Forward-stamping
only — no backfill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
…eeps TEST_DEBT at its frozen 97)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/plugin-auth, @objectstack/plugin-email, @objectstack/service-messaging, @objectstack/spec, touching 16 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getAudit (sdk), meta.getAudit (sdk), meta.publishItem (sdk), meta.rollbackItem (sdk), publishItem (sdk), rollbackItem (sdk))
  • content/docs/automation/email-templates.mdx(via sendTemplate (symbol))
  • content/docs/automation/hooks.mdx(via organizationId (symbol))
  • content/docs/data-modeling/seed-data.mdx(via organizationId (symbol))
  • content/docs/deployment/seed-tenancy-repair.mdx(via organizationId (symbol))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/publish (route), /:type/:name/rollback (route))
  • content/docs/kernel/events.mdx(via organizationId (symbol))
  • content/docs/kernel/index.mdx(via sendTemplate (symbol))
  • content/docs/kernel/runtime-services/audit-service.mdx(via organizationId (symbol))
  • content/docs/kernel/runtime-services/email-service.mdx(via SendEmailInput (symbol), SendTemplateInput (symbol), sendTemplate (symbol))
  • content/docs/kernel/runtime-services/sharing-service.mdx(via organizationId (symbol))
  • content/docs/permissions/authentication.mdx(via organizationId (symbol))
  • content/docs/protocol/kernel/config-resolution.mdx(via organizationId (symbol))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx(via organizationId (symbol))
  • content/docs/releases/v16.mdx(via organizationId (symbol))
  • content/docs/releases/v17.mdx(via /:type/:name/publish (route))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9bc403ef745beea5b60ddc0048ca980214e660d8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5bb0d52729a823a56b1219d4bbd3e893010e4554 — the merge of head 5df5509610e3e678860c9dcfee8b7082b24f602e into base 9bc403ef745beea5b60ddc0048ca980214e660d8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5bb0d52729a823a56b1219d4bbd3e893010e4554 && git checkout 5bb0d52729a823a56b1219d4bbd3e893010e4554
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9bc403ef745beea5b60ddc0048ca980214e660d8 5df5509610e3e678860c9dcfee8b7082b24f602e && git checkout -B drift-repro 9bc403ef745beea5b60ddc0048ca980214e660d8 && git merge --no-ff 5df5509610e3e678860c9dcfee8b7082b24f602e
node scripts/docs-audit/affected-docs.mjs --json 9bc403ef745beea5b60ddc0048ca980214e660d8

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9bc403ef745beea5b60ddc0048ca980214e660d8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
@os-warren
os-warren marked this pull request as ready for review August 24, 2026 21:45
@os-warren
os-warren added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit b706af9Aug 24, 2026
33 checks passed
@os-warren
os-warren deleted the claude/issue-11741-sendemail-organization-id branch August 24, 2026 22:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Widen SendEmailInput with organizationId so sys_email can be stamped at its producers (Decision 2 of #11303)

2 participants

@os-warren@claude