Skip to content

Commit eda859a

Browse files
committed
fix(platform-objects): write sys_identity_email's fields as a literal 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>
1 parent ebe85c6 commit eda859a

4 files changed

Lines changed: 234 additions & 25 deletions

File tree

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ reproduce them. Where it disagrees, it disagrees on the page:
189189
| :--- | :--- | ---: |
190190
| 175 write call sites | quoted in the merged changeset | **231** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 231** decidable, **71** undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 231** decidable, **74** undecidable |
193193
| 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 |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

@@ -201,7 +201,7 @@ at any commit.
201201

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

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

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

252252
### Subtractions the census could NOT defend — enforced
253253

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

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

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

309309
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3434
| Measure | Value |
3535
|---|---:|
3636
| Write call sites | 231 |
37-
| Object name statically decidable | 160 |
38-
| Object name chosen at run time | 71 |
39-
| Against a tenancy-enabled object | 159 |
37+
| Object name statically decidable | 157 |
38+
| Object name chosen at run time | 74 |
39+
| Against a tenancy-enabled object | 156 |
4040
| Against an object declaring tenancy off | 1 |
4141
| Threading a tenant context | 171 |
4242
| Provably carrying none | 8 |
@@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

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

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

102102
## Every site
@@ -157,9 +157,9 @@ Measured on 2026-10-19 at `2623d6d6c`.
157157
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
158158
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
159159
| `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 |
160-
| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | elevated | 1 |
161-
| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | elevated | 1 |
162-
| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | elevated | 1 |
160+
| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `object` | undecidable | elevated | 1 |
161+
| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `object` | undecidable | elevated | 1 |
162+
| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `object` | undecidable | elevated | 1 |
163163
| `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
164164
| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 |
165165
| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 |
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D7, ruling of record 6257510973, Q2 A] `sys_identity_email` is
5+
* `sys_email`'s deployment-level sibling: the same row shape, so one mail
6+
* pipeline serves both, and no organization column.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { SysEmail } from './sys-email.object.js';
11+
import { SysIdentityEmail } from './sys-identity-email.object.js';
12+
13+
describe('sys_identity_email — the identity-mail sibling of sys_email', () => {
14+
it('declares exactly sys_email\'s fields — the row shape the pipeline reads and writes', () => {
15+
expect(Object.keys(SysIdentityEmail.fields)).toEqual(Object.keys(SysEmail.fields));
16+
expect(SysIdentityEmail.fields).toEqual(SysEmail.fields);
17+
});
18+
19+
it('keys the same indexes, so the by-status and by-id reads cost the same', () => {
20+
expect(SysIdentityEmail.indexes).toEqual(SysEmail.indexes);
21+
});
22+
23+
it('opts out of the injected organization column; sys_email keeps it', () => {
24+
expect(SysIdentityEmail.systemFields).toEqual({ tenant: false });
25+
expect(SysEmail.systemFields?.tenant).not.toBe(false);
26+
expect(Object.keys(SysIdentityEmail.fields)).not.toContain('organization_id');
27+
});
28+
29+
it('is append-only, readable by get/list, and behind the platform-only capability', () => {
30+
expect(SysIdentityEmail.managedBy).toBe('append-only');
31+
expect(SysIdentityEmail.enable?.apiMethods).toEqual(['get', 'list']);
32+
expect(SysIdentityEmail.requiredPermissions).toEqual(['manage_platform_settings']);
33+
});
34+
});

‎packages/platform-objects/src/audit/sys-identity-email.object.ts‎

Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { ObjectSchema } from '@objectstack/spec/data';
4-
import { SysEmail } from './sys-email.object.js';
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
54

65
/**
76
* sys_identity_email — Outbound Identity Mail Log
@@ -14,8 +13,8 @@ import { SysEmail } from './sys-email.object.js';
1413
* organization column, instead of as an organization-less row in the tenant
1514
* mail log: ⛔ neither a nullable column nor unpersisted mail.
1615
*
17-
* Same row shape as `sys_email` — the fields ARE `sys_email`'s, so the two
18-
* cannot drift — and the same pipeline: `IEmailService.send` with
16+
* Same row shape as `sys_email` — its fields, held equal by a test — and the
17+
* same pipeline: `IEmailService.send` with
1918
* `identityMail: true` writes the row, and the queue worker, the outbox drain
2019
* hook, the boot outbox sweep and attachment reclamation read it by id and
2120
* status exactly as they read `sys_email`.
@@ -51,7 +50,183 @@ export const SysIdentityEmail = ObjectSchema.create({
5150
titleFormat: '{subject}',
5251
highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'],
5352

54-
fields: SysEmail.fields,
53+
// `sys_email`'s fields, written out: the source scans that judge an
54+
// object's columns (`check:keyed-text-bounds`) read a literal, never a
55+
// reference. `sys-identity-email.object.test.ts` holds the two equal, so
56+
// the row shape cannot drift; the reasoning behind each field lives on
57+
// `sys_email`.
58+
fields: {
59+
id: Field.text({
60+
label: 'Email ID',
61+
required: true,
62+
readonly: true,
63+
group: 'System',
64+
}),
65+
66+
message_id: Field.text({
67+
label: 'Message-ID',
68+
required: false,
69+
maxLength: 255,
70+
description: 'RFC-5322 Message-ID assigned by the transport',
71+
group: 'Envelope',
72+
}),
73+
74+
from_address: Field.text({
75+
label: 'From',
76+
required: true,
77+
maxLength: 320,
78+
searchable: true,
79+
group: 'Envelope',
80+
}),
81+
82+
to_addresses: Field.text({
83+
label: 'To',
84+
required: true,
85+
maxLength: 4000,
86+
searchable: true,
87+
description: 'Comma-separated recipient addresses',
88+
group: 'Envelope',
89+
}),
90+
91+
cc_addresses: Field.text({
92+
label: 'Cc',
93+
required: false,
94+
maxLength: 4000,
95+
group: 'Envelope',
96+
}),
97+
98+
bcc_addresses: Field.text({
99+
label: 'Bcc',
100+
required: false,
101+
maxLength: 4000,
102+
group: 'Envelope',
103+
}),
104+
105+
reply_to: Field.text({
106+
label: 'Reply-To',
107+
required: false,
108+
maxLength: 320,
109+
group: 'Envelope',
110+
}),
111+
112+
subject: Field.text({
113+
label: 'Subject',
114+
required: true,
115+
maxLength: 998,
116+
searchable: true,
117+
group: 'Content',
118+
}),
119+
120+
body_text: Field.textarea({
121+
label: 'Body (text)',
122+
required: false,
123+
searchable: true,
124+
group: 'Content',
125+
}),
126+
127+
body_html: Field.textarea({
128+
label: 'Body (HTML)',
129+
required: false,
130+
group: 'Content',
131+
}),
132+
133+
headers_json: Field.textarea({
134+
label: 'Headers (JSON)',
135+
required: false,
136+
internal: true,
137+
description:
138+
'Custom headers supplied with the message when it was sent, as a JSON object of name → value. '
139+
+ 'Written in both delivery modes (it is audit evidence as much as delivery input). '
140+
+ 'Absent on rows written before this column existed, which read back as "no custom headers". '
141+
+ 'Never returned on the generic data path — headers are the ordinary place a '
142+
+ 'credential goes; the delivery paths recover it through the engine\'s privileged accessor.',
143+
group: 'Content',
144+
}),
145+
146+
attachments_json: Field.textarea({
147+
label: 'Attachments (JSON)',
148+
required: false,
149+
description:
150+
'Attachments as a JSON array of { filename, contentType?, size, hash, cid?, contentForm, '
151+
+ 'inline?, storageKey?, contentReclaimedAt? }. Content up to the plugin-email budget '
152+
+ '(SYS_EMAIL_ATTACHMENT_LIMIT_BYTES, 256 KiB combined raw — ~350 KB of base64 at worst) is base64 in '
153+
+ '`inline`; larger content goes to the file-storage capability and the element carries `storageKey` '
154+
+ 'instead, so the row stays bounded either way. filename/contentType/size/hash are PERMANENT audit '
155+
+ 'evidence; out-of-row content is a delivery artifact and is deleted a grace window after the row '
156+
+ 'reaches a terminal state, at which point `storageKey` is replaced by `contentReclaimedAt`.',
157+
group: 'Content',
158+
}),
159+
160+
status: Field.select(
161+
['queued', 'sent', 'failed'],
162+
{
163+
label: 'Status',
164+
required: true,
165+
defaultValue: 'queued',
166+
description: 'Lifecycle state — queued when the message is submitted for sending, before the first delivery attempt',
167+
group: 'State',
168+
},
169+
),
170+
171+
error: Field.textarea({
172+
label: 'Error',
173+
required: false,
174+
description:
175+
'Why the message failed — a transport error, or the validation that rejected it before delivery.',
176+
group: 'State',
177+
}),
178+
179+
attempt_count: Field.number({
180+
label: 'Attempts',
181+
required: false,
182+
defaultValue: 0,
183+
description: 'Number of delivery attempts performed by the service',
184+
group: 'State',
185+
}),
186+
187+
sent_at: Field.datetime({
188+
label: 'Sent At',
189+
required: false,
190+
description: 'Set when status transitions to "sent"',
191+
group: 'State',
192+
}),
193+
194+
related_object: Field.text({
195+
label: 'Related Object',
196+
required: false,
197+
maxLength: 100,
198+
description: 'Object name of the related record (e.g. lead, opportunity)',
199+
group: 'Source',
200+
}),
201+
202+
related_id: Field.text({
203+
label: 'Related Record',
204+
required: false,
205+
maxLength: 100,
206+
description: 'Record id within related_object',
207+
group: 'Source',
208+
}),
209+
210+
sent_by: Field.lookup('sys_user', {
211+
label: 'Sent By',
212+
required: false,
213+
group: 'Source',
214+
}),
215+
216+
created_at: Field.datetime({
217+
label: 'Created At',
218+
required: true,
219+
defaultValue: 'NOW()',
220+
readonly: true,
221+
group: 'System',
222+
}),
223+
224+
updated_at: Field.datetime({
225+
label: 'Updated At',
226+
required: false,
227+
group: 'System',
228+
}),
229+
},
55230

56231
indexes: [
57232
{ fields: ['status', 'created_at'] },

0 commit comments

Comments
 (0)