Skip to content

feat(plugin-approvals): opening an approval step tells each resolved approver (approval.requested) - #22625

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22607-approval-opened-topic
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22607-approval-opened-topic

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22607
Clause-②: yes

Opening an approval step now tells the people it landed on. openNodeRequest publishes the new topic approval.requested to each concrete approver on the slate the request opened on. It goes through notify(), the same single ingress (ADR-0030) and payload shape that approval.reminder and every other sibling topic use. There is no second notification path. The approvals guide's callout "Opening a request notifies nobody" is rewritten. Its manager_id sentences now name the Console surface that writes the column: Setup → Users → ⋯ → Set Manager.

Dispatched by the domain:services seat 1 PM, session session_013j5gkUCpqQiti4GgPqqmnt. Claim 6094034487, triage 6093976698.

Measured first: who followed the guide's workaround

The guide used to recommend "a notify node next to the approval node" as the way to tell approvers. A flow that did that would now tell the same approvers twice. I counted every flow that holds an approval node, and classified each notify node in it by edge reachability: reachable from an approval node means it runs after the decision (outcome messaging, not the workaround), otherwise it runs before or beside the approval node (a workaround candidate).

tree (commit) flows with an approval node approval nodes notify nodes in those flows before or beside the approval (workaround)
this repo examples/ (d748ae80af; 31 flows scanned) 8 15 1 (showcase_invoice_signoff.notify_cleared, after the decision) 0
objectstack-ai/hotcrm src/ (f0afcbd) 5 6 6 (all notify_approved / notify_rejected) 0
objectstack-ai/hotclm src/ (46e65f0, and PR #101's head 0089ffa) 1 6 2 (notify_approved / notify_rejected) 0

Control on the parser: hotcrm's grep for approval nodes returns 11 hits = 5 doc-comment mentions + the 6 real nodes the parser found. I also searched all three trees for a flow or hook triggered by sys_approval_request creation that notifies approvers. None exists. No measured app double-notifies. The changeset still names the remedy for apps outside these trees.

Where the topic is declared

Measured: nowhere but the call site. The sibling topics are string literals at their notify() call sites in approval-service.ts. service-messaging keeps no topic catalog: a topic is a free string, keyed only by the preference filter, and mandatoryTopics defaults to empty. packages/spec declares none of them. So this PR adds no spec change and no registry row.

The name is approval.requested. The card asks for "an approval.requested-style topic", ADR-0012's Layer-1 event example names approval.requested, and the sibling vocabulary uses past participles (reassigned, escalated, returned, ooo_substituted).

The recipient rule, and how it matches the reminder's

remind() fans out over the slate's concrete identities (a user id or an email), sending one notify() per approver. A type:value literal gets no personal message: notify() drops every slot address from every audience, so the reminder's literal arm reaches nobody. The opening applies the same rule to the slate it actually opened on, which is after OOO delegation and after an onEmptyApprovers: 'fallback' replacement:

  • each concrete approver gets one emit with audience: [approver], source = the request, actionUrl deep-linked to it by notify(), and the opening run's person as the actor;
  • each person is told once: per-group tagging can list one person twice on the slate, and the fan-out de-duplicates;
  • a literal slot (position:NAME with no holders) gets no message. So a slate of literals only notifies nobody, and onEmptyApprovers decides that case: admin_rescue opens and tells nobody, auto_approve and fail open nothing, fallback opens on and tells the fallback approvers;
  • an out-of-office delegate gets exactly one message, the existing approval.ooo_substituted, not a second one for the opening.

The send happens last, after every write of the opening has landed. Like every notify(), it is best-effort: a channel that throws logs a warning and never fails the opening.

Deliberately not included: one-tap Approve / Reject links. ADR-0043 mints those bearer tokens in remind() only, and minting them on every opening is a decision this card does not make.

Pins (approval-requested-notification.integration.test.ts)

These run on the real ApprovalService, on a real ObjectQL over SqlDriver (in-memory better-sqlite3, real DDL). No new fake engine.

  1. Opening a step puts one message in each resolved approver's inbox: a named user plus both holders of a staffed position get exactly three emits, one per person, with the reminder's payload shape, source, deep link and actor.
  2. A person the slate lists under two groups is told once.
  3. A mixed slate (unstaffed position plus a user) tells only the user.
  4. Control: a step whose approvers resolve to nobody notifies no one, and onEmptyApprovers decides it. Four cases: admin_rescue (opens, tells nobody), auto_approve (no request, no emit), fail (refused, no emit), fallback (opens on and tells the fallback approver).
  5. An OOO delegate is told once, by approval.ooo_substituted.
  6. A channel that throws never fails the opening, and the failure is logged with the topic.

Existing batteries widened to the new call site: approval-notification-body.integration.test.ts (the site census is now twelve of thirteen call sites, and every new emit carries a non-empty body and no undeclared payload field) and actor-lookups-hold-ids.integration.test.ts (the opening forwards the person, never the email slot). Four tests in approval-service.test.ts attached messaging before opening and then read emitted[0] or the total count as if their own step were the only sender. Each now asserts that the opening sent exactly approval.requested, clears the array, and keeps its original assertions unchanged.

Ablation: the publish removed

node scripts/ablation-replace.mjs replaced for (const approver of openedOn) { with for (const approver of [] as string[]) { (anchor hits 1 → 0, blob 2a30646be68a → 8e67159c79bf). It ran at committed HEAD 36f03883d3, which holds the implementation and every pin, with a trap-armed restore. The subject is imported relatively from src/, so no dist/ is involved.

  • Mutated: 12 failed / 359 passed across the 4 files. Red: all six positive pins in the new file, both widened batteries, and the four scoped tests. Green, as expected: the three empty-slate controls (admin_rescue, auto_approve, fail).
  • Restored: blob back to 2a30646be68a = HEAD's blob, git diff HEAD empty, git status clean.

Docs (content/docs/automation/approvals.mdx)

Verification

Final runs were taken on HEAD 05acc5d51f, after merging origin/main 18d999031b:

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 :: exit 0, 67 files / 972 tests passed (the new file has 9 tests).
  • pnpm --filter @objectstack/plugin-approvals typecheck :: exit 0 (tsc --noEmit twice, then check:test-typecheck: OK, the test layer compiles with 27 pinned signatures held and no new debt).
  • pnpm --filter @objectstack/spec exec vitest run --project repo (spec test:repo, which scans content/docs) :: exit 0, 54 files / 915 tests passed.
  • pnpm --filter @objectstack/spec check:generated :: exit 0, "All 15 generated artifacts are up to date".
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 96 gate commands from this diff. All 96 ran on this HEAD, each exit 0. --ran reconciliation: "96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero because every line carries its exit code. On the pre-merge HEAD, check:skill-examples, check:i18n and check:dual-build-cjs-loads first answered exit 3 (PREREQUISITE NOT MET, missing dist/). After their named prerequisite builds they measured green, and the post-merge sweep ran them green.
  • Ablation: see above (12 red / 359 green while mutated, restore proven).
  • Not run locally: the integration-tier and dogfood suites of other packages, and the repo-wide lint. Those runs belong to CI. Out-of-package readers of approver inboxes were checked by reading them (Acceptance notes).

Acceptance notes

  • ADR-0043 says the opposite in one parenthetical. docs/adr/0043-actionable-approval-links.md :40–:42 (Accepted) says "(Open-time notification remains the flow author's notify node; templates there can adopt the same links later.)" After this PR that sentence is stale. I read it as a v1 scope note, not a recorded decision: it sits in Mechanics, weighs no alternative, gives no rationale, and anticipates a "later". ADR-0030's single-ingress decision also names "approval requests" as a producer. Amending ADR-0043 is a Tier H edit and is not in this PR. It is raised as an open question in the dev report.
  • docs/qa/platform-checklist/areas/approvals.json :341, :394 and :400 state that "opening a request notifies NOBODY" and that "no approval.opened topic exists". After this PR those notes are stale but harmless: the fixtures they describe still drive remind / reassign. The file is outside this claim's surface; owner: none.
  • No end-to-end inbox pin in packages/qa/dogfood (outside this claim's surface). The new emit uses the reminder's channel, whose delivery to sys_inbox_message the card measured on 17.7.0 (remind → the Executive's inbox total 1).
  • examples/app-showcase/src/automation/flows/index.ts:1262 says the dual sign-off's approvers "are notified concurrently". That is now true for staffed holders, so no change.
  • Existing readers of approver inboxes outside this package all filter by topic or title (approval-notification-body.dogfood.test.ts, seed-ownership-claim-dispatch.dogfood.test.ts, examples/app-showcase/test/approval-resume-relation-expand.test.ts). None counts all of an approver's messages.

Generated by Claude Code

…approver (approval.requested)

openNodeRequest publishes approval.requested to each concrete approver on
the slate the request opened on, through the same notify() ingress and
payload shape as approval.reminder: once per person, never to a type:value
literal, not to an out-of-office delegate already told by
approval.ooo_substituted. A slate of literals notifies nobody, and
onEmptyApprovers decides that case.

Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt
Co-authored-by: Claude <noreply@anthropic.com>
…p sends after the opening

Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt
Co-authored-by: Claude <noreply@anthropic.com>
…es manager_id

The guide's callout that opening a request notifies nobody is rewritten
for the approval.requested topic, with the remedy for flows that added a
notify node beside the approval node. The manager_id sentences name the
Console surface that writes the column: Setup, Users, Set Manager.

Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 4 documentable anchor(s).

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

  • content/docs/automation/approvals.mdx (via approval.requested (literal, a string literal in openNodeRequest), sys_approval_request (literal, a string literal in openNodeRequest))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class), sys_approval_request (literal, a string literal in openNodeRequest))
  • content/docs/automation/workflows.mdx (via sys_approval_request (literal, a string literal in openNodeRequest))
  • content/docs/plugins/packages.mdx (via sys_approval_request (literal, a string literal in openNodeRequest))
  • content/docs/ui/translations.mdx (via sys_approval_request (literal, a string literal in openNodeRequest))

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

  • content/docs/releases/v16.mdx (via sys_approval_request (literal, a string literal in openNodeRequest))
  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class), sys_approval_request (literal, a string literal in openNodeRequest))

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

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

Coarse fallback — 6 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 e22315238f9426708e10cfff1d6a96e56cca33a1 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 05acc5d51fe577358bbdbb714f08983e2c7d1647
Local-runs: none

Rendered 2026-10-10T06:33Z on PR #22625 (card #22607). The head is the merge of origin/main 18d999031b into the branch; the merge-base against origin/main e22315238f is 18d999031b, so the net diff is 7 files, +383 / -15. Inputs: the card body and its three comments (triage 6093976698, claim 6094034487, os-dev-report 6094566317), the PR body and file list, the net diff origin/main...05acc5d51f, and the head's check-runs. Read-only: nothing was built, run or re-run; every derived gate family is answered by the check-runs below.

Check-runs on the head (latest run per name; read six times, one curl a minute, until none was in progress; the last run completed 2026-10-10T06:31Z): 35 runs, 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failed, 0 still running. All seven required contexts green: Lint & Repo Gates, TypeScript Type Check, Test Core (the aggregate and all 6 shards), Dogfood Regression Gate (the aggregate and all 3 shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset, Check PR Size, Spec property liveness, the four claim and closing-target gates and the four Type Check · gates are green too.

① Derived judgments

Governance frame first: the file list touches no governed surface (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md); head repo equals base repo; 398 changed lines. This record is owed because the claim declares Clause-②: yes, not because of a path hit.

  1. Accept-set: unchanged — right. No packages/spec file moves; ApprovalNodeConfig and ApproverType are untouched; openNodeRequest's input type and return type are byte-identical to main; no REST route is added or widened, so no closed query-parameter set is owed. check:api-surface, check:authorable-surface and the liveness gate are green on the head, which agrees.

  2. Public surface: one new notification topic, approval.requested, emitted by openNodeRequest — right, and placed right. It goes through the private notify() ingress (ADR-0030 single ingress) in the same call shape as approval.reminder and approval.reassigned: audience of one approver, actorContext: context, source: sys_approval_request, a per-approver dedupKey, and actionUrl: '/system/approvals' which notify() rewrites centrally to ?request=ID. No second path, no direct inbox write. Measured at the head: this.notify({ has 13 call sites (12 on main), and the body battery's "twelve of the thirteen" count matches. The topic is declared nowhere else: approval.reminder occurs outside plugin-approvals only in the changeset and the QA checklist, so no catalog, i18n bundle or preference list is owed; ADR-0012 line 143 already names approval.requested as its Layer-1 example, so the name is on record. The dev's hypothesis (1) — no spec stop — is confirmed on the head tree.

  3. Recipient rule — right, and it is the reminder's. openedOn is the slate the request actually opened on (after OOO delegation and after an onEmptyApprovers: 'fallback' replacement), filtered by the same concrete-identity predicate remind() uses (!a.includes(':')), de-duplicated by Set, minus OOO delegates already told by approval.ooo_substituted. Consequences I checked against the source: admin_rescue opens on literals only, so zero emits; auto_approve returns before any write, zero; fail throws before any write, zero; fallback tells the fallback approvers. The fan-out is the last statement before return, after mirrorStatusField, so a half-opened request tells no one. A failing channel is caught inside notify() and logged at warn with the topic — a functional degradation, correctly below error.

  4. Actor forwarding — right. actorContext: context hands the ExecutionContext, never a string; recordedActor() yields the person or nothing (ADR-0118 D1); the widened actor battery pins u_sub on the link-opened request and the machine-actor case stays in that file's existing pin.

  5. Pins cover triage's three. (a) one message per resolved approver — new file test 1 (a named user plus two position holders give exactly three single-audience emits in the reminder's payload shape, with deep link and actor), tests 2 and 3 (a twice-listed person told once; a literal slot tells nobody); (b) the empty-slate control with onEmptyApprovers deciding — four cases, fallback as the positive arm; (c) ablation — reported 12 red / 359 green with the fan-out loop emptied and a trap-proved restore. The arithmetic agrees with the diff: 6 positive pins in the new file + 2 widened batteries + 4 scoped approval-service.test.ts assertions = 12, and the 3 no-emit controls stay green by construction. Real ObjectQL over SqlDriver, no new fake engine. The four approval-service.test.ts edits splice the opening's emit off and keep their original assertions unchanged — right.

  6. Docs — right on every sentence I could check. The approvals.mdx callout is rewritten from "notifies nobody" to the new rule, naming who is told, who is not and why, and the double-notify remedy. :380, plus the bounded in-place fixes at :32 and the manager callout, name Setup → Users → ⋯ → Set Manager — verified at the head: sys_user declares the set_user_manager row action labelled "Set Manager" targeting POST /api/v1/auth/admin/set-user-manager (packages/platform-objects/src/identity/sys-user.object.ts:372–:378). docs(approvals): approvals.mdx says an approver entry that resolves to nobody opens an empty slate that "parks forever"; group entries keep their type:value literal, and every empty slate reaches onEmptyApprovers #22584's sentence (:384–:386) is untouched and the single-writer-path gate is green. "Without a messaging service, no approval notification is delivered at all" is true: notify() returns 0 on a missing messaging surface. No other hand-written page states the old rule: a head-wide grep for "notifies nobody", "approval.opened" and "Opening a request notif" hits only the QA checklist (③).

  7. Workaround census — accepted as reported, and the verdict does not rest on it. The in-repo half (8 approval flows, 15 nodes, one notify node and it runs after the decision) is consistent with examples/app-showcase at the head; the hotcrm and hotclm halves are the dev's readings at named commits and lie outside this record's inputs. The changeset names the remedy for out-of-tree apps regardless.

  8. Hygiene. [#22607] in a code comment follows the file's existing convention (56 such anchors on main); no tracker number in a runtime string; no model identifier in the diff; no release-owned or auto-generated file touched; the PR footer is the session-URL form.

② Semver level

.changeset/22607-approval-opened-topic.md declares "@objectstack/plugin-approvals": minor with Clause-②: yes (widening) — one arm from the closed pair, matching the PR body's Clause-②: yes. Right: @objectstack/plugin-approvals is a released package (17.7.0, not private); the diff adds behaviour (a new topic, new inbox rows per opening) and removes or renames nothing an author can write, so minor is the floor and patch would be wrong; nothing is breaking, so no ADR-0087 disposition marker is owed and skip-changeset would be wrong. The body states what is new, who is told, who is not, what is not included (one-tap links), and the one migration-shaped note a consumer needs (remove a notify node that only told the approvers at opening). Check Changeset is green.

Clause-②: yes (widening) — a new public notification topic on a released package; the changeset declares it; nothing in the diff narrows.

③ Boundary flags

  1. ADR-0043 :40–:42, "(Open-time notification remains the flow author's notify node; templates there can adopt the same links later.)" — answered: a v1 scope note, not a recorded decision; the PR reverses nothing in ADR-0043 and leaves one parenthetical stale. I read the ADR at the head. Its decision is the seven-row token table (scope, storage, single-use, TTL, identity, invalidation, scanner-proof) and the mechanics that enforce it; the parenthetical qualifies the "Issue" bullet's wiring ("Wired into remind()"), says "remains" (status quo) and "later" (anticipates change), weighs no alternative, gives no rationale, and open-time notification is not among its "Non-goals (v1)". Everything the ADR decides still holds after this PR: tokens are minted in remind() only and the opening carries none. ADR-0030's single-ingress decision names "approval requests" as a producer and ADR-0012 names approval.requested, so the ADR record as a whole points where this PR goes. Prime Directive 13 is not breached, and keeping docs/adr/** out of this PR was right (Tier H). Escalated to the maintainer, option A: a one-line status amendment on ADR-0043 in a docs-only Tier H PR, saying open-time notification is now the service's approval.requested topic and one-tap links remain remind()-only. The same PR should touch skills/objectstack-automation/references/state-machines-and-approvals.md:382–:383 ("rather than expecting the node to send mail itself"), which I found stale in the same way and which is skills/**, Tier H too. Not a FAIL ground.

  2. One-tap Approve / Reject links on the opening — answered: no, option A as shipped. ADR-0043 scopes token minting to remind() by its own text; minting two 72-hour bearer tokens per approver on every opening widens the session-less bypass lane the ADR calls "deliberate, narrow", and that is an ADR amendment, not a card rider. The card asks that approvers be told, and the opening message deep-links to the request. The changeset and the guide both say the links are reminder-only, so no reader is misled. Not escalated; a future demand goes through an ADR-0043 amendment first.

  3. docs/qa/platform-checklist/areas/approvals.json :341 (knownGaps: "opening a request notifies NOBODY … there is no approval.opened topic") and :394 (source: "the emitting topic set, which has no approval.opened member") — answered: stale after this PR, harmless, correctly outside the claim surface; escalated for a carrier. :400 is a dated history row and stays true as history. The fixtures still drive remind and reassign, so no checklist run misreads, and check:platform-checklist rides the green Lint & Repo Gates. The dev named no carrier. Escalated to the dispatching seat: file a small docs-only follow-up card on the checklist, or fold it into the next checklist-author sweep, so the knownGaps note is removed and the source note names approval.requested. Not a FAIL ground.

  4. No other dev flag. The report's premise_still_valid: true holds on the head: main at 18d999031b had no opening topic before this diff. The two serial constraints the claim cleared are honoured: plugin-approvals: sys_approval_action (the decision log) and sys_approval_approver (the approver index) are served on the generic data door with no read narrowing, the sibling half of #22559 #22589's files (request-read-gate.ts, approvals-plugin.ts) are untouched here, and docs(approvals): approvals.mdx says an approver entry that resolves to nobody opens an empty slate that "parks forever"; group entries keep their type:value literal, and every empty slate reaches onEmptyApprovers #22584's sentence (approvals.mdx:384–:386) is unchanged — the same-page hunk edits :380–:381 only.

Implemented-by: claude/issue-22607-approval-opened-topic
Reviewed-by: session_013j5gkUCpqQiti4GgPqqmnt (contract-review subagent, CONTRACT_REVIEW_TIER)

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 06:35
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 06:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit e8c6666 Oct 10, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22607-approval-opened-topic branch October 10, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants