Skip to content

Commit 7811c3f

Browse files
feat(spec): a comment's author is told which mentioned users will not be notified, and a mention added by editing notifies (#28174)
domain:spec#2 · os-tesla · session_01L9qibxkM6EsYLMNr5Vc8zP <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoCo2Sh8f9RPpXDLsaSokbvc)_ Fixes #27703 Clause-②: yes Metadata-protocol: added Skills-docs: none — a response-only answer member and an in-process listener, nothing a metadata author writes; the docs teach it here (`content/docs/api/data-api.mdx`, `content/docs/kernel/contracts/data-engine.mdx`) Ruling-ref: 6523848208 ## 维护者速读 - 改了什么:评论(`sys_comment`)的新建和编辑应答多了一个只读成员 `withheldMentions`,列出这次 @ 了、但被提及闸门扣下通知的人的 id(多半因为读不到评论或记录);编辑评论时新加的 @ 现在也会通知,通知以编辑者为发起人。客户端 SDK 的 `CreateDataResult` / `UpdateDataResult` 同步声明这个键。 - 为什么改:以前闸门只在服务器日志里记一笔,作者以为对方已被通知,其实没有;编辑时加的 @ 谁也不通知。 - 风险与回滚:只告诉发起这次请求的人本人,只给 id、不给原因,不落库;谁能收到通知的判定没变。回滚即 revert 本 PR,不读这个键的客户端不受影响。 - 先看:`packages/plugins/plugin-audit/src/audit-writers.ts` 的 `writeCommentMentions`(更新时只判新增的 @)、`packages/metadata-protocol/src/protocol.ts` 的 `createData` / `updateData`、`packages/spec/src/contracts/data-engine.ts` 的 `onMentionsWithheld`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-audit exec vitest run src/mention-recipient-gate.integration.test.ts`,预期 28 条全绿。 - 需要你做的:无,席位落地。 ## What changes The `@mention` recipient gate withholds a notification from a mentioned user who cannot read the comment or its record. It only logged that, so the author believed the person had been told. As ruled (6523848208, letter A, disclosure accepted), the requester's own create and update answer now names who will not be notified, from the gate's own verdict: ids only, never the reason, never stored. **Transport.** No new path was built. The engine already hands every `after*` hook the caller's own options bag on `ctx.input.options` (the PHASE rule in `hook.zod.ts`: `buildDriverOptions` widens the bag and never strips a key), and `createData` / `updateData` already collect in-process listeners from it (`onFieldsDropped`, `onValidationAdvisory`). This PR adds one more listener of that family. - **Spec (`packages/spec`).** - `CreateDataResponseSchema` and `UpdateDataResponseSchema` declare an optional `withheldMentions: string[]`, with one shared description (withheld by the mention gate, response-only, ids only, filled only by a `sys_comment` write and only on the requester's own answer, omitted when empty, never stored). It is declared generically, the way `warnings` is, because the protocol types one create answer and one update answer for every object, not one per object. `CloneDataResponseSchema` does not get it: the ruling names create and update only. - `WriteObservabilityOptions` (`contracts/data-engine.ts`) declares `onMentionsWithheld?: (userIds: readonly string[]) => void`. Its docblock says the engine never calls it, that it reaches the mention writer on `ctx.input.options`, and that it never crosses the wire. `HookUpdateOptions` names it among the members it inherits. - `content/docs/references/api/protocol.mdx` regenerated (`gen:docs`). - **plugin-audit.** - `mention-recipient-gate.ts`: `MentionGateInput.onWithheld(userId)` is optional. The gate's existing `withhold()` calls it for every withheld recipient that resolved to a user, whatever the reason. The reason stays in the log line. Who the gate admits is unchanged: the callback is called after the verdict and a throwing callback is swallowed. - `audit-writers.ts`: `writeCommentMentions` is registered on `afterUpdate` as well as `afterInsert`. On an update it judges only the entries the written row's `mentions` gained over `ctx.previous`, so a kept mention is never re-judged or re-notified. After the gate answers, it calls the write's own `onMentionsWithheld` once with the withheld ids (deduplicated, in the order named), and a throwing listener is logged and ignored. The `mentions` parse moved into a `parseCommentMentions` helper so both sides of the update read it one way. - On an edit, the actor is the editor (`ctx.session.userId`), who may be a parent-record editor rather than the author: they are named in the title (resolved from `sys_user` via `resolveActorSnapshot`) and are never notified themselves. A create keeps the comment's `author_id` / `author_name`. - **metadata-protocol.** `createData` and `updateData` pass `onMentionsWithheld` and answer `withheldMentions` omit-when-empty. The set is local to the request, so no other request's answer can carry it. - **client.** `CreateDataResult` and `UpdateDataResult` in `packages/client/src/index.ts` declare `withheldMentions?: string[]`, mirroring the two schemas. - **objectql.** `ENGINE_UPDATE_OPTION_KEYS` admits `onMentionsWithheld`, because `engine.update` refuses unknown option keys. `insert` has no such allowlist. The drift pin in `engine-unknown-option.test.ts` names the new member. - **Docs.** `content/docs/api/data-api.mdx` (POST / PATCH) and `content/docs/kernel/contracts/data-engine.mdx` (`WriteObservabilityOptions`, a new `onMentionsWithheld` section). - **Changeset.** `.changeset/27703-withheld-mentions-answer.md`: `@objectstack/spec`, `@objectstack/plugin-audit`, `@objectstack/metadata-protocol`, `@objectstack/objectql` and `@objectstack/client`, all `minor` (`Metadata-protocol: added` takes at least minor on every package the diff moves), with both declaration lines. **Requester-only and never stored, and how each is pinned.** - The listener exists only on the options of the request that wrote the comment. A nested write that a hook makes carries its own options. Pins: `protocol-withheld-mentions-answer.test.ts` "a nested write … adds nothing to the outer answer" and "each request answers only its own", and plugin-audit's "a comment written by a hook inside another write never reaches the outer write's listener". - Nothing is stored. `sys_comment` declares no such field, the stored row read back as system carries only what the author wrote, and CAROL (a reader of the thread) reads the same row with no verdict about BOB. Pins: plugin-audit "nothing is stored", and objectql "nothing is stored: the persisted row carries no member beyond what was written". - The listener is a function in an in-process bag. A function cannot be serialized, so no REST or SDK caller can pass one or receive one. The only thing on the wire is the answer member. ## Tests The first round ran on `9a38b6c0` (and `eb6b1469`, which differs only by a merge of `origin/main` touching no file of this diff). The patch round after the contract review (`be32a8da`, `51450e76`) changed the describe and docblock wording, added the client mirror and the editor-as-actor behaviour; its runs are listed as such below. - **New contract pins.** `packages/spec/src/api/write-answer-withheld-mentions.test.ts` covers four things. The member is accepted on both answers and an answer without it stays legal. Anything but an array of ids is refused (no per-user objects, no reason). The clone answer does not declare it. The listener type takes ids and may be absent. - **Transport, real engine + real protocol.** `packages/objectql/src/protocol-withheld-mentions-answer.test.ts` uses a real `ObjectQL` and the real `ObjectStackProtocolImplementation`. The one stand-in is the writer, an `after*` hook. It pins five things. `createData` / `updateData` answer the hook's ids, deduplicated, and the answers conform. The key is absent when nothing was reported. Nothing is persisted. A nested write's report never reaches the outer answer. A second request is not told the first one's ids. - **The verdict, real gate.** `mention-recipient-gate.integration.test.ts` runs the kernel, SQLite, `AuditPlugin` and the real recipient gate. It gains 11 cases: - a create tells its own listener the withheld ids, in the order named, while the reader is still notified; - a user named by email is reported by id, once; - the listener is never called when nobody is withheld, for the author, for an unknown address or for an audience expansion; - nothing is stored, and a reader of the thread sees nothing about anyone else; - a hook's nested comment never reaches the outer listener; - an edit that adds mentions notifies the new reader and reports only the withheld new ones; - a kept mention is not re-judged, and a body-only edit judges nothing; - an editor who is not the author (CAROL edits ALICE's comment) is the actor: ALICE is notified, CAROL is not, the event's `actorId` is CAROL and the title names her (patch round); - a throwing listener breaks neither the write nor the notification; - the gate's `onWithheld` gets ids only (none for an unknown email or a `role:` entry); - a throwing `onWithheld` changes no verdict. - **An existing pin updated as a consequence of the change.** `audit-writers.test.ts` "emits only to the recipients the gate admits" pinned the gate input exactly. It now also expects `onWithheld: expect.any(Function)`. - `engine-unknown-option.test.ts`: `onMentionsWithheld` passes on `update`, and the drift pin names it. <details><summary>Package suites and typecheck</summary> - `pnpm --filter @objectstack/plugin-audit test`: Test Files 67/67, Tests 1089/1089 (the first run caught the `audit-writers.test.ts` pin above, which was then fixed, and the file re-ran at 118/118). - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 284 passed, 4 skipped; Tests 21859 passed, 26 skipped. - `pnpm --filter @objectstack/objectql test`: Test Files 495/495, Tests 9631/9631. - `pnpm --filter @objectstack/spec test`: Test Files 757/757, Tests 22520 passed, 1 todo. - `typecheck` for `@objectstack/spec`, `@objectstack/plugin-audit`, `@objectstack/metadata-protocol` and `@objectstack/objectql`: all exit 0 (lock VERDICT command-exit 0), including each package's `check:test-typecheck`. - Patch round, on `51450e76`: `pnpm --filter @objectstack/plugin-audit exec vitest run` Test Files 67/67, Tests 1090/1090; `tsc --noEmit` for `@objectstack/client` and `@objectstack/plugin-audit` exit 0; the spec contract test 7/7; the objectql transport and option tests 32/32. - On `9a38b6c0`: the spec contract tests 16/16, the objectql transport and option tests 32/32, and plugin-audit's gate integration and `audit-writers.test.ts` 145/145. </details> <details><summary>Ablations (one-shot, through <code>scripts/ablation-replace.mjs</code>, each restored to blob == HEAD)</summary> | # | Mutation | Expected | Observed | |:--|:--|:--|:--| | A1 | writer: `onMentionsWithheld(withheld);` replaced by a no-op | listener pins red | 4 failed / 27 (create, email, both edit cases) | | A1b | writer: the update's added-only filter removed | edit pins red | 2 failed / 27 (both edit cases) | | A1c | writer: the `afterUpdate` registration deleted | added-mention pin red | 1 failed / 27 | | A2 | `createData`: answer key renamed (`ablatedA2`); metadata-protocol rebuilt, marker proven in `dist/` | transport pins red | 2 failed / 7; restore leg rebuilt, marker proven absent, 7/7 | | A3 | `createData`: the per-request set hoisted to a process-wide one; rebuilt, marker proven in `dist/` | per-request pins red | 3 failed / 7 (control, nested, second-request); restore leg rebuilt, absent, 7/7 | | A4 | writer (patch round): the edit's actor reverted to `row.author_id` | editor pin red | 1 failed / 28 (the editor case) | plugin-audit imports `AuditPlugin` from source, so A1–A1c needed no build. The objectql transport test resolves `@objectstack/metadata-protocol` through `dist/`, so A2 and A3 rebuilt it on both legs and checked them with `ablation-dist-preflight.mjs`. </details> ## Acceptance notes - **Entries that resolve to no id are not listed.** `withheldMentions` holds the ids the gate judged. A token without `@` is taken as an id as written, so an id that names no user is judged, withheld, and listed like any other. A mention of an unknown email, or an audience expansion (`role:` / `team:` / `owner_of:`), resolves to no id and is withheld with nothing to give back. The picker only offers existing users, so the console does not produce these. - **Failures outside the gate's verdict are not reported.** These are: no `messaging` service composed, no gate composed (fail closed, warned once), the gate itself throwing, and a `messaging.emit` failing for an admitted user. The ruling scopes the member to the gate's withheld verdicts, and each of these failures is logged at `warn` as before. - **Batch, clone and flow writes do not answer it.** Only `createData` and `updateData` pass the listener, as ruled. A comment created through `createManyData` / `batchData`, cloned, or written by a flow still notifies its readers and logs what it withheld. Its caller is not told. - **The edit's actor.** On an edit, the editor is the actor, not the comment's author: `sys_comment` admits an edit by the author or by a holder of EDIT on the parent record, so the editor may not be the author. The editor is never notified and is named in the title; the author, if mentioned, is notified like anyone else. - **The ui half** (read `withheldMentions` in the three Discussion write callbacks, name the users in one sentence, mention chips) is the seat's follow-up `domain:ui` card once this key is on `main`. ## Verification **Patch round, head `51450e76`.** Re-derived against the patched diff: the same 113 families. All 113 re-ran on `51450e76`, each with stdin from `/dev/null` and its exit code recorded before any pipe, reconciled with `--ran`: **113 derived, 112 run, 1 NOT MEASURED, 0 unrun, 0 red.** The one NOT MEASURED is `spec check:skill-examples` (exit 3, client SDK dist prerequisite), CI's. `pnpm --filter @objectstack/spec check:generated` is green after `gen:docs` regenerated `protocol.mdx` for the new describe wording. **First round, head `9a38b6c0`.** Gates were derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` against this diff (113 families). Each ran with stdin from `/dev/null`, and each exit code was recorded before any pipe. The record is reconciled with `--ran`: **113 derived, 110 run, 3 NOT MEASURED, 0 unrun, 0 red.** - NOT MEASURED, prerequisite not met (exit 3, the gate refusing before it reads anything). These are CI's: - `check:i18n` needs the CLI's build closure; - `check:dual-build-cjs-loads` needs a whole-repo `pnpm build`; - `spec check:skill-examples` needs the client SDK dist. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 because its pinned positive control `621a4876` was not in this shallow clone. After fetching that one commit it re-ran green (48 cases), and the record carries the re-run. - `pnpm --filter @objectstack/spec check:generated`: all 14 artefacts up to date on `9a38b6c0`, after a rebuild of the merged source. The two regenerated artefacts are `content/docs/references/api/protocol.mdx` and `packages/spec/authorable-surface/api.json` (two response keys). - Full dogfood: not owed (no `!` in the title, no `(narrowing)` arm). - The full farm is CI's. The PR's check-runs are the evidence for every family outside the derived set. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxkM6EsYLMNr5Vc8zP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d36669 commit 7811c3f

18 files changed

Lines changed: 643 additions & 31 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-audit': minor
4+
'@objectstack/metadata-protocol': minor
5+
'@objectstack/objectql': minor
6+
'@objectstack/client': minor
7+
---
8+
9+
feat(spec): a comment's author is told which @mentioned users will not be notified, and a mention added by editing is notified
10+
11+
Clause-②: yes
12+
Metadata-protocol: added
13+
14+
The `@mention` recipient gate withholds a notification from a mentioned user who cannot read the comment or its record. Until now it only logged that, so the author believed the person had been told. The comment still saves with every mention; now the requester's own answer says who will not be notified.
15+
16+
- **Spec.** `CreateDataResponseSchema` and `UpdateDataResponseSchema` declare an optional, response-only `withheldMentions`: the ids of the users this write @mentioned whose notification the mention gate withheld. Ids only, never the reason. Only a `sys_comment` write fills it, only on the requester's own answer to that request, and the key is omitted when nobody was withheld. It is not a field of `sys_comment` and is never stored, since a stored member would tell every reader who cannot read the thread. `WriteObservabilityOptions` declares the in-process listener that carries it, `onMentionsWithheld(userIds)`, beside `onFieldsDropped` and `onValidationAdvisory`. Like them it never crosses the wire.
17+
- **plugin-audit.** The mention writer reports the gate's own verdict to that listener, which it reads off its hook's `ctx.input.options` (the write's own options bag). Readability is not derived a second time, and who the gate admits is unchanged. The writer now also runs on `sys_comment` `afterUpdate`, so a mention added by editing a comment is judged and notified like one written with it. An edit judges only the mentions it added; a mention the comment already carried is not judged or notified again. The editor, who may be a parent-record editor rather than the author, is the actor of an edit's notifications: they are named in the title and never notified themselves. `MentionGateInput` takes an optional `onWithheld(userId)` callback.
18+
- **metadata-protocol.** `createData` and `updateData` pass the listener and answer the ids as `withheldMentions`. A nested write that a hook or a flow makes inside this one carries its own options, so its verdicts never reach this answer.
19+
- **client.** `CreateDataResult` and `UpdateDataResult` declare `withheldMentions?: string[]`, mirroring the two schemas.
20+
- **objectql.** `engine.update` accepts the `onMentionsWithheld` option. The engine never calls it; it carries it to the `after*` hooks with the rest of the caller's bag.
21+
22+
Nothing to do for existing clients: a client that does not read `withheldMentions` sees the same answer as before.

‎content/docs/api/data-api.mdx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,23 @@ Create a new record.
232232
**Body**: `{ name: "Acme Corp", industry: "Technology" }`
233233
**Response**: `{ object: "account", id: "1", record: { ... } }`
234234

235+
A `sys_comment` create that @mentions users the server will not notify (the
236+
mention gate withheld them, most often because they cannot read the comment or
237+
its record) also answers `withheldMentions`: those users' ids, with no reason, on
238+
the requester's own answer only. The comment is saved
239+
with every mention, and nothing about the withheld users is stored on it. With
240+
nobody withheld, the key is absent.
241+
235242
### `PATCH /data/:object/:id`
236243

237244
Update an existing record (partial update).
238245

239246
**Body**: `{ industry: "Healthcare" }`
240247
**Response**: `{ object: "account", id: "1", record: { ... } }`
241248

249+
A `sys_comment` update answers `withheldMentions` the same way, for the mentions
250+
the update added. A mention the comment already carried is not judged again.
251+
242252
### `DELETE /data/:object/:id`
243253

244254
Delete a record.

‎content/docs/kernel/contracts/data-engine.mdx‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,11 +327,13 @@ interface EngineUpdateOptions {
327327

328328
### WriteObservabilityOptions
329329

330-
The write methods (`insert` / `update`) additionally accept three **in-process**
330+
The write methods (`insert` / `update`) additionally accept four **in-process**
331331
options. Two govern what happens when caller-supplied write fields are legally
332332
stripped from the payload before the driver write: observe the strip
333333
(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). The
334334
third, `onValidationAdvisory`, reports the write's advisory validation-rule hits.
335+
The fourth, `onMentionsWithheld`, reports the users a comment write @mentioned who
336+
will not be notified.
335337

336338
The strips these two options cover are the engine's legal ones:
337339

@@ -357,6 +359,7 @@ on `insert` too since the 2026-09-03 ruling — see **On `insert`** below.
357359
interface WriteObservabilityOptions {
358360
onFieldsDropped?: (event: DroppedFieldsEvent) => void;
359361
onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void;
362+
onMentionsWithheld?: (userIds: readonly string[]) => void;
360363
strictReadonlyWrites?: boolean; // refuse instead of stripping. Default: false
361364
}
362365

@@ -454,6 +457,24 @@ The DataProtocol's `createData`, `updateData` and `cloneData` pass this listener
454457
and answer the hits as `warnings` (`ValidateDataIssue` entries carrying `rule`
455458
and `severity`), present only when there is at least one hit.
456459

460+
#### `onMentionsWithheld` — who a comment's @mentions will not reach
461+
462+
A `sys_comment` write notifies each user it @mentions, but only a user who can
463+
read the comment and its record: the mention recipient gate withholds the rest.
464+
This listener is told the ids of the users the gate withheld on the write the
465+
options were passed to, whatever its reason: ids only, never the reason. The engine never calls it.
466+
It reaches the mention writer the way every member of this bag reaches an
467+
`after*` hook, on `ctx.input.options`, and the writer calls it once per written
468+
comment that withheld anyone. On an `update`, only a mention the update added is
469+
judged; a mention the comment already carried was judged when it was written. A
470+
nested write that a hook or a flow makes inside this one carries its own options,
471+
so its verdicts never reach this listener.
472+
473+
The DataProtocol's `createData` and `updateData` pass this listener and answer the
474+
ids as `withheldMentions`, on the requester's own answer to that request only and present only when at
475+
least one user was withheld. Nothing is stored on the comment: a stored member
476+
would tell every reader of the comment who cannot read its thread.
477+
457478
<Callout type="warn">
458479
**Layering — this is the engine seam, and since the 2026-09-03 ruling the only
459480
one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`,
@@ -465,7 +486,7 @@ declares one; none carries a `readonly` policy of its own any more — see
465486

466487
<Callout type="warn">
467488
`WriteObservabilityOptions` is a **TS-contract-level, in-process-only** bag —
468-
all three members. It is deliberately not part of the serializable Zod options schemas:
489+
all four members. It is deliberately not part of the serializable Zod options schemas:
469490
a function is unrepresentable in JSON Schema and cannot be serialized, so remote
470491
callers (REST, the client SDK) never receive these events; and putting
471492
`strictReadonlyWrites` in the serializable bag would let any client toggle

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
556556
| **record** | `Record<string, any>` | ✅ | The created record, including server-generated fields (created_at, owner). |
557557
| **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. |
558558
| **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. |
559+
| **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. |
559560

560561
### Nested Shape: `CreateDataResponse.droppedFields[number]`
561562

@@ -3041,6 +3042,7 @@ Uninstall package response
30413042
| **record** | `Record<string, any>` | ✅ | Updated record |
30423043
| **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`. |
30433044
| **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. |
3045+
| **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. |
30443046

30453047
### Nested Shape: `UpdateDataResponse.droppedFields[number]`
30463048

‎packages/client/src/index.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,14 @@ export interface CreateDataResult<T = any> {
490490
* person who made it. Present only when at least one rule hit.
491491
*/
492492
warnings?: ValidateDataIssue[];
493+
/**
494+
* [#27703] The ids of the users this write @mentioned whose mention
495+
* notification the mention gate withheld: they will not be notified. Ids only,
496+
* never the reason. Only a `sys_comment` write fills it, only on the answer to
497+
* the request that made the write, and nothing about it is stored on the
498+
* comment. Present only when at least one mention was withheld.
499+
*/
500+
withheldMentions?: string[];
493501
}
494502

495503
/**
@@ -544,6 +552,14 @@ export interface UpdateDataResult<T = any> {
544552
* person who made it. Present only when at least one rule hit.
545553
*/
546554
warnings?: ValidateDataIssue[];
555+
/**
556+
* [#27703] The ids of the users this write @mentioned whose mention
557+
* notification the mention gate withheld: they will not be notified. Ids only,
558+
* never the reason. Only a `sys_comment` write fills it, only on the answer to
559+
* the request that made the write, and nothing about it is stored on the
560+
* comment. Present only when at least one mention was withheld.
561+
*/
562+
withheldMentions?: string[];
547563
}
548564

549565
/**

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13736,9 +13736,17 @@ export class ObjectStackProtocolImplementation implements
1373613736
// — the engine reports them through the write's own options, so a
1373713737
// nested write a hook makes inside this one never adds to them.
1373813738
const warnings: ValidationAdvisoryEvent[] = [];
13739+
// [#27703] The mentioned users the mention recipient gate withheld on
13740+
// THIS write, answered as `withheldMentions` to this request alone: the
13741+
// mention writer reads the listener off its `after*` hook's
13742+
// `ctx.input.options`, which is this bag, so neither a nested write
13743+
// nor any other request's answer ever carries them, and nothing is
13744+
// stored on the comment.
13745+
const withheldMentions = new Set<string>();
1373913746
const opts: any = {
1374013747
onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); },
1374113748
onValidationAdvisory: (e: ValidationAdvisoryEvent) => { warnings.push(e); },
13749+
onMentionsWithheld: (ids: readonly string[]) => { for (const id of ids) withheldMentions.add(id); },
1374213750
};
1374313751
if (request.context !== undefined) opts.context = request.context;
1374413752
const result = await this.engine.insert(request.object, request.data, opts);
@@ -13756,6 +13764,7 @@ export class ObjectStackProtocolImplementation implements
1375613764
record: result,
1375713765
...(dropped.length > 0 ? { droppedFields: dropped } : {}),
1375813766
...(warnings.length > 0 ? { warnings } : {}),
13767+
...(withheldMentions.size > 0 ? { withheldMentions: [...withheldMentions] } : {}),
1375913768
};
1376013769
}
1376113770

@@ -13904,6 +13913,10 @@ export class ObjectStackProtocolImplementation implements
1390413913
// (see `createData`).
1390513914
const warnings: ValidationAdvisoryEvent[] = [];
1390613915
opts.onValidationAdvisory = (e: ValidationAdvisoryEvent) => { warnings.push(e); };
13916+
// [#27703] The mentions this update ADDED that the gate withheld,
13917+
// answered as `withheldMentions` (see `createData`).
13918+
const withheldMentions = new Set<string>();
13919+
opts.onMentionsWithheld = (ids: readonly string[]) => { for (const id of ids) withheldMentions.add(id); };
1390713920
// [#6479] At THIS ingress the row is the one the caller named — `request.id`,
1390813921
// the path `:id` — and nothing in the payload gets to move it.
1390913922
//
@@ -13980,6 +13993,7 @@ export class ObjectStackProtocolImplementation implements
1398013993
record: result,
1398113994
...(dropped.length > 0 ? { droppedFields: dropped } : {}),
1398213995
...(warnings.length > 0 ? { warnings } : {}),
13996+
...(withheldMentions.size > 0 ? { withheldMentions: [...withheldMentions] } : {}),
1398313997
};
1398413998
}
1398513999

‎packages/objectql/src/engine-unknown-option.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => {
196196
await engine.update('task', { title: 'A4' }, { where: { id: a.id }, onValidationAdvisory: () => {} } as any);
197197
});
198198

199+
it('onMentionsWithheld passes on update (contract-declared write observability, #27703)', async () => {
200+
await engine.update('task', { title: 'A5' }, { where: { id: a.id }, onMentionsWithheld: () => {} } as any);
201+
});
202+
199203
it('searchFields passes on find (read by the $search expansion)', async () => {
200204
// Legality pin only — the $or/$contains expansion itself is covered by
201205
// the search-filter tests; this mock driver does not evaluate it.
@@ -259,7 +263,9 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => {
259263
// this list must. Spelling it here is what keeps the engine from
260264
// rejecting a key the TS contract declares. [#22726]
261265
// `onValidationAdvisory` is the third such member.
262-
expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'strictReadonlyWrites', ...PASSTHROUGH]);
266+
// `onMentionsWithheld` (#27703) is the fourth: the engine only carries
267+
// it to the `after*` hooks, on `ctx.input.options`.
268+
expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'strictReadonlyWrites', ...PASSTHROUGH]);
263269
expectSetMatches('delete', EngineDeleteOptionsSchema as any, PASSTHROUGH);
264270
expectSetMatches('count', EngineCountOptionsSchema as any, []);
265271
expectSetMatches('aggregate', EngineAggregateOptionsSchema as any, []);

‎packages/objectql/src/engine.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ const ENGINE_FIND_OPTION_KEYS: ReadonlySet<string> = new Set([
716716
...ENGINE_DRIVER_PASSTHROUGH_KEYS,
717717
]);
718718
const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet<string> = new Set([
719-
'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'strictReadonlyWrites',
719+
'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'strictReadonlyWrites',
720720
...ENGINE_DRIVER_PASSTHROUGH_KEYS,
721721
]);
722722
const ENGINE_DELETE_OPTION_KEYS: ReadonlySet<string> = new Set([

0 commit comments

Comments
 (0)