Skip to content

fix(plugin-webhooks): gate webhook redelivery on webhook-administration access - #27474

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/harden-webhook-redeliver-door
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/harden-webhook-redeliver-door

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Hardening dispatched directly by QA (domain:devx); no tracking issue.

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches the redeliver door (git grep -i redeliver -- skills: 0 hits at b4a2146); the page that does, content/docs/automation/webhooks.mdx section 8, is updated in this diff

Review owed before the queue: this narrows who may call a public door (POST /api/v1/webhooks/redeliver). The contract-tier review of 957b968 (FAIL, one must-fix and four should-fixes) is answered at b4a2146; a delta review by that seat is owed on this head.

维护者速读

  • 改了什么:Webhook 重投递入口(POST /api/v1/webhooks/redeliver,两个入口面)在登录校验之后、读取请求体之前,由安全服务判定调用者是否有 Webhook 管理权限(sys_webhook 的对象级编辑准入);判定所用的调用者上下文按部署的租户隔离姿态解析,与数据 API 一致:隔离姿态下,会话声称的组织若已无有效成员关系,该声明不被采纳,入口以同一个 403 拒绝;重投递只写入调用者被准入的那个组织。
  • 为什么改:原先"任何已登录用户都可重投递"只是早期延后实现;本轮复核指出闸门未带租户姿态,在隔离姿态下与数据 API 的准入不一致(成员关系已结束的会话仍按旧组织解析),现已对齐 2026-09-05 裁决(选项 B)的逐请求成员关系校验。
  • 风险与回滚:依赖非管理员点重投递的部署改为 403(迁移:用权限集授予 sys_webhook 编辑权);隔离姿态下组织声明失效的会话改为 403(切换到自己所属的组织即可);未注册安全服务的部署改为 503,并在启动时打印一次提示。回滚即 revert 本 PR。待维护者定夺(人工底线,未在本 PR 内决定):重投递权限轴,按现实现为 sys_webhook 的 update,备选为投递对象上的专用能力(需改 spec)。
  • 先看:packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts(租户解析器两条腿、被丢弃组织声明的拒绝、失败方向)、webhook-outbox-plugin.ts 中 serveRedeliver 闸门段与重投递所用租户、webhook-redeliver-entitlement.test.ts 新增的隔离姿态用例。
  • 两分钟验证:pnpm --filter @objectstack/plugin-webhooks exec vitest run --maxWorkers=2 src/webhook-redeliver-entitlement.test.ts,预期 21 个用例全绿(含 isolated/group 下组织声明失效 403 且未重投、成员关系有效时放行且限定本组织、真实 ObjectKernel 上租户服务失败 503)。
  • 需要你做的:裁定重投递的权限轴(保持 sys_webhook 的 update,或改为投递对象上的专用能力)。

What changes

  • The gate. serveRedeliver (the one body both faces run: the self-hosted raw-app mount and the webhooks slot's handleRedeliver) asks, right after the auth gate and the 401 and before the request body is read, whether the signed-in caller may administer webhooks (redeliver-entitlement.ts):
    • the caller's ExecutionContext comes from resolveRequestExecutionContext (the one platform composition), fed the session the door already read (session: { established }), so the gate judges exactly the identity the door acts as;
    • the decision is ISecurityService.explain({ object: 'sys_webhook', operation: 'update' }, context).allowed (ADR-0124 D1: server-side);
    • refused: 403 PERMISSION_DENIED, one fixed answer, so nothing in it depends on a delivery row; no security service, or a check that throws: 503 SERVICE_UNAVAILABLE (ADR-0124 D3: an undecided case is never admitted; cause logged at error).
  • The organization (this round's must-fix). The composition is now handed the door's own tenancy resolver (redeliverTenancyResolver), so the session's organization claim is vetted against the posture in force exactly as the data API's doors vet it (the per-request membership check of the 2026-09-05 ruling, option B). The resolver reads the kernel's async accessor (getServiceAsync('tenancy')) where the kernel has one, so classifyAdmissionTenancyPosture sees the registry's own answer: never registered is no posture, registered and failing is the outage the gate's catch answers 503. On a factory-less KernelBase host the sync accessor's only fault is "not registered", absorbed inside the thunk. The admission carries the vetted tenantId, and the replay is scoped to it, never to the raw session claim.
  • A claim the composition did not keep is refused with the same 403 body. The replay is scoped by one organization; a dropped claim leaves none, and threading none would be an unscoped write, wider than any write the data API's gate gives that caller (with no active organization the tenant layer denies every row under isolated and admits only the caller's own organizations under group, a set this door's single-tenant scope cannot express). The principal is not refused: the session stays signed in with no active organization, as option B prescribes; only this door's act, which needs an organization, is declined.
  • Absence is loud at boot. When the door is served on either face and no security service can decide, the plugin says once at kernel:ready, at warn, that every caller will be answered 503, naming the remedy (compose @objectstack/plugin-security).
  • Why update on sys_webhook, not read on sys_http_delivery. Replaying a delivery is a write-shaped act (the row resets and a signed request goes out), the same power as editing or re-activating the subscription; a read-only principal holding a read wildcard must not gain it. The axis is pending the maintainer (see 维护者速读).
  • Unchanged for an entitled caller: the ADR-0069 gate, 401, the 400 body refusals, active-organization scoping (404 outside it), the redelivery veto's 409s, the 200 replay. The door still admits no API key.
  • Docs and records. content/docs/automation/webhooks.mdx section 8 states the gate and the organization vetting; checklist integration-system.webhook-lifecycle r12 (posture sentence) and r13 (this round: acceptance[4].verify no longer cites a no-capability docblock, steps[6] drives the redeliver as the seeded admin, the walled-posture refusal is named) and integration-system.notification-org-and-redeliver-eligibility r4; the two stale "any authenticated user" docblocks in service-messaging (http-outbox.ts RedeliverOptions, sql-http-outbox.ts redeliver) now state the gate; an ADR-0124 anchor for redeliver-entitlement.ts; the dogfood authz matrix row; the engine-double ledger row; the changeset declares the narrowing (Clause-②: no (narrowing), the BREAKING banner, one ADR-0087 disposition not-required (no-migration-prescription)), level patch.

Tests

At b4a2146 (local, on a shared box):

  • @objectstack/plugin-webhooks pnpm test: Test Files 24 passed (24) · Tests 252 passed (252); typecheck (tsc, scripts tsconfig, test layer) exit 0, check:test-typecheck: OK.
  • webhook-redeliver-entitlement.test.ts: 21 cases (11 before this round). New: on isolated, an administrator whose claimed organization no current membership backs is refused with the byte-identical body an unentitled member gets, nothing replayed (both faces); on group, a claim on an organization the caller left is refused for that organization's delivery and for the one it still belongs to (both faces); control, a backed claim is admitted, the replay is threaded with that organization and another organization's delivery answers 404 (both faces); the once-at-boot line for a kernel with no security service; on a real ObjectKernel registry, the resolver reads never-registered as no posture, a walled posture as the drop, and a tenancy factory that throws as 503 with the cause logged and the security service never asked. The security double now records the organization the caller's context was resolved in.
  • @objectstack/service-messaging (comment-only edits): typecheck exit 0; pnpm test Test Files 62 passed (62) · Tests 739 passed (739).
  • @objectstack/http-conformance hono-webhooks-redeliver.conformance.test.ts: 7/7.
Ablations (one-time, not kept; tool: scripts/ablation-replace.mjs, subject resolved from source)
  • tenancy: input.tenancy replaced by tenancy: undefined (anchor 1 to 0, blob ea5e9bd6 to 59923a52): 6 failed | 15 passed (21), the four walled-posture refusals and the two ObjectKernel walled/failing cases red; restored, blob equal to HEAD, git diff HEAD empty.
  • the dropped-claim refusal line deleted (blob ea5e9bd6 to edbf3939): 5 failed | 16 passed (21), the four walled refusals and the ObjectKernel walled case red (the replay went out unscoped); restored, blob equal to HEAD.
  • the once-at-boot report call deleted from the kernel:ready hook (webhook-outbox-plugin.ts, blob c13b3ae5 to 99057719): 1 failed | 20 passed (21), the boot-line case red; restored, blob equal to HEAD, git diff HEAD empty.

Acceptance notes

  • The review suggested reaching the tenancy service "through the same tryGetService discipline". The resolver deliberately does not use the plugin's swallowing tryGetService on a kernel with getServiceAsync: it would read a registered-and-failed service as absent, the permissive-on-failure collapse classifyAdmissionTenancyPosture exists to prevent. The sync, swallowing leg is used only on a factory-less KernelBase host, the same split packages/mcp/src/plugin.ts records.
  • Threading the vetted tenantId rather than the raw claim has no behaviour difference on an admitted call (the gate refuses whenever they differ), so it is pinned structurally (the replay's organization is asserted in the control case) and not ablated separately.
  • Kept as found, noted: http-outbox-parked-record.integration.test.ts's docblock narrates the pre-[security] a dropped webhook subscription leaves no durable record — and the naive fix hands an operator a redeliver button that sends UNSIGNED #8069 state ("which any authenticated user can reach") as history, not as the current posture.
  • tryGetService(['security']) treats a throwing lookup as absent, so a kernel that never registered security answers 503 with a remedy, never a silent admit.
  • The gate is asked of sys_webhook (this plugin's configuration object), not of a host-renamed subscriptionsObject.

Verification

The live readings recorded on 957b968 (fresh showcase boot, member 403 / admin 409, 404, 400, 200 with the sink re-hit / anonymous 401) are unchanged in meaning by this round, which changes only the walled-posture organization vetting; the showcase boots on single, where the claim stands as made. Not re-driven live this round.

Local gates (derived set, b4a2146)

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE: Run reconciliation — 104 derived, 104 run, 0 NOT-MEASURED, 0 UNRUN · ✓ dispatch-gates --ran: 104 derived famil(ies) accounted for, all 104 exit 0 at b4a2146cb6. First-pass reds and their resolution: check:objectql-double-limit exit 1 (the suite's new sys_member double was limit-blind; CI red on 1b07782bdb) fixed in b4a2146cb6, re-run exit 0; check-plugin-teardown-shape --self-test exit 3 (shallow clone, pinned positive control unreachable) re-run after fetching that one commit, exit 0; check:skill-examples, check:dual-build-cjs-loads, check:i18n exit 3 (PREREQUISITE NOT MET: unbuilt dist) re-run after a full pnpm build (114/114), exit 0 each. CI on b4a2146cb6: 50 check-runs, 37 success, 13 skipped, 0 failure; the reds reported on f1c7919300 were cancelled lanes superseded by the next push.

Not run locally

pnpm lint and the repo-wide farm are CI's; test:repo not run (the diff edits no repo-project test). Full dogfood battery owed by the (narrowing) arm, run once at b4a2146cb6 after a full pnpm build: pnpm --filter @objectstack/dogfood test under the verify lock, VERDICT command-exit 0, Test Files 348 passed | 3 skipped (351), Tests 2806 passed | 20 skipped (2826).


Generated by Claude Code

Body refreshed by the PM seat 2026-10-29T15:22Z from the os-dev rework report.

WIP: the redeliver door asks the security service, before the body is
read, whether the signed-in caller may administer webhooks.

Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U
Co-authored-by: Claude <noreply@anthropic.com>
…he HTTP layer

Both faces: an administrator replays; a signed-in caller not entitled is
refused 403 with one answer whatever the request names; no security
service or a throwing decision refuses 503. Existing door suites register
a security double that admits their callers.

Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U
Co-authored-by: Claude <noreply@anthropic.com>
…te, in docs, checklist and changeset

Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U
Co-authored-by: Claude <noreply@anthropic.com>
…e in the pinned ledger

Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U
Co-authored-by: Claude <noreply@anthropic.com>
…the alignment it is

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

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-webhooks, @objectstack/service-messaging, touching 44 documentable anchor(s).

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

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

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

Coarse fallback — 7 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 57c51aab4d2f63293676e9412daed8a5fc54c1bc → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 57c51aab4d2f63293676e9412daed8a5fc54c1bc

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcSwDXZrZg7nGjP6P3U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 957b96882b24c7fdfad77662bf2dfac32515e9de
Local-runs: none
Scope: none (reviewFace over the PR's 15 paths: no schema hit, no governed-path hit; the narrowing face, so the floor is every path outside the prose faces) — packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts, packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts, packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-tenant-scope.test.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-auth-gate.pin.test.ts, packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts, packages/services/service-messaging/src/objects/http-delivery.object.ts, packages/qa/dogfood/test/authz-conformance.matrix.ts, packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts, scripts/engine-double-contract.pinned.json, docs/qa/platform-checklist/areas/integration-system.json + packages/core/src/security/request-execution-context.ts, packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/admission-tenancy-posture.ts, packages/core/src/security/assemble-execution-context.ts, packages/plugins/plugin-security/src/security-plugin-security-service.ts, packages/plugins/plugin-security/src/security-plugin-explain-access.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/tenant-layer.ts, packages/plugins/plugin-security/src/managed-object-write-denies.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-webhooks/src/sys-webhook.object.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-api-key.pin.test.ts, packages/runtime/src/domains/webhooks.ts, packages/runtime/src/security/resolve-execution-context.ts, packages/cli/src/commands/serve.ts, packages/core/src/capability-providers.ts, packages/spec/src/contracts/security-service.ts, scripts/check-adr-0087-registration.mjs, scripts/pm/clause2-line.mjs, scripts/pm/record-recognisers.mjs (the prose faces content/docs/automation/webhooks.mdx and the changeset were read for the declaration and the docs claim, not reviewed as code)

Inputs: PR #27474 body and file list, the net diff against main at the merge-base 9462d8a, the 50 check-runs on this head (37 success, 13 skipped, 0 failure; the ten required contexts are all success), and the dev's open question quoted in the brief. No card by design (maintainer disclosure ruling); the PR author is objectstack-fleet[bot].

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong against the cited code:

  1. The new 403 PERMISSION_DENIED on both door faces — right. Both faces run the one body serveRedeliver (registerAdminRoutes hands it the Hono context; the webhooks slot's handleRedeliver hands it requestDoorContext(request)), and the new gate sits in that body after the ADR-0069 gate and the 401, before c.req.json(). The refusal is one of three module constants, so the body cannot carry a delivery id, subscription id or webhook name; webhook-redeliver-entitlement.test.ts pins one identical body across seven request shapes on each face, redeliverHttp never called, every row left dead. The runtime dispatcher domain (packages/runtime/src/domains/webhooks.ts) forwards the request unread and adds no rule, so the hosted face inherits the gate by construction.
  2. The new 503 SERVICE_UNAVAILABLE — right in direction, new in reach. tryGetService(ctx, ['security']) treats a throwing lookup as absent, and judgeRedeliverEntitlement answers NO_DECIDER (503, remedy in the message) for no service or one without explain, and UNDECIDED (503, cause at error) when the resolution or the decision throws. That is the AGENTS.md direction (prefer failing to falling back). Every composition the repo ships registers security before the door is reached: os serve composes SecurityPlugin on every boot (@objectstack/cli depends on @objectstack/plugin-security, so its optional dynamic import succeeds), and the verify harness, plugin-dev and the schema-migration plugin set compose it too; webhooks is loaded as a capability provider on the same boot. The 503 therefore reaches only a hand-built kernel. See ④ for the one thing missing around it (loudness at boot).
  3. The API-key face — unchanged, right. The door never admitted a key (webhook-redeliver-api-key.pin.test.ts, untouched and green: a key-only request reads no session and is 401 before the gate), and the gate itself is composed with credentials: 'session-only', so resolveAuthzContext is handed no headers for the key arm. Nothing new is accepted.
  4. The organization scoping — right in sequence, wrong in one input. The gate and the replay read the same session once (resolveSession), and activeOrganizationId is threaded into both; the in-process-session-read pin still holds at one read because the composition is fed session: { established } and never calls getSession again. An entitled caller keeps 404 RESOURCE_NOT_FOUND outside the active organization. But the composition is handed tenancy: undefined, so the session arm's organization vetting is not applied here as every other posture-reading door applies it — ④ must-fix.
  5. The 409 veto ordering — unchanged, right. The entitlement gate runs before the body is read, so an unentitled caller never reaches assertHttpRedeliverable or the installed guard; an entitled caller keeps DELIVERY_NOT_ELIGIBLE and DELIVERY_NEVER_SENT as 409. The control case (administrator still answered 409 with the subscription name) pins that the gate hides nothing from an administrator.
  6. The ADR-0069 held session — unchanged, right. evaluateAuthGate(session?.user, '') is still asked first, so a held session is answered the gate's own 403 and the entitlement is never asked; the gate is composed with authGate: 'withhold', so it is not decided twice. The anonymous case pins that the security double is never asked below a 401.
  7. The entitlement itself — right against the shipped sets. sys_webhook is managedBy: 'config', so the registry-driven managed-write denies (the better-auth bucket) do not touch it, and the object declares no capability requirement; admin_full_access, organization_admin and organization_admin_no_bypass carry allowEdit through their objects['*'] wildcard, member_default names no sys_webhook entry. ISecurityService.explain answers the object-level question as !capsDeny && crudAllowed && !denyAll && !delegatorMissing on the same evaluator and RLS compiler enforcement uses; the self-explain branch runs on the request's own context. The docs' sentence "the shipped administrator sets carry it; a member baseline does not" holds.
  8. The published posture statements — mostly updated, with residue. webhooks.mdx section 8, the checklist clause text, the dogfood authz matrix row and the http-delivery.object.ts comment now state the gate. Three places at this head still state the retired posture: packages/services/service-messaging/src/http-outbox.ts line 268 and sql-http-outbox.ts line 477 (both "served to any authenticated user"), and the checklist clause's own verify field (line 648, "the no-capability posture ... in the route's own docblock", a docblock that no longer says that). Should-fix, ④.

② Semver level

  • patch on @objectstack/plugin-webhooks — right for the level. Metadata-protocol: none is true (no key, value or metadata type an author writes changes), and by the Post-Task Checklist a non-protocol change ships patch whatever its direction. No other released package's published surface moves: http-delivery.object.ts changes a comment only; the http-conformance and dogfood edits are test fixtures and a matrix row.
  • The Clause-②: no line should carry the (narrowing) arm — yes. The question the value answers is whether the card widens an accept set or enlarges a public surface: no is true. The arm answers the other fact the value cannot carry: this diff narrows a published accept set of a public door — the set of callers POST /api/v1/webhooks/redeliver admits, which the docs published as "any authenticated user", the platform checklist pinned as the designed posture, and the dogfood authz matrix recorded as enforced. The PR body itself names it so ("this narrows who may call a public door", "deployments that relied on non-administrators pressing redeliver change to 403", with a migration line). That is the definition clause2-line.mjs gives the arm (no (narrowing): not a widening, but breaking), and it is the carrier check-adr-0087-registration.mjs reads a declared narrowing from. As committed, the absent arm declares no direction, so reviewFace reads this tree as the declaration face (TIER_DEFAULT), the opposite of what the body says it owes — the tree and the body disagree. Correcting it: Clause-②: no (narrowing) in the changeset body and the PR body, the **BREAKING** banner, exactly one adr-0087: disposition marker (the gate prints the closed set), the migration line already present, and the Skills-docs: third line the arm owes the PR body. The level stays patch. Recorded as a should-fix in ④ because the ruling made the arm optional and an absent arm is a legal reading, not a false one.

③ Boundary flags

  • Which access governs redelivery (the dev's open question) — ESCALATED to the maintainer, not decided here. The axis is a human-floor decision. Recommendation, with the reasoning the code supports: A, update on sys_webhook (as implemented). Replaying a delivery resets the sys_http_delivery row and sends a signed request to the subscriber: a write-shaped act with an external side effect, the same power as editing or re-activating the subscription. B (read on sys_http_delivery) is the object the button sits on, but a read-wildcard principal (viewer_readonly, or any read-only set) would gain a write-shaped send, and read on the delivery log is also what an auditor holds. One consequence of A the maintainer should weigh: an operator persona that may read deliveries but not edit webhooks loses the button; the migration is an allowEdit grant on sys_webhook through a permission set, which also opens editing the subscription itself. If that coupling is unwanted, the alternative is a dedicated capability on the delivery object, which is a spec change outside this PR.
  • A kernel with no security service now answers 503 (the dev's note) — answered: right direction. Fail closed with a remedy in the message is the rule (prefer failing to falling back); every shipped composition registers security (① item 2), so the new behaviour reaches only a hand-built kernel, and the changeset says so. One gap on loudness at boot, ④ should-fix.
  • The organization vetting input — raised here, judged in ④. The composition the gate uses exists so that no door omits an input another door has; this door omits the tenancy posture.
  • Author: objectstack-fleet[bot] (the relay). No non-fleet login to name.
  • Card: none, by the maintainer disclosure ruling; this record describes the change the way the PR body does and nothing more.

④ Design judgment

Judged against the cited code on main at the merge-base and at this head, not against the PR body's account.

Must-fix (FAIL):

  1. The gate is composed without the tenancy posture, so its admission is not the data API's on a walled posture. judgeRedeliverEntitlement calls resolveRequestExecutionContext with tenancy: undefined, with a comment borrowed from the /auth/me endpoints ("a session-only door, as the /auth/me endpoints are"). The composition's own table says undefined is for a door that reads no posture, and /auth/me qualifies because it writes nothing tenant-scoped. This door does: it threads the session's activeOrganizationId into a write on a tenant-scoped object (the tenant threading the door's docblock records). With no posture, resolveAuthzContext's session arm (vetOrganizationClaim) lets the stored organization claim stand as made, so the caller's grants, positions and the context's tenantId are resolved in the claimed organization; the dispatcher and REST hand the composition their tenancy resolver, so for the same session the data API's write gate drops a claim no current membership backs and resolves the caller with no active organization, where the isolated tenant layer composes to deny-all. The two admissions therefore differ for a session whose membership in the claimed organization has ended, exactly the per-request check the maintainer ruling of 2026-09-05 (option B, recorded in the session arm's own comment) put on every door, and the gate's stated equivalence ("the same admission the data API's write gate gives that caller", "cannot disagree") does not hold on isolated or group. The change is still strictly narrower than main (which admitted every signed-in session and threaded the same unvetted claim), so this is a hardening that stops short of its claim, not a regression. Fix, in the gate: hand the composition the door's own resolver (a tenancy thunk that reads the kernel's tenancy service, through the same tryGetService discipline; classifyAdmissionTenancyPosture already answers a never-registered service quietly and a failed one as AuthzStoreUnavailableError, which the gate's catch turns into the 503 it already has), and then thread the vetted context.tenantId into the replay rather than the raw session claim, so the organization the replay writes into is the one the caller was admitted in. Pin it: the entitlement suite gains a case on a walled posture where the double's tenancy service answers isolated, the session names an organization the member list no longer backs, and the door answers the caller-level refusal with nothing replayed. Delta review by this seat on the new head.

Should-fix:

  1. Declare the narrowing. Clause-②: no (narrowing) in the changeset and PR body, the **BREAKING** banner, one adr-0087: disposition marker, and the Skills-docs: line; level stays patch (② above).
  2. Say the 503 once at boot. AGENTS.md's "absence must be loud": bootRedeliverDoor runs at kernel:ready, after every init, and already warns when the engine or messaging is missing; when no security service is registered it should say once, naming the remedy, that the door is mounted and will answer 503 to every caller. Today the absence is discovered per request in the server log.
  3. Retire the three stale posture statements named in ① item 8 (http-outbox.ts line 268, sql-http-outbox.ts line 477, the checklist clause's verify field), so no comment or checklist field still describes the door as open to any authenticated user.
  4. Anchor the ADR-0124 D1 site. The gate is a load-bearing enforcement of D1 and the diff cites it in code; Prime Directive 13 asks for one anchor JSON under scripts/adr-anchors/ named for redeliver-entitlement.ts.

Held (no finding): the identity judged is the session the door already read (session: { established } carries user.id and session.userId from that read, and the gate refuses on context.userId !== input.userId); an impersonated session resolves as the impersonated user, which is also the identity the door acts as and logs (requestedBy); a held session is stopped by the ADR-0069 gate before the entitlement; the refusal body is a constant and the gate runs before any row-dependent branch on both faces, so neither body nor the branch taken distinguishes rows (the cost of the refusal depends on the caller's grant resolution only); the double registered in the amended suites answers only update on sys_webhook, so those suites cannot pass on a looser question; the engine-double ledger row is recorded.

Implemented-by: claude/harden-webhook-redeliver-door
Reviewed-by: session_017xRBcSwDXZrZg7nGjP6P3U

VERDICT: FAIL

…h the tenancy posture, and scope the replay to it

The entitlement gate now hands the request-context composition the door's
own way to reach the tenancy service, so the session's organization claim is
vetted against the posture in force exactly as it is on the data API (the
per-request membership check of the 2026-09-05 ruling, option B). The
admission carries the vetted tenantId and the replay is scoped to it, never
to the raw session claim; a claim the composition did not keep is refused
with the same caller-level answer, since the replay would have no
organization to be scoped to. The plugin also says once at kernel:ready
when the door is served with no security service to decide, naming the
remedy, instead of leaving the 503 to be discovered per request.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
… retire stale posture text; declare the narrowing

The entitlement suite gains the walled-posture cases (an organization claim
no current membership backs is refused with the caller-level answer and
nothing is replayed, on isolated and group; a backed claim is admitted and
the replay is scoped to that organization), the once-at-boot line for a
kernel with no security service, and the door's tenancy resolver on a real
ObjectKernel registry (never registered reads as no posture; registered and
failing is the 503). The security double records the organization the
caller's context was resolved in.

Three stale "any authenticated user" posture statements are retired: the
two messaging outbox docblocks and the platform checklist clause's verify
field (r13, with its step driving the redeliver as the seeded admin). The
changeset declares the narrowing (Clause-② no (narrowing), BREAKING banner,
one ADR-0087 disposition); the level stays patch. ADR-0124 D1 gains an
anchor for redeliver-entitlement.ts, and the docs page states the
organization vetting.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 29, 2026
…s the caller's bound

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Ruled: maintainer, in the PM session session_017xRBcVy4VXmkQjn2MGeaWk, 2026-10-29 · 2026-10-29T13:05Z

Question (human floor, from the contract review): which permission axis gates webhook redelivery. Answer, verbatim option chosen: 「sys_webhook 的编辑权限」 — update on sys_webhook, as implemented. Accepted cost: an operator who can read deliveries but not edit webhooks loses the redeliver action.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
os-dev-report

{
  "issue": null,
  "status": "done",
  "branch": "claude/harden-webhook-redeliver-door",
  "pr": "https://github.com/objectstack-ai/objectstack/pull/27474",
  "head": "b4a2146cb6c24b77a6a5244274195b2067e295b1",
  "session": "session_017xRBcVy4VXmkQjn2MGeaWk — this run's harness-stamped id (subagent of the PM session; the commit trailers carry it)",
  "premise_still_valid": true,
  "summary": "REWORK of #27474 against contract review 6494462291 (FAIL at 957b96882b), pushed as f1c7919300, 1b07782bdb, b4a2146cb6 (no force-push, no merge of main, still draft). Must-fix: the entitlement gate now hands resolveRequestExecutionContext the door's own tenancy resolver (redeliverTenancyResolver: the kernel's getServiceAsync('tenancy') thunk where the kernel has one, so classifyAdmissionTenancyPosture tells never-registered (no posture) from registered-and-failing (AuthzStoreUnavailableError, which the gate's catch answers 503); the sync swallowing leg only on a factory-less KernelBase host, the split packages/mcp/src/plugin.ts records). The verdict carries the vetted context.tenantId and the replay is scoped to it, never to the raw claim. Beyond the review's literal text, a claim the composition did NOT keep is refused with the same NOT_ENTITLED 403: a dropped claim leaves tenantId undefined, which redeliverHttp treats as an UNSCOPED write, so threading it would have widened the door (ablation 2 measured exactly that: without the line the walled cases replay with no scope). Should-fixes: changeset declares Clause-② no (narrowing), the BREAKING banner and one adr-0087 not-required (no-migration-prescription) marker, summary line fix(plugin-webhooks)!:, level stays patch; once-at-boot warn at kernel:ready when the door is served (either face) and no security service can decide, naming @objectstack/plugin-security; the two service-messaging docblocks and the checklist clause verify field retired (webhook-lifecycle r13 citing #27474; steps[6] 'as an authenticated user' also fixed, same item); ADR-0124 anchor scripts/adr-anchors/packages__plugins__plugin-webhooks__src__redeliver-entitlement.ts.json (D1, D3 named in its invariant); docs section 8 states the organization vetting. Permission axis kept as implemented (update on sys_webhook) and marked pending maintainer in the proposed 维护者速读. The PR BODY IS NOT REWRITTEN: os-dev.md lets the dev write a PR body only at pr_create and never PATCH it, so the full proposed body (Clause-② no (narrowing), Metadata-protocol none, Skills-docs line, 维护者速读, notes, readings) is in field pr_body_rewrite and at /tmp/claude-0/-home-user-objectstack/bdda939c-7424-5594-af38-0e6fe5d7eeed/scratchpad/redeliver-rework/pr-body.md for the seat to write; the seat may also want the title to carry `!` (fix(plugin-webhooks)!: ...) to match the changeset.",
  "tests": "All at b4a2146cb6, shared box, heavy runs under scripts/pm/os-verify-lock.sh. (1) pnpm --filter @objectstack/plugin-webhooks test: 'Test Files 24 passed (24) · Tests 252 passed (252)' (242 before), then typecheck (tsc + tsconfig.scripts + check:test-typecheck: OK), VERDICT command-exit 0. (2) webhook-redeliver-entitlement.test.ts alone: 'Tests 21 passed (21)' (11 before; new: isolated and group dropped-claim refusals with the byte-identical body of an unentitled member and nothing replayed, both faces; backed-claim control admitted with redeliverHttp called with tenantId org_a and the other org's delivery 404, both faces; boot line said exactly once with no security service; ObjectKernel registry: unregistered = no posture/admitted in claim, isolated = 403, tenancy factory throws = 503 + cause logged + security never asked). (3) @objectstack/http-conformance hono-webhooks-redeliver.conformance.test.ts 7/7; @objectstack/service-messaging typecheck exit 0 and pnpm test 'Test Files 62 passed (62) · Tests 739 passed (739)' (comment-only edits). (4) Ablations via scripts/ablation-replace.mjs (subject resolved from src, no dist leg), each restored with blob == HEAD and git diff HEAD empty: A1 tenancy: input.tenancy -> undefined (blob ea5e9bd6 to 59923a52): '6 failed | 15 passed (21)', the four walled refusals plus the ObjectKernel walled and failing cases red; A2 dropped-claim refusal line deleted (blob ea5e9bd6 to edbf3939): '5 failed | 16 passed (21)', the walled cases replayed unscoped; A3 boot-report call deleted (webhook-outbox-plugin.ts blob c13b3ae5 to 99057719): '1 failed | 20 passed (21)'. Direction observed: red, as predicted. Threading verdict.tenantId instead of the raw claim is not separately ablatable (they are equal on every admitted call by construction).",
  "gates": "Local derived set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (tree b4a2146c; it warned the tree is 29 commits behind origin/main, 6 derived-from files changed in that range; main not merged, per the after-PR-open merge rule, CI green), 104 commands run each with its exit captured before any pipe, then --ran: 'Run reconciliation — 104 derived, 104 run, 0 NOT-MEASURED, 0 UNRUN' · '✓ dispatch-gates --ran: 104 derived famil(ies) accounted for'. First-pass non-zeros and resolution: check:objectql-double-limit exit 1 (new limit-blind sys_member double; the red CI reported on 1b07782bdb 'Lint · CI, release & engine gates' / job 123411210842) fixed in b4a2146cb6, re-run exit 0; check-plugin-teardown-shape --self-test exit 3 (shallow clone, pinned control 621a4876 unreachable) re-run exit 0 after fetching that one commit at depth 1; check:skill-examples, check:dual-build-cjs-loads, check:i18n exit 3 (PREREQUISITE NOT MET) re-run exit 0 after full pnpm build (114/114). Also check:adr-anchors exit 0, check-nul-bytes OK. The reds the PM named on f1c7919300 ('Lint & Repo Gates' job 123411154846, 'TypeScript Type Check' job 123411158250) are roster jobs whose member lanes concluded 'cancelled' (superseded by the 1b07782bdb push), not findings. CI on final head b4a2146cb6 (check-runs read after the dogfood run): 50 check-runs, 37 success, 13 skipped, 0 failure; Lint & Repo Gates success, TypeScript Type Check success, Test Core success (8/8 shards), Build Core success, Governed Surface Queue Guard success, Test UI success, Lint · CI, release & engine gates success, Check Changeset success; Lint UI / Type Check UI / Build & E2E UI / Live E2E UI skipped (no ui/ paths). Note: CI's PR-body readers still read the old body ('Clause-②: no'); they re-run once the seat writes pr_body_rewrite.",
  "dogfood_battery": "b4a2146cb6 · 'os-verify-lock: VERDICT command-exit 0 · held the lock 1362s (22m42s)' · Test Files 348 passed | 3 skipped (351) · Tests 2806 passed | 20 skipped (2826) · no failures (owed by the (narrowing) arm; run once after a full pnpm build)",
  "mcp_calls": "0 — no MCP GitHub tool used; GitHub reads went through gh api (PR, review comment, check-runs) and scripts/pm/ci-failure.mjs",
  "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/27474/comments (this os-dev-report, through scripts/pm/post-stamped.mjs and the fleet-write relay). Not REST: three git pushes to claude/harden-webhook-redeliver-door (f1c7919300, 1b07782bdb, b4a2146cb6). PR body, title, labels, draft flag: not written.",
  "deviations": [
    "PR body not rewritten (os-dev.md: the dev writes a PR body once, at pr_create, never PATCH; the dispatch asked for the declaration lines in the PR body). The text is in pr_body_rewrite for the seat. Conflict noted, not silently resolved.",
    "Checklist: the dispatch said bump the item and append a history row citing this PR; r12 of integration-system.webhook-lifecycle was already this PR's, so the bump is r12 -> r13 (ref #27474). The seat may collapse r12 and r13 into one row before landing if preferred.",
    "The tenancy thunk does not use the plugin's swallowing tryGetService on an ObjectKernel (the review's wording): that would read a registered-and-failed tenancy service as absent. Reasoning in the module docblock and the proposed acceptance notes.",
    "Added the dropped-claim 403 (not in the review's literal fix) because threading the vetted undefined tenantId would have been an unscoped replay; measured by ablation A2.",
    "One security-class out-of-scope finding is withheld from the GitHub copy of this report under the disclosure ruling; the handback copy carries it in full."
  ],
  "open_questions": [
    {
      "question": "Permission axis for redelivery (human floor, pending maintainer): keep `update` on sys_webhook as implemented, or a dedicated capability on the delivery object?",
      "options": [
        "A keep update on sys_webhook (as implemented; operators who may read deliveries but not edit webhooks lose the button; migration is an allowEdit grant)",
        "B a dedicated capability on sys_http_delivery (decouples redelivery from editing the subscription; a spec change outside this PR)"
      ],
      "recommendation": "A, as the contract review also recommends: a replay is a write-shaped act with an external side effect, the same power as re-activating the subscription; B is a follow-up only if the coupling proves unwanted"
    }
  ],
  "out_of_scope_findings": [
    "class: a · reach: exception: security · withheld from GitHub under the 2026-10-29 disclosure ruling (security findings get no card); the full entry is in the handback report to the dispatching PM session",
    "class: none (noted, not filed) · carrier: none · `packages/services/service-messaging/src/http-outbox-parked-record.integration.test.ts` docblock still says the redeliver door is one \"which any authenticated user can reach\"; it narrates the pre-#8069 state as history, so it was left as found and named in the proposed PR body's acceptance notes"
  ],
  "pr_body_rewrite": "not inlined here: its details/summary blocks are tag-shaped text the comment sanitizer may eat. The full proposed body is in the handback copy of this report (field pr_body_rewrite) and in the dispatching session's scratchpad at /tmp/claude-0/-home-user-objectstack/bdda939c-7424-5594-af38-0e6fe5d7eeed/scratchpad/redeliver-rework/pr-body.md"
}

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b4a2146cb6c24b77a6a5244274195b2067e295b1
Local-runs: none
Scope: none (reviewFace over the PR's 18 paths: no schema hit, no governed-path hit; the committed changeset's Clause-②: line carries the (narrowing) arm, so this is the narrowing face and the floor is every path outside the prose faces) — packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts, packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts, packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-tenant-scope.test.ts, packages/plugins/plugin-webhooks/src/webhook-redeliver-auth-gate.pin.test.ts, packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts, packages/services/service-messaging/src/http-outbox.ts, packages/services/service-messaging/src/sql-http-outbox.ts, packages/services/service-messaging/src/objects/http-delivery.object.ts, packages/qa/dogfood/test/authz-conformance.matrix.ts, packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts, scripts/adr-anchors/packages__plugins__plugin-webhooks__src__redeliver-entitlement.ts.json, scripts/engine-double-contract.pinned.json, docs/qa/platform-checklist/areas/integration-system.json + packages/core/src/security/request-execution-context.ts, packages/core/src/security/admission-tenancy-posture.ts, packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/api-key.ts, packages/core/src/service-not-registered.ts, packages/core/src/kernel.ts, packages/core/src/kernel-base.ts, packages/core/src/plugin-loader.ts, packages/rest/src/rest-server.ts, packages/mcp/src/plugin.ts, packages/plugins/plugin-security/src/security-plugin-explain-access.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/tenant-layer.ts, packages/plugins/plugin-security/src/security-plugin-security-service.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-webhooks/src/sys-webhook.object.ts, packages/plugins/plugin-webhooks/src/index.ts, docs/adr/0124-server-enforces-client-is-courtesy.md, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-adr-anchors.mjs (the prose faces content/docs/automation/webhooks.mdx and the changeset were read for the declaration and the docs claim, not reviewed as code)

Delta review of the rework against this seat's record 6494462291 (FAIL at 957b968): commits f1c7919, 1b07782, b4a2146. Inputs: the PR body as rewritten by the PM seat, the file list, the net diff against main at the merge-base 9462d8a and the delta diff 957b968..b4a2146, the check-runs on this head (the push run and the re-run after the body rewrite: every gate-carrying job success, 0 failure, the skipped jobs are the ui-only lanes), ruling 6497487947, and the dev's os-dev report 6498261955 read for its flags only. No card by design (maintainer disclosure ruling); the PR author is objectstack-fleet[bot].

① Derived judgments

Each accept-set or public-surface change the delta implies, named against the cited code:

  1. Prior must-fix — resolved. judgeRedeliverEntitlement now hands resolveRequestExecutionContext the door's own tenancy resolver (redeliverTenancyResolver), and the composition classifies it per request (classifyAdmissionTenancyPosture, request-execution-context.ts line 240, reached whatever credentials says), so the session arm's vetOrganizationClaim runs with the posture in force: on isolated / group a claim absent from accessible_org_ids is dropped and the grants re-resolved with no organization, exactly as REST and the dispatcher resolve the same session. The resolver's two legs are the repo's existing split: kernel.getServiceAsync where the kernel has one (rest-server.ts line 3314, mcp/plugin.ts line 126), so the branded never-registered rejection of PluginLoader.getService reads quiet and every other rejection (a factory that throws) raises AuthzStoreUnavailableError('tenancy'), which the gate's catch answers 503; the sync, swallowing leg only on a KernelBase host, whose getService over a Map raises exactly one fault, "not found" (kernel-base.ts line 93), and which cannot hold a factory (registerServiceFactory throws there). The dev's refusal to route the thunk through the plugin's swallowing tryGetService is right: that would read a registered-and-failing service as absent, the collapse the classifier exists to prevent. security and tenancy are both registered as static instances (security-plugin-security-service.ts line 415, auth-plugin.ts line 843), so neither the per-request lookup nor the boot report can false-negative on an unbuilt factory.
  2. The replay's organization — right. serveRedeliver threads verdict.tenantId, the composition's vetted value, into messaging.redeliverHttp; the raw session claim no longer reaches the write. On an admitted call the two are equal by construction (item 3), so this is pinned structurally (the control case asserts redeliverCalls carries ORG_A), as the dev says.
  3. The new refusal arm (a claim the composition did not keep is 403 PERMISSION_DENIED) — right, and necessary. Had the gate threaded the vetted tenantId alone, a dropped claim would have threaded undefined, and redeliverHttp with tenantId: undefined is the unscoped write RedeliverOptions' own docblock names (the reads and the reset lose their organization_id predicate; only the driver's tenant-audit line fires). That is wider than the data API's answer for the same session, which with no active organization is RLS_DENY_FILTER under isolated and the $in of the caller's own organizations under group (tenant-layer.ts lines 126 to 131), a set the door's single-tenant scope cannot express. Refusing is therefore the narrower of the two readings of option B at this door, and the principal is not refused (the session stays signed in; the door's act alone is declined), so the ruling's consequence stands. The refusal is the same constant as every other caller-level refusal and is decided before explain is asked, so neither the body nor the branch taken names a row. The dev measured the alternative by ablation (the walled cases replay unscoped without the line); the four walled refusals and the backed-claim control pin it on both faces.
  4. The no-claim session — held, unchanged from main. A session that never selected an organization carries no claim, so this arm does not fire (undefined on both sides) and the retained paragraph of serveRedeliver's docblock still applies: undefined is threaded and the driver's audit line reports it. The gate's explain closes the walled case the way the data API does: with no tenant, resolveUserAuthzGrants applies no stored grant row, so a tenant administrator holds no allowEdit and is refused; only a platform administrator (the Default Organization's admin_full_access anchor) passes, and the data API's Layer 0 exempts exactly that principal. sys_webhook is tenant-scoped (unique per organization, ADR-0131 D2), so the tenant layer is in the explain's composition for it. Not a delta change; noted so the next reader does not take the new arm for a no-organization refusal.
  5. The further narrowing is declared. Against 957b968 this head refuses one more class of caller (a walled-posture session whose claim no current membership backs) and answers one more 503 (a tenancy service registered and unable to answer). The changeset's Clause-② line, its organization bullet, its fail-closed bullet and the migration line name both; webhooks.mdx section 8 and the checklist clause name the refusal. Right.
  6. No public surface moves. redeliver-entitlement.ts and its testkit are not reachable from the package's . export (index.ts exports neither; exports maps only . and ./schema), so RedeliverVerdict, canDecideRedeliverEntitlement and redeliverTenancyResolver are shipped bytes, not the published accept set; judgeRedeliverEntitlement's changed return type is the same. The Clause-②: no value stays true and the marker's "no exported declaration changes" holds. http-outbox.ts, sql-http-outbox.ts and http-delivery.object.ts change comments only.
  7. The boot line — right. reportRedeliverEntitlementDecider runs at kernel:ready after bootRedeliverDoor and registerAdminRoutes, only when a face is served, says once at warn that every caller will be answered 503 and names the remedy; the door still decides per request. warn is the right level by AGENTS.md's one question (the door is visibly unavailable; nothing claimed persisted goes missing).
  8. The prior should-fixes 4 and 5 — resolved in the tree. The two service-messaging docblocks the record named (RedeliverOptions, SqlHttpOutbox.redeliver) and the checklist clause's verify field now state the gate; steps[6] drives the clause as the seeded admin; the ADR-0124 anchor exists with the derived filename and check:adr-anchors is green. One sibling sentence survives, ④ should-fix 1.

② Semver level

  • patch on @objectstack/plugin-webhooks — right. Metadata-protocol: none is true (no key, value or metadata type an author writes changes), and a non-protocol change ships patch whatever its direction, keeping its BREAKING banner and ADR-0087 disposition. No other released package's published surface moves.
  • The declaration — consistent on changeset and body. Changeset: Clause-②: no (narrowing) — … (the arm is the parenthetical opened as the next thing after the value, so readArmToken reads narrowing and leaves the em-dash reasoning unread, clause2-line.mjs), Metadata-protocol: none, the **BREAKING** banner, the ! on the summary, exactly one adr-0087: marker, not-required (no-migration-prescription) with its reasoning, and the migration line. PR body: Clause-②: no (narrowing), Metadata-protocol: none, and the Skills-docs: none — REASON third line the arm owes (SKILL.md line 313), whose reason is true at this head (skills/** names the redeliver door nowhere). The category is the right one on the facts: nothing authorable, stored or exported moves, so there is no prescription for objectstack migrate meta to carry, and the gate that re-validates that category on every run (check-adr-0087-registration, in Lint & Repo Gates) is green on this head; Check Changeset re-ran after the body rewrite and is green. Note, no finding: the PR title does not carry the ! the changeset summary does; no gate reads the title for it.

③ Boundary flags

  • Which access governs redelivery — CLOSED by the maintainer. Ruling 6497487947: 「sys_webhook 的编辑权限」, update on sys_webhook as implemented, the cost (an operator who reads deliveries but does not edit webhooks loses the action) accepted. The tree implements A and nothing in the delta moves the axis. The PR body, rewritten after that ruling, still presents the axis as pending the maintainer (the 维护者速读's 待维护者定夺 and 需要你做的 lines, and "The axis is pending the maintainer" under What changes); that is a body fact for the seat to amend, ④ should-fix 2, not a tree finding.
  • The tenancy thunk does not use tryGetService (dev deviation) — answered: right, ① item 1.
  • The dropped-claim 403 beyond the review's literal fix (dev deviation) — answered: right, ① item 3. The review's own fix sentence ("thread the vetted context.tenantId into the replay") would have produced the unscoped replay the dev measured; the arm is the correct completion of it.
  • Checklist r12 and r13 both this PR's (dev deviation) — accepted. Two rows, each citing its round; collapsing them is the seat's choice, not owed.
  • http-outbox-parked-record.integration.test.ts docblock left as history (dev note) — accepted: it narrates the pre-[security] a dropped webhook subscription leaves no durable record — and the naive fix hands an operator a redeliver button that sends UNSIGNED #8069 state as history. The sibling sentence in http-outbox.ts does not, ④ should-fix 1.
  • One security-class finding withheld from GitHub under the disclosure ruling (dev deviation) — outside this record by that ruling; carried to the dispatching seat in the handback.
  • Author: objectstack-fleet[bot] (the relay). No non-fleet login to name. Card: none, by the maintainer disclosure ruling.

④ Design judgment

Narrowing face, no hits: the narrowing itself is judged against the published spec on main, and each delta file against the code it cites. It takes a declared capability away at the consumer (the door published as open to any signed-in caller now admits a webhook administrator in an organization a current membership backs), the changeset carries the FROM → TO and the one-line remedy for both classes of caller, and it makes the spec's own refusal real at this door: the per-request membership vetting of the 2026-09-05 ruling (option B), which main applied on every other posture-reading door and this door omitted. Judged against the cited code on main at the merge-base and at this head, not against the PR body's account.

Must-fix: none. The prior must-fix is resolved as stated and pinned on both faces; the new refusal arm is the right completion of it; nothing in the delta admits a caller main or 957b968 refused.

Should-fix:

  1. One stale posture sentence survives in a file this PR edits for exactly that sentence. packages/services/service-messaging/src/http-outbox.ts, the assertHttpRedeliverable docblock (around line 490): "POST /api/v1/webhooks/redeliver is reachable by any authenticated user. Without this refusal … would hand that user a button …". The record's item 4 asked that no comment still describe the door as open to any authenticated user; the two sibling docblocks were retired and this one, eight lines from the parked-row guard it explains, was not. Retire it as the other two were (a caller entitled to administer webhooks, in the organization it was admitted in). The CHANGELOG entries that say the same are release-owned and stay.
  2. The PR body still escalates a question the maintainer has ruled. Amend the 维护者速读 (待维护者定夺, 需要你做的) and the "pending the maintainer" sentence to cite ruling 6497487947 and its accepted cost, so the body does not ask for a decision already on the record. Body only; the seat's write.
  3. Say the no-claim case where the dropped-claim case is said. serveRedeliver's retained paragraph ("A session with no active organization threads undefined … the deployment could not tell us who is asking") now describes only a session that never selected an organization, since a dropped claim is refused above it; one clause saying so, and that the entitlement's explain admits only a platform administrator there (the data API's own exemption), would stop the next reader taking the new arm for a no-organization refusal. Prose; ① item 4 holds either way.

Held (no finding): the posture is read per request through the kernel's registry and recorded nowhere, so the startup-registry rule is not engaged; the boot report reads the registry at kernel:ready, after every init() and start(), and records nothing but a log line; the ObjectKernel resolver suite is driven on a real registry with a real factory that throws, so the branded/unbranded classification it pins is the registry's own; the entitlement suite's sys_member double honours where and limit (check:objectql-double-limit green on this head, red on 1b07782 for the same double, fixed in b4a2146); the in-process-session-read pin still holds at one read, the composition being fed session: { established }; the dropped-claim refusal is decided before explain, so the security double is never asked for a dropped claim and the recorded tenantId the double now captures is the vetted one on every admitted call; the anchor's invariant text cites D1 and D3, both ### headings of ADR-0124; the engine-double ledger row is unchanged from the prior head.

Implemented-by: claude/harden-webhook-redeliver-door
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head b4a2146cb6c24b77a6a5244274195b2067e295b1 · 2026-10-29T16:44Z

维护者速读

  • 改了什么:webhook 重投入口要求调用方持有 sys_webhook 的编辑权限;判权按部署的租户隔离姿态解析调用方上下文,与数据 API 一致;重投范围限定在核验过的组织内。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)。
  • 风险与回滚:属于 BREAKING 收窄,没有编辑权限的用户失去重投能力;回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts。
  • 两分钟验证:pnpm --filter @objectstack/plugin-webhooks exec vitest run src/webhook-redeliver-entitlement.test.ts,预期 21 个用例全过。
  • 需要你做的:无。权限挂在哪已按裁决定案(评论 6497487947),正文中"待定"的说法以这条裁决为准。

席位意见:收。增量契约复核 PASS(6501041079),dogfood 全套 348 个文件、0 失败。

席位核对

  1. 形态:draft、目标 main;不建卡,正文无关闭关键词。
  2. 前提实测:复核记录 6494462291 和 6501041079 都读过代码。
  3. 门禁:本 head 共 39 个 success、15 个 skipped、0 失败;两个必需 job 都是 success。
  4. 接受的偏差:http-outbox.ts 约第 490 行还留着一句旧的权限描述,归入后续收尾。
  5. 范围外去向:有一个属于安全类的线索,已交由 PM 私下处理,不在 GitHub 上记录。Skills-docs: none — REASON 属实。
  6. 落地:不属于受管面,1020 行 → pr_ready + automerge_enable。

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 16:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 29, 2026 16:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 6cc5374 Oct 29, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/harden-webhook-redeliver-door branch October 29, 2026 17:30
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant