Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/24531-identity-mail-sibling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
'@objectstack/platform-objects': minor
'@objectstack/plugin-email': minor
'@objectstack/plugin-auth': minor
'@objectstack/cli': patch
---

feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column

Clause-②: yes (widening)

ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing.

- **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links.
- **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written.
- **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`.
- **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`.
- **What a reader of `sys_email` sees.**
- **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall.
- **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed.
- **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`.
- **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release.
22 changes: 11 additions & 11 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ reproduce them. Where it disagrees, it disagrees on the page:
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **231** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 231** decidable, **71** undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 231** decidable, **74** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 95 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

Expand All @@ -201,7 +201,7 @@ at any commit.

Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 40 sites reached
through an erased (`any`) receiver, and the 52 that name their object through a
through an erased (`any`) receiver, and the 51 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.

Expand All @@ -224,9 +224,9 @@ cannot read, and they are neither in nor out.
| what | count |
| :--- | ---: |
| write call sites on the application surface | **231** |
| …whose object name is statically decidable | 160 |
| …whose object name is chosen at run time | 71 |
| …against an object with tenancy ENABLED | 159 |
| …whose object name is statically decidable | 157 |
| …whose object name is chosen at run time | 74 |
| …against an object with tenancy ENABLED | 156 |
| …against an object that declares tenancy off | 1 |
| threading a tenant context | 171 |
| PROVABLY carrying none (options read, no context key) | **8** |
Expand All @@ -244,10 +244,10 @@ cannot read, and they are neither in nor out.
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 108 |
| object name spelled through a `const` | 52 |
| object name spelled inline | 106 |
| object name spelled through a `const` | 51 |
| object name is an `object: string` parameter | 7 |
| object name is some other run-time expression | 64 |
| object name is some other run-time expression | 67 |

### Subtractions the census could NOT defend — enforced

Expand Down Expand Up @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-19 at `2623d6d6c`.
Measured on 2026-10-19 at `ebe85c6d3`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 670 |
| tracked non-test sources scanned | 672 |
| engine-shaped types recognised | 79 |
| declared objects in the registry | 116 |
| declared objects in the registry | 117 |
| same-named calls subtracted as non-engine | 166 |

{/* END GENERATED: tenant-audit-census */}
2 changes: 1 addition & 1 deletion content/docs/plugins/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ metadata it names.

**Email Plugin** — Outbound email channel and templates.

- **Features**: Provider transports (Postmark, Resend), a minimal mustache-style `{{placeholder}}` template renderer, delivery tracking on `sys_email`
- **Features**: Provider transports (Postmark, Resend), a minimal mustache-style `{{placeholder}}` template renderer, delivery tracking on `sys_email` (identity mail — verification, password reset, sign-in links — on its deployment-level sibling `sys_identity_email`)
- **When to use**: Transactional and workflow-driven email

### @objectstack/plugin-webhooks
Expand Down
18 changes: 9 additions & 9 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
| Measure | Value |
|---|---:|
| Write call sites | 231 |
| Object name statically decidable | 160 |
| Object name chosen at run time | 71 |
| Against a tenancy-enabled object | 159 |
| Object name statically decidable | 157 |
| Object name chosen at run time | 74 |
| Against a tenancy-enabled object | 156 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 171 |
| Provably carrying none | 8 |
Expand Down Expand Up @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-19 at `2623d6d6c`.
Measured on 2026-10-19 at `ebe85c6d3`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 670 |
| tracked non-test sources scanned | 672 |
| engine-shaped types recognised | 79 |
| declared objects in the registry | 116 |
| declared objects in the registry | 117 |
| same-named calls subtracted as non-engine | 166 |

## Every site
Expand Down Expand Up @@ -157,9 +157,9 @@ Measured on 2026-10-19 at `2623d6d6c`.
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
| `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 |
| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | elevated | 1 |
| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | elevated | 1 |
| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | elevated | 1 |
| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `object` | undecidable | elevated | 1 |
| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `object` | undecidable | elevated | 1 |
| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `object` | undecidable | elevated | 1 |
| `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 |
| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 |
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/utils/organization-ownership-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,12 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
{ object: 'sys_comment', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; a reply's parent comment carries the organization`, anchors: [parent('parent_id', 'sys_comment')] },
{ object: 'sys_comment_reaction', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); the reaction's comment carries the organization`, anchors: [parent('comment_id', 'sys_comment')] },
{ object: 'sys_email', census: P1, fate: 'attribution', citation: `${D7}: sys_email is stamped at its producers; the related record carries the organization`, anchors: [subject('related_object', 'related_id')] },
{
object: 'sys_identity_email',
census: PMAIN,
fate: 'column-drop',
citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: sys_email's sibling for mail about a person (ruling record 6257510973, Q2 A)`,
},
{ object: 'sys_http_delivery', census: P1, fate: 'attribution', citation: `${D7}: a delivery is stamped from its webhook's organization`, anchors: [parent('ref_id', 'sys_webhook')] },
{
object: 'sys_webhook',
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
export { SysNotification } from './sys-notification.object.js';
export { SysAttachment } from './sys-attachment.object.js';
export { SysEmail } from './sys-email.object.js';
export { SysIdentityEmail } from './sys-identity-email.object.js';
export { SysEmailTemplate } from './sys-email-template.object.js';
// sys_saved_report / sys_report_schedule retired with the saved-report stack (#20102).
// sys_approval_request / sys_approval_action moved to @objectstack/plugin-approvals (ADR-0029 K2.b).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0131 D7, ruling of record 6257510973, Q2 A] `sys_identity_email` is
* `sys_email`'s deployment-level sibling: the same row shape, so one mail
* pipeline serves both, and no organization column.
*/

import { describe, it, expect } from 'vitest';
import { SysEmail } from './sys-email.object.js';
import { SysIdentityEmail } from './sys-identity-email.object.js';

describe('sys_identity_email — the identity-mail sibling of sys_email', () => {
it('declares exactly sys_email\'s fields — the row shape the pipeline reads and writes', () => {
expect(Object.keys(SysIdentityEmail.fields)).toEqual(Object.keys(SysEmail.fields));
expect(SysIdentityEmail.fields).toEqual(SysEmail.fields);
});

it('keys the same indexes, so the by-status and by-id reads cost the same', () => {
expect(SysIdentityEmail.indexes).toEqual(SysEmail.indexes);
});

it('opts out of the injected organization column; sys_email keeps it', () => {
expect(SysIdentityEmail.systemFields).toEqual({ tenant: false });
// Non-vacuity: the tenant log does not opt out (neither `false` nor `{ tenant: false }`).
const tenantLog = SysEmail.systemFields;
expect(tenantLog === false || (typeof tenantLog === 'object' && tenantLog.tenant === false)).toBe(false);
expect(Object.keys(SysIdentityEmail.fields)).not.toContain('organization_id');
});

it('is append-only, readable by get/list, and behind the platform-only capability', () => {
expect(SysIdentityEmail.managedBy).toBe('append-only');
expect(SysIdentityEmail.enable?.apiMethods).toEqual(['get', 'list']);
expect(SysIdentityEmail.requiredPermissions).toEqual(['manage_platform_settings']);
});
});
Loading
Loading