Skip to content

feat(spec): a hook can tell the person who saved a record what the save set in motion - #28247

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-qo9o0z
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-qo9o0z

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

domain:spec#2 · os-tesla · session_01L9qibx5r5nhDMwpRs4SL6D

Requested by Tesla · project thread

Fixes #28000

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up #28253 (skills/objectstack-data/rules/lifecycle.md, to land with the server half #28251)

维护者速读

  • 改了什么:单条记录的新建和更新应答多了一个只读成员 notice:一条由作者写的提示,告诉保存的人这次保存触发了什么(例如「已提交经理审批」),信息级,和 warnings 分开。钩子通过本次写入自带的 onNotice 监听器设置它,createData / updateData 把最后一条非空消息答回去;客户端 SDK 同步声明这个键。
  • 为什么改:钩子或记录触发的流程没法告诉用户保存启动了什么,HotCRM 只能在详情页挂一个 record:alert 横幅来补。
  • 风险与回滚:只是应答里新增的可选键,只回给发起这次请求的人,不落库;嵌套写入的提示不会串到外层。不读这个键的客户端毫无变化,回滚即 revert 本 PR。
  • 先看:packages/spec/src/contracts/data-engine.ts 的 onNotice、packages/spec/src/api/protocol.zod.ts 的 WRITE_ANSWER_NOTICE_DESCRIPTION、packages/metadata-protocol/src/protocol.ts 的 createData / updateData。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/protocol-save-notice-answer.test.ts,预期 14 条全绿。
  • 需要你做的:无,席位落地。

What changes

A beforeInsert / beforeUpdate / afterInsert / afterUpdate hook could not tell the person saving what the save set in motion. The single-record write answer carried warnings (advisory validation-rule hits) and nothing else a save could say, so HotCRM shows a record:alert banner after the fact. This is the spec half of #28000: the answer key, and the contract a hook uses to set it.

