Skip to content

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

Closed

Description

@objectstack-fleet

QA-source: #21845 · approvals.sla-escalation · acceptance[3]

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.

Reproduction

  1. Boot the showcase. User A comments on an approval request where user B is a party (or: an SLA breach with notifySubmitter:true, see QA run · follow-up: 6 previously unrun or published-only items (6/6) · 316be321 + 53021e3a · 2026-10-05 · 4 PASS / 0 PARTIAL / 2 FAIL / 0 BLOCKED / 0 NOT-RUN #21845).
  2. As user B, GET /api/v1/notifications.
  • 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.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    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.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    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.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    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.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    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/main 5e0b489b), 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


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  6. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: PR #21888 at 7ca65ce7 · seat domain:services#1 · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T15:17Z

    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.
    • Gates: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN.

    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

  7. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · seat domain:services#1 · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T16:20Z


    Generated by Claude Code

  8. added a commit that references this issue on Oct 7, 2026
    255a777
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions