Skip to content

fix(plugin-webhooks, runtime): webhook remedies name the ?package= address, and a name refused whole answers 409 at every address - #33450

Queued
objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/area-E2-33263
Queued

objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/area-E2-33263

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Fixes #33263

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:声明式 webhook 的名称守卫、重投否决、派发器告警里给出的开关地址,统一改为按包精确寻址 webhook/NAME?package=PACKAGE;元数据服务路径下"环境自有声明 + 一个包声明同名"的整名拒绝,现在在所有地址上都回 409 并点名两个持有者;升级影响报告里禁用包地址"回 409"的说法更正为"唯一持有者回 404,有其他持有者时回 409"。
  • 为什么改:命名空间地址对同一命名空间的两个共有者会回 409、对未声明命名空间的包根本不存在,提示给出的是走不通的路;整名拒绝时 ?package= 回 404"没有启用的包声明此名",与事实相反。
  • 风险与回滚:只改提示文案与一类拒绝的状态码(404 改 409,读失败时 404 改 503),不放宽任何解析,不改写入;回滚即 revert 本 PR。
  • 先看:packages/runtime/src/domains/catalog-activation.ts 的 wholeWebhookNameRefusal 与两处调用;packages/plugins/plugin-webhooks/src/declared-webhooks.ts 的 listedRefusal、wholeNameRefusal、名称守卫;packages/plugins/plugin-webhooks/src/redeliver-guard.ts 的 judgeDeclared。
  • 两分钟验证:pnpm --filter @objectstack/plugin-webhooks exec vitest run --maxWorkers=2 src/declared-webhooks.test.ts,预期全部通过,含新增的 "a name the metadata service lists for the environment beside a package" 一组。
  • 需要你做的:无,席位落地。

What changes

Card items, in order:

  1. Name guard and redelivery veto on the exact address. declaredWebhookActivationPath(name, packageId?) now spells POST /api/v1/security/_activation/webhook/NAME?package=PACKAGE for a package's declaration (the bare address for the environment's own). The name guard's conflict, the redelivery veto's switched-off refusal and the dispatcher's withheld-row warning all name it, so two co-owners of one namespace, and a package that declares no namespace, each get an address that switches exactly that holder. The namespace address stays served by the door; nothing names it as a remedy any more.
  2. The environment's declaration beside one packaged holder on the metadata-service path. The name stays refused whole (ADR-0150 D3: nothing on that host says whether the environment's item is the package's customization or a webhook of its own, so nothing is guessed). What changes is that every reader now says so:
    • the refusal sentence names both holders and says neither is dispatched and no address switches one, instead of "address it by its package";
    • the activation door answers that refusal 409 RESOURCE_CONFLICT, candidates naming the environment and the package, at ?package=PACKAGE and at the namespace address too (both answered 404 "no enabled package declares the name"); a read that fails while looking is 503, never a 404;
    • the name guard quotes the refusal instead of naming a namespace placeholder address;
    • the redelivery veto refuses a declared:PACKAGE/NAME delivery with the refusal instead of calling the package disabled.
      A disabled sole holder still answers 404 at its address, and two packages listed once each are still served and switched each at its own address.
  3. Record nit N5. The upgrade impact report's withheld-webhook line (and the packageDisabled field doc) said a disabled package's address answers 409; it answers 404 while that package is the name's only holder and 409 naming the other holders beside another.

Fold-in (docs): content/docs/automation/webhooks.mdx says an activation row naming a package that no longer declares the name switches no remaining holder (ADR-0150 D8), and the operator keeps a webhook off by switching each holder at the ?package= address.

In-place fix under the bounded exemption: the dispatcher's two-holder warning for a row naming no package also named the namespace address (as a placeholder); it now names ?package=PACKAGE, the same defect class and the same file as item 1.

Tests

Pinned in declared-webhooks.test.ts (plugin-webhooks): the remedies name each holder's ?package= address (FROM namespace address); two co-owners of one namespace get two distinct exact addresses in the name guard and the redelivery veto; the unowned-row warning names ?package=PACKAGE; a new top-level describe for the environment beside a package on the metadata-service path (bare refusal, name guard, redelivery veto) with controls for two listed packages and one holder listed twice. In catalog-activation-door.test.ts (runtime): a name refused whole answers 409 naming both holders at the bare, exact and namespace addresses, nothing written, and a failed read there is 503. In upgrade-impact-report.test.ts (plugin-security): the N5 sentence.

Local readings at f5a399c, under the verify lock:

  • plugin-webhooks: 25 files, 307 passed.
  • plugin-security: 267 files, 6653 passed, 45 skipped.
  • runtime: 600 files, 10757 passed, 19 skipped.
  • Typecheck exit 0 for all three. Runtime needed @objectstack/driver-turso built first for two untouched main test files; the test-typecheck ledger is unchanged.
  • ESLint --no-inline-config on the 8 changed source files: exit 0.

Acceptance notes

  • The namespace address webhook/NS/NAME is still served and still refuses two co-owners of one namespace (by design: the path names a namespace, not a package); only the remedies stopped naming it.
  • No change to resolution: the environment-beside-package name is refused exactly as before; only the sentence and the door's status at the qualified addresses change.

Verification

Ablations and gates

Ablations used scripts/ablation-replace.mjs. Each leg landed its mutation and was restored to the HEAD blob.

  • A1, the ?package= suffix dropped from declaredWebhookActivationPath: 4 red.
  • A2b, the whole-refusal sentence bypassed: 3 red.
  • A3, the name-guard refusal dropped: 1 red.
  • A4, the veto whole-refusal branch disabled: 1 red.
  • A5, the door's exact-address whole refusal removed: 1 red.
  • A6, the door's namespace-address whole refusal removed: 1 red.
  • A7, N5 put back to 409: 2 red.

dispatch-gates --commands derived 99 families. 96 ran with exit 0. 3 are NOT MEASURED locally (exit 3, prerequisite not met): check:skill-examples, check:dual-build-cjs-loads and check:type-check-debt. CI covers the last one in "Type Check · debt ledger". Every CI check at f5a399c is green.


Generated by Claude Code

…lder's exact address; a name refused whole is refused at every address

The name guard, the redelivery veto and the dispatcher's withheld and
unowned-row warnings named the namespace address (webhook/NS/NAME), which
refuses two co-owners of one namespace and does not exist for a package
that declares no namespace. They now name webhook/NAME?package=PACKAGE,
the exact qualifier that switches one holder (ADR-0150 D2/D5).

On the metadata-service path, the environment's declaration listed
beside a package's is refused whole (ADR-0150 D3). Its refusal now says
no declaration is dispatched and no address switches one, naming both
holders; the activation door answers that refusal 409 at the exact and
the namespace address (was 404 "no enabled package declares the name"),
the name guard quotes it, and the redelivery veto refuses with it
instead of calling the package disabled.

Record nit N5: the upgrade impact report's withheld-webhook line said a
disabled package's address answers 409; it answers 404 while that
package is the name's only holder.

Docs: the declared-webhook switch paragraph says a departed package's
row switches no remaining holder (ADR-0150 D8).

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-security, @objectstack/plugin-webhooks, @objectstack/runtime, touching 15 documentable anchor(s).

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

  • content/docs/automation/webhooks.mdx _(via AutoEnqueuer (symbol, a top-level class), /api/v1/security/_activation (route, a path literal in declaredWebhookActivationPath), /api/v1/security/activation/webhook (route, a path literal in reportUnownedRows))
  • content/docs/concepts/metadata-lifecycle.mdx _(via /api/v1/security/activation (route, a path literal in declaredWebhookActivationPath))
  • content/docs/permissions/authorization.mdx _(via /api/v1/security/activation (route, a path literal in declaredWebhookActivationPath))
  • content/docs/permissions/system-context.mdx (via bindDeclaredWebhookNameGuard (symbol, a top-level function))

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

  • content/docs/releases/v18/18-0.mdx (via DeclaredWebhookNameConflictError (symbol, a top-level class))

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
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 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 — 42 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 7e61983e18f9f39dc04ba4255f5a8817af688383 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7e61983e18f9f39dc04ba4255f5a8817af688383

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

…, and the name guard's conflict is typed

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

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
Request: review · area E2 · head f5a399c

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head f5a399c · 2026-11-14T23:16Z

review#3.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Review — PASS

Head-sha: f5a399c56a91d4f003921207f4b9431c6d514aa6
Local-runs: none

Clause-②: no holds (your question). Every row of the FROM→TO table is a refusal before and a refusal after. 404→409 at the exact and namespace addresses, 404→503 on a failed read, and the name guard, the redelivery veto and the dispatcher warnings changing their text: none of these accepts something that was refused, or refuses something that was accepted. Nothing is dispatched that was not, and nothing written moves. This matches the line drawn on #33491: there a 503 became a determinate accepting answer, which is why it was a widening. Here no acceptance moves either way. patch on all three packages is right, and Metadata-protocol: none holds.

The change is right.

  • Remedies name the exact address. declaredWebhookActivationPath(name, packageId) names ?package=PKG, which switches exactly one holder. That includes two co-owners of one namespace and a package that declares no namespace, where the old namespace address answered 409 or did not exist. The namespace route is not removed, so a scripted call keeps working where it worked.
  • Whole refusal. slotsOf sets environmentBesidePackage and listedTwice and serves no slot. listedRefusal(…, whole) says so instead of "address it by its package". The runtime's wholeWebhookNameRefusal runs only once the served slots are none, and reads the plugin's DeclaredWebhookAmbiguousError through packageScopedRefusalOf's duck type (code: RESOURCE_CONFLICT, refusal.status: refused, candidates, message), which that error class satisfies. A miss still answers 404: nothing declares the name, or its only holder is disabled and the bare read returns null.
  • Fail-closed. A failed bare read throws to the door's existing catch and answers 503 with nothing written. In the redelivery guard, a throw from wholeNameRefusal propagates, and assertRedeliverAllowed turns it into a refusal. "We could not check" never reads as allowed.
  • Guard without list. The name guard with a reader that has no list now names each candidate's address instead of a <namespace> placeholder.
  • Pins: 9 cases cover FROM and TO for every row, and include CONTROLs for two packages listed once each (served, no whole refusal) and for one holder listed twice. With the seven ablations, the coverage is sufficient.

Notes (non-blocking):

  • N1. Under a whole refusal, a ?package=B naming a package that does not declare the name at all, and a namespace address whose namespace owns no holder, also answer the 409 naming the holders. Before, both were 404. That is consistent with not-held-by-qualifier (also 409 naming the holders) and still a refusal. The changeset's "Same state" row reads as if only the holders' addresses move, so one clause saying "any ?package= or namespace address" would make it exact.
  • N2. The door's whole-refusal pin uses a reader double that throws a duck-typed error. Nothing pins the real DeclaredWebhookSource behind the door, so if either side renames refusal or code, the answer silently drops back to 404. A follow-up pin that wires the real source would close that. It is not needed for this PR.
  • N3. wholeNameRefusal (plugin) and wholeWebhookNameRefusal (runtime) are near twins. The runtime cannot import the plugin, so keeping two is fine.

CI at this head: 40 success, 17 skipped, none failing. The ACCEPT re-reads every check at the landing head.

Implemented-by: claude/area-E2-33263
Reviewed-by: session_016WV4d1PGptBW3QRQNmy5e6

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
ACCEPT · area E2 (area-lease pilot #33059) · Fixes #33263 · head f5a399c

  • Review: review#3 PASS at f5a399c (record 6885783408). It confirms Clause-②: no: every moved row is a refusal before and after.
  • Governed: check-governed-merges --pr 33450 NOT governed (0 of 10 paths); +395 / -48.
  • Checks: every check run at f5a399c is success or skipped by its path selector.
  • Nits carried, not pushed:
    • N1: the changeset's "Same state" row could say "any ?package= or namespace address".
    • N2: a follow-up pin wiring the real DeclaredWebhookSource behind the door.
  • Landing: maintainer's instruction in the area thread (15:21Z): PRs in this area that passed review go ready + auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 23:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Any commits made after this event will not be merged.
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 43663304554 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (8/8) — 失败步骤: Run this shard's tests

    @objectstack/plugin-approvals:test:  FAIL  src/action-page-member.integration.test.ts > [#22578] handleActionPage — the ADR-0043 action page from a Request > GET renders the confirm page for a live to
      ↳ 失败原因: @objectstack/plugin-approvals:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 22 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
Queue note · head f5a399c

Queue build 43663304554 failed on @objectstack/plugin-approvals src/action-page-member.integration.test.ts. The cause was "Test timed out in 5000ms", a timeout and not an assertion. The test is outside this PR's diff (plugin-webhooks), and the same signature has ejected 8 PRs across 5 speculative stacks in 24h; #33508 anchors it. This is this PR's first queue failure, so I am re-arming auto-merge once. A second queue failure on the same signature waits for #33508.

This branch has not been deployed

No deployments
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-webhooks: loose ends of PR #33204 — name guard and redelivery veto on the namespace address, the environment-declaration switch, record nit N5

2 participants