Skip to content

feat: hooks and record-triggered flows set the save notice (ctx.notice, save_notice node) - #28753

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-9kerqb
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-9kerqb

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

Fixes #28251

Clause-②: yes (widening)
Metadata-protocol: added

维护者速读

  • 钩子(含沙箱化的 shipped body)现在可以调用 ctx.notice(message) 设置保存提示,新建/更新的应答里带回 notice。
  • 记录触发的流程新增 save_notice 节点,同样设置这次写入的 notice。只有一条通道,就是写入自己的 onNotice。
  • save_notice 只能放在由记录新建/更新触发的流程里,其他地方在发布时就会被响亮拒绝。审批或等待之后恢复的分支、子流程、map 子项在运行时会按名字拒绝。
  • 流程提示可翻译,键是 flows.<flow>.notices.<node_id>.message,按写入者的语言渲染。notice 在线上仍是纯字符串。
  • 范围只限单条记录的新建和更新。批量、克隆和流程节点自己的写入都不带提示。
  • 钩子这一面的提示要不要也有翻译目录(hooks.<hook>.notices.<KEY>.message):席位已裁定 A,暂不做(卡上 6562118323),等第一个需要本地化提示的钩子再立卡。不需要你拍板。

What changes

Hook face: ctx.notice(message). HookContextSchema gains an optional notice member (HookNoticeSetter). ObjectQL's triggerHooks binds it on every beforeInsert / afterInsert / beforeUpdate / afterUpdate dispatch (packages/objectql/src/hook-notice.ts). Every dispatch site therefore has it, and no site can forget it. A call reads the write's own onNotice listener off ctx.input.options at call time and hands it the message. A write that passed no listener sets nothing. A message that is not a non-empty string throws a TypeError naming the call, so the write fails loudly instead of silently dropping the notice. Delete and read dispatches carry no member. The body runner passes the member through, and the QuickJS runner installs it as a host function, so shipped bodies (and handlers the build lowers into bodies) can call it. It needs no capability.

Flow face: the save_notice node. FlowNodeAction gains save_notice, and its config is SaveNoticeConfigSchema ({ message }, one {{ }} text slot). The record-change trigger already runs the flow inside the write that fired it. It now hands the run that write's channel (AutomationContext.saveNotice, wrapping ctx.notice) and closes the channel when the handler returns. The executor renders the message and sets it.

  • At publish, the FlowSchema parse refuses the node at any region depth unless the flow's start is a create or update record trigger (save-notice-placement.ts). The refusal is INVALID_METADATA 422 at nodes.<i>.type.
  • At run, the node refuses by name when the run holds no channel: a leg restored after an approval or a wait, a direct execute, or a subflow or map child (both delete the channel from the child context). It also refuses when the channel is closed, which covers a leg resumed in memory after the write answered.

Why a node, not a member of an existing node (measured). The flow that asked for this (HotCRM's deal submission, #27380 row 3) sends the record for approval. Its graph has no record-write node before the approval node that suspends the run, so a member on update_record would have nowhere to go. A member on end would run only after the approval, on a resumed leg, when the write has already answered. A dedicated node is the smallest surface that can stand before the suspension.

Message form (Wanted 3). The platform's translatable-message shape is a catalog key that the server renders in the reader's locale (flows.<flow>.refusals.<node_id>.message, hooks.<hook>.refusals.<CODE>.message). The flow notice uses that shape: flows.<flow>.notices.<node_id>.message (flowNoticeMessageKey), picked in the writer's locale before the holes are filled. os i18n extract scaffolds the key, the coverage gate demands it, and objectstack validate refuses a key over an unknown flow or node or a non-save_notice node. So the wire notice stays a plain string, and no widening of CreateDataResponseSchema.notice / UpdateDataResponseSchema.notice is needed. A hook-side catalogue is left to a maintainer decision (see the report).

Other surfaces.

  • The translation liveness row for flows.notices is live, cited to its reader.
  • The lint closed-binder set includes save_notice, which binds nothing.
  • The showcase demonstrates the node on showcase_task_completed (with zh-CN).
  • The platform checklist matrix item counts the new variant.
  • Spec artifacts were regenerated.

Why

#28247 added notice to the create and update answers, but only an in-process caller passing onNotice could set it. Shipped hook bodies and flows had no way in, so HotCRM faked the sentence with a record:alert banner.

Verification

  • Red first through REST (live showcase, pnpm --filter @objectstack/example-showcase dev -- --fresh). The probe installed app.probe1, saved an object, a hook whose body calls ctx.notice, and a record-after-update flow with a save_notice node, then created and updated a record.
    • Base 4bf48a8c (spec, objectql, runtime, trigger and automation rebuilt from base): POST → 201 with no notice; PATCH → 200 with no notice. A save_notice flow under record-after-delete was saved, 200.
    • Head: POST → 201 "notice":"Sent Acme for approval"; PATCH → 200 "notice":"Moved Acme to review"; the delete-trigger flow → 422 INVALID_METADATA, issues[0].path nodes.1.type.
  • New tests.
    • spec/src/automation/save-notice-node.test.ts (17)
    • objectql/src/protocol-save-notice-answer.test.ts, the Save notice from a sandboxed hook or a flow: hook context and flow node set the write answer's notice #28251 block (8 of 22)
    • runtime/src/sandbox/hook-notice.integration.test.ts: real ObjectQL, SqlDriver and a QuickJS body
    • trigger-record-change: save-notice-channel.test.ts (3), plus 2 cases in record-change-integration.test.ts
    • service-automation/src/builtin/save-notice-node.test.ts (7, real in-memory i18n)
    • lint/.../validate-translation-references.test.ts (3)
    • cli/test/i18n-flow-save-notice-coverage.test.ts (6)
  • Ablations, all red then restored to the HEAD blob (scripts/ablation-replace.mjs). Each one below was broken in turn:
    • the bindHookNotice call
    • the member's refusal of a non-string
    • the QuickJS install
    • the body-runner spread
    • the trigger's channel spread
    • the trigger's channel close
    • the subflow delete and the map delete
    • the executor's no-channel refusal
    • the placement refusal
    • the lint collect and orphan check
    • the CLI extract
  • Gates. dispatch-gates.mjs --commands: 116 derived; --ran reconciles 115 run and 1 NOT MEASURED (check:dual-build-cjs-loads, exit 3, needs every package's dist). eslint over the seven touched packages: exit 0.

Acceptance notes

  • A notice set from a fire-and-forget async after* hook that resolves after the write answered is dropped, because the answer has already gone. Await the work, or set the notice in a before* hook.
  • When several hooks or nodes set a notice on one write, the last one wins, as the feat(spec): a hook can tell the person who saved a record what the save set in motion #28247 contract says.
  • ctx.notice(42) in a body fails the write as a hook body fault, the same way any other TypeError in a body does.
  • The Studio palette (ui/.../flow-canvas-parts.tsx NODE_PALETTE) does not list save_notice yet. The node still saves and runs, and the actions feed carries its descriptor.
  • HotCRM can drop its record:alert workaround once this lands. That belongs to the hotcrm lane; this PR does not touch hotcrm.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…ies, add the save_notice node

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5omF1nCbQTGgW1nbRA
…ype new slot lookups

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

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 7 package(s): @objectstack/cli, @objectstack/lint, @objectstack/objectql, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, @objectstack/trigger-record-change, touching 48 documentable anchor(s). ⚠️ 15 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/api-surface/contracts.json, packages/spec/api-surface/data.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d08b859a7177cf16d6190163f2e42efa2d980d3a.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 15 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/api-surface/contracts.json, packages/spec/api-surface/data.json, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 180 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 d08b859a7177cf16d6190163f2e42efa2d980d3a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e4ea437604fa364500a132b2b488cbc171be59e9 — the merge of head 36c1fb8f0982c07095b112c8dab0bd2b9343f221 into base d08b859a7177cf16d6190163f2e42efa2d980d3a, 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 e4ea437604fa364500a132b2b488cbc171be59e9 && git checkout e4ea437604fa364500a132b2b488cbc171be59e9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d08b859a7177cf16d6190163f2e42efa2d980d3a 36c1fb8f0982c07095b112c8dab0bd2b9343f221 && git checkout -B drift-repro d08b859a7177cf16d6190163f2e42efa2d980d3a && git merge --no-ff 36c1fb8f0982c07095b112c8dab0bd2b9343f221

node scripts/docs-audit/affected-docs.mjs --json d08b859a7177cf16d6190163f2e42efa2d980d3a

⚠️ 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 d08b859a7177cf16d6190163f2e42efa2d980d3a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…-9kerqb

# Conflicts:
#	packages/spec/dropped-refinements.baseline.json
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 36c1fb8f0982c07095b112c8dab0bd2b9343f221
Local-runs: none
Scope: packages/spec/src/api/protocol.zod.ts, packages/spec/src/automation/builtin-node-config.zod.ts, packages/spec/src/automation/flow-node-config-refusals.ts, packages/spec/src/automation/flow-text-slot-template.ts, packages/spec/src/automation/flow.zod.ts, packages/spec/src/automation/save-notice-placement.ts, packages/spec/src/contracts/automation-service.ts, packages/spec/src/contracts/data-engine.ts, packages/spec/src/data/hook.zod.ts, packages/spec/src/system/i18n-resolver.ts, packages/spec/src/system/translation.zod.ts + packages/objectql/src/hook-notice.ts, packages/objectql/src/engine.ts (triggerHooks, the four insert/update dispatch sites, ENGINE_UPDATE_OPTION_KEYS), packages/runtime/src/sandbox/body-runner.ts (buildSandboxContext), packages/runtime/src/sandbox/quickjs-runner.ts (installCtx), packages/runtime/src/sandbox/script-runner.ts (ScriptContext), packages/triggers/trigger-record-change/src/record-change-trigger.ts (triggerTypeToHookEvents, start, buildContext, saveNoticeChannelOf, closeSaveNoticeChannel), packages/services/service-automation/src/builtin/save-notice-node.ts, packages/services/service-automation/src/builtin/subflow-node.ts, packages/services/service-automation/src/builtin/map-node.ts, packages/services/service-automation/src/engine.ts (renderFlowTextSlot, SuspendedRun, cloneVariablesAtPause), packages/services/service-automation/src/suspended-run-store.ts, packages/services/service-automation/src/plugin-extension-api.ts (deriveTriggerBinding), packages/spec/src/automation/control-flow.zod.ts (collectFlowGraphs), packages/lint/src/validate-translation-references.ts, packages/lint/src/flow-cel-root-scope.ts, packages/lint/src/validate-flow-trigger-readiness.ts, packages/cli/src/utils/i18n-extract.ts, packages/metadata-protocol/src/protocol.ts (the two onNotice sinks), .changeset/28251-save-notice-from-hooks-and-flows.md, scripts/check-changeset-no-major.mjs (header, the level axis)

Inputs read: card #28251 (body; comments 6532349633, 6533331783, 6560220427, 6562102476, 6562118323 — the last is the seat ruling on the dev's open question); PR #28753 body and 63-file list; the net diff git diff b3572509 36c1fb8f (63 files, +1664 −54; file list identical to the PR's); the check-runs on the head at two reads. The PR body's account was not the evidence: every claim below is read in the tree at 36c1fb8f.

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged:

  1. HookContextSchema gains optional notice (HookNoticeSetter, a cast z.unknown() like api); HookNoticeSetter exported from @objectstack/spec/data. Right. The schema stays runtime-only (JSON Schema {}), authorable-surface/data.json and api-surface/data.json each grow by one, both regenerated. The member is bound by the one producer (bindHookNotice inside ObjectQL.triggerHooks, so no dispatch site can miss it), reads the caller's bag off context.input.options at call time (the insert path builds input: { data, options: opCtx.options }; onNotice is in ENGINE_UPDATE_OPTION_KEYS, so the update path keeps it), and is deleted on every non-insert/update event. Widening: nothing an existing stack writes is refused.
  2. FlowNodeAction gains save_notice; SaveNoticeConfigSchema ({ message }, one {{ }} text slot, superRefine through textSlotTemplateRefusal) registered in getBuiltinNodeConfigContracts and FLOW_NODE_TEXT_SLOTS. Right. The three spec registries a builtin text-slot node must join are all joined; flow-builtin-node-config-keys/values and flow-text-slot-template pins are updated in the same diff; json-schema.manifest/automation.json, declaration-map/automation.json, api-surface/automation.json regenerated. The superRefine is a JSON-Schema-dropped refinement, which is why dropped-refinements.baseline.json grows by one entry — the correct ledger for it, not a workaround.
  3. FlowSchema refuses a save_notice node, at any region depth, unless the top-level start node's config.triggerType matches record-(before|after)-(create|insert|update|write). Right against its consumer: the regex is the trigger's own grammar minus delete (triggerTypeToHookEvents, where write is insert ∪ update and delete is excluded), it reads the same nodes.find(type === 'start').config.triggerType that deriveTriggerBinding in plugin-extension-api.ts reads, and it anchors on [...graph.path, 'nodes', index, 'type'] exactly as the sibling arms in FlowSchema do. An array-form triggerType falls to the refusal; that shape is unsupported and never binds (start() warns and returns), so the refusal is right in effect (wording: see ④).
  4. AutomationContext gains optional saveNotice: SaveNoticeChannel (set(message), readonly locale?); SaveNoticeChannel exported from @objectstack/spec/contracts. Right. It is the first function-valued member on the context, which the dev's doc states plainly ("a function does not survive a suspension"). Persistence is JSON in both stores (jsonClone / context_json), so a suspended run's row keeps { locale } and drops set; the executor guards typeof channel.set !== 'function', so a restored leg refuses by name rather than crashing. Only the record-change trigger spreads it; subflow and map delete it from the child context.
  5. flows.FLOW.notices.NODE_ID.message added to TranslationDataSchema (strict object, flowTextSlotTranslation(), aliases notice/text/body/description → message), flowNoticeMessageKey exported from @objectstack/spec/system. Right: a one-for-one mirror of the refusals block one group over, judged by the same text-slot judge; the extract walk, the coverage test, the orphan check in validate-translation-references and the liveness row (Spec property liveness is green on the head) are all wired in this diff.
  6. WriteObservabilityOptions.onNotice doc and WRITE_ANSWER_NOTICE_DESCRIPTION reworded; the wire notice stays z.string().min(1). Right. Wanted (c) asked whether the answer key should widen to a translatable shape; the diff answers it by rendering server-side in the writer's locale (the A screen flow's select options and screen description have no key in the flows translation face — the zh-CN intake wizard translates its labels and placeholders and keeps its options and body text in English (17.7.0) #22507 posture), so the wire shape is unchanged and no widening is needed. The prose now matches what the sandbox does (the frozen { multi, where } projection stays; the member reaches the listener instead).
  7. No governed surface in the file list (no docs/adr/**, .claude/**, skills/**, .github/workflows/**, AGENTS.md, CLAUDE.md); head repo is the base repo; 1718 changed lines (under 3,000). Governed Surface Queue Guard is green. The landing tier is the ordinary queue, gated by this record (needs:contract-review) and green checks.

② Semver level

Changeset .changeset/28251-save-notice-from-hooks-and-flows.md: @objectstack/spec: minor; objectql, runtime, trigger-record-change, service-automation, cli, lint: patch. Package names match each package.json.

  • Clause-②: yes (widening) on the PR body and the changeset, with the widening named in the arm (HookContext.notice, FlowNodeAction.save_notice, flows.notices, AutomationContext.saveNotice). Matches the diff: nothing is removed, renamed, tombstoned or tightened; no (narrowing) arm, so no BREAKING banner, migration or ADR-0087 disposition is owed.
  • Metadata-protocol: added on both. Right: a new node type, a new config key and a new translation group are things a metadata author can newly write; no key is removed and no default or meaning changes.
  • Level: added moves @objectstack/spec at minor, which is the level axis the gate enforces (check-changeset-no-major.mjs: "a declared kind must grade AT LEAST ONE package whose published source it moves minor or above"; the level follows the metadata protocol alone, patch for everything else). Consumer packages at patch are within that rule. Check Changeset is green on the head (both runs).

Clause-②: yes (widening)

③ Boundary flags

  • open_questions[0] — a hook-face translation catalogue (hooks.HOOK.notices.CODE.message, ctx.notice({ key, params }))? Answered by the seat ruling (comment 6562118323): A, not now; ctx.notice takes the rendered sentence; the flow face keeps its catalogue; B opens as its own card with the first hook that needs it. The diff implements A exactly (HookNoticeSetter is a function of one string returning nothing; the member refuses anything but a non-empty string). Closed.
  • Deviation 1 — the full gate pass ran on ae1462ff, later commits had targeted reruns. Answered by the head's check-runs, which are the gate verdicts: at this read Lint & Repo Gates, every Lint · job, Type Check · consumer gates / debt ledger / source gates, Build Docs, Build UI Deps, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Dogfood Regression Gate, Live E2E UI, PR-body guards are success; Build Core, Test Core (1–8/8), Type Check · workspace, Type Check UI, Test UI (1–16/16) and Build & E2E UI were still in_progress. None red. The landing still waits on those (Multi-agent discipline §7: in_progress is not a pass); this record does not substitute for them.
  • Deviation 2 — scope additions forced by gates (showcase save_notice on showcase_task_completed with zh-CN; platform-checklist matrix revision 12). Accepted: each is the second half of a registry change this repo demands in the same PR, not a scope expansion. ⛔ No hotcrm edit in the file list, as the order required.
  • Deviation 3 — dropped-refinements.baseline.json grows (one entry, nine sites); header totals corrected in 3e38b814. Accepted: a new refinement and nine new translation sites are what the ledger exists to count; the spec gates are green on the head.
  • Deviation 4 — seven pushes. Not a deviation (WIP pushes before every long step are the rule).
  • out_of_scope_findings — HotCRM record:alert retirement; Studio NODE_PALETTE without save_notice. Both correctly left out: hotcrm is another lane, and the palette is ui/ polish on a node that saves, runs and is in the actions feed. Noted, not filed, per PD#10 (neither is a defect, contract violation or authoring trap).
  • No flag in the dev report or PR body is left unanswered.

④ Design judgment

Each hits file against the code it cites and the scenario it is accepted against (HotCRM deal submission: a save_notice ahead of an approval in a record-after-update flow; a sandboxed hook body calling ctx.notice).

  • packages/spec/src/data/hook.zod.ts — Holds. Every sentence of the member's doc is true in the tree: bound on the four events only (NOTICE_EVENTS in hook-notice.ts), per row of a batch (rowHookContexts[i] each go through triggerHooks), a no-op when no listener (options?.onNotice?.(message)), a TypeError naming the call for a non-string or blank (judged by the member, not the listener), deleted on find/delete. The sandbox claim holds: buildSandboxContext spreads engineCtx.notice only when it is a function and installCtx wires it as a synchronous host function with hostErrorToVm, so the body gets the engine's own refusal. The describe() text carries no tracker number. Tried to break: a before* hook sets a notice and the write is then refused — the two onNotice sinks in metadata-protocol/protocol.ts only answer notice on a successful write, as the doc says.
  • packages/spec/src/contracts/data-engine.ts — Holds. The reworded sentence is the only one that was wrong on main after feat(spec): a hook can tell the person who saved a record what the save set in motion #28247 ("does not carry it") and is now exact: the projection stays frozen, the member is the door.
  • packages/spec/src/api/protocol.zod.ts — Holds. The description names the two setters and nothing else changes; the wire notice stays one non-empty string, which ① item 6 judges right.
  • packages/spec/src/automation/flow.zod.ts — Holds. The enum comment states the restriction; the refusal arm sits beside the other collectFlowGraphs-walked arms and anchors the same way; apiTriggerSecretRefRefusals is its exact precedent for a start-node-conditioned refusal.
  • packages/spec/src/automation/save-notice-placement.ts — Holds against its consumer (judged in ① item 3). Tried to break: (a) record-after-write — allowed, and the trigger binds it to insert+update only, so every run under it holds a channel; (b) record-before-create — allowed; a refused write answers no notice; (c) a flow whose start node carries an array triggerType containing record-after-update — refused with "This flow's start has no record trigger"; that is the right verdict (the array never binds, start() warns and returns, lint validate-flow-trigger-readiness flags it) but the sentence misdescribes the shape. Should-fix: spell the array case in the refusal ("an array-form triggerType, which binds no trigger") so the prescription is accurate for the one shape it currently misnames; the verdict itself needs no change. (d) Index renumbering when a region holds a non-record sibling — identical to every peer arm in FlowSchema, and the region's own schema already refuses that sibling; not a finding.
  • packages/spec/src/automation/builtin-node-config.zod.ts — Holds. The "node, not a member" argument is measured against the accepted scenario and is right: a member on update_record has nowhere to stand before the suspending approval, and end runs after it. The aliases are the words the answer key and prose spell a notice in; the guidance refuses title/severity with the reason. The config is strictObject with a text-slot superRefine, the same shape as EndConfigSchema.message, and renderTextSlot / renderFlowTextSlot is the one renderer the executor uses.
  • packages/spec/src/automation/flow-node-config-refusals.ts, flow-text-slot-template.ts — Hold. Registry joins; the "nine slots" pin and the keys/values pins are updated in the diff; flow-cel-root-scope's closed-binder set gains save_notice (the executor binds no variable).
  • packages/spec/src/contracts/automation-service.ts — Holds against the trigger and the executor. The channel is open only while callback(automationCtx) is awaited and is closed in finally, keyed by the channel object (a shallow context copy keeps it), so a leg resumed in memory refuses with the channel's own sentence; a leg restored from a store carries no set and refuses by name; subflow/map delete it. One gap in measurement rather than design — should-fix: no test pins the accepted scenario end to end (a save_notice ahead of an approval in a record-triggered run: suspend with the channel in the context, persist through a SuspendedRunStore, resume, and show the resumed leg's save_notice refuses by name and the original write's answer carried the notice). The in-memory closed-channel case and the no-channel case are pinned; the persisted round trip is reasoned from jsonClone dropping the function, not measured. Add the pin (service-automation, with InMemorySuspendedRunStore, which already round-trips through JSON).
  • packages/spec/src/system/translation.zod.ts, i18n-resolver.ts — Hold. notices is the refusals block with the node-specific aliases and sentence; flowTextSlotTranslation() is the one judge; the executor overrides the run's locale with the channel's (the writer's) before renderFlowTextSlot, and the key it reads is flowNoticeMessageKey. The lint orphan check and the CLI extract read the same save_notice node universe at any depth. The doc's claim "refuses a key over an unknown flow, an unknown node, or a node that is not a save_notice" is what validateTranslationReferences now does.

Lane remainder, read for the judgments above and not reviewed here (TIER_DEFAULT is the lane seat's): should-fix for the lane — packages/lint/src/lint-flow-patterns.ts keeps two "record of readings" lists that a new builtin must be checked into rather than inherit (INERT_CONDITION_NODE_TYPES: the save_notice executor ignores a node condition; FALLIBLE_NODE_TYPES: the executor can refuse), and neither names save_notice, so a condition on the node goes unflagged and the fallibility hint is silent. Same PR or a follow-up card; not a contract matter.

No must-fix. Three should-fix items above.

Implemented-by: claude/project-thread-9kerqb
Reviewed-by: session_01FEkQ5kE5BrPUPNMMyv6oCH-review-28753

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

席位核对 · ACCEPT · PR #28753 · 2026-11-01T19:16Z

Head-sha: 36c1fb8f0982c07095b112c8dab0bd2b9343f221

维护者速读(席位终稿)

  • 改了什么:保存提示有了两条入口,走同一条通道(写入自己的 onNotice)。钩子(含沙箱里的 shipped body)可调用 ctx.notice(message);记录新建/更新触发的流程可用新节点 save_notice。新建/更新的应答里带回 notice,线上仍是纯字符串。流程提示可翻译,键为 flows.<flow>.notices.<node_id>.message,按写入者语言渲染。
  • 为什么改:Save notice from a sandboxed hook or a flow: hook context and flow node set the write answer's notice #28251:HotCRM 提交审批时要告诉用户"已送审",此前只能靠 record:alert 绕路。
  • 风险与回滚:中低。纯扩大:发布时 save_notice 只许放在记录新建/更新触发的流程里,否则 422;审批/等待后恢复的分支、子流程、map 子项在运行时按名字拒绝。批量、克隆、流程节点自身的写入不带提示。回滚即 revert。
  • 先看:packages/objectql/src/hook-notice.ts;packages/spec/src/automation/save-notice-placement.ts;packages/services/service-automation/src/builtin/save-notice-node.ts。
  • 两分钟验证:showcase 起服后 POST 一条记录,应答带 "notice":"Sent Acme for approval";PATCH 应答带 "notice":"Moved Acme to review"(dev 已在 base 红、head 绿实测)。
  • 需要你做的:无。钩子一面要不要翻译目录,席位已裁定 A(暂不做,卡上 6562118323)。席位按常设授权落地,合并即关 Save notice from a sandboxed hook or a flow: hook context and flow node set the write answer's notice #28251。
  • 席位意见:契约复核 PASS,可落。

核对六项

  1. 形态:draft → main;Fixes #28251,合并关卡正确,与派单 pm-orders/server/order-28251.md 一致。文件 63 个(含生成的 spec 产物与测试),在认领 6560220427 申报面内;另有两处被门禁逼出的增项(showcase 的 save_notice 示例、platform-checklist 节点矩阵第 12 版),dev 已在偏差里说明。
  2. 前提实测:dev 在 showcase 上 live REST base 红、head 绿(钩子与流程两面各一),删除触发的流程放 save_notice 得 422 nodes.1.type;13 组消融各自变红后还原。用节点而非成员的理由已实测:HotCRM 送审流程在挂起的 approval 之前没有写记录节点。
  3. 门禁:本 head check-runs 59 success、14 skipped(路径未命中)、0 失败或待跑。dev 本地 116 条派生门禁中 115 条 exit 0,check:dual-build-cjs-loads 本地 NOT MEASURED(缺全量 dist),由 CI Build Core(success)读取;cli 全量套件本地未跑完,交 CI Test Core(success)。合入 main 后 dev 重跑了 spec 生成类门禁与各包 save_notice 测试,全部 exit 0。
  4. 接受的偏差:门禁只在 ae1462ff 跑过全量,之后三次提交只做了定向重跑,本 head CI 全绿补齐;dropped-refinements.baseline.json 增 1 个 schema、10 个位点,按 main 文件加本分支增量重建(248 / 983);分支推送 7 次。复核三条 should-fix 记为后续,不挡落地:(1)save-notice-placement.ts 对数组形 triggerType 的拒绝语句写成"没有记录触发",应点明数组形;(2)缺一条端到端测试钉住"save_notice 在 approval 之前 → 挂起持久化 → 恢复后的分支按名字拒绝、原写入应答已带提示";(3)lint-flow-patterns.ts 的 INERT_CONDITION_NODE_TYPES / FALLIBLE_NODE_TYPES 未登记 save_notice。
  5. 范围外去向:钩子翻译目录 → 裁定 A,有首个需求时另立卡。HotCRM 的 record:alert 绕路可在 hotcrm 车道拆除,合并后由席位立卡。Skills-docs:PR 正文未声明;skills/objectstack-automation 讲流程节点与钩子,但未教 save_notice 和 ctx.notice,随复核 should-fix 一起记入后续卡。
  6. 落地条款:Clause-②: yes (widening) + packages/spec/src 路径 ⇒ CONTRACT_REVIEW_TIER 完整复核 PASS(6562490492,Head-sha 与现 head 一致)。Metadata-protocol: added ⇒ spec minor、六个消费包 patch,与 changeset 一致(Check Changeset 本 head success)。check-governed-merges --pr 28753:NOT governed,1718 行(≤ 3000)。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 19:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 1, 2026 19:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit a00f335 Nov 1, 2026
78 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-9kerqb branch November 1, 2026 19:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Save notice from a sandboxed hook or a flow: hook context and flow node set the write answer's notice

1 participant