From d2c1f11ee7737742b410df41df866edf6ecc82d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:54:07 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec):=20FieldSchema.mailAssociation?= =?UTF-8?q?=20=E2=80=94=20an=20email=20field=20declares=20itself=20a=20cor?= =?UTF-8?q?respondent=20address=20for=20inbound=20mail=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../src/builtin-column-collision.ts | 1 + .../lint/src/lint-liveness-properties.test.ts | 17 +- .../src/validate-predicate-path-refs.test.ts | 13 +- .../src/object-schema-fls-references.ts | 4 + ...l.meta-types-degenerate-derivation.test.ts | 8 +- packages/spec/liveness/field.json | 8 + .../src/data/field-mail-association.test.ts | 147 ++++++++++++++++++ packages/spec/src/data/field.form.ts | 8 + packages/spec/src/data/field.zod.ts | 56 +++++++ 9 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 packages/spec/src/data/field-mail-association.test.ts diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index e90064c59d0..01b451f653f 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -123,6 +123,7 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = dueLike: 'presentation', // declares a date a deadline (#22227) — read by the renderer's date cells, never by `createColumn`: the column is the date the type already sizes settledWhen: 'presentation', // the deadline's settle predicate (#22227), display only like `visibleWhen` — no DDL reads it conditionalFormatting: 'presentation', // a field's cell formatting rules (#22228), display only — no DDL reads it + mailAssociation: 'presentation', // marks an email field as a correspondent address for inbound-mail matching (#22870) — read by the mailbox service, never by `createColumn`: the column is the email string the type already sizes requiredPermissions: 'presentation', maskingRule: 'presentation', ackPlaintextMasking: 'presentation', diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 56ad86ad4f9..fe67205b24b 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -1611,17 +1611,22 @@ const fieldLedgerDir = () => ledgerDirWith((dir) => writeLedger(dir, 'field', SY describe('the object/field walk, against a synthetic ledger directory (#19268)', () => { // The state that makes this block necessary, asserted rather than recalled. - // If `field.json` ever warns again these two flip, and the block above - // ("field walk: a malformed `fields` array …") can take its real subject back - // — but this block keeps working either way, which is the point. + // If `field.json` ever warns on a STABLE subject again, the first assertion + // below names it, and the block above ("field walk: a malformed `fields` + // array …") can take its real subject back — but this block keeps working + // either way, which is the point. it('the SHIPPED field ledger warns on no stable subject — which is why the walk needs a subject of its own', () => { // `picklist` was the last opted-in row (`planned` + `authorWarn`) and left // when the server began resolving picklist references (`live`). What is // left is `conditionalRequired`, a `retiredKey` tombstone whose `dead` row // warns on its own since #16094 — but a parse refuses it first, so it is no - // subject for #11385 either. The synthetic slot below is warned by nothing - // shipped. - expect([...authorWarnedProperties('field')]).toEqual(['conditionalRequired']); + // subject for #11385 either. `mailAssociation` (#22870) opted in as + // `planned` + `authorWarn` — the inbound-mail correspondent flag on an email + // field, declared ahead of the mailbox ingest that reads it — and it leaves + // this set when that reader lands and the row flips `live`: a verdict + // scheduled to move, so no stable subject either. The synthetic slot below + // is warned by nothing shipped. + expect([...authorWarnedProperties('field')]).toEqual(['conditionalRequired', 'mailAssociation']); expect( lintLivenessProperties({ objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index d08e68b4fa1..23bbf65a391 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -667,7 +667,14 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // == true`, and removes none. Neither reaches the literal-comparison census // below: one is an `in`-list gate, and the other compares against the bare // `true`, which is not a quoted literal. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(87); + // It is 88 today, an ADDITION of ONE: #22870 offered the field form's + // `mailAssociation` row, a PARSE gate like the deadline pair (`FieldSchema` + // refuses the key off `email`). Measured, not inferred: the shipped + // `*.form.ts` corpus differenced against the merge base `018b077fb` by + // `
::::`, 87 → 88, adds exactly + // `field :: mailAssociation :: data.type == 'email'` and removes none. It + // does reach the literal-comparison census below. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(88); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -774,7 +781,9 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // It is 64 today: #22727's `field :: currencyConfig` gate also opens for a // currency formula, `data.type == 'formula' && data.returnType == 'currency'`, // two more single-quoted literal comparisons. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(64); + // It is 65 today: #22870's `field :: mailAssociation` row is gated on + // `data.type == 'email'`, a single-quoted literal comparison. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(65); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 743571123c8..cf72436b0ef 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -594,6 +594,10 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { // A boolean facet of this date field: it declares the field a deadline // (#22227) and names no field. dueLike: keep, + // A boolean facet of this email field: it marks the field as a + // correspondent address for inbound-mail matching (#22870) and names no + // field. + mailAssociation: keep, // Names of ANOTHER object's fields — that object's projection governs them. // `displayField` / `descriptionField` / `lookupColumns` / `lookupFilters` // name fields of the `reference` target (the lookup picker reads them); diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index d99eb33ca93..66e99ad4515 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -309,15 +309,17 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * moved 44 → 45 the same way when it gained `attachedOnRead` (#22386, the * blocks a service attaches per caller on read). `field` moved 75 → 77 the * same way when it gained `dueLike` and `settledWhen` (#22227, a date's - * deadline and when it is settled), and 77 → 78 when it gained - * `conditionalFormatting` (#22228, a field's cell formatting rules). + * deadline and when it is settled), 77 → 78 when it gained + * `conditionalFormatting` (#22228, a field's cell formatting rules), and + * 78 → 79 when it gained `mailAssociation` (#22870, the inbound-mail + * correspondent flag on an email field). * `dashboard` moved 21 → 22 the same way when it gained * `requiredPermissions` (#22611, the audience gate). `position` moved * 12 → 13 the same way when it gained `permissionSets` (ADR-0131 D3/D4, * the sets a position distributes). */ const CARD_PROPERTY_COUNTS: Record = { - agent: 26, app: 30, dashboard: 22, dataset: 16, field: 78, flow: 23, + agent: 26, app: 30, dashboard: 22, dataset: 16, field: 79, flow: 23, hook: 22, object: 45, page: 25, position: 13, report: 21, skill: 17, tool: 14, }; diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 5e48a50aa12..44bb18c2cbf 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -245,6 +245,14 @@ "evidence": "packages/metadata-protocol/src/seed-loader.ts (load step 4.5 collects declared pointer pairs; loadDataset derives a per-row reference through the sibling column and routes it through the shared resolution loop — same probes, pass-2 deferral, loud refusal); packages/plugins/plugin-audit/src/objects/sys-activity.object.ts (record_id via object_name, source_id via source_object)", "note": "[#11339] Polymorphic pointer pair (ADR-0052 §5 ActivityPointer): marks a text field as the id half whose target object the SIBLING column names per row. Consumer (seed-time natural-key resolution + loud refusal of unresolvable/un-addressable pointers) lands in the same PR (ADR-0049 declare = enforce; the maskingRule precedent). Authoring contradictions refused at parse (text-only, exclusive with reference) and create (sibling must be declared). Proven in packages/metadata-protocol/src/seed-loader-pointer-pair.test.ts and packages/spec/src/data/field.test.ts." }, + "mailAssociation": { + "status": "planned", + "verifiedAt": "2026-10-12", + "authorWarn": true, + "authorHint": "Keep it: no mailbox service reads this yet; it takes effect when the mailbox capability ships.", + "evidence": "packages/spec/src/data/field.zod.ts#mailAssociation (the parse-time door: the FieldSchema superRefine refuses the key on every type but `email`, beside the `referenceVia` checks); packages/spec/src/data/field-mail-association.test.ts (the accept and refuse pins)", + "note": "PLANNED — declared ahead of its one reader by the card's own sequencing (#22870 PR 1 of 2; ruling 6116489638, Q1 → A: the inbound-mail association seam is a declared field-level key, valid only on `type: 'email'`). The card's constraint: \"Declared = enforced. Every key this card adds must get its runtime reader in the sibling ingest card, or it is not added. Liveness rows start `planned` and leave it when the reader lands.\" The reader is the mailbox service's ingest and re-match (the sibling ingest card #22871, Blocked-by #22870), which matches every flagged email field in the mailbox's organization against a message's participant addresses and records one association per match. No reader in this repo today, measured at 018b077fb before this change: `git grep mailAssociation -- packages` answers 0 hits (control: `referenceVia` answers 12 in field.zod.ts alone). `authorWarn` per enforce-or-mark (the externalSharingModel / page.print shape): an author who sets it is told no mailbox service reads it yet. Flip to `live` when the ingest lands, citing its matcher symbol, and drop `authorWarn` / `authorHint` in that same edit." + }, "autonumberFormat": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/src/data/field-mail-association.test.ts b/packages/spec/src/data/field-mail-association.test.ts new file mode 100644 index 00000000000..ce0b64219d2 --- /dev/null +++ b/packages/spec/src/data/field-mail-association.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22870] `FieldSchema.mailAssociation` — the inbound-mail association seam + * as a declared field-level key (ruling 6116489638, Q1 → A). + * + * TRUE marks an email field as identifying a message correspondent: the + * mailbox service's ingest associates a message with every record whose value + * in a flagged field equals one of the message's participant addresses. The + * platform learns which fields carry a correspondent's address and never what + * a contact is. + * + * Declared = enforced (ADR-0049) at the parse seam: the key is refused on + * every type but `email`, in the `referenceVia` refusal's shape. Its runtime + * reader lands with the ingest card; until then the liveness ledger row is + * `planned` with `authorWarn` (pinned in `@objectstack/lint`'s + * `lint-liveness-properties.test.ts`). + * + * The accepted type is HARDCODED below on purpose (not read off the schema), + * so these pins are an independent measurement rather than a tautology — the + * #11949 discipline. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { FieldSchema } from './field.zod'; +import { fieldForm } from './field.form'; + +// ========================================================================= +// 1. The accepted shape — full parse green, the authored value kept +// ========================================================================= + +describe('FieldSchema accepts mailAssociation on an email field', () => { + it('accepts mailAssociation: true on type email, keeping it through parse', () => { + const result = FieldSchema.safeParse({ name: 'email', label: 'Email', type: 'email', mailAssociation: true }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.mailAssociation).toBe(true); + }); + + it('accepts mailAssociation: false on type email — an explicit "not a correspondent address"', () => { + const result = FieldSchema.safeParse({ name: 'billing_email', label: 'Billing Email', type: 'email', mailAssociation: false }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.mailAssociation).toBe(false); + }); + + it('control: an email field without the key parses, and no default materializes', () => { + const result = FieldSchema.safeParse({ name: 'email', label: 'Email', type: 'email' }); + expect(result.success).toBe(true); + if (result.success) expect('mailAssociation' in result.data).toBe(false); + }); + + it('refuses a non-boolean value on an email field (the key is a flag)', () => { + const result = FieldSchema.safeParse({ name: 'email', label: 'Email', type: 'email', mailAssociation: 'yes' }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path[0] === 'mailAssociation'); + expect(issue?.code).toBe('invalid_type'); + } + }); +}); + +// ========================================================================= +// 2. The refusal — every other type, one custom issue at [mailAssociation] +// ========================================================================= + +const issuesAt = (input: Record) => { + const result = FieldSchema.safeParse(input); + expect(result.success).toBe(false); + return result.success ? [] : result.error.issues.filter((i) => i.path[0] === 'mailAssociation'); +}; + +describe('mailAssociation is refused off type email', () => { + // `text` is the near miss an author reaches for (an address kept in a plain + // string column); `url` and `phone` are the sibling contact-string types; + // `lookup` points at a record that may HOLD an address, but is not one; + // `formula` may compute an address per read, with nothing stored to match. + const wrongTypes = ['text', 'url', 'phone', 'textarea', 'lookup', 'formula', 'number', 'boolean'] as const; + for (const type of wrongTypes) { + it(`refuses mailAssociation on type: '${type}' with one custom issue at [mailAssociation] naming the type`, () => { + const input: Record = { name: 'contact_address', label: 'Contact Address', type, mailAssociation: true }; + if (type === 'lookup') input.reference = 'crm_contact'; + if (type === 'formula') input.expression = "record.first_name + '@example.com'"; + const issues = issuesAt(input); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['mailAssociation']); + // Named subjects: the legal type and the offending one. + expect(issues[0]!.message).toContain("`type: 'email'`"); + expect(issues[0]!.message).toContain(`\`${type}\``); + }); + } + + it('refuses mailAssociation: false off-type too — an authored value on a type it describes nothing on', () => { + const issues = issuesAt({ name: 'notes', label: 'Notes', type: 'text', mailAssociation: false }); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + }); + + it('the refusal is the only issue on an otherwise valid text field — the fixture is not malformed some other way', () => { + const result = FieldSchema.safeParse({ name: 'contact_address', label: 'Contact Address', type: 'text', mailAssociation: true }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((i) => i.path.join('.'))).toEqual(['mailAssociation']); + } + // POSITIVE CONTROL — the same field without the key parses. + expect(FieldSchema.safeParse({ name: 'contact_address', label: 'Contact Address', type: 'text' }).success).toBe(true); + }); +}); + +// ========================================================================= +// 3. The JSON-schema projection carries the key +// ========================================================================= + +describe('the JSON-schema projection of FieldSchema carries mailAssociation', () => { + it('declares an optional boolean property whose description names its one legal type', () => { + const js = z.toJSONSchema(FieldSchema as unknown as z.ZodType, { + unrepresentable: 'any', + io: 'input', + }) as { properties?: Record; required?: string[] }; + const prop = js.properties?.mailAssociation; + expect(prop).toBeDefined(); + expect(prop!.type).toBe('boolean'); + expect(prop!.description).toContain('`email` fields only'); + expect(js.required ?? []).not.toContain('mailAssociation'); + }); +}); + +// ========================================================================= +// 4. The offer side — the field form shows the key exactly where it parses +// ========================================================================= + +describe('field.form offers mailAssociation on exactly the type the door accepts', () => { + const rows = (fieldForm.sections ?? []).flatMap((s) => s.fields ?? []) as Array<{ field?: string; visibleWhen?: unknown }>; + // `defineForm` normalizes a bare predicate string to its `{ dialect, source }` + // envelope, so the gate is read off `source`. + const gateOf = (row: { visibleWhen?: unknown }) => { + const v = row.visibleWhen as string | { source?: string } | undefined; + return typeof v === 'string' ? v : String(v?.source); + }; + + it('offers one mailAssociation row, gated on the email type', () => { + const matching = rows.filter((r) => r.field === 'mailAssociation'); + expect(matching).toHaveLength(1); + expect(gateOf(matching[0]!)).toBe("data.type == 'email'"); + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index f688615d6d3..2892cb941bf 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -131,6 +131,14 @@ export const fieldForm = defineForm({ { field: 'language', visibleWhen: "data.type == 'code'", helpText: 'Editor language for syntax highlighting (e.g. javascript, python, sql).' }, { field: 'autonumberFormat', visibleWhen: "data.type == 'autonumber'", helpText: 'Literal text plus a {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens in the business time zone, and {field_name} interpolation. The counter resets per rendered prefix. Omitted on an autonumber field it defaults to {0000}.' }, { field: 'referenceVia', visibleWhen: "data.type == 'text'", helpText: 'Makes this text field the id half of a polymorphic pointer: names the SIBLING field on the same object that holds the target object name, per row (ADR-0052 §5). snake_case; text fields only, and mutually exclusive with `reference`.' }, + // #22870 (ruling 6116489638, Q1 → A) — inbound mail association. A + // PARSE gate like the deadline pair below: `FieldSchema` refuses the + // key on every type but `email`, so the row shows exactly where it + // parses. Its reader is the mailbox service's ingest, which lands + // after this declaration; the help text says where it takes effect + // rather than when, and the liveness row's author warning carries the + // "not read yet" until that reader lands. + { field: 'mailAssociation', label: 'Match Inbound Mail', visibleWhen: "data.type == 'email'", helpText: "Links inbound mail to records of this object whose value in this field matches the message's sender or a recipient (case-insensitive; the mailbox's own address excluded). Email fields only. Takes effect only where a mailbox service is set up." }, // #22227 (objectui#11815 ruling D) — the deadline semantic. Unlike the // meaningfulness gates above, these two ARE parse gates: `FieldSchema` // refuses both keys outside date / datetime (DEADLINE_FIELD_TYPES in diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index c06f28ed992..2a30495308c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1467,6 +1467,44 @@ export const FieldSchema = lazySchema(() => { + 'natural key against the object the sibling column names, and an unresolvable pointer is refused ' + 'loudly instead of stored verbatim. Adds no referential integrity or $expand behavior.' ), + /** + * Inbound mail association — `email` fields only (#22870; ruling + * `6116489638`, Q1 → A, under the inbound-mail design record on #8998). + * + * Marks this email field as identifying a message correspondent. The one + * closed extension point of inbound mail — "given these participant + * addresses, which record(s) does this message belong to?" — is answered by + * this declaration, never by app code: + * + * - **Input:** the participant set — the normalised addr-specs of From, + * Sender, Reply-To, To and Cc, plus those of a forwarded message the mail + * carries, minus the receiving mailbox's own addresses — scoped to the + * mailbox's organization. + * - **Output:** one association per match, naming the object, the record id, + * this field and the matched address. An empty set leaves the message in + * the mailbox's unassociated pool. + * + * The platform learns which fields carry a correspondent's address, the same + * kind of fact as `searchable`, and never what a contact is: every object + * that flags a field is treated alike. No resolver contract is published. + * + * Declared = enforced (ADR-0049). The parse seam refuses it on every type + * but `email` (the superRefine below, beside the `referenceVia` checks): no + * other type stores an address to match. The runtime reader is the mailbox + * service's ingest and re-match, which lands with the ingest card; until it + * does, the liveness ledger row is `planned` with `authorWarn`, so an author + * who sets it is told it takes no effect yet. No default: `undefined` always + * means "not authored". + */ + mailAssociation: z.boolean().optional().describe( + 'Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. ' + + 'When a mailbox service ingests a message, it associates the message with every record of this object ' + + "whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To " + + 'and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox\'s ' + + "own addresses excluded; only within the mailbox's organization). A message no record matches waits in " + + "the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service " + + 'is composed.' + ), // `referenceFilters` (string[]) removed in the 16.x line (#2377, ADR-0049): // the lookup picker reads the structured `lookupFilters` ({field,operator,value}), // never this string[] form — as authored it filtered nothing. Use `lookupFilters`. @@ -2197,6 +2235,24 @@ export const FieldSchema = lazySchema(() => { 'lookup/master_detail field) for a fixed relationship.', }); } + // [#22870] `mailAssociation` marks a field as holding a correspondent's + // address, matched against the participant addresses of inbound mail — and + // only an `email` column stores an address to match. On any other type the + // flag would parse and match nothing, so it is refused at the authoring + // seam, in the `referenceVia` refusal's shape (the offending type named, the + // remedy one keystroke away). An authored `false` off-type is refused too: + // the key describes nothing on that type. + if (field.mailAssociation !== undefined && field.type !== 'email') { + ctx.addIssue({ + code: 'custom', + path: ['mailAssociation'], + message: + `\`mailAssociation\` is only valid on \`type: 'email'\` (this field is \`${field.type}\`): it marks ` + + "a field as holding a correspondent's address, matched against the participant addresses of " + + 'inbound mail, and only an `email` field stores an address to match. Change the field to ' + + "`type: 'email'` if it holds an email address, or drop the key.", + }); + } // [#13632] (ADR-0049 declared = enforced): the `reference` TSDoc above has // always called the key REQUIRED on the relationship types, but the schema From a65acf708b76c14e87780937d29e1f47cb82a4ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:04:45 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(spec):=20mailAssociation=20=E2=80=94?= =?UTF-8?q?=20locale=20leaves,=20the=20translated-label=20control,=20the?= =?UTF-8?q?=20changeset=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .changeset/22870-field-mail-association.md | 13 +++++++++++++ .../translations/en.metadata-forms.generated.ts | 4 ++++ .../translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../object-lifecycle-panel-echo-decisions.test.ts | 5 ++++- .../translations/zh-CN.metadata-forms.generated.ts | 4 ++++ 6 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 .changeset/22870-field-mail-association.md diff --git a/.changeset/22870-field-mail-association.md b/.changeset/22870-field-mail-association.md new file mode 100644 index 00000000000..8e94667ab83 --- /dev/null +++ b/.changeset/22870-field-mail-association.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +--- + +An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + +Clause-②: yes (widening) + +- **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. +- **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. +- **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. +- **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. +- **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 95ea6517369..9d3b2096f16 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -632,6 +632,10 @@ export const enMetadataForms: NonNullable = { label: "Reference Via", helpText: "Makes this text field the id half of a polymorphic pointer: names the SIBLING field on the same object that holds the target object name, per row (ADR-0052 §5). snake_case; text fields only, and mutually exclusive with `reference`." }, + mailAssociation: { + label: "Match Inbound Mail", + helpText: "Links inbound mail to records of this object whose value in this field matches the message's sender or a recipient (case-insensitive; the mailbox's own address excluded). Email fields only. Takes effect only where a mailbox service is set up." + }, dueLike: { label: "Deadline", helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 1ce68c098bd..1348a2fdc6d 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -632,6 +632,10 @@ export const esESMetadataForms: NonNullable = label: "Referencia mediante", helpText: "Convierte este campo de texto en la mitad de id de un puntero polimórfico: nombra el campo HERMANO del mismo objeto que guarda, fila a fila, el nombre del objeto destino (ADR-0052 §5). En snake_case; solo campos de texto y excluyente con `reference`." }, + mailAssociation: { + label: "Asociar correo entrante", + helpText: "Vincula el correo entrante a los registros de este objeto cuyo valor en este campo coincide con el remitente o con algún destinatario del mensaje (sin distinguir mayúsculas; se excluye la dirección del propio buzón). Solo campos de email. Solo surte efecto donde hay un servicio de buzón configurado." + }, dueLike: { label: "Fecha límite", helpText: "Marca esta fecha como fecha límite: una vez pasada, las vistas pueden mostrarla como vencida (texto relativo y un color de vencimiento). Desactivado o sin definir: no es una fecha límite, y no se deduce nada del nombre del campo." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 162e66518f5..26506f90d02 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -632,6 +632,10 @@ export const jaJPMetadataForms: NonNullable = label: "ポリモーフィック参照の相棒項目", helpText: "このテキスト項目をポリモーフィック参照の id 側にします。ここには、対象オブジェクト名を行ごとに保持する同一オブジェクト上の別項目名を指定します(ADR-0052 §5)。snake_case で記述し、テキスト項目のみ、`reference` とは併用できません。" }, + mailAssociation: { + label: "受信メールの照合", + helpText: "この項目の値がメッセージの送信者またはいずれかの受信者のアドレスと一致する、このオブジェクトのレコードに受信メールを関連付けます(大文字と小文字は区別しません。メールボックス自身のアドレスは除きます)。メール項目のみ。メールボックスサービスが設定されている環境でのみ有効です。" + }, dueLike: { label: "期限", helpText: "この日付を期限としてマークします。日付を過ぎると、ビューで期限切れとして表示されることがあります(相対的な表現と期限切れの色)。オフまたは未設定の場合は期限ではなく、項目名から推測されることもありません。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index eb64841c734..60909f49bba 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1183,7 +1183,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 669 since the position form offers `permissionSets` (ADR-0131 D3/D4), // the sets a position distributes — one new row label, authored in all // three locales. - expect(translated.length, `${locale} positive control`).toBe(669); + // 670 since the field form offers `mailAssociation` on email fields, the + // inbound-mail correspondent flag — one new row label, authored in all + // three locales. + expect(translated.length, `${locale} positive control`).toBe(670); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 47464261675..91350090585 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -632,6 +632,10 @@ export const zhCNMetadataForms: NonNullable = label: "多态指针伴随字段", helpText: "把该文本字段声明为多态指针的 id 半边:此处填写同一对象上保存目标对象名的另一个字段,目标逐行决定(ADR-0052 §5)。使用 snake_case;仅限文本字段,且与 `reference` 互斥。" }, + mailAssociation: { + label: "匹配入站邮件", + helpText: "把入站邮件关联到本对象中该字段值与邮件发件人或某位收件人地址相同的记录(不区分大小写;不含邮箱自身的地址)。仅限邮箱字段。只在配置了邮箱服务的环境中生效。" + }, dueLike: { label: "截止日期", helpText: "将此日期标记为截止日期:过了这个日期后,视图可将其显示为逾期(相对时间措辞与逾期颜色)。关闭或未设置:不是截止日期,也不会根据字段名推测。" From 52bcc842928e29022be432fa6eb3fd3535701775 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:17:29 +0000 Subject: [PATCH 3/4] chore(spec): regenerate the artifacts check:generated proved stale for mailAssociation authorable-surface/data.json (written by the spec build), the reference docs (gen:docs) and liveness/state-counts/field.md (gen:liveness-counts). Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- content/docs/references/data/field.mdx | 1 + content/docs/references/data/object.mdx | 2 ++ content/docs/references/system/migration.mdx | 2 ++ packages/spec/authorable-surface/data.json | 1 + packages/spec/liveness/state-counts/field.md | 2 +- 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index dfbf5b78aa4..574c95de81d 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -78,6 +78,7 @@ const result = CurrencyConfigSchema.parse(data); | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index c98486082ef..6f4500efaa3 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -243,6 +243,7 @@ const result = ApiMethod.parse(data); | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | @@ -584,6 +585,7 @@ const result = ApiMethod.parse(data); | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 86603a356df..e301d3d7175 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -79,6 +79,7 @@ Add a new field to an existing object | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | @@ -505,6 +506,7 @@ Add a new field to an existing object | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index c3a152ecaa0..cfcacaaa115 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -353,6 +353,7 @@ "data/Field:lookupColumns", "data/Field:lookupFilters", "data/Field:lookupPageSize", + "data/Field:mailAssociation", "data/Field:maskingRule", "data/Field:max", "data/Field:maxLength", diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 1ea14114cde..9cefcb90213 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 94 | 0 | 0 | 1 | 3 | 98 | +| `field` | 94 | 0 | 0 | 1 | 4 | 99 | From 1986ccb7414e85791d12961955aa9c83d90b1af2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:24:09 +0000 Subject: [PATCH 4/4] chore(changeset): name driver-sql, metadata-core and platform-objects for the mailAssociation rows Each touched published package names itself: the FIELD_KEY_STORAGE_CLASS row, the FIELD_REFERENCE_POSITIONS scrub row, and the field designer's new row label and help text in four locales. spec stays minor. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .changeset/22870-field-mail-association.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.changeset/22870-field-mail-association.md b/.changeset/22870-field-mail-association.md index 8e94667ab83..261762987de 100644 --- a/.changeset/22870-field-mail-association.md +++ b/.changeset/22870-field-mail-association.md @@ -1,5 +1,8 @@ --- '@objectstack/spec': minor +'@objectstack/driver-sql': patch +'@objectstack/metadata-core': patch +'@objectstack/platform-objects': patch --- An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` @@ -11,3 +14,6 @@ Clause-②: yes (widening) - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". +- **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. +- **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. +- **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES.