You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
approvals: every approval notification reaches the recipient with an empty body — the service puts its text in payload.message, messaging reads payload.body (comment and request-info text are lost) #21847
Clause A4 of approvals.sla-escalation (rev 2) fails in the follow-up run #21845 (subject 316be321e); an independent verifier (RUNNER rule 7) confirmed it from source and widened the scope: medium, predates 17.6.0.
Expected: the notification's body carries the text (the comment, the request-info question, the escalation action).
Actual: title only, body "" (inbox body_md '').
Mechanism
packages/plugins/plugin-approvals/src/approval-service.ts — the notify helper (~1373-1410) and all 12 call sites (OOO substituted / skipped, returned, reassigned, reminders, request-info, comment, escalated, SLA breached) put the text in payload.message.
packages/services/service-messaging/src/dispatcher.ts:341 and the emit-time notification (messaging-service.ts ~1101, ~1221) read only payload.body; the EmitInput contract documents title/body/url. The flow notify node and @mention producers already send body; approvals is the only producer affected.
Done when
Approval notifications send body (or the dispatcher honours a documented alias, not both), and a test reads a comment's text from the recipient's notification.
Path: approvals and automation — approvals in the UI: inbox and notifications | approvals.sla-escalation | P2
Triage: first grade — bug · priority:p2 · domain:services · area:workflow · pm:queue. The approval producer sends the documented body; no alias in the dispatcher
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-05T08:56Z. ⛔ Not a claim, ⛔ not a dispatch.
Triage: lands in packages/plugins/plugin-approvals/src/approval-service.ts (the notify helper, about :1373–:1410) ⇒ domain:services; rationale: EmitInput documents title/body/url, every other producer sends body, and approvals alone sends message.
Why p2. Every approval notification reaches its recipient with no text: comments, request-info questions and escalations are all lost. It predates 17.6.0.
Direction: the card's first option. The helper sends body, covering all 12 call sites. ⛔ No message alias in the dispatcher: one field, as documented.
Pins: a comment's text, and a request-info question, read back from the recipient's notification.
Claim: PM loop round 1 (17.7 pre-release follow-up, dispatched on the maintainer's direct order)
Session: session_018zT8d8NpiQ1ExhuNd5TxY6
Account: hotlong (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-21847-approval-notification-body
Worktree: objectstack-issue-21847
Domain: domain:services (card label, as triage set it)
File surface: packages/plugins/plugin-approvals/src, .changeset/
Container & model: M, mode:subagent, model: opus (default tier; no path-derived mandate)
Clause-②: no
Thread-read: 5991283949
Serial constraints cleared: none named
Provenance: the maintainer, in Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「都开单派发」; landing per the standing order 「开发完整就进队列合并」. Dispatched one at a time within the session's load cap. Where the card withholds detail, this session holds it and the dispatch carries it privately.
Release: session session_018zT8d8NpiQ1ExhuNd5TxY6 · cause: re-route before dispatch (no dev was started on this card) · destination: back to pm:queue for the domain:services lane seat to claim.
Provenance: the maintainer, in Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「只有不能公开的才需要你派发处理」. This card withholds nothing, so its dispatch belongs to the lane. The claim above is withdrawn, the assignee is cleared, and the triage grading stands.
Claim: PM loop round 3 · 2026-10-05T13:58Z
Session: session_011K3zqE8Pv1Evw5hc8tZCnN
Account: os-steve (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-21847-approval-notification-body
Worktree: objectstack-issue-21847
Domain: domain:services
Seat: domain:services#1 (seat post #6021)
File surface (at origin/main5e0b489b), per triage's direction 5991283949:
packages/plugins/plugin-approvals/src/approval-service.ts: the notify helper (about :1373–:1410) sends its text as the documented body, covering all 12 call sites.
plugin-approvals tests, plus a door pin in a NEW file under packages/qa/dogfood/test/ (declared cross-lane on [PM seat] domain:cli — ⏳ vacant #6024 in this act): a comment's text and a request-info question, read back from the recipient's notification.
content/docs/** sentences this makes false, and a patch changeset.
⛔ No message alias in the dispatcher or in service-messaging: one field, as EmitInput documents. ⛔ No change to who is notified, or when.
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier).
Clause-②: no
The notification's text reaches the recipient in the field the contract already documents. No accepted input moves.
Thread-read: 5995642354
Serial constraints cleared: at 2026-10-05T13:58Z:
The earlier claim by session_018zT8d8NpiQ1ExhuNd5TxY6 was released before any dev started (5995642354), on the maintainer's routing 「只有不能公开的才需要你派发处理」. No branch of it exists.
{
"issue": 21847,
"status": "done",
"branch": "claude/issue-21847-approval-notification-body",
"pr": "https://github.com/objectstack-ai/objectstack/pull/21888",
"session": "session_011K3zqE8Pv1Evw5hc8tZCnN — this run's harness-stamped Claude-Session (subagent of the PM session)",
"premise_still_valid": true,
"summary": "H1 confirmed and fixed at the producer: all twelve notify() call sites in approval-service.ts now send their (byte-identical) text as payload.body, the one field messaging reads, instead of payload.message; notify() declares a local, non-exported ApprovalNotificationPayload (title/body/actionUrl/actions) so a call site spelling it any other way is a compile error. No service-messaging or spec edit, no message alias, no change to who is notified or when; H2: no call site bypasses the helper; H3: no reader of payload.message on approval notifications in objectstack or objectui@0abd4f9f (only an unmeasurable tenant-authored notification template could have used {{ message }}; named in the changeset). Pinned by a new plugin-approvals battery over every call site that can notify (11 of 12; the slot-literal reminder never reaches messaging by construction) and a new dogfood door pin reading a comment and a request-info question back from GET /api/v1/notifications; both red on the unfixed service and under ablation. Draft PR #21888 with a patch changeset.",
"tests": "All at HEAD 7ca65ce74c unless marked. RED first, at 68baad43a0 (the two new pins committed ahead of the fix, service unfixed): `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/approval-notification-body.integration.test.ts` -> exit 1, 'Tests 2 failed | 1 passed (3)': comment read 'expected [ [ 'u_alice' ], undefined ]', 22 offenders ('body is undefined' + \"undeclared payload field 'message'\") over the 11 reached call sites, coverage pin green; `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 --project isolated test/approval-notification-body.dogfood.test.ts` -> exit 1, \"AssertionError: the comment is the notification's body: expected '' to be 'The signed contract is attached to th…'\" (one row, non-empty title: the H1 defect at the door). Build at 68baad43a0: `pnpm turbo run build --filter='@objectstack/dogfood^...' --filter='@objectstack/plugin-approvals^...' --concurrency=1` 63/63 (48 cached). GREEN at 7ca65ce74c: plugin-approvals build exit 0; `pnpm --filter @objectstack/plugin-approvals typecheck` exit 0 (src tsc + scripts tsc + check:test-typecheck: '8 file(s) / 324 error(s) / 27 pinned signature(s) held', new test file clean); `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2` -> 'Test Files 61 passed (61) / Tests 898 passed (898)'; door pin -> 'Tests 1 passed (1)'; `pnpm --filter @objectstack/dogfood typecheck` exit 0. Resolution: both pins read plugin-approvals SOURCE (unit imports ./approval-service.js; dogfood isolated project aliases @objectstack/plugin-approvals to src/index.ts) — proven by Leg A going red at the door while dist held the fix, so no rebuild leg was owed. ABLATION (scripts/ablation-replace.mjs wrap mode + outer trap restore, REPO_ROOT-absolute path): Leg A 'message back in the helper' — anchor \"severity: 'info', ...event, payload, audience,\" x1->x0, replacement sending { ...rest, message: body } x0->x1, blob 9a1822845a2f->d066658afd62; unit exit 1 (2 failed: comment body undefined; 22 offenders), door exit 1 (\"expected '' to be 'The signed contract…'\"); restore blob 9a1822845a2f == HEAD blob, git diff HEAD empty, trap restore leg repeated the proof. Leg B 'a call site spells message' — request-info site 'body: input.comment.trim(),' -> 'message: input.comment.trim(),' (marker count 1); `pnpm --filter @objectstack/plugin-approvals exec tsc --noEmit` exit 1: \"src/approval-service.ts(4810,11): error TS2353: Object literal may only specify known properties, and 'message' does not exist in type 'ApprovalNotificationPayload'.\"; restore blob == HEAD; git status --porcelain empty after both legs. Texts unchanged: the 12 removed 'message:' lines equal the 12 added 'body:' lines byte-for-byte after the key, indentation included. Narrowed lint (full pnpm lint is CI's): `pnpm exec eslint --no-inline-config --format json` on the 3 touched .ts files -> JSON lists 3 files, 0 errors 0 warnings; population = eslint.config.mjs packages/**/*.{ts,…} blocks, none of the three ignored; invariance = the config enables no type-aware linting (no parserOptions.project), so untouched files' verdicts cannot move. Every build/test/typecheck/ablation ran under scripts/pm/os-verify-lock.sh (slot issue-21847-dev; one queue-timeout 99 on the first red attempt, resumed on the same slot) with NODE_OPTIONS=--max-old-space-size=3072, turbo --concurrency=1, vitest --maxWorkers=2. NOT MEASURED locally (CI's): the full dogfood suite, turbo-wide test/typecheck. CI on PR #21888: not awaited (in_progress at report time).",
"mcp_calls": "0 — no MCP GitHub tool called; reads were REST GETs via gh api (card, comments, PR, PR events, fleet-write runs) and a shallow git fetch of objectui at the .objectui-sha pin.",
"api_writes": "4 REST write requests from this seat (git push x3 are not REST: empty-branch probe, test commit 68baad43a0, fix commit 7ca65ce74c). (1) pr_create via scripts/pm/with-fleet.sh --via dispatch: POST /repos/objectstack-ai/objectstack/dispatches (204) -> relay run 37329933314 -> POST /repos/objectstack-ai/objectstack/pulls (draft) = #21888, read-back 8399 bytes identical. (2)+(3) label-write --assign os-steve: POST /repos/objectstack-ai/objectstack/dispatches (204), then POST /repos/objectstack-ai/objectstack/issues/21888/assignees (201), read-back matches. (4) this os-dev-report comment via scripts/pm/post-stamped.mjs --comment=21847 (relay).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted in PR #21888 Acceptance notes, not filed · evidence: approval-service.ts remind() sends slot-literal pending entries (colon-bearing, e.g. position:P) to notify(), whose first step filters every colon-bearing audience entry, so that call returns 0 without emitting — the 'plain nudge' its own comment promises never goes out; measured only by reading the code and by the unit battery reaching 11 of 12 sites, NOT at a public door (POST /api/v1/approvals/requests/ID/remind on a position-routed request) · dedupe words: approval remind position literal notify audience filter, slot literal reminder never emitted, remind notified 0 position"
],
"gates": [
"exit 0 · node scripts/check-adr-0087-registration.mjs --base origin/main",
"exit 0 · node scripts/check-adr-0087-registration.mjs --self-test",
"exit 0 · node scripts/check-changeset-no-major.mjs --base origin/main",
"exit 0 · node scripts/check-changeset-no-major.mjs --self-test",
"exit 0 · node scripts/check-ci-filter-parity.mjs",
"exit 0 · node scripts/check-closing-keyword-parity.mjs",
"exit 0 · node scripts/check-closing-keyword-parity.mjs --self-test",
"exit 0 · node scripts/check-comment-mask-adoption.mjs",
"exit 0 · node scripts/check-comment-mask-adoption.mjs --self-test",
"exit 0 · node scripts/check-comment-mask-corpus.mjs",
"exit 0 · node scripts/check-dts-emitted.mjs --self-test",
"exit 0 · node scripts/check-empty-changeset.mjs --base origin/main",
"exit 0 · node scripts/check-empty-changeset.mjs --self-test",
"exit 0 · node scripts/check-issue-citations.mjs",
"exit 0 · node scripts/check-keyed-text-bounds.mjs",
"exit 0 · node scripts/check-keyed-text-bounds.mjs --self-test",
"exit 0 · node scripts/check-platform-object-tenancy-census.mjs",
"exit 0 · node scripts/check-platform-object-tenancy-census.mjs --self-test",
"exit 0 · node scripts/check-plugin-teardown-shape.mjs",
"exit 0 · node scripts/check-plugin-teardown-shape.mjs --self-test",
"exit 0 · node scripts/check-registry-log-declared.mjs",
"exit 0 · node scripts/check-registry-log-declared.mjs --self-test",
"exit 0 · node scripts/check-rest-log-spy-declared.mjs",
"exit 0 · node scripts/check-rest-log-spy-declared.mjs --self-test",
"exit 0 · node scripts/check-system-context-census.mjs",
"exit 0 · node scripts/check-system-context-census.mjs --self-test",
"exit 0 · node scripts/check-tenant-audit-census.mjs",
"exit 0 · node scripts/check-tenant-audit-census.mjs --self-test",
"exit 0 · node scripts/check-undeclared-dep-imports.mjs",
"exit 0 · node scripts/check-undeclared-dep-imports.mjs --self-test",
"exit 0 · node scripts/docs-audit/check-affected-docs.mjs",
"exit 0 · node scripts/docs-audit/check-drift-comment.mjs",
"exit 0 · node scripts/pm/release-rehearsal-clone.mjs --self-test",
"exit 0 · node scripts/release-pending-publish.mjs --self-test",
"exit 0 · pnpm --filter @objectstack/spec run check:duration-unit-keys",
"exit 0 · pnpm --filter @objectstack/spec run check:empty-state",
"exit 0 · pnpm --filter @objectstack/spec run check:liveness",
"exit 0 · pnpm --filter @objectstack/spec run check:strictness-ledger",
"exit 0 · pnpm --filter @objectstack/spec run check:variant-docs",
"exit 0 · pnpm check:changeset-gate-self-tests",
"exit 0 · pnpm check:cross-package-test-inputs",
"exit 0 · pnpm check:dispatcher-error-vocabulary",
"exit 0 · pnpm check:doc-authoring",
"exit 0 · pnpm check:driver-memory-census",
"exit 0 · pnpm check:dts-closure",
"exit 3 then 0 · pnpm check:dual-build-cjs-loads (first run PREREQUISITE NOT MET: 8 packages without dist; built them, 41/41 cached; re-run exit 0, 106 entry points / 66 packages load)",
"exit 0 · pnpm check:engine-double-contract",
"exit 0 · pnpm check:gitlink-declared",
"exit 0 · pnpm check:i18n",
"exit 0 · pnpm check:i18n-stale-fill",
"exit 0 · pnpm check:issue-citations",
"exit 0 · pnpm check:lean-entry-closure",
"exit 0 · pnpm check:logger-receiver-detach",
"exit 0 · pnpm check:nul-bytes",
"exit 0 · pnpm check:objectql-double-limit",
"exit 0 · pnpm check:objectui-changeset",
"exit 0 · pnpm check:org-identifier",
"exit 0 · pnpm check:page-declaration-shape",
"exit 0 · pnpm check:pm-changeset-deadline-census",
"exit 0 · pnpm check:published-files",
"exit 0 · pnpm check:query-options-erasure",
"exit 0 · pnpm check:refd-timer-probe",
"exit 0 · pnpm check:slot-lookup",
"exit 0 · pnpm check:sourcemap-no-sources-content",
"exit 0 · pnpm check:test-source-alias",
"exit 0 · pnpm check:tier-file-adoption",
"exit 0 · pnpm check:type-check-coverage",
"exit 0 · pnpm check:type-check-debt",
"exit 0 · pnpm check:watch-hint-literal",
"exit 0 · pnpm check:where-matcher",
"--ran verdict: node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack -> exit 0, 'Run reconciliation — 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN.' (derived at 7ca65ce74c, no paths; ran.list records the re-run exit for check:dual-build-cjs-loads)"
],
"line_budget": "n/a",
"deviations": [
"Base: worktree cut from origin/main e864db56df (main had moved past the dispatch base 5e0b489b before the worktree was created); BASE recorded as e864db56df.",
"H2 / Zone 3 measured: notify() does not build the payload — each call site does — so 'rename in the helper' is not a one-line edit. Route taken: rename at the 12 call sites (texts byte-identical) and make the helper's one change its declared payload type, which turns any other spelling into TS2353 (Leg B). No helper-side message->body remapping (that would be an internal alias).",
"Unit battery reaches 11 of 12 call sites: the reminder to slot literals (approval-service.ts remind(), literals branch) can never emit, because its audience is only colon-bearing slot addresses and notify() drops those before calling messaging. Its rename is held by the compiler, not by a runtime pin. Recorded as an Acceptance note, not filed.",
"Unit pin file is named approval-notification-body.integration.test.ts (it drives real ObjectQL over SqlDriver/better-sqlite3, the package's existing integration naming).",
"check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET) on the first pass; I built the 8 named packages (cache hits) and re-ran it to exit 0. ran.list records the re-run exit.",
"No content/docs change: no sentence there states the field or an empty body (approvals.mdx checked); the file surface's docs line is empty by measurement.",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session + 'Co-authored-by: Claude'), and the PR body ends with AGENTS.md's session-URL footer form, rather than the harness reminder's model-named trailer and footer spelling (AGENTS.md / os-dev precedence).",
"Labels: zero label writes on PR #21888 — the dispatch named none and skip-changeset does not apply (a real patch changeset ships). The label-write call set the PR assignee only."
],
"files_changed": [
"packages/plugins/plugin-approvals/src/approval-service.ts (+36/-14: local ApprovalNotificationPayload type; notify() payload typed; 12 message->body renames)",
"packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts (new, 251 lines)",
"packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts (new, 190 lines; fixture inline, no existing packages/qa file edited)",
".changeset/21847-approval-notification-body.md (new, patch for @objectstack/plugin-approvals, Clause-②: no)"
]
}
Verdict on the dev report 5997275648 (status: done), checked against GitHub and the tree. Accepted as built. No contract review is owed: Clause-②: no, and no packages/spec path is touched.
Checked:
All twelve approval notifications now send their text as payload.body, the one field messaging reads, instead of payload.message. The texts are byte-identical: the twelve removed message: lines equal the twelve added body: lines after the key.
notify() declares a local, non-exported ApprovalNotificationPayload (title, body, action URL, actions), so a call site that spells the text any other way does not compile. That is the producer-side guard. There is no message alias anywhere, and no service-messaging or spec edit.
H2: no call site bypasses the helper. H3: nothing reads payload.message from approval notifications, in this repo or in objectui at the pin 0abd4f9f. The only conceivable reader is a tenant-authored template using that variable, which cannot be measured, and the changeset names it.
Pins: a plugin-approvals battery over every call site that can notify (11 of 12), and a door pin reading a comment and a request-info question back from GET /api/v1/notifications. Both were red on the unfixed service.
Ablation:message put back in the helper turned both pins red. A call site spelling message fails tsc with TS2353. Both restores were proven.
Carried, not filed (in the PR's Acceptance notes): the reminder sent to unresolved slot literals never reaches messaging, because notify() filters colon-bearing audience entries first. So the "plain nudge" its comment promises is never sent. It was read in code only and not measured at a door.
Tooling, filed separately: this PR's assignee was written directly under the session's own login. label-write's relay wait timed out, and it fell back to a direct write. The effect is right; the write identity is not the fleet's.
Fixes #21847 closed this card completed. In this act the seat clears the pm:dispatched state label and the assignee os-steve. The domain:services, area:workflow, priority:p2 and bug labels stay.
What shipped: every approval notification sends its text as payload.body, the field messaging delivers, so a comment, a request-info question and an escalation reach the recipient's notification. The texts are unchanged. A local payload type makes any other spelling a compile error, and there is no message alias.
Activity
objectstack-fleet commentedon Oct 5, 2026
Path: approvals and automation — approvals in the UI: inbox and notifications | approvals.sla-escalation | P2
Triage: first grade —
bug·priority:p2·domain:services·area:workflow·pm:queue. The approval producer sends the documentedbody; no alias in the dispatcherTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-05T08:56Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/plugins/plugin-approvals/src/approval-service.ts(the notify helper, about:1373–:1410) ⇒domain:services; rationale:EmitInputdocumentstitle/body/url, every other producer sendsbody, and approvals alone sendsmessage.body, covering all 12 call sites. ⛔ Nomessagealias in the dispatcher: one field, as documented.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Claim: PM loop round 1 (17.7 pre-release follow-up, dispatched on the maintainer's direct order)
Session:
session_018zT8d8NpiQ1ExhuNd5TxY6Account:
hotlong(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-21847-approval-notification-bodyWorktree:
objectstack-issue-21847Domain:
domain:services(card label, as triage set it)File surface:
packages/plugins/plugin-approvals/src,.changeset/Container & model:
M,mode:subagent,model: opus(default tier; no path-derived mandate)Clause-②: no
Thread-read: 5991283949
Serial constraints cleared: none named
Provenance: the maintainer, in Claude Code session
session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「都开单派发」; landing per the standing order 「开发完整就进队列合并」. Dispatched one at a time within the session's load cap. Where the card withholds detail, this session holds it and the dispatch carries it privately.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Release: session
session_018zT8d8NpiQ1ExhuNd5TxY6· cause: re-route before dispatch (no dev was started on this card) · destination: back topm:queuefor thedomain:serviceslane seat to claim.Provenance: the maintainer, in Claude Code session
session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「只有不能公开的才需要你派发处理」. This card withholds nothing, so its dispatch belongs to the lane. The claim above is withdrawn, the assignee is cleared, and the triage grading stands.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Claim: PM loop round 3 · 2026-10-05T13:58Z
Session:
session_011K3zqE8Pv1Evw5hc8tZCnNAccount:
os-steve(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21847-approval-notification-bodyWorktree:
objectstack-issue-21847Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface (at
origin/main5e0b489b), per triage's direction5991283949:packages/plugins/plugin-approvals/src/approval-service.ts: the notify helper (about:1373–:1410) sends its text as the documentedbody, covering all 12 call sites.plugin-approvalstests, plus a door pin in a NEW file underpackages/qa/dogfood/test/(declared cross-lane on [PM seat] domain:cli — ⏳ vacant #6024 in this act): a comment's text and a request-info question, read back from the recipient's notification.content/docs/**sentences this makes false, and apatchchangeset.⛔ No
messagealias in the dispatcher or inservice-messaging: one field, asEmitInputdocuments. ⛔ No change to who is notified, or when.Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier).Clause-②: no
Thread-read: 5995642354
Serial constraints cleared: at 2026-10-05T13:58Z:
session_018zT8d8NpiQ1ExhuNd5TxY6was released before any dev started (5995642354), on the maintainer's routing 「只有不能公开的才需要你派发处理」. No branch of it exists.plugin-approvals/srcorservice-messaging/src. Noarea:workflowcard is in flight in this seat.Selection: the lane read fresh at this pick holds four p2 cards in
pm:queue: security(automation): a paused flow run's stored state keeps record-change values that #21830's mask removes elsewhere — detail withheld pending maintainer #21867, automation: an approval node's escalation values are checked only at execution — timeoutHours 0.5 registers and activates, then every run fails and the record is created with no approval gate #21848, approvals: every approval notification reaches the recipient with an empty body — the service puts its text in payload.message, messaging reads payload.body (comment and request-info text are lost) #21847 and plugin-audit's recordLabel guesses from a fixed key list instead of the ADR-0079 resolver, so an object titled by company_name shows its record id on every activity row #21878. security(automation): a paused flow run's stored state keeps record-change values that #21830's mask removes elsewhere — detail withheld pending maintainer #21867 withholds its detail, and by the maintainer's routing it is dispatched by the session holding that detail. This card is the oldest of the other three, and it takes the slot plugin-security: a data-door edit of a permission set saved into a writable runtime package forks it — the write-through's update leg saves without the row's package, leaving two activesys_metadatarows for one name #21861's build freed.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
os-dev-report
{ "issue": 21847, "status": "done", "branch": "claude/issue-21847-approval-notification-body", "pr": "https://github.com/objectstack-ai/objectstack/pull/21888", "session": "session_011K3zqE8Pv1Evw5hc8tZCnN — this run's harness-stamped Claude-Session (subagent of the PM session)", "premise_still_valid": true, "summary": "H1 confirmed and fixed at the producer: all twelve notify() call sites in approval-service.ts now send their (byte-identical) text as payload.body, the one field messaging reads, instead of payload.message; notify() declares a local, non-exported ApprovalNotificationPayload (title/body/actionUrl/actions) so a call site spelling it any other way is a compile error. No service-messaging or spec edit, no message alias, no change to who is notified or when; H2: no call site bypasses the helper; H3: no reader of payload.message on approval notifications in objectstack or objectui@0abd4f9f (only an unmeasurable tenant-authored notification template could have used {{ message }}; named in the changeset). Pinned by a new plugin-approvals battery over every call site that can notify (11 of 12; the slot-literal reminder never reaches messaging by construction) and a new dogfood door pin reading a comment and a request-info question back from GET /api/v1/notifications; both red on the unfixed service and under ablation. Draft PR #21888 with a patch changeset.", "tests": "All at HEAD 7ca65ce74c unless marked. RED first, at 68baad43a0 (the two new pins committed ahead of the fix, service unfixed): `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/approval-notification-body.integration.test.ts` -> exit 1, 'Tests 2 failed | 1 passed (3)': comment read 'expected [ [ 'u_alice' ], undefined ]', 22 offenders ('body is undefined' + \"undeclared payload field 'message'\") over the 11 reached call sites, coverage pin green; `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 --project isolated test/approval-notification-body.dogfood.test.ts` -> exit 1, \"AssertionError: the comment is the notification's body: expected '' to be 'The signed contract is attached to th…'\" (one row, non-empty title: the H1 defect at the door). Build at 68baad43a0: `pnpm turbo run build --filter='@objectstack/dogfood^...' --filter='@objectstack/plugin-approvals^...' --concurrency=1` 63/63 (48 cached). GREEN at 7ca65ce74c: plugin-approvals build exit 0; `pnpm --filter @objectstack/plugin-approvals typecheck` exit 0 (src tsc + scripts tsc + check:test-typecheck: '8 file(s) / 324 error(s) / 27 pinned signature(s) held', new test file clean); `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2` -> 'Test Files 61 passed (61) / Tests 898 passed (898)'; door pin -> 'Tests 1 passed (1)'; `pnpm --filter @objectstack/dogfood typecheck` exit 0. Resolution: both pins read plugin-approvals SOURCE (unit imports ./approval-service.js; dogfood isolated project aliases @objectstack/plugin-approvals to src/index.ts) — proven by Leg A going red at the door while dist held the fix, so no rebuild leg was owed. ABLATION (scripts/ablation-replace.mjs wrap mode + outer trap restore, REPO_ROOT-absolute path): Leg A 'message back in the helper' — anchor \"severity: 'info', ...event, payload, audience,\" x1->x0, replacement sending { ...rest, message: body } x0->x1, blob 9a1822845a2f->d066658afd62; unit exit 1 (2 failed: comment body undefined; 22 offenders), door exit 1 (\"expected '' to be 'The signed contract…'\"); restore blob 9a1822845a2f == HEAD blob, git diff HEAD empty, trap restore leg repeated the proof. Leg B 'a call site spells message' — request-info site 'body: input.comment.trim(),' -> 'message: input.comment.trim(),' (marker count 1); `pnpm --filter @objectstack/plugin-approvals exec tsc --noEmit` exit 1: \"src/approval-service.ts(4810,11): error TS2353: Object literal may only specify known properties, and 'message' does not exist in type 'ApprovalNotificationPayload'.\"; restore blob == HEAD; git status --porcelain empty after both legs. Texts unchanged: the 12 removed 'message:' lines equal the 12 added 'body:' lines byte-for-byte after the key, indentation included. Narrowed lint (full pnpm lint is CI's): `pnpm exec eslint --no-inline-config --format json` on the 3 touched .ts files -> JSON lists 3 files, 0 errors 0 warnings; population = eslint.config.mjs packages/**/*.{ts,…} blocks, none of the three ignored; invariance = the config enables no type-aware linting (no parserOptions.project), so untouched files' verdicts cannot move. Every build/test/typecheck/ablation ran under scripts/pm/os-verify-lock.sh (slot issue-21847-dev; one queue-timeout 99 on the first red attempt, resumed on the same slot) with NODE_OPTIONS=--max-old-space-size=3072, turbo --concurrency=1, vitest --maxWorkers=2. NOT MEASURED locally (CI's): the full dogfood suite, turbo-wide test/typecheck. CI on PR #21888: not awaited (in_progress at report time).", "mcp_calls": "0 — no MCP GitHub tool called; reads were REST GETs via gh api (card, comments, PR, PR events, fleet-write runs) and a shallow git fetch of objectui at the .objectui-sha pin.", "api_writes": "4 REST write requests from this seat (git push x3 are not REST: empty-branch probe, test commit 68baad43a0, fix commit 7ca65ce74c). (1) pr_create via scripts/pm/with-fleet.sh --via dispatch: POST /repos/objectstack-ai/objectstack/dispatches (204) -> relay run 37329933314 -> POST /repos/objectstack-ai/objectstack/pulls (draft) = #21888, read-back 8399 bytes identical. (2)+(3) label-write --assign os-steve: POST /repos/objectstack-ai/objectstack/dispatches (204), then POST /repos/objectstack-ai/objectstack/issues/21888/assignees (201), read-back matches. (4) this os-dev-report comment via scripts/pm/post-stamped.mjs --comment=21847 (relay).", "open_questions": [], "out_of_scope_findings": [ "carrier: 承接者:无 · noted in PR #21888 Acceptance notes, not filed · evidence: approval-service.ts remind() sends slot-literal pending entries (colon-bearing, e.g. position:P) to notify(), whose first step filters every colon-bearing audience entry, so that call returns 0 without emitting — the 'plain nudge' its own comment promises never goes out; measured only by reading the code and by the unit battery reaching 11 of 12 sites, NOT at a public door (POST /api/v1/approvals/requests/ID/remind on a position-routed request) · dedupe words: approval remind position literal notify audience filter, slot literal reminder never emitted, remind notified 0 position" ], "gates": [ "exit 0 · node scripts/check-adr-0087-registration.mjs --base origin/main", "exit 0 · node scripts/check-adr-0087-registration.mjs --self-test", "exit 0 · node scripts/check-changeset-no-major.mjs --base origin/main", "exit 0 · node scripts/check-changeset-no-major.mjs --self-test", "exit 0 · node scripts/check-ci-filter-parity.mjs", "exit 0 · node scripts/check-closing-keyword-parity.mjs", "exit 0 · node scripts/check-closing-keyword-parity.mjs --self-test", "exit 0 · node scripts/check-comment-mask-adoption.mjs", "exit 0 · node scripts/check-comment-mask-adoption.mjs --self-test", "exit 0 · node scripts/check-comment-mask-corpus.mjs", "exit 0 · node scripts/check-dts-emitted.mjs --self-test", "exit 0 · node scripts/check-empty-changeset.mjs --base origin/main", "exit 0 · node scripts/check-empty-changeset.mjs --self-test", "exit 0 · node scripts/check-issue-citations.mjs", "exit 0 · node scripts/check-keyed-text-bounds.mjs", "exit 0 · node scripts/check-keyed-text-bounds.mjs --self-test", "exit 0 · node scripts/check-platform-object-tenancy-census.mjs", "exit 0 · node scripts/check-platform-object-tenancy-census.mjs --self-test", "exit 0 · node scripts/check-plugin-teardown-shape.mjs", "exit 0 · node scripts/check-plugin-teardown-shape.mjs --self-test", "exit 0 · node scripts/check-registry-log-declared.mjs", "exit 0 · node scripts/check-registry-log-declared.mjs --self-test", "exit 0 · node scripts/check-rest-log-spy-declared.mjs", "exit 0 · node scripts/check-rest-log-spy-declared.mjs --self-test", "exit 0 · node scripts/check-system-context-census.mjs", "exit 0 · node scripts/check-system-context-census.mjs --self-test", "exit 0 · node scripts/check-tenant-audit-census.mjs", "exit 0 · node scripts/check-tenant-audit-census.mjs --self-test", "exit 0 · node scripts/check-undeclared-dep-imports.mjs", "exit 0 · node scripts/check-undeclared-dep-imports.mjs --self-test", "exit 0 · node scripts/docs-audit/check-affected-docs.mjs", "exit 0 · node scripts/docs-audit/check-drift-comment.mjs", "exit 0 · node scripts/pm/release-rehearsal-clone.mjs --self-test", "exit 0 · node scripts/release-pending-publish.mjs --self-test", "exit 0 · pnpm --filter @objectstack/spec run check:duration-unit-keys", "exit 0 · pnpm --filter @objectstack/spec run check:empty-state", "exit 0 · pnpm --filter @objectstack/spec run check:liveness", "exit 0 · pnpm --filter @objectstack/spec run check:strictness-ledger", "exit 0 · pnpm --filter @objectstack/spec run check:variant-docs", "exit 0 · pnpm check:changeset-gate-self-tests", "exit 0 · pnpm check:cross-package-test-inputs", "exit 0 · pnpm check:dispatcher-error-vocabulary", "exit 0 · pnpm check:doc-authoring", "exit 0 · pnpm check:driver-memory-census", "exit 0 · pnpm check:dts-closure", "exit 3 then 0 · pnpm check:dual-build-cjs-loads (first run PREREQUISITE NOT MET: 8 packages without dist; built them, 41/41 cached; re-run exit 0, 106 entry points / 66 packages load)", "exit 0 · pnpm check:engine-double-contract", "exit 0 · pnpm check:gitlink-declared", "exit 0 · pnpm check:i18n", "exit 0 · pnpm check:i18n-stale-fill", "exit 0 · pnpm check:issue-citations", "exit 0 · pnpm check:lean-entry-closure", "exit 0 · pnpm check:logger-receiver-detach", "exit 0 · pnpm check:nul-bytes", "exit 0 · pnpm check:objectql-double-limit", "exit 0 · pnpm check:objectui-changeset", "exit 0 · pnpm check:org-identifier", "exit 0 · pnpm check:page-declaration-shape", "exit 0 · pnpm check:pm-changeset-deadline-census", "exit 0 · pnpm check:published-files", "exit 0 · pnpm check:query-options-erasure", "exit 0 · pnpm check:refd-timer-probe", "exit 0 · pnpm check:slot-lookup", "exit 0 · pnpm check:sourcemap-no-sources-content", "exit 0 · pnpm check:test-source-alias", "exit 0 · pnpm check:tier-file-adoption", "exit 0 · pnpm check:type-check-coverage", "exit 0 · pnpm check:type-check-debt", "exit 0 · pnpm check:watch-hint-literal", "exit 0 · pnpm check:where-matcher", "--ran verdict: node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack -> exit 0, 'Run reconciliation — 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN.' (derived at 7ca65ce74c, no paths; ran.list records the re-run exit for check:dual-build-cjs-loads)" ], "line_budget": "n/a", "deviations": [ "Base: worktree cut from origin/main e864db56df (main had moved past the dispatch base 5e0b489b before the worktree was created); BASE recorded as e864db56df.", "H2 / Zone 3 measured: notify() does not build the payload — each call site does — so 'rename in the helper' is not a one-line edit. Route taken: rename at the 12 call sites (texts byte-identical) and make the helper's one change its declared payload type, which turns any other spelling into TS2353 (Leg B). No helper-side message->body remapping (that would be an internal alias).", "Unit battery reaches 11 of 12 call sites: the reminder to slot literals (approval-service.ts remind(), literals branch) can never emit, because its audience is only colon-bearing slot addresses and notify() drops those before calling messaging. Its rename is held by the compiler, not by a runtime pin. Recorded as an Acceptance note, not filed.", "Unit pin file is named approval-notification-body.integration.test.ts (it drives real ObjectQL over SqlDriver/better-sqlite3, the package's existing integration naming).", "check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET) on the first pass; I built the 8 named packages (cache hits) and re-ran it to exit 0. ran.list records the re-run exit.", "No content/docs change: no sentence there states the field or an empty body (approvals.mdx checked); the file surface's docs line is empty by measurement.", "Commit trailers use AGENTS.md's model-free pair (Claude-Session + 'Co-authored-by: Claude'), and the PR body ends with AGENTS.md's session-URL footer form, rather than the harness reminder's model-named trailer and footer spelling (AGENTS.md / os-dev precedence).", "Labels: zero label writes on PR #21888 — the dispatch named none and skip-changeset does not apply (a real patch changeset ships). The label-write call set the PR assignee only." ], "files_changed": [ "packages/plugins/plugin-approvals/src/approval-service.ts (+36/-14: local ApprovalNotificationPayload type; notify() payload typed; 12 message->body renames)", "packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts (new, 251 lines)", "packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts (new, 190 lines; fixture inline, no existing packages/qa file edited)", ".changeset/21847-approval-notification-body.md (new, patch for @objectstack/plugin-approvals, Clause-②: no)" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Review: PR #21888 at
7ca65ce7· seatdomain:services#1·session_011K3zqE8Pv1Evw5hc8tZCnN· 2026-10-05T15:17ZVerdict on the dev report
5997275648(status: done), checked against GitHub and the tree. Accepted as built. No contract review is owed:Clause-②: no, and nopackages/specpath is touched.Checked:
payload.body, the one field messaging reads, instead ofpayload.message. The texts are byte-identical: the twelve removedmessage:lines equal the twelve addedbody:lines after the key.notify()declares a local, non-exportedApprovalNotificationPayload(title, body, action URL, actions), so a call site that spells the text any other way does not compile. That is the producer-side guard. There is nomessagealias anywhere, and noservice-messagingor spec edit.payload.messagefrom approval notifications, in this repo or in objectui at the pin0abd4f9f. The only conceivable reader is a tenant-authored template using that variable, which cannot be measured, and the changeset names it.plugin-approvalsbattery over every call site that can notify (11 of 12), and a door pin reading a comment and a request-info question back fromGET /api/v1/notifications. Both were red on the unfixed service.messageput back in the helper turned both pins red. A call site spellingmessagefailstscwithTS2353. Both restores were proven.Carried, not filed (in the PR's Acceptance notes): the reminder sent to unresolved slot literals never reaches messaging, because
notify()filters colon-bearing audience entries first. So the "plain nudge" its comment promises is never sent. It was read in code only and not measured at a door.Tooling, filed separately: this PR's assignee was written directly under the session's own login.
label-write's relay wait timed out, and it fell back to a direct write. The effect is right; the write identity is not the fleet's.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Landed · seat
domain:services#1·session_011K3zqE8Pv1Evw5hc8tZCnN· 2026-10-05T16:20Z255a7775, which is an ancestor oforigin/main(verified withgit merge-base --is-ancestorafter a fetch). Merged at 2026-10-05T16:18Z.Fixes #21847closed this cardcompleted. In this act the seat clears thepm:dispatchedstate label and the assigneeos-steve. Thedomain:services,area:workflow,priority:p2andbuglabels stay.payload.body, the field messaging delivers, so a comment, a request-info question and an escalation reach the recipient's notification. The texts are unchanged. A local payload type makes any other spelling a compile error, and there is nomessagealias.5997420679and the PR's Acceptance notes: the reminder sent to unresolved slot literals is filtered out before it reaches messaging (read in code, not measured at a door). The write-identity fallback inlabel-writeseen on this PR is filed as finding(pm tooling): label-write falls back to a direct write under the seat's personal login when an accepted relay dispatch shows no run within 90 s — the identity exchange #19774 forbade, on a second branch #21892.Generated by Claude Code
fix(plugin-approvals): approval notifications reach their recipient w…