From 57b4721da7ee5954343509383927000b5ffab9e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:14:39 +0000 Subject: [PATCH 1/3] feat(spec): a hook can tell the person who saved a record what the save set in motion The single-record create and update answers declare an optional `notice`: one author-written message, info severity, distinct from `warnings`. A hook sets it through the write's own `onNotice` listener (`WriteObservabilityOptions`) on `ctx.input.options`; metadata-protocol's createData / updateData pass the listener and answer the last non-empty message. The engine admits the option on update; the client mirrors the key. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyvb1yBsrocHdqS4Ua --- packages/client/src/index.ts | 16 ++ packages/metadata-protocol/src/protocol.ts | 12 + .../src/engine-unknown-option.test.ts | 10 +- packages/objectql/src/engine.ts | 2 +- .../src/protocol-save-notice-answer.test.ts | 245 ++++++++++++++++++ packages/spec/src/api/protocol.zod.ts | 17 ++ .../spec/src/api/write-answer-notice.test.ts | 74 ++++++ packages/spec/src/contracts/data-engine.ts | 40 +++ 8 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 packages/objectql/src/protocol-save-notice-answer.test.ts create mode 100644 packages/spec/src/api/write-answer-notice.test.ts diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index de0c91bd1f2..6967aa244fa 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -490,6 +490,14 @@ export interface CreateDataResult { * person who made it. Present only when at least one rule hit. */ warnings?: ValidateDataIssue[]; + /** + * [#28000] One author-written message a hook on this write set to tell the + * person who made it what the save set in motion (for example "Sent to your + * manager for approval"). Info severity, distinct from `warnings`: show it + * with, or in place of, your own success message. Present only when a hook + * set one; never stored on the record. + */ + notice?: string; } /** @@ -544,6 +552,14 @@ export interface UpdateDataResult { * person who made it. Present only when at least one rule hit. */ warnings?: ValidateDataIssue[]; + /** + * [#28000] One author-written message a hook on this write set to tell the + * person who made it what the save set in motion (for example "Sent to your + * manager for approval"). Info severity, distinct from `warnings`: show it + * with, or in place of, your own success message. Present only when a hook + * set one; never stored on the record. + */ + notice?: string; } /** diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8e80dc55815..17e8595ac11 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -13736,9 +13736,16 @@ export class ObjectStackProtocolImplementation implements // — the engine reports them through the write's own options, so a // nested write a hook makes inside this one never adds to them. const warnings: ValidationAdvisoryEvent[] = []; + // [#28000] The one message a hook on THIS write set to tell the + // requester what the save set in motion, answered as `notice`. The hook + // reads the listener off `ctx.input.options`, which is this bag, so a + // nested write's hook never reaches it; a later call replaces an + // earlier one, and anything but a non-empty string sets nothing. + let notice: string | undefined; const opts: any = { onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, onValidationAdvisory: (e: ValidationAdvisoryEvent) => { warnings.push(e); }, + onNotice: (message: unknown) => { if (typeof message === 'string' && message.trim() !== '') notice = message; }, }; if (request.context !== undefined) opts.context = request.context; const result = await this.engine.insert(request.object, request.data, opts); @@ -13756,6 +13763,7 @@ export class ObjectStackProtocolImplementation implements record: result, ...(dropped.length > 0 ? { droppedFields: dropped } : {}), ...(warnings.length > 0 ? { warnings } : {}), + ...(notice !== undefined ? { notice } : {}), }; } @@ -13904,6 +13912,9 @@ export class ObjectStackProtocolImplementation implements // (see `createData`). const warnings: ValidationAdvisoryEvent[] = []; opts.onValidationAdvisory = (e: ValidationAdvisoryEvent) => { warnings.push(e); }; + // [#28000] The hook-set message answered as `notice` (see `createData`). + let notice: string | undefined; + opts.onNotice = (message: unknown) => { if (typeof message === 'string' && message.trim() !== '') notice = message; }; // [#6479] At THIS ingress the row is the one the caller named — `request.id`, // the path `:id` — and nothing in the payload gets to move it. // @@ -13980,6 +13991,7 @@ export class ObjectStackProtocolImplementation implements record: result, ...(dropped.length > 0 ? { droppedFields: dropped } : {}), ...(warnings.length > 0 ? { warnings } : {}), + ...(notice !== undefined ? { notice } : {}), }; } diff --git a/packages/objectql/src/engine-unknown-option.test.ts b/packages/objectql/src/engine-unknown-option.test.ts index dc613eff3f5..8fe2cbe94f6 100644 --- a/packages/objectql/src/engine-unknown-option.test.ts +++ b/packages/objectql/src/engine-unknown-option.test.ts @@ -196,6 +196,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => { await engine.update('task', { title: 'A4' }, { where: { id: a.id }, onValidationAdvisory: () => {} } as any); }); + it('onNotice passes on update (contract-declared write observability, #28000)', async () => { + await engine.update('task', { title: 'A6' }, { where: { id: a.id }, onNotice: () => {} } as any); + }); + it('searchFields passes on find (read by the $search expansion)', async () => { // Legality pin only — the $or/$contains expansion itself is covered by // the search-filter tests; this mock driver does not evaluate it. @@ -258,8 +262,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => { // in the serializable bag, so the schema cannot vouch for them and // this list must. Spelling it here is what keeps the engine from // rejecting a key the TS contract declares. [#22726] - // `onValidationAdvisory` is the third such member. - expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'strictReadonlyWrites', ...PASSTHROUGH]); + // `onValidationAdvisory` is the third such member. `onNotice` (#28000) + // is another: the engine only carries it to the hooks, on + // `ctx.input.options`. + expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'onNotice', 'strictReadonlyWrites', ...PASSTHROUGH]); expectSetMatches('delete', EngineDeleteOptionsSchema as any, PASSTHROUGH); expectSetMatches('count', EngineCountOptionsSchema as any, []); expectSetMatches('aggregate', EngineAggregateOptionsSchema as any, []); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b60ba8c1828..ed337ce11ca 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -716,7 +716,7 @@ const ENGINE_FIND_OPTION_KEYS: ReadonlySet = new Set([ ...ENGINE_DRIVER_PASSTHROUGH_KEYS, ]); const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet = new Set([ - 'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'strictReadonlyWrites', + 'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'onNotice', 'strictReadonlyWrites', ...ENGINE_DRIVER_PASSTHROUGH_KEYS, ]); const ENGINE_DELETE_OPTION_KEYS: ReadonlySet = new Set([ diff --git a/packages/objectql/src/protocol-save-notice-answer.test.ts b/packages/objectql/src/protocol-save-notice-answer.test.ts new file mode 100644 index 00000000000..ac71e979344 --- /dev/null +++ b/packages/objectql/src/protocol-save-notice-answer.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28000] A hook tells the person who saved a record what the save set in + * motion, and the message reaches THAT person's write answer as `notice`. + * + * The transport, as built: + * + * createData / updateData → pass THIS write's `onNotice` listener + * (`WriteObservabilityOptions`) in the engine bag + * engine (insert / update) → never calls it; hands the caller's bag to every + * hook on `ctx.input.options` (a `before*` hook reads + * it as passed, an `after*` hook after + * `buildDriverOptions` widened it, never stripped) + * a hook → calls it with one message + * createData / updateData → answer the last non-empty message as `notice`, + * omit-when-absent + * + * A REAL `ObjectQL` engine and the REAL `ObjectStackProtocolImplementation` run + * below, on both hook surfaces an app reaches the bag from: the raw + * `registerHook` envelope and a declarative `handler` bound through + * `bindHooksToEngine` (whose flat record view passes `options` through to the + * envelope). The hooks say what the written row asks them to: `say` from the + * `before*` phase, `say_after` from the `after*` phase. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { CreateDataResponseSchema, UpdateDataResponseSchema } from '@objectstack/spec/api'; +import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; +import { ObjectQL } from './engine.js'; +import { bindHooksToEngine } from './hook-binder.js'; + +const DEAL = { + name: 'sn_deal', + label: 'Deal', + fields: { + body: { name: 'body', label: 'Body', type: 'text' }, + say: { name: 'say', label: 'Say', type: 'text' }, + say_after: { name: 'say_after', label: 'Say after', type: 'text' }, + }, +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) return true; + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + return (row[k] ?? null) === (expected ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = [...storeFor(object).values()].filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const row = { ...data, id: (data.id as string) ?? `r_${nextId}` }; + storeFor(object).set(row.id as string, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const row = { ...storeFor(object).get(id), ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +const noopLogger = { info() {}, debug() {}, warn() {}, error() {} }; + +let engine: ObjectQL; +let protocol: ObjectStackProtocolImplementation; +let storeFor: ReturnType['storeFor']; +/** Set by a test: the after-hook writes a nested deal asking for this notice inside the outer write. */ +let nestedSay: string | undefined; + +const listenerOf = (ctx: any) => (ctx.input?.options as WriteObservabilityOptions | undefined)?.onNotice; + +/** Raw-envelope hooks: `ctx.input.data` is the payload, `ctx.input.options` the caller's bag. */ +const beforeSay = async (ctx: any) => { + const say = ctx.input?.data?.say; + if (say !== undefined) listenerOf(ctx)?.(say); + if (ctx.input?.data?.body === 'refuse') throw new Error('refused by a before hook'); +}; +const afterSay = async (ctx: any) => { + const say = ctx.result?.say_after; + if (say !== undefined) listenerOf(ctx)?.(say); + if (nestedSay !== undefined && ctx.event === 'afterInsert') { + const nested = nestedSay; + nestedSay = undefined; + await engine.insert(DEAL.name, { body: 'nested', say: nested }); + } +}; + +async function boot(surface: 'envelope' | 'declarative') { + engine = new ObjectQL({ logger: noopLogger } as any); + const d = makeMemoryDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject(DEAL as never, 'com.objectstack.test.28000'); + if (surface === 'envelope') { + for (const e of ['beforeInsert', 'beforeUpdate'] as const) engine.registerHook(e, beforeSay, { object: DEAL.name }); + for (const e of ['afterInsert', 'afterUpdate'] as const) engine.registerHook(e, afterSay, { object: DEAL.name }); + } else { + // A declarative `handler` sees the flat record view: `input.say` is the + // field, and `input.options` passes through to the envelope's bag. + const bound = bindHooksToEngine(engine, [ + { + name: 'sn_deal_before', object: DEAL.name, events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx: any) => { if (ctx.input.say !== undefined) listenerOf(ctx)?.(ctx.input.say); }, + }, + { + name: 'sn_deal_after', object: DEAL.name, events: ['afterInsert', 'afterUpdate'], + handler: async (ctx: any) => { if (ctx.result?.say_after !== undefined) listenerOf(ctx)?.(ctx.result.say_after); }, + }, + ] as never, { logger: noopLogger as never }); + expect(bound.errors).toEqual([]); + expect(bound.registered).toBe(2); + } + protocol = new ObjectStackProtocolImplementation(engine as never); + nestedSay = undefined; +} + +describe('[#28000] createData answers a hook-set message as `notice`', () => { + beforeEach(() => boot('envelope')); + + it('answers the message a beforeInsert hook set, and the answer conforms', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: 'Sent for approval' } }); + expect(answer.notice).toBe('Sent for approval'); + expect(CreateDataResponseSchema.safeParse(answer).success).toBe(true); + }); + + it('answers the message an afterInsert hook set', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say_after: 'Renewal created' } }); + expect(answer.notice).toBe('Renewal created'); + }); + + it('control: no hook set one → no `notice` key at all', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + expect('notice' in answer).toBe(false); + }); + + it('one message: the last call wins', async () => { + const answer: any = await protocol.createData({ + object: DEAL.name, data: { body: 'hi', say: 'first', say_after: 'second' }, + }); + expect(answer.notice).toBe('second'); + }); + + it('a blank message sets nothing, and never clears an earlier one', async () => { + const blank: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: ' ' } }); + expect('notice' in blank).toBe(false); + const kept: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: 'kept', say_after: '' } }); + expect(kept.notice).toBe('kept'); + }); + + it('a refused write answers its error, never the notice a before hook set first', async () => { + await expect(protocol.createData({ object: DEAL.name, data: { body: 'refuse', say: 'Sent for approval' } })) + .rejects.toThrow(/refused by a before hook/); + }); + + it('nothing is stored: the persisted row and the answered record carry no `notice`', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: 'Sent for approval' } }); + expect(Object.keys(storeFor(DEAL.name).get(answer.id)!)).not.toContain('notice'); + expect('notice' in answer.record).toBe(false); + }); + + it('a nested write a hook makes inside this one adds nothing to the outer answer, though its hook DID call', async () => { + nestedSay = 'nested notice'; + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'outer' } }); + expect(nestedSay).toBeUndefined(); // the nested deal was written, asking for a notice … + expect([...storeFor(DEAL.name).values()].some((r) => r.say === 'nested notice')).toBe(true); + expect('notice' in answer).toBe(false); // … and the outer answer was told nothing. + }); + + it('each request answers only its own: a second create is not told the first one\'s notice', async () => { + const first: any = await protocol.createData({ object: DEAL.name, data: { body: 'a', say: 'Sent for approval' } }); + const second: any = await protocol.createData({ object: DEAL.name, data: { body: 'b' } }); + expect(first.notice).toBe('Sent for approval'); + expect('notice' in second).toBe(false); + }); +}); + +describe('[#28000] updateData answers a hook-set message as `notice`', () => { + beforeEach(() => boot('envelope')); + + it('answers the message a beforeUpdate hook set, and the answer conforms', async () => { + const created: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + const answer: any = await protocol.updateData({ object: DEAL.name, id: created.id, data: { say: 'Sent for approval' } }); + expect(answer.notice).toBe('Sent for approval'); + expect(UpdateDataResponseSchema.safeParse(answer).success).toBe(true); + }); + + it('answers the message an afterUpdate hook set, the last call winning', async () => { + const created: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + const answer: any = await protocol.updateData({ + object: DEAL.name, id: created.id, data: { say: 'first', say_after: 'Approval requested' }, + }); + expect(answer.notice).toBe('Approval requested'); + }); + + it('control: no hook set one → no `notice` key at all', async () => { + const created: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + const answer: any = await protocol.updateData({ object: DEAL.name, id: created.id, data: { body: 'edited' } }); + expect('notice' in answer).toBe(false); + }); +}); + +describe('[#28000] a declarative `handler` reaches the same listener through its flat record view', () => { + beforeEach(() => boot('declarative')); + + it('a beforeInsert handler sets the create answer\'s notice', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: 'Sent for approval' } }); + expect(answer.notice).toBe('Sent for approval'); + }); + + it('an afterUpdate handler sets the update answer\'s notice', async () => { + const created: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + const answer: any = await protocol.updateData({ object: DEAL.name, id: created.id, data: { say_after: 'Approval requested' } }); + expect(answer.notice).toBe('Approval requested'); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 16610731343..8a9748b3962 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2641,6 +2641,21 @@ const WRITE_ANSWER_WARNINGS_DESCRIPTION = 'Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write ' + 'a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit.'; +/** + * The `notice` member the create and update answers declare (#28000) — one + * sentence, so the two answers cannot drift apart. Response-only: it is no + * field of any object, it is never stored, and a request never sends it. + */ +const WRITE_ANSWER_NOTICE_DESCRIPTION = + 'One author-written message telling the person who made THIS write what the save set in motion (for example ' + + '"Sent to your manager for approval"), set by a hook running on the write through the write\'s own `onNotice` ' + + 'listener (`WriteObservabilityOptions`). Info severity: it reports an outcome, never a problem, so it is distinct ' + + 'from `warnings` (the advisory validation-rule hits) and a client shows it with, or in place of, its own ' + + 'success message. One message: when more than one call sets it, the LAST one wins. Present ONLY when a hook set ' + + 'a non-empty message on a write that succeeded; otherwise the key is omitted. A nested write a hook or a flow ' + + 'made inside this one carries its own options, so its notice never reaches this answer. Response-only: never ' + + 'stored on the record.'; + /** * Create Data Response */ @@ -2659,6 +2674,7 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ 'shape backward-compatible for existing clients.' ), warnings: z.array(ValidateDataIssueSchema).optional().describe(WRITE_ANSWER_WARNINGS_DESCRIPTION), + notice: z.string().min(1).optional().describe(WRITE_ANSWER_NOTICE_DESCRIPTION), })); /** @@ -2840,6 +2856,7 @@ export const UpdateDataResponseSchema = lazySchema(() => z.object({ 'clients that only read `record`.' ), warnings: z.array(ValidateDataIssueSchema).optional().describe(WRITE_ANSWER_WARNINGS_DESCRIPTION), + notice: z.string().min(1).optional().describe(WRITE_ANSWER_NOTICE_DESCRIPTION), })); /** diff --git a/packages/spec/src/api/write-answer-notice.test.ts b/packages/spec/src/api/write-answer-notice.test.ts new file mode 100644 index 00000000000..6a413a32dcf --- /dev/null +++ b/packages/spec/src/api/write-answer-notice.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28000] The save-notice write answer, at the contract. + * + * A hook running on a record save tells the person who saved what the save set + * in motion ("Sent to your manager for approval"). The create and update + * answers carry that one author-written message as `notice`: info severity, + * distinct from `warnings`, omitted when no hook set one. These pins hold the + * member optional on both answers (an existing client's reading does not + * move), hold it to one non-empty message, and hold the in-process listener a + * hook calls to set it. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateDataResponseSchema, + UpdateDataResponseSchema, + CloneDataResponseSchema, +} from './protocol.zod'; +import type { WriteObservabilityOptions } from '../contracts/data-engine'; + +const base = { object: 'deal', id: 'd1', record: { id: 'd1' } }; +const shapeKeys = (schema: unknown) => Object.keys((schema as { shape: Record }).shape); +const describeOf = (schema: unknown) => + ((schema as { shape: Record }).shape.notice?.description ?? ''); + +describe('[#28000] the create and update answers declare an optional one-message `notice`', () => { + it.each([ + ['CreateDataResponseSchema', CreateDataResponseSchema], + ['UpdateDataResponseSchema', UpdateDataResponseSchema], + ] as const)('%s accepts the message, and an answer with no key', (_name, schema) => { + expect(schema.safeParse({ ...base, notice: 'Sent to your manager for approval' })) + .toMatchObject({ success: true, data: { notice: 'Sent to your manager for approval' } }); + expect(schema.safeParse(base)).toMatchObject({ success: true }); + expect((schema.parse(base) as Record).notice).toBeUndefined(); + }); + + it.each([ + ['CreateDataResponseSchema', CreateDataResponseSchema], + ['UpdateDataResponseSchema', UpdateDataResponseSchema], + ] as const)('%s refuses anything but one non-empty message: no list, no object, no empty string', (_name, schema) => { + expect(schema.safeParse({ ...base, notice: ['a', 'b'] }).success).toBe(false); + expect(schema.safeParse({ ...base, notice: { message: 'a', severity: 'info' } }).success).toBe(false); + expect(schema.safeParse({ ...base, notice: '' }).success).toBe(false); + }); + + it('it is a member of its own, beside `warnings`, never folded into it', () => { + for (const schema of [CreateDataResponseSchema, UpdateDataResponseSchema]) { + expect(shapeKeys(schema)).toEqual(expect.arrayContaining(['warnings', 'notice'])); + } + }); + + it('the clone answer does not declare it: the card names the single-record create and update answers', () => { + expect(shapeKeys(CloneDataResponseSchema)).not.toContain('notice'); + }); + + it('the description is one sentence shared by both answers, and says it is never stored', () => { + expect(describeOf(CreateDataResponseSchema)).toBe(describeOf(UpdateDataResponseSchema)); + expect(describeOf(CreateDataResponseSchema)).toMatch(/never\s+stored/); + expect(describeOf(CreateDataResponseSchema)).toMatch(/LAST one wins/); + }); +}); + +describe('[#28000] the listener is declared on the in-process write contract', () => { + it('`onNotice` takes one message; absent stays legal', () => { + const seen: string[] = []; + const opts: WriteObservabilityOptions = { onNotice: (message) => { seen.push(message); } }; + opts.onNotice?.('Sent to your manager for approval'); + expect(seen).toEqual(['Sent to your manager for approval']); + const unset: WriteObservabilityOptions = {}; + expect(unset.onNotice).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index cca21669b86..6bc6968fa4e 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -94,6 +94,46 @@ export interface WriteObservabilityOptions { */ onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void; + /** + * The channel a hook uses to tell the person who made this write what the + * save set in motion (#28000) — "Sent to your manager for approval", "A + * renewal opportunity was created". One author-written message, info + * severity: it reports an outcome, never a problem, so it is not a + * validation finding and never rides `onValidationAdvisory`. + * `metadata-protocol`'s `createData` and `updateData` pass one and answer the + * message as `notice`, on the requester's own answer to that request and + * nowhere else. + * + * The ENGINE never calls it. A hook reaches it the way every member of this + * bag reaches a hook — on `ctx.input.options`, the caller's own bag, which a + * `before*` handler reads as passed and an `after*` handler reads after + * `buildDriverOptions` widened it (it never strips a key) — and calls it + * with the message (`ctx.input` is typed as a plain record, so the bag is + * cast to this interface, as the platform's own hooks do): + * + * const opts = ctx.input.options as WriteObservabilityOptions | undefined; + * opts?.onNotice?.('Sent to your manager for approval'); + * + * Absent is normal: a write no person is waiting on (a seed, a flow node, a + * batch, an import) passes no listener, so a hook always calls it + * optionally. The sandboxed `body` face does not carry it: its + * `ctx.input.options` is the frozen `{ multi, where }` projection, never the + * caller's bag. + * + * One message per write: a later call REPLACES an earlier one, so the last + * call before the write answers wins, and a message that is not a non-empty + * string sets nothing. A write that is refused answers its error, never a + * notice, even if a `before*` hook set one first. A nested write a hook or a + * flow makes inside this one carries its own options, so its notice never + * reaches this listener. Nothing is stored. + * + * In-process only, like `onFieldsDropped`: a function cannot cross the RPC + * boundary. Because the hook calls the listener directly, nothing between + * them catches, so a listener must only record the message and never throw + * — `metadata-protocol`'s does exactly that. + */ + onNotice?: (message: string) => void; + /** * Refuse the write instead of stripping (#5126). Default `false` — off. * From 7f5907b4111aa837a0a800aed3ed3db86d8e78b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:19:13 +0000 Subject: [PATCH 2/3] test(objectql): a declarative handler registers once per event Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyvb1yBsrocHdqS4Ua --- packages/objectql/src/protocol-save-notice-answer.test.ts | 2 +- packages/spec/authorable-surface/api.json | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/protocol-save-notice-answer.test.ts b/packages/objectql/src/protocol-save-notice-answer.test.ts index ac71e979344..33e15a864d5 100644 --- a/packages/objectql/src/protocol-save-notice-answer.test.ts +++ b/packages/objectql/src/protocol-save-notice-answer.test.ts @@ -138,7 +138,7 @@ async function boot(surface: 'envelope' | 'declarative') { }, ] as never, { logger: noopLogger as never }); expect(bound.errors).toEqual([]); - expect(bound.registered).toBe(2); + expect(bound.registered).toBe(4); // one registration per (hook, event) } protocol = new ObjectStackProtocolImplementation(engine as never); nestedSay = undefined; diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 0bc05d2676d..65a6f1ce064 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -340,6 +340,7 @@ "api/CreateDataRequest:object", "api/CreateDataResponse:droppedFields", "api/CreateDataResponse:id", + "api/CreateDataResponse:notice", "api/CreateDataResponse:object", "api/CreateDataResponse:record", "api/CreateDataResponse:warnings", @@ -1736,6 +1737,7 @@ "api/UpdateDataRequest:object", "api/UpdateDataResponse:droppedFields", "api/UpdateDataResponse:id", + "api/UpdateDataResponse:notice", "api/UpdateDataResponse:object", "api/UpdateDataResponse:record", "api/UpdateDataResponse:warnings", From e7c4791296d3968491bf485b3fe724838e0b7081 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:31:26 +0000 Subject: [PATCH 3/3] docs(spec): teach the save notice on the data API and the engine contract pages Regenerates the protocol reference and the api authorable-surface shard for the new answer member, and adds the changeset. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyvb1yBsrocHdqS4Ua --- .changeset/28000-save-notice-answer.md | 29 +++++++++++++ content/docs/api/data-api.mdx | 9 ++++ content/docs/kernel/contracts/data-engine.mdx | 41 ++++++++++++++++++- content/docs/references/api/protocol.mdx | 2 + packages/spec/authorable-surface/api.json | 2 + packages/spec/src/contracts/data-engine.ts | 6 +-- 6 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 .changeset/28000-save-notice-answer.md diff --git a/.changeset/28000-save-notice-answer.md b/.changeset/28000-save-notice-answer.md new file mode 100644 index 00000000000..ba2b8cbcd19 --- /dev/null +++ b/.changeset/28000-save-notice-answer.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/objectql': minor +'@objectstack/client': minor +--- + +feat(spec): a hook can tell the person who saved a record what the save set in motion + +Clause-②: yes +Metadata-protocol: added + +A `beforeInsert` / `beforeUpdate` / `afterInsert` / `afterUpdate` hook had no way to tell the person saving what the save started, such as sending a deal for approval. The single-record write answer carried `warnings` (advisory validation-rule hits) and nothing else a save could say. Now a hook can attach one message to the save's answer. + +- **Spec.** `CreateDataResponseSchema` and `UpdateDataResponseSchema` declare an optional, response-only `notice`: one author-written message, info severity, separate from `warnings`. When more than one call sets it, the last one wins. The key is omitted when no hook set one, and it is never stored on the record. `WriteObservabilityOptions` declares the in-process listener a hook calls to set it, `onNotice(message)`, beside `onFieldsDropped`, `onValidationAdvisory` and `onMentionsWithheld`. Like them it never crosses the wire. +- **metadata-protocol.** `createData` and `updateData` pass the listener and answer the last non-empty message as `notice`. A nested write that a hook or a flow makes inside this one carries its own options, so its notice never reaches this answer. +- **client.** `CreateDataResult` and `UpdateDataResult` declare `notice?: string`, mirroring the two schemas. +- **objectql.** `engine.update` accepts the `onNotice` option. The engine never calls it; it carries it to the hooks with the rest of the caller's bag. + +How a hook sets it: read the write's own options bag off `ctx.input.options` and call the listener when it is there. + +```ts +const opts = ctx.input.options as WriteObservabilityOptions | undefined; +opts?.onNotice?.('Sent to your manager for approval'); +``` + +A hook whose handler runs as code in the server process (registered with `registerHook`, or a declarative `handler` bound as a function) can call it today. A hook that runs in the sandbox (a `body`, including a `handler` the build lowers into one) cannot yet, and neither can a record-triggered flow: both are follow-up work. + +Nothing to do for existing clients: a client that does not read `notice` sees the same answer as before. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 2583e760c3c..973200b4e44 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -239,6 +239,13 @@ the requester's own answer only. The comment is saved with every mention, and nothing about the withheld users is stored on it. With nobody withheld, the key is absent. +When a hook on the write tells the person who saved what the save set in motion +("Sent to your manager for approval"), the answer also carries `notice`: that one +author-written message, info severity, separate from `warnings`. Show it with, or +in place of, your own success message. If more than one hook sets it, the last one +wins. A hook on a nested write never reaches it, nothing about it is stored, and +with no notice set the key is absent. + ### `PATCH /data/:object/:id` Update an existing record (partial update). @@ -249,6 +256,8 @@ Update an existing record (partial update). A `sys_comment` update answers `withheldMentions` the same way, for the mentions the update added. A mention the comment already carried is not judged again. +It answers `notice` the same way as a create, when a hook on the update set one. + ### `DELETE /data/:object/:id` Delete a record. diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index 4662915e927..e42457553e3 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -327,13 +327,15 @@ interface EngineUpdateOptions { ### WriteObservabilityOptions -The write methods (`insert` / `update`) additionally accept four **in-process** +The write methods (`insert` / `update`) additionally accept five **in-process** options. Two govern what happens when caller-supplied write fields are legally stripped from the payload before the driver write: observe the strip (`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). The third, `onValidationAdvisory`, reports the write's advisory validation-rule hits. The fourth, `onMentionsWithheld`, reports the users a comment write @mentioned who will not be notified. +The fifth, `onNotice`, is how a hook tells the person who made the write what +the save set in motion. The strips these two options cover are the engine's legal ones: @@ -360,6 +362,7 @@ interface WriteObservabilityOptions { onFieldsDropped?: (event: DroppedFieldsEvent) => void; onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void; onMentionsWithheld?: (userIds: readonly string[]) => void; + onNotice?: (message: string) => void; strictReadonlyWrites?: boolean; // refuse instead of stripping. Default: false } @@ -475,6 +478,40 @@ ids as `withheldMentions`, on the requester's own answer to that request only an least one user was withheld. Nothing is stored on the comment: a stored member would tell every reader of the comment who cannot read its thread. +#### `onNotice` — what the save set in motion + +A hook that starts something the person saving should hear about ("Sent to your +manager for approval") calls this listener with one author-written message. It is +info severity: it reports an outcome, never a problem, so it is not a validation +finding and never rides `onValidationAdvisory`. The engine never calls it. A hook +reaches it the way every member of this bag reaches a hook, on +`ctx.input.options`: a `before*` handler reads the caller's bag as passed, an +`after*` handler after `buildDriverOptions` widened it. + +{/* os:check */} +```typescript +import type { HookContext } from '@objectstack/spec/data'; +import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; + +export async function announceApproval(ctx: HookContext): Promise { + const opts = ctx.input.options as WriteObservabilityOptions | undefined; + opts?.onNotice?.('Sent to your manager for approval'); +} +``` + +The listener is often absent (a seed, a flow node, a batch or an import passes +none), so call it optionally. One message per write: a later call replaces an +earlier one, and a message that is not a non-empty string sets nothing. A write +that is refused answers its error, never a notice. A nested write that a hook or a +flow makes inside this one carries its own options, so its notice never reaches +this listener. The sandboxed `body` face (including a `handler` the build lowers +into a body) does not carry it: its `ctx.input.options` is the frozen +`{ multi, where }` projection. + +The DataProtocol's `createData` and `updateData` pass this listener and answer +the message as `notice`, on the requester's own answer to that request only and +present only when a hook set one. Nothing is stored on the record. + **Layering — this is the engine seam, and since the 2026-09-03 ruling the only one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`, @@ -486,7 +523,7 @@ declares one; none carries a `readonly` policy of its own any more — see `WriteObservabilityOptions` is a **TS-contract-level, in-process-only** bag — -all four members. It is deliberately not part of the serializable Zod options schemas: +all five members. It is deliberately not part of the serializable Zod options schemas: a function is unrepresentable in JSON Schema and cannot be serialized, so remote callers (REST, the client SDK) never receive these events; and putting `strictReadonlyWrites` in the serializable bag would let any client toggle diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index cd7b52779a7..3c6ed44e69f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -557,6 +557,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | | **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. | | **withheldMentions** | `string[]` | optional | The ids of the users this write @mentioned whose mention notification was WITHHELD by the mention recipient gate, so they will not be notified: the gate's own verdict on THIS write, ids only, never the reason. Only a `sys_comment` write fills it, and only on the requester's own answer to the request that made the write; on an update, only a mention the update added is judged. The comment was saved with every mention; the requester is told here that the named users will not be notified. Response-only: never stored on the comment (a stored member would tell every reader who cannot read the thread). Present ONLY when at least one mention was withheld; otherwise the key is omitted. | +| **notice** | `string` | optional | One author-written message telling the person who made THIS write what the save set in motion (for example "Sent to your manager for approval"), set by a hook running on the write through the write's own `onNotice` listener (`WriteObservabilityOptions`). Info severity: it reports an outcome, never a problem, so it is distinct from `warnings` (the advisory validation-rule hits) and a client shows it with, or in place of, its own success message. One message: when more than one call sets it, the LAST one wins. Present ONLY when a hook set a non-empty message on a write that succeeded; otherwise the key is omitted. A nested write a hook or a flow made inside this one carries its own options, so its notice never reaches this answer. Response-only: never stored on the record. | ### Nested Shape: `CreateDataResponse.droppedFields[number]` @@ -3043,6 +3044,7 @@ Uninstall package response | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` or a TRUE `readonlyWhen` predicate. Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. | | **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. | | **withheldMentions** | `string[]` | optional | The ids of the users this write @mentioned whose mention notification was WITHHELD by the mention recipient gate, so they will not be notified: the gate's own verdict on THIS write, ids only, never the reason. Only a `sys_comment` write fills it, and only on the requester's own answer to the request that made the write; on an update, only a mention the update added is judged. The comment was saved with every mention; the requester is told here that the named users will not be notified. Response-only: never stored on the comment (a stored member would tell every reader who cannot read the thread). Present ONLY when at least one mention was withheld; otherwise the key is omitted. | +| **notice** | `string` | optional | One author-written message telling the person who made THIS write what the save set in motion (for example "Sent to your manager for approval"), set by a hook running on the write through the write's own `onNotice` listener (`WriteObservabilityOptions`). Info severity: it reports an outcome, never a problem, so it is distinct from `warnings` (the advisory validation-rule hits) and a client shows it with, or in place of, its own success message. One message: when more than one call sets it, the LAST one wins. Present ONLY when a hook set a non-empty message on a write that succeeded; otherwise the key is omitted. A nested write a hook or a flow made inside this one carries its own options, so its notice never reaches this answer. Response-only: never stored on the record. | ### Nested Shape: `UpdateDataResponse.droppedFields[number]` diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 65a6f1ce064..d009855db44 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -344,6 +344,7 @@ "api/CreateDataResponse:object", "api/CreateDataResponse:record", "api/CreateDataResponse:warnings", + "api/CreateDataResponse:withheldMentions", "api/CreateFlowRequest:_lock", "api/CreateFlowRequest:_lockDocsUrl", "api/CreateFlowRequest:_lockReason", @@ -1741,6 +1742,7 @@ "api/UpdateDataResponse:object", "api/UpdateDataResponse:record", "api/UpdateDataResponse:warnings", + "api/UpdateDataResponse:withheldMentions", "api/UpdateFlowRequest:definition", "api/UpdateFlowRequest:name", "api/UpdateFlowResponse:data", diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index cccc1ca795c..8be469b2a4a 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -146,9 +146,9 @@ export interface WriteObservabilityOptions { * * Absent is normal: a write no person is waiting on (a seed, a flow node, a * batch, an import) passes no listener, so a hook always calls it - * optionally. The sandboxed `body` face does not carry it: its - * `ctx.input.options` is the frozen `{ multi, where }` projection, never the - * caller's bag. + * optionally. The sandboxed `body` face (including a `handler` the build + * lowers into a body) does not carry it: its `ctx.input.options` is the + * frozen `{ multi, where }` projection, never the caller's bag. * * One message per write: a later call REPLACES an earlier one, so the last * call before the write answers wins, and a message that is not a non-empty