From a29e0b399300455f740572fea63c64b0cbfbc8c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 22:50:56 +0000 Subject: [PATCH 1/2] fix(platform-objects): sys_email.highlightFields names to_addresses, not to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...sys-email-highlight-fields-to-addresses.md | 13 ++++ ...sys-email.highlight-fields-resolve.test.ts | 71 +++++++++++++++++++ .../src/audit/sys-email.object.ts | 2 +- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .changeset/sys-email-highlight-fields-to-addresses.md create mode 100644 packages/platform-objects/src/audit/sys-email.highlight-fields-resolve.test.ts diff --git a/.changeset/sys-email-highlight-fields-to-addresses.md b/.changeset/sys-email-highlight-fields-to-addresses.md new file mode 100644 index 0000000000..9caa18e9bb --- /dev/null +++ b/.changeset/sys-email-highlight-fields-to-addresses.md @@ -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. diff --git a/packages/platform-objects/src/audit/sys-email.highlight-fields-resolve.test.ts b/packages/platform-objects/src/audit/sys-email.highlight-fields-resolve.test.ts new file mode 100644 index 0000000000..097035a285 --- /dev/null +++ b/packages/platform-objects/src/audit/sys-email.highlight-fields-resolve.test.ts @@ -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 = Object.values(audit).filter( + (o): o is { name: string } => + typeof (o as { name?: unknown })?.name === 'string' && (o as { 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]', + ]); + }); +}); diff --git a/packages/platform-objects/src/audit/sys-email.object.ts b/packages/platform-objects/src/audit/sys-email.object.ts index c154aba052..0acfa6bfe0 100644 --- a/packages/platform-objects/src/audit/sys-email.object.ts +++ b/packages/platform-objects/src/audit/sys-email.object.ts @@ -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({ From f449a6670a6c7244d9f0cb2939cb47b8bea05626 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:08:27 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../src/audit/sys-email.highlight-fields-resolve.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/platform-objects/src/audit/sys-email.highlight-fields-resolve.test.ts b/packages/platform-objects/src/audit/sys-email.highlight-fields-resolve.test.ts index 097035a285..baff817b7b 100644 --- a/packages/platform-objects/src/audit/sys-email.highlight-fields-resolve.test.ts +++ b/packages/platform-objects/src/audit/sys-email.highlight-fields-resolve.test.ts @@ -49,10 +49,10 @@ import { SysEmail } from './sys-email.object.js'; */ describe('sys_email — highlightFields resolves at the object write door', () => { /** The audit module's other shipped objects, as the live resolution context. */ - const siblings = Object.values(audit).filter( - (o): o is { name: string } => - typeof (o as { name?: unknown })?.name === 'string' && (o as { name: string }).name !== 'sys_email', - ); + const siblings: unknown[] = Object.values(audit as Record).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 } });