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

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.ts — SendEmailInput.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.ts — send() 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.ts — sendInvitationEmail 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:

Producer Evidence Value threaded
service-messaging email channel, plain arm packages/services/service-messaging/src/email-channel.ts:229 (send call); org on channel.ts:32 delivery.notification.organizationId
service-messaging email channel, template arm packages/services/service-messaging/src/email-channel.ts:188 (sendTemplate call) delivery.notification.organizationId
plugin-auth sendInvitationEmail packages/plugins/plugin-auth/src/auth-manager.ts:2672 invitation.organizationId
plugin-email sendTemplate → send() (internal producer) packages/plugins/plugin-email/src/email-service.ts:1281 forwarded 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: #11832 — SendTemplateInput.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 test — 1519 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=2 — 129/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

claude added 2 commits August 24, 2026 18:47
…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 9bc403ef745beea5b60ddc0048ca980214e660d8 → packageMentionDocs.

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-actions github-actions Bot 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 queue Aug 24, 2026
Merged via the queue into main with commit b706af9 Aug 24, 2026
33 checks passed
@os-warren
os-warren deleted the claude/issue-11741-sendemail-organization-id branch August 24, 2026 22:14
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ts that decided them (objectstack-ai#20757)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the eleventh stage of the `domain:services` lane of the
dead-citation sweep. It covers `packages/plugins/plugin-email/src/**`
and nothing else. By the seat's census at the claim (`5902547086`), it
is the largest package in the lane that no in-flight work holds. Later
stages cover the other packages, so this PR says `Part of` and the card
stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 10 (PR objectstack-ai#20609 as
`422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR
objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as
`9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`, PR objectstack-ai#20729 as `d2820876f`, PR
objectstack-ai#20737 as `4dfff176b`, PR objectstack-ai#20742 as `697845d19`). That is **16 sites on
16 lines in 8 files, covering 4 numbers**:

- 7 census sites (every census site this package has);
- 9 sites in test comments, which the census defers. Three of them carry
`objectstack-ai#13190`, a dead number that stands only in test files here, so the
census never judged it; it was read on its own (404);
- no site the gate's grammar cannot see (the package has none that is
dead, see Acceptance notes).

Each rewritten line now cites the commit in `origin/main` history that
decided what the line describes, and says in its own words what was
decided: **4 distinct shas**. No number in this package has an ADR or
ruling record of its own (a grep of `docs/adr/` and
`scripts/adr-anchors/` finds only ADR-0131 naming `objectstack-ai#11741`, as evidence
in its D7, not as the record of that decision; nothing else under
`docs/` names the four), so every anchor is a commit, per ruling C's
order. No number was dropped.

Only comments changed. Every touched source file keeps its line count
(16 lines out, 16 in, over 8 files), so no line citation into these
files moves. Every one of the 16 changed lines carried a dead citation;
there is no reflow line. No code token moves (see the guard below).

**No citation number is added.** The added lines carry no tracker number
at all. Over the whole diff, added minus removed is negative for the
four dead numbers and zero for every other number, and no number is new
to the diff. No PR number is the citation on an added line: the two `PR
objectstack-ai#8675` spellings became that pull request's squash commit.

10 dead sites are left on purpose, all of them `describe` / `it` titles
(see the list below).

One more file: a `patch` changeset for `@objectstack/plugin-email`,
because the rewritten prose ships (see Changeset below).

## Census: `plugin-email`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/plugins/plugin-email/`. Each run counts as a reading only
because its board frontier equals the newest issue or pull-request
number, read by a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
plugin-email sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `97005aed0`, run 2026-09-30T02:00:45Z to 02:04:02Z |
enumerated, 186 pages, frontier objectstack-ai#20748 (newest objectstack-ai#20747 before, objectstack-ai#20748
after: a pull request opened at 02:03:20Z, inside the run) | 1,064 |
**7** | 7 | 4 | 3 |
| after | head `15a7d69a7`, run 02:11:19Z to 02:14:30Z | enumerated, 186
pages, frontier objectstack-ai#20753 (newest objectstack-ai#20753 before and after) | 1,057 | **0**
| 0 | 0 | 0 |

The before count matches the seat's census and A1 (7 sites: `objectstack-ai#13189` ×4,
`objectstack-ai#11741` ×2, `objectstack-ai#8675` ×1). The before run's board moved during the run;
its frontier equals the newest number at the run's end, which is A1's
criterion (stage 7's precedent). The whole-repo drop is 7, exactly this
diff's census sites. The `resolves` tally is 33,029 in both runs, and
`resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did
not move either. The after run was taken on `15a7d69a7`; the head
`23283d394` adds only the changeset. No run was truncated or discarded:
both enumerations read 186 pages at the newest frontier.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `namesThisRepository`
over every `.ts` file under `plugin-email/src` (50 files). It takes its
verdicts from the before census's own board reading rather than from a
second enumeration: a number is dead when that census reported it
`allocated-but-absent`, and alive when that census judged it on this
board anywhere (its `--list` extraction, 37,072 rows) and did not report
it. The eleven numbers the census never saw, because they stand only in
test files or as the second half of a slash pair here, were read one by
one on the issues endpoint: `objectstack-ai#13190` answers 404; `objectstack-ai#5169`, `objectstack-ai#5286`,
`objectstack-ai#10619`, `objectstack-ai#16506`, `objectstack-ai#20374`, `objectstack-ai#5197` answer 200 as issues, and `objectstack-ai#8348`,
`objectstack-ai#5191`, `objectstack-ai#5211`, `objectstack-ai#5232` as pull requests.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `97005aed0` | 360 | **26** | 7 | 9 | 0 | 10 |
| after, `15a7d69a7` | 344 | **10** | 0 | 0 | 0 | 10 |

Its src-comment column equals the census's 7, which is the control on
the second instrument. The 323 live citations are the same in both
readings, and the drop of 16 citations is exactly the rewritten sites.
11 extracted tokens are not tracker references at all and are not
judged: the HTML entity `&objectstack-ai#39;` (6 sites in the template engine and its
tests) and the fixture subjects `Invoice objectstack-ai#42` to `Invoice objectstack-ai#45` (5
sites). A third, raw reading (every `#` followed by 2 to 6 digits,
whatever surrounds it) finds 371 occurrences and 26 dead before, 355 and
10 after. Beyond the gate's grammar it sees 11 tokens, none dead: the
nine second numbers of the `#A/#B` lines (all live), the excused `Prime
Directive objectstack-ai#12`, and the CSS colour `#2563eb`.

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject, and `git blame` at the base puts every
rewritten line in its anchor commit or in a later commit that descends
from it (`merge-base --is-ancestor` exit 0 for all 16 line and anchor
pairs).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#13189` | 13/4 | 8/5 | `33fbd3566` (PR objectstack-ai#13375): the SMTP port guard
tests integrality (`Number.isInteger`), so a fractional port such as
`587.5` is refused at construction, and the generated refusal sentence
reads `(expected an integer 1-65535)`, the range still rendered from the
constants. Its changeset headline names `objectstack-ai#13189`; its diff writes the
integrality docblocks the rewritten lines sit in. New to the sweep |
| `objectstack-ai#13190` | 5/1 | 3/2 | `56c5b1dbe` (PR objectstack-ai#13316):
`smtpOptionsFromMailSettings` passes a present-but-unreadable
`smtp_port` through to the guard instead of omitting it (which had
silently fallen back to 587); absent and `''` still mean "not set", and
no second refusal was added. Its changeset headline names `objectstack-ai#13190`; its
diff writes the `objectstack-ai#13190` comment block itself. New to the sweep |
| `objectstack-ai#11741` | 6/3 | 3/3 | `b706af987` (PR objectstack-ai#11839): `SendEmailInput` /
`SendTemplateInput` gain an optional `organizationId`, which
`plugin-email`'s writer stamps verbatim onto `sys_email.organization_id`
(pass-through only, no resolution or fabrication), and `sendTemplate`
forwards it as a producer of `send()`. Its message names `objectstack-ai#11741` as the
card that commit closed; `git blame` puts all three rewritten lines in
it. The `plugin-auth` stage's anchor for the same number |
| `objectstack-ai#8675` | 2/2 | 2/0 | `c9f595083`: the squash commit of the pull
request that was `objectstack-ai#8675` (its subject ends `(objectstack-ai#7987) (objectstack-ai#8675)`):
`sys_account`'s OAuth token columns are declared `internal: true`. Its
diff records the trap both lines describe: those columns are `required:
false`, so inferring "key missing, therefore the strip ran" broke
ordinary sign-in (16 red tests), which is why the readback carries the
`absenceProvesStrip` discriminator. New to the sweep |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 4), and every one is an
ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 4;
control leg: stage 1's landing `422db788a` exit 0; the history is
complete, `--is-shallow-repository` false, 15,155 commits). Each of the
4 numbers answers 404 on the issues endpoint, which serves pull requests
too. Independently, the package's own shipped `CHANGELOG.md` pairs
`b706af9`, `33fbd35` and `56c5b1d` with the same three decisions.

## Wordings to check

- **Tag swaps in parentheses.** 「(objectstack-ai#13189)」 became 「(commit 33fbd35)」
at `transports/smtp-port-contract.ts:87` (a section heading), `:134` and
`transports/smtp.ts:68`.
- **Line openers.** 「objectstack-ai#11741 —」 became 「Commit b706af9 —」 at
`email-service.ts:742` and `:1439`; 「objectstack-ai#13190 —」 became 「Commit 56c5b1d
—」 at `transports/smtp.test.ts:221`; 「## objectstack-ai#13189 —」 became 「## Commit
33fbd35 —」 at `transports/smtp-port-contract.test.ts:34`.
- **`email-service.test.ts:342`**, a section rule: 「── objectstack-ai#11741 —」 became
「── Commit b706af9 —」, and its trailing rule was shortened by 10
characters so the line keeps its width exactly.
- **`internal-header-readback.ts:37`.** 「(PR objectstack-ai#8675 hit exactly this on
`sys_account`'s optional」 became 「(Commit c9f5950 records exactly this
on `sys_account`'s optional」: a commit does not "hit" a trap, it records
one, and that commit's own diff is where the 16 red tests are recorded.
- **`email-headers-internal.integration.test.ts:251`.** 「The regression
PR objectstack-ai#8675 measured on a sibling card」 became 「The regression commit
c9f5950 records from a sibling card」, the same reading.
- **`transports/smtp-port-contract.test.ts:228`.** 「objectstack-ai#13189 is the card
that SPENDS that」 became 「Commit 33fbd35 is the change that SPENDS
that」, so the noun matches the anchor.
- **`transports/smtp.ts:127`, `transports/smtp.test.ts:272`, `:276`,
`:281`, `:283`.** The number became 「commit SHA」 in place (「until commit
33fbd35:」, 「The bucket commit 56c5b1d never had to name」, 「Commit
33fbd35 made the guard test」, 「Commit 56c5b1d's rule is that」,
「commit 33fbd35 changed which numbers」).

## The 10 sites left

- **Test strings, 10 sites on 9 lines**, all `describe` / `it` titles,
left as stages 1 to 10 left theirs: `email-service.test.ts:349` and
`send-template.test.ts:63`, `:88` (`objectstack-ai#11741`);
`transports/smtp-port-contract.test.ts:225`, `:309`, `:340` (`objectstack-ai#13189`);
`transports/smtp.test.ts:230` (`objectstack-ai#13190`), `:271` (`objectstack-ai#13189`), `:293`
(`objectstack-ai#13190` and `objectstack-ai#13189`).
- No source string, operator log string, assertion message, quoted
maintainer ruling or generated file in this package carries a dead
number.
- Outside `src`, the package's `CHANGELOG.md` names three of these
numbers on 5 lines. It is release-owned and deliberately not edited here
(see Acceptance notes).

## Mechanical guard: no code token moves

The guard compares the TypeScript parser's leaf nodes (a `forEachChild`
walk, so comments are trivia and JSDoc nodes are never visited), base
`97005aed0` against head. String and template literals are therefore
read in full. It ran over all 8 touched `.ts` files.

- Real run: 7,035 base leaf tokens, **0 files with a token change**
(exit 0).
- Comment control in `email-service.ts` (「no resolution, no default, no
fabrication」 to 「… no default and no fabrication」): 0 files changed, as
expected (exit 0).
- Positive control, a code token added in `transports/smtp.ts`
(`isValidSmtpPort(port)` given `as number`): DIFFER, 587 to 588 leaf
tokens (exit 1).
- Positive control, one digit changed inside a kept test title
(`transports/smtp.test.ts:293`, `objectstack-ai#13189` to `objectstack-ai#13188`): DIFFER (exit 1).

Every mutation went through `scripts/ablation-replace.mjs` (wrap mode)
under a shell trap that restores by absolute path, and each landed
(anchor 1 to 0, blob changed). Each restore was proven byte-identical to
the HEAD blob (`1e99bd5e2bcb`, `46c13267611b`, `da5314910bc4`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/plugin-email`
(`.changeset/20596-plugin-email-provenance-anchors.md`) is included. Its
body is stage 10's, word for word, with the package name changed.

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`, and the package is not private. After the build,
`b706af987` appears twice in each of `dist/index.js` and
`dist/index.mjs` (the two inline comments in `email-service.ts`, which
the bundle keeps). `c9f595083` appears once in each of `dist/index.d.ts`
and `dist/index.d.mts` (the `internal-header-readback.ts` docblock), and
so does `33fbd3566` (the docblock on `SmtpTransportOptions.port`).
`56c5b1dbe` reaches nothing (test files only). Positive controls, one
unchanged line beside each shipped rewrite, land exactly where their
neighbours do: 「context, so the input's organization is the one fact it
may stamp:」 and 「caller's organization so the sys_email row it persists
is stamped.」 once in each JS file; 「token columns: inheriting」 and the
unchanged line just above the rewritten one in the `port` docblock once
in each declaration file. A never-written negative phrase appears
nowhere in `dist`. None of the 4 dead numbers is left in `dist`.

## Gates (head `23283d394`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
exits 0. `node scripts/check-issue-citations.mjs` exits 0: the
diff-scoped run found no citation added against `97005aed0` (4 files
read; test files are a deferred surface).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `23283d394` derived 61 commands:
all 55 derived at dispatch, plus `check:engine-double-contract`,
`check:objectql-double-limit`, `check:query-options-erasure`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:where-matcher`. Each ran with its exit code captured before any
pipe, and all 61 exit 0. `--ran`, fed each command with its exit code,
reports 61 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A
full `turbo run build` of `./packages/*` and `./packages/*/*` ran first
under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an
unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in directories this diff touches): `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit
0.
- **Tests and typecheck, under the verify lock:**
- `pnpm --filter @objectstack/plugin-email test`: 31 files pass and 510
tests pass. `vitest list --filesOnly` names 31 files, all the tracked
test files, the 4 touched ones included.
- `pnpm --filter @objectstack/plugin-email typecheck` exits 0 (`tsc` on
`tsconfig.json`, then `check:test-typecheck` on `tsconfig.test.json`: 0
files and 0 errors in its debt ledger). `tsc --listFiles` holds all 8
touched files in both programs, and the test program holds all 50 files
under `src/`.
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the 8 touched `.ts` files, gives 8 files, 0 errors and 0 warnings.
All 8 are in eslint's own population (`isPathIgnored` is false for each;
a `dist` file, as the control, is ignored). `eslint.config.mjs` never
enables type-aware linting (no `parserOptions.project`, as its own lines
327-328 state), so a comment edit here cannot move the verdict on any
untouched file. The repo-wide `pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 9 changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked.**
`CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`
(objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word
「option」. In this package: `#N-word` none, `#A/#B` 9 lines, `option #N`
none, at the base and at the head, which is the claim's 0 / 9 / 0. Every
second number on the 9 slash lines answers 200 (`objectstack-ai#5197` ×2, `objectstack-ai#5191`,
`objectstack-ai#5211`, `objectstack-ai#5232` ×2, `objectstack-ai#5177`, `objectstack-ai#4251`, `objectstack-ai#5094`), so nothing there needed
rewriting.
- **ADR-0131 names `objectstack-ai#11741`.** Its D7 cites `objectstack-ai#11741` as the writer fact
that keeps `sys_email` tenant data. That is evidence inside a later
record, not the record of what `objectstack-ai#11741` decided, so it is not this
stage's anchor, and `docs/adr/**` is a governed Tier H surface outside
this card's stages. It joins the ADR-tree residue the seat already
carries (ADR-0131's `objectstack-ai#14484`, stage 2).
- **`CHANGELOG.md` is left.**
`packages/plugins/plugin-email/CHANGELOG.md` names `objectstack-ai#11741`, `objectstack-ai#13189`,
`objectstack-ai#13190` and `objectstack-ai#8675` on 5 lines. It is release-owned (AGENTS.md,
Documentation Guardrails), a deferred surface of the citation gate, and
⛔ not part of this stage.
- **「This card」 phrases are left.** 20 comment lines in 8 files of this
package speak of 「this card」, 「the card」 or 「the two cards」. They carry
no number and neither instrument sees them. Inside the `objectstack-ai#13189` test
block, they still have the kept `(objectstack-ai#13189)` title as their referent; the
one rewritten line that said 「the card」 now says 「the change」 (above).
The rest are unchanged, as in stages 8 to 10.
- **The census instrument did not truncate in this stage.** Both
enumerations read 186 pages at the newest frontier.
- **Anchors the next stages can reuse**, each checked here: `objectstack-ai#13189` →
`33fbd3566`; `objectstack-ai#13190` → `56c5b1dbe`; `objectstack-ai#8675` → `c9f595083`. `objectstack-ai#11741` →
`b706af987` reuses the `plugin-auth` stage's anchor.
- **Base.** The branch is on `main` at `97005aed0`. `main` has since
moved two commits (`9c8f113c6`, `a6866da0c`). Their 14 files touch
nothing under `plugin-email`, nor `scripts/check-issue-citations.mjs`,
`.changeset/config.json` or the `doc-authoring-prose-id` baseline, and
the three console-injection scripts they change are not among this
diff's 61 derived families. So no merge was taken; the merge queue
rebuilds on the merged generation.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants