Skip to content

Commit b3820c3

Browse files
fix(platform-objects): sys_email.highlightFields names to_addresses, not to (#16076)
* fix(platform-objects): sys_email.highlightFields names to_addresses, not to `highlightFields` read ['subject', 'to', 'status', 'sent_at']. `sys_email` has no field `to` — its recipient column is `to_addresses` — so the entry was a dangling field reference on a shipped platform object. Every consumer of `highlightFields` silently skips an entry it cannot resolve, so the default list columns, record cards, previews and the detail highlight strip each rendered one field short, with no error anywhere. And since `object-field-ref-unknown` crossed onto the object write door, the body could not be republished through `PUT /api/v1/meta/object` or a package publish (422 INVALID_METADATA). It reaches the runtime as a code-shipped registry object, so boot was unaffected. The new pin drives the real door — `runRuntimeAuthoringRules({ type: 'object' })` with the audit module's siblings as context — plus a control that restores the old entry and requires the same call to refuse it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(platform-objects): type-clean the sys_email write-door pin The package's tsconfig excludes `**/*.test.ts`, so `pnpm typecheck` never compiled this file; an ad-hoc program that includes tests found TS2677 on the sibling filter's type predicate. Replaced with a plain predicate over `unknown[]`, which is what `context.objects` takes anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 33e939f commit b3820c3

3 files changed

Lines changed: 85 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
`sys_email.highlightFields` names the recipient column that exists, so the platform's own email log stops rendering one column short (#15629)
6+
7+
The list read `['subject', 'to', 'status', 'sent_at']`. Three of those four resolve; `to` does not — `sys_email`'s recipient column is `to_addresses`. It now reads `['subject', 'to_addresses', 'status', 'sent_at']`, and nothing else about the object moved.
8+
9+
`highlightFields` is the object's ordered "most important fields" pointer (ADR-0085): it drives the default list columns, record cards, previews and the detail highlight strip. Every consumer **silently skips** an entry it cannot resolve — nothing throws and nothing logs — so each of those surfaces rendered one field short, and the field missing from the platform's own outbound-email log was the recipient.
10+
11+
There was a second, louder consequence that nobody could reach by accident. Since `object-field-ref-unknown` crossed onto the object write door (#15254), this body could not be republished through `PUT /api/v1/meta/object` or a package publish: the door answers `422 INVALID_METADATA`. `sys_email` reaches the runtime as a code-shipped registry object instead — `EmailServicePlugin` hands it to the manifest service, a path that runs no authoring gate — so boot was never affected and no deployment was failing. It was a trap laid for whoever next edited the object through a door rather than the file.
12+
13+
`sys-email.highlight-fields-resolve.test.ts` pins it through that real door rather than by comparing the array against `Object.keys(fields)`: it runs `runRuntimeAuthoringRules({ type: 'object' })` over the shipped declaration with the audit module's other objects as resolution context, and a control case restores the old entry and requires the same call to refuse it — so a green result means the door read this object and accepted it, never that nothing looked.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { runRuntimeAuthoringRules } from '@objectstack/lint';
5+
import * as audit from './index.js';
6+
import { SysEmail } from './sys-email.object.js';
7+
8+
/**
9+
* [#15629] `sys_email.highlightFields` names fields `sys_email` actually has.
10+
*
11+
* ## The state this pins, measured on `origin/main` (5f4f1f6e2)
12+
*
13+
* The list read `['subject', 'to', 'status', 'sent_at']`. Three of the four
14+
* resolve; `to` does not — the recipient column on this object is
15+
* `to_addresses`. So the shipped declaration carried a dangling field
16+
* reference, and the two consequences are of different kinds:
17+
*
18+
* - **Silent, on every render.** `highlightFields` is the object's ordered
19+
* "most important fields" pointer (`packages/spec/src/data/object.zod.ts`
20+
* — "Drives default columns, cards, previews, detail highlight strip"),
21+
* and every consumer of it SKIPS an entry it cannot resolve. Nothing
22+
* throws, nothing logs: the platform's own email log just renders one
23+
* column short, and the missing one is the recipient.
24+
* - **Loud, but only through a door nothing here walks.** Since #15254
25+
* crossed `object-field-ref-unknown` onto the object write door, this body
26+
* could not be republished through `PUT /api/v1/meta/object` or a package
27+
* publish — `runtime-authoring-gate.ts` turns any `error` finding into
28+
* `INVALID_METADATA` / 422. `sys_email` reaches the runtime as a
29+
* code-shipped registry object instead (`EmailServicePlugin` hands it to
30+
* the manifest service), and that path runs no authoring gate at all, so
31+
* boot was never affected. A live trap for whoever next edits it through a
32+
* door rather than the file — not an active failure, which is why the card
33+
* was p2.
34+
*
35+
* ## Why the pin is driven through the door and not by reading the array
36+
*
37+
* Comparing `highlightFields` against `Object.keys(fields)` here would pin a
38+
* MIRROR of the rule, and a mirror is exactly what stops agreeing with the
39+
* rule the moment either moves — the rule resolves injected system columns
40+
* and semantic-role aliases that a naive key comparison does not. So this
41+
* calls the real gate, with the audit module's other objects as resolution
42+
* context, the same shape the finding was measured with.
43+
*
44+
* The second case is not decoration. A gate assertion that yields zero
45+
* findings is indistinguishable from a gate that never ran, so the control
46+
* restores the one bad entry and requires the SAME call to refuse it, at the
47+
* same path. Green here therefore means "the door read this object and
48+
* accepted it", never "nothing looked".
49+
*/
50+
describe('sys_email — highlightFields resolves at the object write door', () => {
51+
/** The audit module's other shipped objects, as the live resolution context. */
52+
const siblings: unknown[] = Object.values(audit as Record<string, unknown>).filter((o) => {
53+
const name = (o as { name?: unknown } | null)?.name;
54+
return typeof name === 'string' && name !== 'sys_email';
55+
});
56+
57+
const gate = (item: unknown) =>
58+
runRuntimeAuthoringRules({ type: 'object', item, context: { objects: siblings } });
59+
60+
it('publishes clean — the door adds no error to the shipped declaration', () => {
61+
const result = gate(SysEmail);
62+
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
63+
});
64+
65+
it('control: the pre-fix entry is still refused, so the case above is not vacuous', () => {
66+
const result = gate({ ...SysEmail, highlightFields: ['subject', 'to', 'status', 'sent_at'] });
67+
expect(result.errors.map((e) => `${e.rule} ${e.path}`)).toEqual([
68+
'object-field-ref-unknown objects.sys_email.highlightFields[1]',
69+
]);
70+
});
71+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const SysEmail = ObjectSchema.create({
2929
displayNameField: 'subject',
3030
nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
3131
titleFormat: '{subject}',
32-
highlightFields: ['subject', 'to', 'status', 'sent_at'],
32+
highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'],
3333

3434
fields: {
3535
id: Field.text({

0 commit comments

Comments
 (0)