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
13 changes: 13 additions & 0 deletions .changeset/sys-email-highlight-fields-to-addresses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/platform-objects": patch
---

`sys_email.highlightFields` names the recipient column that exists, so the platform's own email log stops rendering one column short (#15629)

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.

`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.

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.

`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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { runRuntimeAuthoringRules } from '@objectstack/lint';
import * as audit from './index.js';
import { SysEmail } from './sys-email.object.js';

/**
* [#15629] `sys_email.highlightFields` names fields `sys_email` actually has.
*
* ## The state this pins, measured on `origin/main` (5f4f1f6e2)
*
* The list read `['subject', 'to', 'status', 'sent_at']`. Three of the four
* resolve; `to` does not — the recipient column on this object is
* `to_addresses`. So the shipped declaration carried a dangling field
* reference, and the two consequences are of different kinds:
*
* - **Silent, on every render.** `highlightFields` is the object's ordered
* "most important fields" pointer (`packages/spec/src/data/object.zod.ts`
* — "Drives default columns, cards, previews, detail highlight strip"),
* and every consumer of it SKIPS an entry it cannot resolve. Nothing
* throws, nothing logs: the platform's own email log just renders one
* column short, and the missing one is the recipient.
* - **Loud, but only through a door nothing here walks.** Since #15254
* crossed `object-field-ref-unknown` onto the object write door, this body
* could not be republished through `PUT /api/v1/meta/object` or a package
* publish — `runtime-authoring-gate.ts` turns any `error` finding into
* `INVALID_METADATA` / 422. `sys_email` reaches the runtime as a
* code-shipped registry object instead (`EmailServicePlugin` hands it to
* the manifest service), and that path runs no authoring gate at all, so
* boot was never affected. A live trap for whoever next edits it through a
* door rather than the file — not an active failure, which is why the card
* was p2.
*
* ## Why the pin is driven through the door and not by reading the array
*
* Comparing `highlightFields` against `Object.keys(fields)` here would pin a
* MIRROR of the rule, and a mirror is exactly what stops agreeing with the
* rule the moment either moves — the rule resolves injected system columns
* and semantic-role aliases that a naive key comparison does not. So this
* calls the real gate, with the audit module's other objects as resolution
* context, the same shape the finding was measured with.
*
* The second case is not decoration. A gate assertion that yields zero
* findings is indistinguishable from a gate that never ran, so the control
* restores the one bad entry and requires the SAME call to refuse it, at the
* same path. Green here therefore means "the door read this object and
* accepted it", never "nothing looked".
*/
describe('sys_email — highlightFields resolves at the object write door', () => {
/** The audit module's other shipped objects, as the live resolution context. */
const siblings: unknown[] = Object.values(audit as Record<string, unknown>).filter((o) => {
const name = (o as { name?: unknown } | null)?.name;
return typeof name === 'string' && name !== 'sys_email';
});

const gate = (item: unknown) =>
runRuntimeAuthoringRules({ type: 'object', item, context: { objects: siblings } });

it('publishes clean — the door adds no error to the shipped declaration', () => {
const result = gate(SysEmail);
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
});

it('control: the pre-fix entry is still refused, so the case above is not vacuous', () => {
const result = gate({ ...SysEmail, highlightFields: ['subject', 'to', 'status', 'sent_at'] });
expect(result.errors.map((e) => `${e.rule} ${e.path}`)).toEqual([
'object-field-ref-unknown objects.sys_email.highlightFields[1]',
]);
});
});
2 changes: 1 addition & 1 deletion packages/platform-objects/src/audit/sys-email.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const SysEmail = ObjectSchema.create({
displayNameField: 'subject',
nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
titleFormat: '{subject}',
highlightFields: ['subject', 'to', 'status', 'sent_at'],
highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'],

fields: {
id: Field.text({
Expand Down
Loading