Transport. No new path. It is the one #27703 used: the engine already hands every hook the caller's own options bag on ctx.input.options (the PHASE rule in hook.zod.ts: a before* handler reads it as passed, an after* handler after buildDriverOptions widened it, never stripped), and createData / updateData already collect in-process listeners from it. This PR adds one more listener of that family. Without the transport the answer key would be declared with no producer.

  • Spec (packages/spec).
    • CreateDataResponseSchema and UpdateDataResponseSchema declare an optional, response-only notice: string (non-empty), with one shared description: one author-written message, info severity, distinct from warnings, the last call wins, omitted when no hook set one, never stored, never carried from a nested write. CloneDataResponseSchema does not get it: the card names the single-record create and update answers.
    • WriteObservabilityOptions (contracts/data-engine.ts) declares onNotice?: (message: string) => void. Its docblock says the engine never calls it, how a hook reaches and calls it (with the typed cast the platform's own hooks use), that the sandboxed body face does not carry it, the one-message rule, and that it never crosses the wire. HookUpdateOptions names it among the members it inherits.
    • content/docs/references/api/protocol.mdx and authorable-surface/api.json regenerated (two answer keys each).
  • metadata-protocol. createData and updateData pass onNotice and answer notice omit-when-absent. The listener keeps the last message that is a non-empty string; a blank or non-string call sets nothing and never clears an earlier message. The variable is local to the request, so no other request's answer can carry it.
  • objectql. ENGINE_UPDATE_OPTION_KEYS admits onNotice, because engine.update refuses unknown option keys (insert has no such allowlist). The drift pin in engine-unknown-option.test.ts names it.
  • client. CreateDataResult and UpdateDataResult declare notice?: string, mirroring the two schemas.
  • Docs. content/docs/api/data-api.mdx (POST / PATCH) and content/docs/kernel/contracts/data-engine.mdx (WriteObservabilityOptions, a new onNotice section with an os:check example that check:skill-examples type-checks).
  • Changeset. .changeset/28000-save-notice-answer.md: @objectstack/spec, @objectstack/metadata-protocol, @objectstack/objectql and @objectstack/client, all minor, with both declaration lines.

The shape, and the alternatives not taken.

  • A new key, not warnings with an info severity. warnings elements are validation findings (field, code, rule) that the console renders as a problem with the save. A notice reports an outcome, has no field or rule, and the console shows it with, or in place of, its success toast. Folding it in would make every reader of warnings tell the two apart by a severity value.
  • One message, not a list. The card asks for one message, and a save shows one toast (console: what a save or bulk action tells the user: a bulk action reports Succeeded N / N whatever each row did, a warning save shows two toasts, a hook cannot add a sentence to the save toast, and Delete has no per-object confirm text #27380 row 2's one-toast rule). When more than one call sets it, the last one wins: an after* hook knows more than a before* one about what the save actually started. A list would push the merge rule onto every client.
  • A plain string, not an object. Nothing else is known about a notice today (no field, no link, no severity choice). An object can be introduced later as a new key if one is needed.
  • The listener on the write's options, not a new HookContext member. A HookContext method needs the engine to bind it at every dispatch site and the sandbox runner to marshal it, which is the server half the card files separately. Declaring one here with no producer would be a declared-never-produced key.

Tests

  • New contract pins. packages/spec/src/api/write-answer-notice.test.ts (8): the member is accepted on both answers and an answer without it stays legal; a list, an object and an empty string are refused; it sits beside warnings, never folded in; the clone answer does not declare it; the description is shared and says it is never stored and the last one wins; the listener type takes one message and may be absent.
  • Transport, real engine + real protocol. packages/objectql/src/protocol-save-notice-answer.test.ts (14) uses a real ObjectQL and the real ObjectStackProtocolImplementation, with hooks on both surfaces an app reaches the bag from: the raw registerHook envelope and a declarative handler bound through bindHooksToEngine. It pins: a beforeInsert and an afterInsert hook each set the create answer's notice, and the answer conforms; no key when no hook set one; the last call wins; a blank message sets nothing and never clears an earlier one; a refused write answers its error; nothing is stored; a nested write's notice never reaches the outer answer; a second request is not told the first one's notice; beforeUpdate and afterUpdate set the update answer's notice and it conforms; a declarative handler reaches the same listener on create and on update.
  • engine-unknown-option.test.ts: onNotice passes on update, and the drift pin names it.
Package suites and typecheck, on e7c47912
  • pnpm --filter @objectstack/objectql test: Test Files 500/500, Tests 9738/9738.
  • pnpm --filter @objectstack/metadata-protocol test: Test Files 284 passed, 4 skipped; Tests 21860 passed, 26 skipped.
  • pnpm --filter @objectstack/client test: Test Files 52/52, Tests 682/682.
  • pnpm --filter @objectstack/spec test: Test Files 760/760, Tests 22573 passed, 1 todo.
  • typecheck for @objectstack/spec, @objectstack/metadata-protocol, @objectstack/objectql and @objectstack/client: lock VERDICT command-exit 0.
  • Before the merge of origin/main (head 7f5907b4): the spec contract test 8/8; the objectql transport and option tests 39/39.
Ablations (one-shot, through scripts/ablation-replace.mjs, each restored to blob == HEAD)

The transport test resolves @objectstack/metadata-protocol through dist/, so each leg rebuilt it and checked the marker with ablation-dist-preflight.mjs (present on the mutation leg, absent on the restore leg).

# Mutation (metadata-protocol protocol.ts) Expected Observed
A1 createData: the onNotice listener replaced by one that records nothing create pins red 6 failed / 14 (both create hooks, last-wins, blank, second-request, declarative create); restore 14/14
A2 updateData: the onNotice listener replaced by one that records nothing update pins red 3 failed / 14 (beforeUpdate, afterUpdate, declarative update); restore 14/14
A3 createData: the blank-message guard removed blank pin red 1 failed / 14 (the blank case); restore 14/14

The os:check example in data-engine.mdx was mutated once to pass a number to onNotice: check:skill-examples went red at that line (exit 1), and green again after the restore.

Acceptance notes

  • Only a hook whose handler runs as code can set it today. A sandboxed body (including a handler the build lowers into one, which is how HotCRM's hooks run) sees ctx.input.options as the frozen { multi, where } projection, and a record-triggered flow has no hook context at all. Giving those two a way to set the notice (a HookContext member the engine binds and the sandbox marshals, and a flow node) is the server half, a follow-up card.
  • Batch, clone and flow writes do not answer it. Only createData and updateData pass the listener, as the card scopes it. A hook on a write made by createManyData / batchData, a clone or a flow node finds no listener and sets nothing.
  • The console toast (show notice with, or in place of, the record-named success message in AppContent crud_success, RecordFormPage and the inline edit save) is the domain:ui follow-up card.
  • Calling the listener is the hook's own act. The engine never calls it, so nothing between the hook and the listener catches. The protocol's listener only records the message and never throws.

Verification

Head e7c47912 (branch merged with origin/main at 3739d8d6, after #28174 landed; the one later main commit touches none of these files).

  • Gates were derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack against this diff: 109 families. Each ran with stdin from /dev/null, and each exit code was recorded before any pipe. Reconciled with --ran: 109 derived, 108 run, 1 NOT MEASURED, 0 unrun, 0 red.
  • NOT MEASURED: check:dual-build-cjs-loads (exit 3, needs a whole-repo pnpm build). CI's.
  • Two gates first exited 3 and were re-run green once their prerequisite was in place: node scripts/check-plugin-teardown-shape.mjs --self-test (its pinned control 621a4876 was missing from the shallow clone; fetched, 48 cases) and spec check:skill-examples (needed the client SDK dist; built, 277 examples type-check). The record carries the re-runs.
  • pnpm --filter @objectstack/spec check:generated: after a rebuild of the merged source, check:docs was stale and --fix regenerated content/docs/references/api/protocol.mdx; the re-check passed. The build rewrote authorable-surface/api.json; against main it adds only the two notice rows.
  • 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

…ve 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyvb1yBsrocHdqS4Ua
…-qo9o0z

# Conflicts:
#	packages/client/src/index.ts
#	packages/metadata-protocol/src/protocol.ts
#	packages/objectql/src/engine-unknown-option.test.ts
#	packages/objectql/src/engine.ts
#	packages/spec/src/api/protocol.zod.ts
#	packages/spec/src/contracts/data-engine.ts
…ract pages

Regenerates the protocol reference and the api authorable-surface shard for
the new answer member, and adds the changeset.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyvb1yBsrocHdqS4Ua
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/client, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/spec, touching 21 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json, packages/spec/src/data/hook-api.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via meta.publishItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/publish, selected by route anchor /:type/:name/publish), publishItem (sdk, the bare tail of client method meta.publishItem, bound to POST /api/v1/meta/:type/:name/publish))
  • content/docs/api/metadata-api.mdx (via /:type/:name/publish (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/concepts/metadata-lifecycle.mdx (via /:type/:name/publish (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/data-modeling/field-types.mdx (via WriteObservabilityOptions (symbol, a top-level interface), strictReadonlyWrites (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS))
  • content/docs/data-modeling/import-mappings.mdx (via /:object/import/jobs (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/data-modeling/validation-rules.mdx (via WriteObservabilityOptions (symbol, a top-level interface), onFieldsDropped (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS), strictReadonlyWrites (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS))
  • content/docs/kernel/contracts/data-engine.mdx (via WriteObservabilityOptions (symbol, a top-level interface), createData (symbol, a method of class ObjectStackProtocolImplementation), onNotice (symbol, a field of interface WriteObservabilityOptions), updateData (symbol, a method of class ObjectStackProtocolImplementation), onFieldsDropped (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS), onMentionsWithheld (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS), onNotice (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS), onValidationAdvisory (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS), strictReadonlyWrites (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS))
  • content/docs/kernel/contracts/metadata-service.mdx (via /:type/:name/publish (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/kernel/runtime-services/data-service.mdx (via CreateDataResponseSchema (symbol, a top-level const), CreateDataResult (symbol, a top-level interface), UpdateDataResponseSchema (symbol, a top-level const), UpdateDataResult (symbol, a top-level interface))
  • content/docs/kernel/services-checklist.mdx (via createData (symbol, a method of class ObjectStackProtocolImplementation), updateData (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/system-context.mdx (via /:object/import/jobs (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via /:type/:name/publish (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/protocol/objectql/security.mdx (via onFieldsDropped (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS), strictReadonlyWrites (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS))
  • content/docs/protocol/objectql/state-machine.mdx (via /:object/import/jobs (route, bridged from symbol createData — its route source's handler names it))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via /:type/:name/publish (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/releases/v17/17-1.mdx (via /:type/:name/publish (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/releases/v17/17-2.mdx (via /:type/:name/publish (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via createData (symbol, a method of class ObjectStackProtocolImplementation), onFieldsDropped (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS), strictReadonlyWrites (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS), /:type/:name/publish (route, bridged from symbol createData — its route source's handler names it))
  • content/docs/releases/v17/17-6.mdx (via strictReadonlyWrites (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS))
  • content/docs/releases/v18/18-1.mdx (via strictReadonlyWrites (literal, a string literal in ENGINE_UPDATE_OPTION_KEYS))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json, packages/spec/src/data/hook-api.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: updateData (4 routes)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 175 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7c3cc889f7c64d465486bb94ded8c8ea558d6945 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from acc7f1dddcd6b2bc1219a856422bd19516e54ffb — the merge of head e7c4791296d3968491bf485b3fe724838e0b7081 into base 7c3cc889f7c64d465486bb94ded8c8ea558d6945, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin acc7f1dddcd6b2bc1219a856422bd19516e54ffb && git checkout acc7f1dddcd6b2bc1219a856422bd19516e54ffb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7c3cc889f7c64d465486bb94ded8c8ea558d6945 e7c4791296d3968491bf485b3fe724838e0b7081 && git checkout -B drift-repro 7c3cc889f7c64d465486bb94ded8c8ea558d6945 && git merge --no-ff e7c4791296d3968491bf485b3fe724838e0b7081

node scripts/docs-audit/affected-docs.mjs --json 7c3cc889f7c64d465486bb94ded8c8ea558d6945

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7c3cc889f7c64d465486bb94ded8c8ea558d6945 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head e7c4791296d3968491bf485b3fe724838e0b7081 · 2026-10-30T18:58Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e7c4791296d3968491bf485b3fe724838e0b7081
Local-runs: none
Scope: packages/spec/src/api/protocol.zod.ts, packages/spec/src/contracts/data-engine.ts, packages/spec/src/data/hook-api.ts + packages/metadata-protocol/src/protocol.ts (createData / updateData listener and answer), packages/objectql/src/engine.ts (ENGINE_UPDATE_OPTION_KEYS) and engine-unknown-option.test.ts, packages/objectql/src/protocol-save-notice-answer.test.ts, packages/spec/src/api/write-answer-notice.test.ts, packages/client/src/index.ts (CreateDataResult / UpdateDataResult mirror), content/docs/api/data-api.mdx and content/docs/kernel/contracts/data-engine.mdx (hand-written), the regenerated content/docs/references/api/protocol.mdx and authorable-surface/api.json, .changeset/28000-save-notice-answer.md; read for the claims: packages/spec/src/data/hook.zod.ts (the PHASE rule on input.options), the #27703 / PR #28174 transport this one repeats

Inputs: card #28000 (body, claim 6528694653, os-dev-report 6531067088), PR body, the 14-file diff against merge base 3739d8d, the head's check-runs (read 2026-10-30T19:21Z: check-runs on e7c4791: 55 total, 42 success (Auto Label, Build & E2E UI, Build Core, Build Docs, Build UI Deps, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, Lint · CI, release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Live E2E UI, PR-body guards (claim · duplicate · Part-of · single-writer path), Plan Test Core shards, Spec property liveness, Test Core, Test Core (all 8), Test UI, Type Check UI, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter); 13 skipped path-filtered (18.x upgrade harness (opt-in), Auto Label, Check PR Size, Dogfood Regression Gate (matrix shard/3), Dogfood Verify CLI, Lint UI, Packed-tarball smoke (opt-in), Schedule gate · skip when a green push run covers the commit, Temporal Conformance (live PG + MySQL), Test UI (matrix shard/16), UI Published Artifacts); 0 failure, 0 in progress). Author user.login is objectstack-fleet[bot], so no login note is owed. The reviewer is not the drafter (session_01L9qibyvb1yBsrocHdqS4Ua).

① Derived judgments

  • The key: notice: z.string().min(1).optional() on CreateDataResponseSchema and UpdateDataResponseSchema, one shared WRITE_ANSWER_NOTICE_DESCRIPTION const (so the two cannot drift), placed beside warnings and withheldMentions; CloneDataResponseSchema is deliberately without it. The describe states every rule the transport implements: one author-written message, info severity, distinct from warnings, last call wins, present only when a hook set a non-empty message on a write that succeeded, never from a nested write, never stored.
  • The producer contract: WriteObservabilityOptions.onNotice (an optional listener taking the notice string, returning nothing), the fifth in-process listener beside onFieldsDropped / onValidationAdvisory / onMentionsWithheld; the docblock says the engine never calls it, how a hook reaches it (ctx.input.options, the before-as-passed / after-widened PHASE rule hook.zod.ts states at line 879 on main), that the sandboxed body face does not carry it, the one-message and never-throw rules. HookUpdateOptions's doc lists it. This is the comments: the @mention picker offers a member who cannot read the thread, and the author is never told that person will not be notified #27703 transport exactly (memory of PR feat(spec): a comment's author is told which mentioned users will not be notified, and a mention added by editing notifies #28174: an in-process listener on the caller's bag reaches after hooks with no new plumbing), so the key ships with a real producer, not as declared-never-produced.
  • metadata-protocol: createData and updateData each hold a request-local notice, pass a listener that keeps the last non-empty string (a blank or non-string call sets nothing and never clears), and answer omit-when-absent. Request-local means a second request and a nested write cannot leak into the answer, which the transport test pins (nested hook DID call, outer answer told nothing; second create not told the first's notice).
  • objectql: ENGINE_UPDATE_OPTION_KEYS admits onNotice because engine.update refuses unknown keys (insert has no allowlist); the drift pin names it. Without this line a hook-bearing update would be refused at the engine, so it belongs to the spec half.
  • client: CreateDataResult / UpdateDataResult declare notice?: string with the same reading, the comments: the @mention picker offers a member who cannot read the thread, and the author is never told that person will not be notified #27703 mirror precedent.
  • Tests: 8 contract pins (accepted on both, absent legal, list / object / empty refused, beside warnings, clone excluded, shared description says never stored and last wins, listener type) and 14 transport pins on a REAL ObjectQL plus the real ObjectStackProtocolImplementation on both hook surfaces (registerHook envelope and declarative handler via bindHooksToEngine): before and after hooks on create and update, no-hook control, last wins, blank guard, refused write answers its error, nothing stored, nested write isolated, per-request isolation. The three one-shot ablations (A1 / A2 / A3) and the os:check docs example mutation show the pins bite.
  • Changeset: @objectstack/spec, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/client all minor, Clause-②: yes, Metadata-protocol: added, with the hook-side usage recorded; matches the diff and the feat(spec): a comment's author is told which mentioned users will not be notified, and a mention added by editing notifies #28174 precedent. Skills-docs follow-up (skills/objectstack-data/rules/lifecycle.md) declared on the PR body as owed with the server half.

② Semver level

minor. An optional response-only member on two answers and an optional listener on the in-process write contract widen the protocol an author and a client read (Metadata-protocol: added); no accept set narrows, an answer without the key parses as before, a client that does not read it is unchanged. Clause-②: yes (widening) on the PR body and the changeset is the right declaration.

③ Boundary flags

④ Design judgment

The change holds against the code it cites and the scenario it is accepted against. Against the card: an author-written notice on the single-record create and update answers, one message, info severity, distinct from warnings, with the server and console halves left as the follow-up cards the card itself orders. Against the transport: the engine already hands every hook the caller's bag on ctx.input.options and createData / updateData already harvest in-process listeners from that bag (#27703 landed on exactly this path), so the smallest sound shape is one more listener and one more answer key, with no engine, driver or wire change; the objectql allowlist line is the one engine touch the refuse-unknown-keys rule forces. Against the alternatives: folding it into warnings would make every reader of a validation finding sort outcomes from problems by a severity value; a list would move the merge rule onto each client where a save shows one toast; an object would declare members nothing knows yet; a HookContext method would declare a producer the sandbox cannot marshal today. Each refusal is recorded with its reason, and the string-not-object choice is reversible by a new key. Isolation is proven rather than asserted: request-local state, nested-write and second-request pins, and a refused write answering only its error.

Implemented-by: claude/project-thread-qo9o0z
Reviewed-by: session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibx5r5nhDMwpRs4SL6D

维护者速读

席位核对 · ACCEPT · domain:spec seat 2 (#18549) · 2026-10-30T19:26Z

Head e7c4791296d3968491bf485b3fe724838e0b7081. Card #28000, claim 6528694653.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 19:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit e26cf9a Oct 30, 2026
62 of 63 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-qo9o0z branch October 30, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Save answer: a hook or record-triggered flow can attach one user-facing notice to a record save, shown with the save toast

1 participant