Repository navigation
Commit 7811c3f
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
- .changeset
- content/docs
- api
- kernel/contracts
- references/api
- packages
- client/src
- metadata-protocol/src
- objectql/src
- plugins/plugin-audit/src
- spec
- authorable-surface
- src
- api
- contracts
- data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
235 | 242 | | |
236 | 243 | | |
237 | 244 | | |
238 | 245 | | |
239 | 246 | | |
240 | 247 | | |
241 | 248 | | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
242 | 252 | | |
243 | 253 | | |
244 | 254 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
327 | 327 | | |
328 | 328 | | |
329 | 329 | | |
330 | | - | |
| 330 | + | |
331 | 331 | | |
332 | 332 | | |
333 | 333 | | |
334 | 334 | | |
| 335 | + | |
| 336 | + | |
335 | 337 | | |
336 | 338 | | |
337 | 339 | | |
| |||
357 | 359 | | |
358 | 360 | | |
359 | 361 | | |
| 362 | + | |
360 | 363 | | |
361 | 364 | | |
362 | 365 | | |
| |||
454 | 457 | | |
455 | 458 | | |
456 | 459 | | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
457 | 478 | | |
458 | 479 | | |
459 | 480 | | |
| |||
465 | 486 | | |
466 | 487 | | |
467 | 488 | | |
468 | | - | |
| 489 | + | |
469 | 490 | | |
470 | 491 | | |
471 | 492 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
556 | 556 | | |
557 | 557 | | |
558 | 558 | | |
| 559 | + | |
559 | 560 | | |
560 | 561 | | |
561 | 562 | | |
| |||
3041 | 3042 | | |
3042 | 3043 | | |
3043 | 3044 | | |
| 3045 | + | |
3044 | 3046 | | |
3045 | 3047 | | |
3046 | 3048 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
490 | 490 | | |
491 | 491 | | |
492 | 492 | | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
493 | 501 | | |
494 | 502 | | |
495 | 503 | | |
| |||
544 | 552 | | |
545 | 553 | | |
546 | 554 | | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
547 | 563 | | |
548 | 564 | | |
549 | 565 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13736 | 13736 | | |
13737 | 13737 | | |
13738 | 13738 | | |
| 13739 | + | |
| 13740 | + | |
| 13741 | + | |
| 13742 | + | |
| 13743 | + | |
| 13744 | + | |
| 13745 | + | |
13739 | 13746 | | |
13740 | 13747 | | |
13741 | 13748 | | |
| 13749 | + | |
13742 | 13750 | | |
13743 | 13751 | | |
13744 | 13752 | | |
| |||
13756 | 13764 | | |
13757 | 13765 | | |
13758 | 13766 | | |
| 13767 | + | |
13759 | 13768 | | |
13760 | 13769 | | |
13761 | 13770 | | |
| |||
13904 | 13913 | | |
13905 | 13914 | | |
13906 | 13915 | | |
| 13916 | + | |
| 13917 | + | |
| 13918 | + | |
| 13919 | + | |
13907 | 13920 | | |
13908 | 13921 | | |
13909 | 13922 | | |
| |||
13980 | 13993 | | |
13981 | 13994 | | |
13982 | 13995 | | |
| 13996 | + | |
13983 | 13997 | | |
13984 | 13998 | | |
13985 | 13999 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
196 | 196 | | |
197 | 197 | | |
198 | 198 | | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
199 | 203 | | |
200 | 204 | | |
201 | 205 | | |
| |||
259 | 263 | | |
260 | 264 | | |
261 | 265 | | |
262 | | - | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
263 | 269 | | |
264 | 270 | | |
265 | 271 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
716 | 716 | | |
717 | 717 | | |
718 | 718 | | |
719 | | - | |
| 719 | + | |
720 | 720 | | |
721 | 721 | | |
722 | 722 | | |
| |||
0 commit comments