Skip to content

feat(plugin-email,plugin-auth): identity mail persists to sys_identity_email, the deployment-level sibling of sys_email (18.x step 3, 8b-0d′) - #24587

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-24531-8b0d-identity-mail-sibling
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-24531-8b0d-identity-mail-sibling

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #24531
Clause-②: yes (widening)

18.x step 3 (8b-0d′), per ruling of record 6257510973 on #24377 (letter Q2 A) and ruling records 6266750393 and 6267391172 on #15194. Merge gate: this PR stays a draft until #24522's upgrade harness has merged. It then merges main and lands only when R1–R4 hold with the harness green.

What changed

  • New object sys_identity_email (packages/platform-objects/src/audit/sys-identity-email.object.ts). It has the same fields and indexes as sys_email; a new test holds the two equal. It declares systemFields: { tenant: false }, so it has no organization column. It is managedBy: 'append-only', exposes apiMethods: ['get', 'list'], and requires manage_platform_settings to read, like sys_job, because its rows carry reset and sign-in links. EmailServicePlugin registers it beside sys_email.

  • Contract: SendEmailInput.identityMail?: boolean and SendTemplateInput.identityMail?: boolean (packages/spec/src/contracts/email-service.ts).

    • true sends the row to sys_identity_email.
    • Absent sends it to sys_email, unchanged.
    • identityMail: true together with organizationId is refused as VALIDATION_FAILED. The refusal is recorded as a rejected row in the sibling, with no organization written.
  • plugin-email: one pipeline over both logs. email-log-objects.ts names the two logs once. The following now read and write the log the row lives in:

    • send() and sendTemplate();
    • the persistence shim (insert / update / readHeadersJson take the log as an optional last argument);
    • the email.send.async worker;
    • the afterInsert outbox drain hook;
    • the boot outbox sweep, which sweeps each log on its own;
    • attachment reclamation and the internal-header readback.

    Job payloads work like this:

    • A delivery or reclaim job for a sibling row carries object: 'sys_identity_email'.
    • A sys_email job keeps the exact 18.0 payload ({ rowId }). A job with no object reads sys_email.
    • A job naming anything else is refused at error and touches no row. A reclaim job of that kind stalls; it is not re-armed.
  • plugin-auth: five identity sends now say identityMail: true and name no organization, in every posture: auth.verify_email, auth.password_reset, auth.account_invitation, auth.magic_link and auth.email_change_notice. The now-unused authMailOrganizationArg / derivableWriteOrganizationId are removed. auth.invitation (organization invitation) is unchanged tenant mail.

  • Inventory: sys_identity_email gets its row next to sys_email's in organization-ownership-inventory.ts (column-drop, PMAIN).

  • Regenerated with the repo's tooling:

    • scripts/platform-object-tenancy-census.json (gen:platform-object-tenancy-census: 84 registered, 40 out of reach).
    • The tenant-audit census (tenant-audit-census.mjs --write). Three write sites in plugin-email now name their object at run time: the persistence insert, the persistence update and the reclaim update, so 160 decidable became 157 and 71 undecidable became 74. Two hand-written prose numbers on that page are updated to match.
  • Docs: content/docs/plugins/packages.mdx names the sibling.

18.0-row behaviour (expand-first)

Nothing is moved, deleted or re-stamped. These keep working from sys_email:

  • organization-less identity rows an 18.0 install wrote there under a wall;
  • identity rows stamped with the Default Organization under single;
  • the { rowId } jobs an 18.0 process queued for them.

The boot sweep still delivers them, the worker still delivers a job with no object from sys_email, and reclamation of a job with no object still reads sys_email. Pinned against an 18.0-shaped fixture (rows seeded into sys_email with related_object: 'sys_user' and no organization, plus a raw { rowId } job) in email-plugin.outbox-sweep.test.ts. The #24522 harness is not merged yet; the seat will ask for a main merge and a harness run when it is.

What the step-5 ceremony owes (debt recorded, nothing done here)

  • sys_email: move the identity rows to sys_identity_email, without organization_id. These are rows about a person: related_object = 'sys_user', or no related object (the magic link). Under a wall they are organization-less. Under single an 18.0 install stamped them with the Default Organization, so the ceremony must decide whether those move too. The ruling names "walled rows anchored on sys_user or on nothing".
    • Move only terminal rows (sent / failed).
    • A queued row's pending { rowId } job reads sys_email. After a move it would find no row, and the message would never be delivered.
    • A pending reclaim job with no object reads sys_email. After a move it would take the row-gone branch and delete that row's attachment content. Either rewrite those jobs' payloads to add object, or move rows only after their reclaim window has passed.
  • sys_activity: rows whose object_name / source_object is sys_email for a moved row point at a missing record. Re-point them or delete them; Q3 A's null-activity deletion already covers the organization-less ones.
  • sys_email.organization_id NOT NULL (8e): only once the move leaves zero NULL rows.
  • Step 7 (dual reads): the "no object ⇒ sys_email" default stays correct for tenant jobs. Once no 18.0 job can still be pending, nothing identity-specific is left to remove.
  • Outside this repo: cloud's wall-test control is rewritten against the sibling (cloud lane, per the ruling). ADR-0131 D7's Q2 A note says "has not shipped" and needs its dated update; that is Tier H, and docs/adr/** is outside this card.

Verification (head 3bc88f5f1, after merging main at ba4afcc23)

  • pnpm --filter @objectstack/plugin-email typecheck + vitest run: 34 files, 571 passed.
  • pnpm --filter @objectstack/plugin-auth typecheck + vitest run: 165 files, 3168 passed, 10 skipped. That run was before the merge; main's two commits do not touch plugin-auth. The targeted auth mail suites were re-run on the final head: 77 passed.
  • pnpm --filter @objectstack/platform-objects typecheck + vitest run: 78 files, 1188 passed. The src/audit subset was re-run on the final head: 27 passed.
  • @objectstack/spec: platform-object-names.test.ts and email-service.test.ts passed (15); typecheck passed.
  • @objectstack/cli unit: organization-ownership-inventory.test.ts, 25 passed.
  • Dogfood (real stack): signup-organization-census.dogfood.test.ts, 3 passed. It now asserts:
    • the sign-up's verification mail is in sys_identity_email and absent from sys_email;
    • the sibling's registered schema has no organization_id, with sys_email's having it as the non-vacuity control.
  • Fresh showcase boot (pnpm dev -- --fresh -p 38531, sign-in as the seeded admin):
    • POST /auth/request-password-reset produced one sys_identity_email row, Reset your ObjectStack password, sent, related_object: sys_user, no organization_id key.
    • A sign-up after opening the audience produced one sys_identity_email row, Verify your ObjectStack email address, sent, no organization_id key.
    • POST /auth/organization/invite-member produced one sys_email row, Dev Admin invited you to Default Organization, related_object: sys_invitation, stamped organization_id.
    • sys_email held no identity mail. The server was stopped by its recorded PID and the port is free.
  • Ablations. Each mutation went through scripts/ablation-replace.mjs, then the tests ran, then the file was restored to the HEAD blob with git diff HEAD empty.
    • A1, route every send to sys_email: 4 red.
    • A2, the worker ignores the job's object: 2 red.
    • A3, a job with no object reads the sibling: 11 red, including the 18.0 { rowId } pin.
    • A4, reclaim always reads sys_email: 2 red.
    • A5, drop identityMail from the verification send (plugin-auth): 3 red. The first A5 attempt was refused by the tool (the replacement was a substring of the anchor) and measured nothing.
    • A6, the same mutation in plugin-auth's dist/: the dogfood census went 2 red, then 3 green after the restore and rebuild, with ablation-dist-preflight present / absent. The first A6 attempt's mutated build failed on a TS no-overlap error, so that reading is void.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 126 derived. All 126 were run on 3bc88f5f1 with exit codes captured before any pipe: 126 exit 0. --ran reconciliation: 126 derived, 126 run, 0 NOT MEASURED. Plus check:dev-prereqs exit 0. The 51 artifact-roster families, the CI-only jobs and the type-check lanes are CI's.

Acceptance notes

  • sys_identity_email gets no translation bundle entries, like sys_platform_setting; its labels are English in every locale.
  • check:keyed-text-bounds reads only a literal fields, so the sibling spells its fields out instead of fields: SysEmail.fields. The equality test is what keeps the row shape single-sourced.
  • Out-of-row attachment storage keys keep the sys_email/attachments/ prefix for sibling rows. Keys travel in the row and the job, so nothing reads the prefix as a table name.
  • Not measured: whether a non-administrator can list sys_email rows (it has no requiredPermissions). 18.0 identity rows there carry reset links. The sibling is gated.

Generated by Claude Code

…y_email, the deployment-level sibling of sys_email (WIP)

Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL
Co-authored-by: Claude <noreply@anthropic.com>
…il and the 18.0 sys_email dual read

Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL
Co-authored-by: Claude <noreply@anthropic.com>
…ail, not sys_email

Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL
Co-authored-by: Claude <noreply@anthropic.com>
… held equal to sys_email's; regenerate the tenant-audit census

Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-email, @objectstack/spec, touching 65 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/platform-objects/src/audit/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

64 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2667e2402c48e9201550caca2adbcf9188d925b3.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/platform-objects/src/audit/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 2 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 38 pages), sys_user (literal, 41 pages)
  • 12 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 153 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 2667e2402c48e9201550caca2adbcf9188d925b3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b227d7415381da4f6c3744e94dd2d71b81588698 — the merge of head cd6fe0b4fbeb9c1b50160a05d6972197daf647cb into base 2667e2402c48e9201550caca2adbcf9188d925b3, 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 b227d7415381da4f6c3744e94dd2d71b81588698 && git checkout b227d7415381da4f6c3744e94dd2d71b81588698
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2667e2402c48e9201550caca2adbcf9188d925b3 cd6fe0b4fbeb9c1b50160a05d6972197daf647cb && git checkout -B drift-repro 2667e2402c48e9201550caca2adbcf9188d925b3 && git merge --no-ff cd6fe0b4fbeb9c1b50160a05d6972197daf647cb

node scripts/docs-audit/affected-docs.mjs --json 2667e2402c48e9201550caca2adbcf9188d925b3

⚠️ 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 2667e2402c48e9201550caca2adbcf9188d925b3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cd6fe0b4fbeb9c1b50160a05d6972197daf647cb
Local-runs: none
Scope: packages/spec/src/contracts/email-service.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/platform-objects/src/audit/sys-identity-email.object.ts, packages/plugins/plugin-email/src/email-log-objects.ts, packages/plugins/plugin-email/src/email-service.ts, packages/plugins/plugin-email/src/email-plugin.ts, packages/plugins/plugin-email/src/outbox-sweep.ts, packages/plugins/plugin-email/src/attachment-reclaim.ts, packages/plugins/plugin-email/src/attachment-storage.ts, packages/plugins/plugin-email/src/internal-header-readback.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/cli/src/utils/organization-ownership-inventory.ts, .changeset/24531-identity-mail-sibling.md

Reviewer: an isolated at-tier reviewer subagent of the adopting seat (epic:#15194). Face: schema (reviewFace on the PR's file list hits packages/spec/src/contracts/email-service.ts and packages/spec/src/system/constants/platform-object-names.ts), so the tier is CONTRACT_REVIEW_TIER; the Clause-②: yes (widening) line is not itself a face. Read-only: the net diff origin/main...refs/review/pr-24587 (27 files, +1111/-291), the card #24531 with 6267492651, 6270006270 and 6270136912, the rulings 6266750393, 6267391172, 6270035914, the decision 6257510973 (Q2 A), step 0's plan 6268559156 (step_plan.steps[2], F1-F3), the merged harness packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts and its fixture, AGENTS.md, scripts/check-changeset-*.mjs, and the head's check-runs. The head did not move during the review (re-resolved at the end: cd6fe0b4f). The PR body's verification section names 3bc88f5f1; the addendum 6270136912 is the harness run on cd6fe0b4f (one merge of main later), which is the head reviewed here.

Check-runs on cd6fe0b4f: 41 runs, 36 success, 5 skipped, 0 failure. Skipped: Console Pin Gate, Dogfood Regression Gate shards (1-3), Dogfood Verify CLI, Temporal Conformance, Packed-tarball smoke — all off the per-PR path by ci.yml's event deny-list (the aggregate Dogfood Regression Gate concluded success with zero legs). Green: Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates (every lane), Spec property liveness, Test Core (6/6), the four Type Check lanes, Build Core, Build Docs, the three claim/branch guards. No governed path in the file list (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, .github/workflows/**).

① Derived judgments

(a) Fresh install — every identity send lands in the sibling, with no organization. HOLDS.

  • The five sends at four sites in packages/plugins/plugin-auth/src/auth-manager.ts each say identityMail: true and name no organizationId: password reset :2048 and account invitation :2033 (one sendResetPassword callback, branched on invitation), verification :2113, magic link :4131, email-change notice :5977. authMailOrganizationArg / derivableWriteOrganizationId are deleted, so no identity send can regain an organization by the old path.
  • The organization invitation :3888-3890 is untouched: it threads invitation.organizationId and carries no identityMail, so it stays in sys_email.
  • Routing is decided once per send in email-service.ts:752 (emailLogObjectOf, :237) and every write of that send carries it: insert :820, updateRow on the sent and failed branches, the queue payload :1103, the reclaim job, the rejection record :911. sendTemplate forwards the flag :1511. Template resolution is by (name, locale) only (SendTemplateInput.organizationId is pass-through to the row stamp, packages/spec/src/contracts/email-service.ts:222-240), so routing identity mail organization-less under single changes which table the row lands in and nothing about which template renders.
  • identityMail: true beside an organizationId is refused as VALIDATION_FAILED before normalisation (:711, :247) and the refusal is recorded in the sibling without the organization (:925). Contract-first: no silent drop, no silent win.
  • The sibling (packages/platform-objects/src/audit/sys-identity-email.object.ts) declares systemFields: { tenant: false } :45, sys_email's fields and indexes held equal by sys-identity-email.object.test.ts, managedBy: 'append-only', apiMethods: ['get','list'] :240. Registered beside sys_email by EmailServicePlugin (email-plugin.ts:406), so boot-time schema sync creates it (F1: the boot path only adds). Named in PLATFORM_OBJECTS_BY_PACKAGE (platform-object-names.ts:55) and the regenerated tenancy census (84 registered, 40 out of reach).
  • Pins: identity-mail-sibling.test.ts drives reset, verification, magic link and change-email under single, walled and no-tenancy-service postures and asserts the flag present and organizationId absent, with the organization invitation as the non-vacuity control; email-plugin.outbox-sweep.test.ts (new describe blocks after :605) pins a fresh send into the sibling with no organization_id key and sys_email empty, the tenant control, and the refusal record; send-template.test.ts pins both writes naming sys_identity_email; the dogfood census (signup-organization-census.dogfood.test.ts) asserts the sign-up's verification mail is in sys_identity_email, absent from sys_email, and that the registered schema of the sibling has no organization_id with sys_email's as the control. The PR body's fresh showcase boot reports one reset row and one verification row in the sibling, the invite-member row in sys_email.
  • Gap, non-blocking: identity-mail-sibling.test.ts drives the reset branch only; auth.account_invitation (same callback, invitation set) is changed identically in the diff but is not driven by any test. Suggest one more drive with invitation set.

(b) 18.0 database — every outbox reader covers both tables. HOLDS. The two logs are named once in email-log-objects.ts:22-46; emailLogObjectOfJob reads absent as sys_email (every 18.0 job), a log name as that log, anything else as undefined, which every caller refuses loudly.

  • Drain hook email-plugin.ts:752: isEmailLogObject(object) admits both logs; the re-read :766 and deliverPersistedRow carry the hook's object.
  • Queue consumer email-plugin.ts:840: emailLogObjectOfJob(data?.object); a { rowId } job with no object reads sys_email (pinned: "an 18.0 { rowId } job with no object reads sys_email and delivers that row"); a job naming a non-log is refused at error and touches no row (pinned). A sys_email job keeps the exact 18.0 payload email-service.ts:1103, and the idempotency key for a sys_email row stays sys_email:rowId :1117, so a sweep re-publish of an 18.0 queued row still collapses onto its pending 18.0 job.
  • Boot sweep email-plugin.ts:1008: for (const object of EMAIL_LOG_OBJECTS), each log swept on its own with its own error line, results summed (sumSweepResults carries all six fields of OutboxSweepResult). outbox-sweep.ts:183/:191/:220/:227 thread the object. Pinned: the 18.0 identity row seeded into sys_email is delivered in place and nothing moves to the sibling.
  • Attachment reclaim attachment-reclaim.ts:199: emailLogObjectOfJob(payload?.object); find :220 and the update :273-274 name it; a job naming a non-log stalls without re-arm and without the row-gone delete branch (pinned). The re-arm email-plugin.ts:919-925 republishes the same payload, so object survives a re-arm; the reclaim idempotency key for sys_email rows is unchanged.
  • Header readback internal-header-readback.ts:107/:143: per-call object; the internal: true flag is on the sibling's headers_json :136, and the engine's redaction and resolveInternalField are keyed on that flag, not on the object name (objectql/src/engine.ts:11281-11290, core/src/utils/internal-write-response.ts:129), so the sibling inherits the omission on the generic data path.
  • Readers hunted and not missed: a grep of the whole tree at the head for engine reads of 'sys_email' outside plugin-email finds none; no file outside plugin-email imports OUTBOX_OBJECT, RECLAIM_OBJECT, SYS_EMAIL_OBJECT or the new constants; plugin-auth has no rate limiter or resend cooldown that counts mail rows (its remaining sys_email mentions are comments and the invitation stamp); no Setup nav entry, page or list view names sys_email (only translation echo tests); the console (objectui) reads sys_email only as the source_object label of an activity item. The audit mirror (plugin-audit/src/audit-writers.ts) is generic per object (enable.activities !== false, sys_email is not in SKIP_OBJECTS), so sys_identity_email writes are ledgered and mirrored by construction, under Q3 A's derivation (Default Organization under single, no row under a wall) — the dogfood census asserts the resulting sys_activity rows name the Default Organization.
  • F3 (select by shape, never NULL): no reader selects by organization_id IS NULL; every read is by table, id or status. The stamped 18.0 rows stay in sys_email and keep being delivered, swept and reclaimed.

(c) Access — who may read the sibling. HOLDS. requiredPermissions: ['manage_platform_settings'] :46 is a top-level object key (ADR-0066 D3). On main, plugin-security enforces it as the AND-gate before the CRUD grant for every non-system caller holding permission sets (security-plugin.ts:2678-2725); a caller with no sets falls to the CRUD check, which no grant satisfies (fail-closed). The system context bypasses the middleware (security-plugin.ts:2388), which is how the pipeline's SYSTEM_CTX reads and writes both logs and how sys_job already works. The capability is scope: 'platform' (spec/src/security/capabilities.ts:48), so no tenant administrator holds it by role — the deployment-level-no-tenant-column.test.ts roster pins that for sys_job and its siblings. /meta exposes the schema (fields, requiredPermissions), no rows. headers_json stays omitted on the generic read path (above). Net: the sibling is strictly tighter than sys_email, whose reads stay open — the dev's filed observation that 18.0 identity rows in sys_email carry reset links behind no requiredPermissions is pre-existing and outside this card (③).

(d) Nothing moves or deletes 18.0 rows. HOLDS. No migration, no data move, no column change on sys_email; RECLAIM_OBJECT, OUTBOX_OBJECT and SYS_EMAIL_OBJECT keep their values. The inventory row for sys_identity_email (organization-ownership-inventory.ts:547-552, fate column-drop, PMAIN) is inert on an 18.0 database: dropColumnsThroughDrift acts only on drop_column drift entries, and a table that never had the column produces none. The boot guard (F2) flags only a tenant: false object with a physical organization_id; the sibling is created fresh without one on both a fresh install and an 18.0 database. Harness test 3 (every reshaped row keeps its key columns, including the fixture's two stamped sys_email rows) passed on this head per 6270136912.

(e) R1-R4. The addendum reports the harness 8/8 on cd6fe0b4f: R1 and R2 boot, the fixture's people sign in, R2 data, R3 refused. R4 is not in the harness (no walled case). Judgement: it does not matter for this PR. The diff touches no posture or boot-guard code; the walled refusal lives in packages/cli/src/commands/serve.ts:6266 (walledPostureRefusal) and is pinned by serve-walled-posture-refusal.test.ts, which Test Core ran green on this head. R4 therefore holds by construction and by CI. The missing walled case is a step-0 harness gap for the seat, not a defect of this card (step 4 rewrites that case anyway). Two notes: the harness evidence is the dev's local run, since ci.yml keeps the dogfood shards off the per-PR and merge-group paths and the opt-in lane (#24554) is open Tier H; and the plan's pr_cut.proof wanted a harness case for "the 18.0 reset row is still readable through the outbox's dual read" — the claim (6267492651) forbade touching #24522's harness files, so the PR pins it at unit level instead (email-plugin.outbox-sweep.test.ts, 18.0-shaped sys_email fixture plus a raw { rowId } job). Acceptable at this tier.

(f) Complete feature (6270035914). HOLDS. New installs write the new shape, the 18.0 shape is still read, and nothing waits on a later PR: the object, the contract member, the five producers, the five readers, the gate, the inventory row, the census and the docs land together, exercised end to end on the real stack by the dogfood census. No dormant code: identityMail is consumed by send(), the constants are used, the exports are the plugin's public names for the two logs.

② Semver level

Clause-②: yes (widening) at the start of a line in both the PR body and the committed changeset .changeset/24531-identity-mail-sibling.md; readClause2Line reads declared, arm widening. The arm matches the diff: a new object, a new optional contract member on SendEmailInput and SendTemplateInput, optional trailing parameters on EmailPersistence.insert/update/readHeadersJson, deliverPersistedRow, enqueuePersistedRow, sweepStrandedOutbox and OutboxSweepService, a new optional object member on both queue payloads, and five new exports — every existing caller and implementer compiles unchanged, and every existing value (SYS_EMAIL_OBJECT, OUTBOX_OBJECT, RECLAIM_OBJECT, the sys_email job payload and idempotency keys) is preserved. No accept set narrows; no ADR-0087 disposition is owed. The changeset grades @objectstack/spec, @objectstack/platform-objects, @objectstack/plugin-email and @objectstack/plugin-auth minor (the level axis: at least one published source moved minor — four are) and @objectstack/cli patch (one inventory row); no major, so the launch-window guard is quiet; Check Changeset is green on the head. The body states the before/now/remedy for a reader of sys_email and names the touched surfaces; no release-owned file (content/docs/releases/, CHANGELOG.md) is edited. content/docs/plugins/packages.mdx is a hand edit of a non-generated page; the tenant-audit census page and counts file are regenerated outputs. Spec touched: no generated artifact is stale (Lint & Repo Gates and Spec property liveness green).

③ Boundary flags

  1. Decided open questions (6270006270), all consistent with Q2 A: name sys_identity_email + identityMail (A); refuse identityMail + organizationId and record the rejection (A); reads behind manage_platform_settings (A); identity mail is deployment-level in every posture, so under single it no longer carries the Default Organization (A — the ruling says deployment-level "by un-mixing", not "under a wall only"). No question is left for the seat.
  2. ceremony_owes (the step-5 identity-mail move), recorded table by table and matching the plan's ceremony_must_later: move terminal identity rows out of sys_email without organization_id; rewrite or drain pending { rowId } send jobs and no-object reclaim jobs first (a moved row's reclaim job would take the row-gone branch and delete content); re-point or delete sys_activity rows about moved mail; 8e NOT NULL on sys_email.organization_id only after the move leaves zero NULL rows; step 7 keeps the no-object-reads-sys_email default for tenant jobs. One refinement for the step-5 card: the plan's selector ("related_object = 'sys_user' or template, not NULL") does not cover the magic-link rows, which carry no related_object (auth-manager :4127-4131 sends none) and sys_email has no template column — the ceremony must define their shape (or leave them) before it moves anything. F3's single-posture rows are stamped with the Default Organization; whether they move is the ceremony's decision, as the dev notes.
  3. Harness evidence is local, by design of ci.yml: the dogfood shards (which hold the harness and the modified census test) run only on push and schedule; the per-PR label lane is ci: an opt-in label lane runs the 18.x upgrade harness on a line PR's merge preview (step 0 wiring) #24554, open Tier H. The 8/8 on cd6fe0b4f is the addendum's. If the head moves before landing, the seat must have the harness re-run on the new head; this record names cd6fe0b4f only.
  4. ADR-0131 D7 already carries the dated Q2 A note (docs/adr/0131-…md:538-546), so Prime Directive [WIP] Add Chinese version of the documentation #13 is satisfied; its closing "it has not shipped" needs the dated update once this lands — Tier H, the maintainer's PR, off this card. Cloud's wall-test non-vacuity control is rewritten against the sibling in the cloud lane (per the ruling).
  5. Pre-existing exposure, out of scope: sys_email declares no requiredPermissions, and 18.0 identity rows there carry reset links; whether a non-administrator can list them was not measured by the dev (filed as an observation, no card). Strictly outside this card's surface; the seat may want a follow-up card.
  6. Non-blocking suggestions: add sys_identity_email to the hand-kept DEPLOYMENT_LEVEL roster in packages/platform-objects/src/deployment-level-no-tenant-column.test.ts (the PR's own test asserts the same three facts, so nothing is unpinned); drive the auth.account_invitation branch in identity-mail-sibling.test.ts.

Implemented-by: claude/issue-24531-8b0d-identity-mail-sibling
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

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 protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(plugin-auth,plugin-email): 18.x step 3 (8b-0d′): identity mail goes to a deployment-level sibling of sys_email, expand-first

2 participants