Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/28000-save-notice-answer.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand Down
41 changes: 39 additions & 2 deletions content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
}

Expand Down Expand Up @@ -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<void> {
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.

<Callout type="warn">
**Layering — this is the engine seam, and since the 2026-09-03 ruling the only
one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`,
Expand All @@ -486,7 +523,7 @@ declares one; none carries a `readonly` policy of its own any more — see

<Callout type="warn">
`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
Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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]`

Expand Down Expand Up @@ -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]`

Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,14 @@ export interface CreateDataResult<T = any> {
* comment. Present only when at least one mention was withheld.
*/
withheldMentions?: string[];
/**
* [#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;
}

/**
Expand Down Expand Up @@ -560,6 +568,14 @@ export interface UpdateDataResult<T = any> {
* comment. Present only when at least one mention was withheld.
*/
withheldMentions?: string[];
/**
* [#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;
}

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13743,10 +13743,17 @@ export class ObjectStackProtocolImplementation implements
// nor any other request's answer ever carries them, and nothing is
// stored on the comment.
const withheldMentions = new Set<string>();
// [#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); },
onMentionsWithheld: (ids: readonly string[]) => { for (const id of ids) withheldMentions.add(id); },
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);
Expand All @@ -13765,6 +13772,7 @@ export class ObjectStackProtocolImplementation implements
...(dropped.length > 0 ? { droppedFields: dropped } : {}),
...(warnings.length > 0 ? { warnings } : {}),
...(withheldMentions.size > 0 ? { withheldMentions: [...withheldMentions] } : {}),
...(notice !== undefined ? { notice } : {}),
};
}

Expand Down Expand Up @@ -13917,6 +13925,9 @@ export class ObjectStackProtocolImplementation implements
// answered as `withheldMentions` (see `createData`).
const withheldMentions = new Set<string>();
opts.onMentionsWithheld = (ids: readonly string[]) => { for (const id of ids) withheldMentions.add(id); };
// [#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.
//
Expand Down Expand Up @@ -13994,6 +14005,7 @@ export class ObjectStackProtocolImplementation implements
...(dropped.length > 0 ? { droppedFields: dropped } : {}),
...(warnings.length > 0 ? { warnings } : {}),
...(withheldMentions.size > 0 ? { withheldMentions: [...withheldMentions] } : {}),
...(notice !== undefined ? { notice } : {}),
};
}

Expand Down
11 changes: 8 additions & 3 deletions packages/objectql/src/engine-unknown-option.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => {
await engine.update('task', { title: 'A5' }, { where: { id: a.id }, onMentionsWithheld: () => {} } 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.
Expand Down Expand Up @@ -263,9 +267,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => {
// 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.
// `onMentionsWithheld` (#27703) is the fourth: the engine only carries
// it to the `after*` hooks, on `ctx.input.options`.
expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'strictReadonlyWrites', ...PASSTHROUGH]);
// `onMentionsWithheld` (#27703) is the fourth and `onNotice` (#28000)
// the fifth: the engine only carries them to the hooks, on
// `ctx.input.options`.
expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'onNotice', 'strictReadonlyWrites', ...PASSTHROUGH]);
expectSetMatches('delete', EngineDeleteOptionsSchema as any, PASSTHROUGH);
expectSetMatches('count', EngineCountOptionsSchema as any, []);
expectSetMatches('aggregate', EngineAggregateOptionsSchema as any, []);
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,7 @@ const ENGINE_FIND_OPTION_KEYS: ReadonlySet<string> = new Set([
...ENGINE_DRIVER_PASSTHROUGH_KEYS,
]);
const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet<string> = new Set([
'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'strictReadonlyWrites',
'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'onNotice', 'strictReadonlyWrites',
...ENGINE_DRIVER_PASSTHROUGH_KEYS,
]);
const ENGINE_DELETE_OPTION_KEYS: ReadonlySet<string> = new Set([
Expand Down
Loading
Loading