Skip to content

feat(plugin-auth): MCP OAuth over plain HTTP on a private / link-local deployment host - #19534

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-19489-oauth-private-host-transport-rule
Sep 21, 2026
Merged

huangyiirene merged 3 commits into
mainfrom
claude/issue-19489-oauth-private-host-transport-rule

Conversation

@huangyiirene

@huangyiirene huangyiirene commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19489

Clause-②: no

MCP OAuth becomes eligible over plain HTTP when the deployment's own canonical origin is loopback or a private / link-local address — RFC 1918 10/8, 172.16/12, 192.168/16; RFC 4193 fc00::/7; 169.254/16, fe80::/10. A public host keeps TLS-required and fail-closed, exactly as today. Shape ruled by the maintainer, 2026-09-21, verbatim: 「要(开发模式)」 and 「19342 同意兼容」 — a deployment-level transport rule with the semantics of Keycloak's sslRequired=external. No configuration key, no environment variable, and no separate dev-mode branch point (a dev bind on a LAN address is a private address).

Landed at 91e0ec6e0, which every reading below was taken against.

The two readings the card asked for first

1. Do the mainstream MCP clients complete OAuth against an http:// server at all?

Yes — nothing in the reference client stack refuses the scheme. This compatibility is not bought for nothing.

  • The spec text says the opposite of what the implementations do. The MCP authorization specification (2025-06-18) carries exactly two transport sentences: "All authorization server endpoints MUST be served over HTTPS" and "All redirect URIs MUST be either localhost or use HTTPS."
  • The reference TypeScript SDK enforces neither of them on the server's scheme. Measured in this checkout, against the version this repository installs — @modelcontextprotocol/sdk 1.30.0, at node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@4.6.1/node_modules/@modelcontextprotocol/sdk. Its whole client OAuth implementation (dist/esm/client/auth.js) contains one https: comparison, inside isHttpsUrl at line 322, and that predicate has exactly one caller: line 233, validating a SEP-991 URL-based client_id. The server URL, the authorization-server metadata URL, the protected-resource metadata URL, the authorization endpoint and the token endpoint are never compared against a scheme, in client/auth.js or in shared/auth-utils.js.
  • The redirect-URI clause is satisfied regardless of the server's scheme, because the client's redirect URI is its own loopback callback, not the server's origin.
  • Honest limit on this reading. It is a source reading of the reference SDK at one pinned version, plus the spec text — not a driven run of the Claude Desktop, Cursor or Claude Code binaries, none of which is installed in this container and none of which is open source. What it establishes is that the standard client path contains no scheme refusal to trip over; a product shipping its own extra refusal on top would not be visible from here. The checklist item added by this PR carries a step that records the answer from a real client when a run has two machines.

2. The hostname case — which reading is pinned, and why

The rule judges the HOST LITERAL of the deployment's own canonical origin. An intranet hostname is not an IP literal, so http://crm.corp and http://host.docker.internal stay refused; the remedy is to configure the base URL on the private address the deployment already binds, e.g. http://192.168.1.10:3000. This is stated in the function's docblock and pinned by tests, not left silent.

Both alternatives the card named were considered and rejected, on the record:

  • Resolving the hostname in DNS turns a pure synchronous predicate into a network round trip on every isMcpOAuthEnabled() call — including the per-request bearer path — and a name whose answer can change, or be rebound, makes the transport verdict something no test can pin.
  • Judging the requester's peer address, which is what Keycloak does, cannot decide what a deployment advertises at mount time: the protected-resource document either exists or does not. It also carries a false-safe that would be adopted knowingly — a plain-HTTP reverse proxy on a public address forwards every request from a private peer, so every requester looks internal and plaintext OAuth is served to the public internet.
  • The same false-safe reaches the chosen reading from the other side: a deployment whose canonical origin is private but which a public plain-HTTP proxy fronts is eligible here. It is documented rather than keyed, exactly as Keycloak documents its own — a configuration key for it would be reachable on a public host, which is the deployment this rule exists to refuse.

Control legs — every one of them, with its reading

All from pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/auth-manager.mcp-oauth.test.ts at 91e0ec6e0: 65 passed, 0 failed.

1. Positive — private / link-local over plain HTTP is eligible

origin verdict
http://10.0.0.5:3000 eligible
http://192.168.1.10 eligible
http://172.16.0.1 eligible
http://172.31.255.255 eligible
http://[fc00::1] eligible
http://169.254.1.1 eligible
http://[fe80::1] eligible

2. Negative — the security floor. A public host over plain HTTP is still refused

origin verdict
http://example.com refused
http://203.0.113.5 refused
http://intranet.corp:3000 refused
http://host.docker.internal:3000 refused

Carried through the manager as well, not only the predicate: new AuthManager({ baseUrl: 'http://example.com' }) answers isMcpOAuthEnabled() === false and getMcpResourceMetadataUrl() === null — nothing advertised, fail-closed.

3. Invariants — untouched

https:// on any host (acme.example.com, intranet.corp, 203.0.113.5) is eligible. ftp://localhost and ws://10.0.0.5 are false; a non-URL is false. Loopback in every form is eligible: localhost, 127.0.0.1, [::1], *.localhost — and 127.0.0.2, since 127.0.0.0/8 is loopback and leaving it refused beside an eligible 10.0.0.5 would be incoherent.

4. Boundary traps — each one refused in the unsafe direction

origin verdict why
http://172.15.0.1 refused outside RFC 1918's 172.16.0.0/12
http://172.32.0.1 refused outside it on the other side
http://10.0.0.5.evil.com refused a hostname that merely begins with a private IPv4 string
http://192.168.1.10.attacker.test refused the same shape, other range
http://[fec0::1] refused site-local, deprecated by RFC 3879, outside fc00::/7
http://[febf::1] eligible the last fe80::/10 prefix — the inclusive edge
http://[fdff:ffff::1] eligible the last fc00::/7 prefix — the inclusive edge
http://[::ffff:10.0.0.5] refused IPv4-mapped IPv6, refused rather than unwrapped
http://0.0.0.0:3000 refused wildcard bind, not a reachable origin
http://[::]:3000 refused the same
http://167772161 eligible WHATWG URL canonicalises this to hostname 10.0.0.1 — it is that address

The refusals are IP-literal parses, not text prefixes: the IPv4 branch is a both-ends-anchored four-octet match, and the IPv6 branch expands :: elision to eight groups and masks bits (first & 0xfe00 === 0xfc00, first & 0xffc0 === 0xfe80).

5. The warning fires once, on plaintext only

packages/plugins/plugin-auth/src/mcp-oauth-plaintext-notice.test.ts — 6 passed. It drives the discovery mount and counts occurrences of the ruled wording 「OAuth 未加密:仅限可信内网」 in the captured log:

  • http://192.168.1.10:3000 — 1 occurrence, naming the issuer http://192.168.1.10:3000/api/v1/auth
  • http://localhost:3000 — 1 occurrence (loopback is plain HTTP too; the ruling exempts only TLS)
  • https://acme.example.com — 0 occurrences
  • http://10.0.0.5:3000 — 1 occurrence while the same call mounts several routes, so route count cannot move notice count
  • emitted at warn, never at info
  • exporting OS_ALLOW_INSECURE_OAUTH_HTTP=true changes none of it

No key and no variable exists. isOAuthEligibleBaseUrl takes one argument — the deployment's own origin — and reads no process state; the test asserts its arity and re-runs the public refusals with OS_ALLOW_INSECURE_OAUTH_HTTP=true exported. git grep OS_ALLOW_INSECURE_OAUTH_HTTP finds it in test setup only.

Reverse verification — three ablations, each landed on disk and restored byte-identical

Run through node scripts/ablation-replace.mjs from the committed state, so each mutation's landing and each restore is the tool's verdict rather than a claim. Predicted direction for all three: turn red. Observed: turn red, in the predicted legs.

mutation blob move result restore
unanchor the IPv4 literal regex (drop the trailing $) 171d493d987a to 833fb090cb6d 2 failed — 10.0.0.5.evil.com and 192.168.1.10.attacker.test both became eligible blob back to 171d493d987a, git diff HEAD empty
return isPrivateOrLoopbackHostLiteral(host) becomes return true 171d493d987a to 9ed3b10ffe2d 15 failed — all four public-arm legs, eight boundary legs, the no-key leg, and both manager-level legs blob back to 171d493d987a, git diff HEAD empty
disable the plaintext notice at its if 0c0ec2f98d6c to 3e57b0cbb649 5 of 6 failed — every leg but the TLS one, which is the leg that asserts absence blob back to 0c0ec2f98d6c, git diff HEAD empty

Resolution note: both test files import the subject by relative source path, so the subject is src/, not dist/ — no rebuild step stands between a mutation and the run it is measured by.

Verification

  • pnpm --filter '@objectstack/plugin-auth^...' build --concurrency=2 — exit 0 (under scripts/pm/os-verify-lock.sh, VERDICT command-exit 0)

  • pnpm --filter @objectstack/plugin-auth test — 114 files / 2421 tests passed, exit 0 (locked, VERDICT command-exit 0)

  • pnpm --filter @objectstack/plugin-auth build && pnpm --filter @objectstack/plugin-auth typecheck — exit 0 (locked). check:test-typecheck held its shrink-only ledger at 10 files / 94 errors / 23 pinned signatures

  • pnpm lint — the repo-wide eslint . --no-inline-config, exit 0. Not a narrowed run: the whole governed population was linted, so no narrowing needs proving

  • pnpm check:platform-checklist — exit 0; 15 areas, 265 items, 619/637 symbol anchors resolved

  • Derived gate families: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 66; all 66 were run and reconciled with their exit codes through --ran. 64 exit 0. The remaining two are NOT MEASURED, not red — both exit 3, PREREQUISITE NOT MET:

    • pnpm check:dual-build-cjs-loads — needs a whole-workspace dist/; it names 40 unbuilt packages, none of them in this diff. Covered by CI's Build Core.
    • pnpm check:type-check-debt — refuses to re-measure without the built dependency closure, on purpose. Covered by CI's TypeScript Type Check, which builds the closure first.

    Neither can be moved by this diff: it adds no package, no export and no entry point, and touches source in one already-built package whose own typecheck is green above.

Acceptance notes

  • A mixed-language log line, declared rather than slipped in. The ruling fixes the warning's wording verbatim as 「OAuth 未加密:仅限可信内网」; AGENTS.md says repository artifacts keep their existing language convention, and git grep finds no CJK in any package source today outside translations/. The line therefore leads with the ruled clause verbatim and continues in English. Flagging it so a reviewer can rule the other way — the alternative was to keep the ruled phrase only in a code comment, which would leave nothing in the log for the checklist item's grep to score.
  • 127.0.0.0/8 is now recognised as loopback, where the old rule matched only the exact string 127.0.0.1. Strictly inside "loopback", which the ruling's first arm names, and refusing 127.0.0.2 beside an eligible 10.0.0.5 would have been incoherent. Noted rather than assumed.
  • The contributor's strict-spelling refusal pin was kept as the production arm's, with its title corrected: http://intranet.corp:3000 is refused because it is a hostname rather than an IP literal, which the old title's "TLS rule" no longer describes.
  • Noted, not filed — the mount-time OAuth warning is not reachable by any test in this package other than the one added here. registerOidcDiscoveryRoutes is private, async, and dynamically imports the provider, so the new test drives it through a cast. Carrier: none — no queued card and no PR touches this seam, and it is an observation about test ergonomics rather than a defect, a contract violation or an authoring trap.

维护者速读(草稿)

改了什么 —— MCP 的 OAuth 以前只有 TLS 部署(外加 loopback)能用;现在内网部署也能用:部署自己的地址是 10.x / 192.168.x / 172.16-31.x / 169.254.x / IPv6 的 fc00::/7 / fe80::/10 时,走明文 HTTP 也照常提供 OAuth。公网地址一个字节没动,仍然拒绝。

为什么改 —— 一个已经用明文 HTTP 提供登录页和会话 cookie 的部署,OAuth 再拒绝明文并不多保护什么,只是让这个部署用不上 MCP。开发人员在局域网上跑 os dev 是同一件事,所以没有单独的"开发模式"开关。

风险与代价(含回滚) —— 风险集中在一处:私网判断写错就等于把明文 OAuth 开给公网。为此判断读的是 IP 字面量而不是文本前缀,172.15.x、10.0.0.5.evil.com、fec0::1 这些"看着像内网"的全部拒绝,并且用三次消融证明这些断言真的会失败。无配置键、无环境变量,所以没有"被误开"的面。回滚 = revert 本 PR 一个提交,isOAuthEligibleBaseUrl 回到只认 loopback,无数据、无迁移、无发布面残留。内网主机名(crm.corp)仍然拒绝,改法是把 baseUrl 写成内网 IP。

席位意见 —— (留空,待席位定稿)

你要做的 —— 确认两点:① 明文告警里保留中文原话是否可接受(全仓代码目前无中文);② 内网主机名继续拒绝、只认 IP 字面量,是否就是你要的边界。


Generated by Claude Code

⚠️ 正文订正 —— 以 final head 18f44168d1 为准(本节由认领席 domain:services 补写)

dev 正文只写一次、⛔ 不 PATCH,而本 PR 走了三轮。以上正文写于第一轮 head 91e0ec6e0,其中若干陈述已过期。冲突处以本节为准。

head 轨迹:91e0ec6e0 → e7c4df5a34 → 18f44168d1。

1. 🔴 127.0.0.0/8 —— 正文当时称已钉,当时并没有;现在有了

第一轮正文在 Invariants 段写「and 127.0.0.2, since 127.0.0.0/8 is loopback」。隔离达档复核实测:把 127.0.0.0/8 改回 127.0.0.1/32 的消融当时失败 0 条 ⇒ 树上没有这条断言。行为一直是对的,声明的钉子不存在。

现已补钉(第二轮):127.0.0.2 / 127.255.255.254 eligible,126.0.0.1 / 128.0.0.1 refused;同一消融现在失败 2 条。

2. 日志措辞:提出 → 被席位误裁 → 撤回 → 按裁定原文落地

第一轮正文把「中文原话留日志 vs 只留注释」挂出来请维护者裁,这是对的。认领席一度判了后者并已撤回(更正 5757931777)——那是重裁,不是裁量:裁定第 ④ 条逐字含「(「OAuth 未加密:仅限可信内网」)」,验收段写明日志要 carries 它。

最终实况(运行时实测,⛔ 非读源码):发出的字符串以裁定原话开头,英文操作性说明接在同一行后半。checklist 锚点随之归位。

⚠️ 与 AGENTS.md:31(仓库产物以英文为惯例)的张力未在本 PR 解决,已另立决策卡 #19571 交维护者。

3. 告警条件:补了资格判断,并因此新增了公网腿

auth-plugin.ts 的条件由「只看 scheme」改为「明文 且 eligible」。修的是一句假陈述:公网明文 boot 上原条件会打印「This is accepted only because the host is loopback or a private / link-local address」,而该部署恰恰未被接受(复核用真源码驱动复现,PROBE-PUBLIC-NOTICE-COUNT=1)。

告警腿现为 1 / 1 / 0 / 1 + 公网 0:私网、loopback 各 1;TLS 0;多路由挂载仍 1;三个公网明文 origin(example.com、203.0.113.5、intranet.corp:3000)各 0 —— 这一面此前零覆盖(文件里每个 origin 都是 eligible 的)。

4. 边界表新增五行 —— 闭一个既存覆盖缺口

10/8、192.168/16、169.254/16 三个块此前没有任何相邻块拒绝腿:把 10.0.0.0/8 放宽到 /6(静默放开 8.8.8.8)、192.168.0.0/16 放宽到 /8(放开整个 192.0.0.0/8)等变异,失败断言均为 0。

⚠️ 这不是 CIDR 重构引入的 —— 复核把同样变异打在重构前的算术实现上,同样零红。但重构把范围语义搬进了一张一个字符就能改的表,误改门槛下降。

现补:11.0.0.1 · 192.167.0.1 · 192.169.0.1 · 169.253.0.1 · 169.255.0.1 全 refused。对应五个消融各自变红(1/2/1/2/1)。

5. IPv4 解析器去重(第二轮,复核的非阻塞质量注)

删 parseIpv4Literal,IPv4 臂改走同文件 ADR-0069 D5 的 ipv4ToInt / ipMatchesRange + 具名 CIDR 表。

🔴 这次重构让第一轮的复核结论失效过一次,因为被扫的正是这条路径。已重扫:接受面 83/83 逐行不变(CHANGED=0);ipMatchesRange 对 393,216 个地址与独立算术参考谓词对拍 0 mismatch;IPv6 臂 131,092 个字面量对拍 0 差异;ipv4ToInt 新增的 .trim() 实测在 u.hostname 取值域里不可达,且即便可达也只归一化、不放宽。

final head 18f44168d1 的 auth-manager.ts 与被重扫的 e7c4df5a34 逐字节相同(认领席现验)⇒ 重扫结论适用于将要落地的实现。

6. 反向验证:三轮共 11 次消融(3 + 3 + 5)

正文原三行表已被取代。每次消融均自 committed 状态落盘、按 blob 核实落地与还原、git diff HEAD 为空。

7. ⛔ 刻意未做

公网明文部署仍挂 3 条 AS 发现路由(main 上的既有形态,本 PR 未改),且除 OAuth track is NOT live 外没有一行说「AS 正在公网明文提供」。裁定第 ④ 条按字面读是否仍欠它一行,是裁量分叉 ⇒ 已进决策卡 #19571,⛔ 不在本 PR 自裁。

本节由 domain:services 席(session_01AhQASwqJr2Z7XfGWUdvnbF)补写,依 .claude/agents/os-dev.md「PR 正文 dev 只写一次…事后要改的报告点名改法,席位代写」。


Generated by Claude Code

…l deployment host

The OAuth 2.1 transport rule for the MCP track becomes a DEPLOYMENT-level
rule with the semantics of Keycloak's `sslRequired=external`: plain HTTP is
eligible when the deployment's own canonical origin is loopback or a private
/ link-local address (RFC 1918 10/8, 172.16/12, 192.168/16; RFC 4193
fc00::/7; 169.254/16, fe80::/10). A PUBLIC host keeps TLS-required and
fail-closed, byte-for-byte as before.

A deployment already serving its login form and session cookies over plain
HTTP gains nothing from OAuth refusing plain HTTP — the refusal only removes
MCP from that deployment. An intranet install and a developer's `os dev`
bound to a LAN address are the same case, so development mode is subsumed
and there is no separate dev-mode branch point.

No configuration key and no environment variable can open plain HTTP on a
public host; a switch would be reachable exactly where this rule must keep
refusing. One loud line at discovery-route mount whenever OAuth is served
over plain HTTP, none under TLS.

The rule judges the HOST LITERAL of the deployment's own origin, so a non-IP
intranet hostname (crm.corp, host.docker.internal) stays refused over plain
HTTP. Resolving the name in DNS and judging the requester's peer address
were both considered and rejected on the record, in the function's docblock.

Adds the standing platform-checklist item the card was filed for
(ai.mcp-oauth-private-host-transport): no item asserted MCP OAuth from a
non-loopback host at all, so neither the arm that opened nor the public arm
that must stay closed had a run.

The requirement comes from the community fork PR, whose author reported that
MCP cannot be tested from a development environment behind the https
requirement; its implementation and its `OS_ALLOW_INSECURE_OAUTH_HTTP`
escape hatch are deliberately NOT adopted.

Co-authored-by: jinyitao123 <jinyitao123@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ai/agents.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/ai/connect-mcp.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/ai/index.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/ai/natural-language-queries.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/api/index.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/capabilities/ai.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/deployment/cli.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/deployment/environment-variables.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/deployment/index.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/deployment/self-hosting.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/getting-started/build-with-claude-code.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authorization.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))

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

  • content/docs/releases/v13.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))
  • content/docs/releases/v16.mdx (via /api/v1/mcp (route, a path literal in registerOidcDiscoveryRoutes))

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 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 14 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 b3615f1a4cd7f3ff59ff0548530daa042627f732 → packageMentionDocs.

Which tree this was computed on

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

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 21, 2026
…e ACCEPTED the origin

Contract-review round 2 on the draft PR. Three findings, all on the notice
and the pins around it; the predicate's verdicts are untouched.

1. The notice was keyed on the issuer's SCHEME ALONE, so a PUBLIC plain-HTTP
   boot emitted it — and its own sentence, that the transport rule accepts
   this origin, is FALSE of a deployment the same rule refused and whose
   OAuth track is dark. An operator reading it would conclude a public
   plaintext authorization server is a posture this rule permits. The
   condition now carries both halves: plain HTTP AND eligible. That
   deployment's line stays the `OAuth track is NOT live` warning below it —
   a different sentence with the opposite meaning. Three public origins are
   pinned at zero notices; before this commit the file's every origin was an
   eligible one, so the face was uncovered.

2. The log line is English, as every other repository artefact is; the
   ruling's own wording is preserved verbatim, as the quotation it is, in the
   comment directly above the emit site. The platform-checklist item's grep
   anchor moves with it to `OAuth is served UNENCRYPTED`, so it scores
   something a boot log actually carries instead of becoming an empty anchor.

3. Loopback 127.0.0.0/8 had no pin: reverting it to the single literal
   `127.0.0.1` failed nothing. `127.0.0.2` and `127.255.255.254` are pinned
   eligible, with `126.0.0.1` and `128.0.0.1` beside them as the public
   blocks on either side.

Also drops the duplicated dotted-quad regex the review flagged: the IPv4 half
reads through this file's own ADR-0069 D5 helpers (`ipv4ToInt`,
`ipMatchesRange`), and the ranges are spelled as the CIDR blocks the ruling
names. Behaviour-identical — the helper carries the same anchored regex and
the same octet refusal — and no existing caller is touched.

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

Contract-review round 3. The predicate's verdicts are untouched for the
second round running; this is the warning's text, the changeset's accuracy,
and five boundary legs.

1. The ruling fixes this warning's wording and its acceptance says the
   startup log CARRIES it, so the phrase belongs in the emitted string and
   not only in a comment beside it. The English operational clause follows it
   on the same line, and the platform-checklist item's grep anchor moves back
   with it. The general repository convention that artefacts are English and
   the specific ruling genuinely pull apart here — this is the first CJK
   string in a package source — and that tension is a decision of its own,
   not one this call site settles.

2. The changeset said the line is emitted "whenever OAuth is served over
   plain HTTP". It is not, since the previous commit: a public plain-HTTP
   deployment is refused by the transport rule and gets the separate
   "OAuth track is NOT live" warning instead. It now says what runs.

3. Three of the five eligible IPv4 blocks had no adjacent-block refusal at
   all: widening 10.0.0.0/8 to /7 or /6, 192.168.0.0/16 to /8, or
   169.254.0.0/16 to /15 changed no assertion in this tree. The gap predates
   the CIDR table — the same widenings were silent against the arithmetic
   form too — but a range spelled in one character is a range a typo moves,
   so each block now owes its neighbours: 11.0.0.1, 192.167.0.1,
   192.169.0.1, 169.253.0.1 and 169.255.0.1, all refused.

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

Copy link
Copy Markdown
Collaborator Author

契约复核 provenance —— 安全边界卡,达档复核两轮 PASS 在案

domain:services seat · session_01AhQASwqJr2Z7XfGWUdvnbF · 写于 2026-09-21T10:18Z。

Reviewed-by: 隔离达档子代理(CONTRACT_REVIEW_TIER),⛔ 认领席未自审(本席判断档)
Reviewed-heads: 91e0ec6e0(第一轮)· e7c4df5a34(第二轮重扫)
Landing-head: 18f44168d1

为什么复核跑了两轮

第一轮判后,补丁轮顺手做掉了复核自己提的非阻塞质量注:删 parseIpv4Literal,IPv4 臂改走 ipv4ToInt / ipMatchesRange + 具名 CIDR 表。

⇒ 被穷举扫过的那条代码路径被换掉了。 dev 的报告写着「no predicate logic line was touched」,但该句被其自身的改动项证伪。⛔ 拿旧实现的通行证给新实现放行,是本席明确拒绝的做法 ⇒ 派回重扫。

重扫结论(第二轮,⛔ 未复用第一轮任何读数):

读数 值
上一轮 83 行输入表,新旧实现逐行对拍 CHANGED = 0
ipMatchesRange 对独立算术参考谓词 393,216 个地址,0 mismatch
IPv6 臂字面量对拍 131,092 个,0 差异
ipv4ToInt 新增的 .trim() 12 种空白拼法穿真 new URL,无一能让 u.hostname 带空白;即便可达也只归一化不放宽
(A) 有无公网 → eligible 的路径 没有

⭐ 本席现验:落地 head 18f44168d1 的 auth-manager.ts 与被重扫的 e7c4df5a34 逐字节相同(git diff --stat 为空)⇒ 重扫结论适用于将要落地的实现,⛔ 不是一份已被取代的。

三轮补丁修掉的东西

  1. 告警条件由「只看 scheme」改为「明文且 eligible」—— 原条件在公网明文 boot 上会打印一句假陈述(复核用真源码驱动复现)。新增三条公网腿钉在 0 次告警,该面此前零覆盖。
  2. 127.0.0.0/8 补钉 —— 正文曾声称已钉而实际没有(消融当时 0 条红,现 2 条红)。
  3. 边界表补五行 —— 10/8、192.168/16、169.254/16 此前无任何相邻块拒绝腿(把 10.0.0.0/8 放宽到 /6 可静默放开 8.8.8.8 而零断言红)。⚠️ 这是既存缺口,复核实测旧实现同样零红,⛔ 非重构引入。

席位自身的一处越界,已撤回

日志措辞的中文/英文之争由 dev 正确上呈请维护者裁,本席一度自判并已撤回(更正 5757931777)——裁定第 ④ 条逐字含该措辞,验收段要求日志 carries 它。现按裁定原文落地,运行时实测该句为发出字符串的首个 token。

入队闸(全部现读)

CI(按 check-name 取最新,钉 18f44168d1)29 success · 5 skipped · 0 失败 · 0 挂起 · check-expected-skips exit 0 · check-clause2-carriers --pair 19534 exit 0 · check-governed-merges NOT governed · .gitattributes 当场重读 18 条,六个文件无一命中 · needs:contract-review 两载体同态(不存在,条款② no 不需要)。

⛔ 刻意未决,已上呈

公网明文 AS 的告警归属、以及裁定中文措辞与 AGENTS.md:31 的张力 ⇒ 决策卡 #19571,⛔ 本 PR 不自裁。


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 21, 2026 10:18
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit 2aac821 Sep 21, 2026
43 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-19489-oauth-private-host-transport-rule branch September 21, 2026 10:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nencrypted, in English (objectstack-ai#19689)

Fixes objectstack-ai#19571

Implements ruling batch objectstack-ai#210 item 5 (letter B/B, maintainer 「210 同意」),
which governs over the issue body. Upstream ruling: objectstack-ai#19489 item ④. PR
objectstack-ai#19534 is landed and is **not** reverted here — this is its follow-up.

Clause-②: no

## D1 — eligibility decides WHICH sentence, never WHETHER one is emitted


`packages/plugins/plugin-auth/src/auth-plugin.ts#registerOidcDiscoveryRoutes`.

The structural defect, verified on `origin/main` before writing code:
the three AS discovery routes
(`/.well-known/oauth-authorization-server`,
`/.well-known/openid-configuration` and the RFC 8414 §3.1 path-insertion
variant) are mounted unconditionally, while the only line that mentioned
a refused transport — `MCP server is enabled but the OAuth track is NOT
live` — sits **inside** `if (readMcpServerEnabledEnv() && ...)`. A
public plain-HTTP boot with `OS_MCP_SERVER_ENABLED=false` therefore
emitted **no warning at all**, while publishing its authorization-server
metadata in the clear. The loudest-needed configuration was the
quietest.

The plain-HTTP branch now has a sibling `else if`, beside it and **not**
inside the MCP block:

- accepted origin (loopback / private / link-local) — unchanged line,
minus its CJK prefix: `OAuth is served UNENCRYPTED: ...`
- refused origin (public host) — new, distinct line: `OAuth discovery is
served over PUBLIC plain HTTP: ...`, naming (a) the discovery documents
and the issuer, (b) that the MCP OAuth track is DISABLED, (c) that TLS
is the remedy.

Both fire once at mount, neither under TLS, and no configuration key or
environment variable gates either.

**⛔ No admission decision changes.** `isOAuthEligibleBaseUrl` and every
transport-rule predicate are byte-unchanged; the discovery routes are
mounted exactly where and when they were. ⛔ No new exported symbol
(`servedOverPlainHttp` is a local const) — the `Clause-②: no`
declaration stands, and the mechanical floor at
`references/contract-review.md:12` is not tripped.

## D2 — the emitted string is English; the ruled sentence moves to the
comment

The `'OAuth 未加密:仅限可信内网 — '` prefix is out of the executable string. The
maintainer's sentence is kept verbatim in the code comment beside the
call, cited to the objectstack-ai#19489 ruling and to batch objectstack-ai#210 item 5, and read as
the line's meaning rather than its literal encoding. ⛔ No CJK executable
string remains in either file's `src` tree; ⛔ no bilingual line.
`AGENTS.md` is untouched.

The comment block above the branch previously argued the **opposite** —
that the Chinese belongs in the emitted string "not only in this
comment". That paragraph is falsified by this ruling and has been
rewritten to state the current rule with its citation.

## Carriers moved with it

1. `packages/plugins/plugin-auth/src/auth-plugin.ts` — the two branches
and the comment.
2. `packages/plugins/plugin-auth/src/mcp-oauth-plaintext-notice.test.ts`
— `NOTICE_MARKER` re-anchored to the English line;
`PUBLIC_NOTICE_MARKER` added; the "does NOT fire on a PUBLIC plain-HTTP
deployment" leg rewritten to assert the sentence **swaps** rather than
vanishes; new coverage for the MCP-surface-OFF boot, MCP-independence,
once-per-mount, warn level, mutual exclusivity and the no-env-var floor.
15 tests, all green.
3. `docs/qa/platform-checklist/areas/ai.json` —
`ai.mcp-oauth-private-host-transport` revision 1 → 2 with its history
entry. The grep anchors move to the two English lines; case (b) moves
from asserting **0** occurrences to asserting **exactly 1** of the new
line; step text, `clause`, `verify`, the `negative` conflation entry,
the `source` line and the title moved together.
4. `.changeset/19489-oauth-private-host-transport-rule.md` — still
unreleased on `origin/main` (the file is present in `.changeset/`, so
`changeset version` has not consumed it). Its last bullet asserted the
startup line carries the Chinese and that a public plain-HTTP boot gets
no such line; both become false when this lands, so the sentence is
corrected. The Chinese is kept only as a quoted ruling citation.

Plus a new changeset,
`.changeset/19571-plaintext-oauth-public-host-notice.md`
(`@objectstack/plugin-auth: patch`).

## ⚠️ One gate is red BY DESIGN and needs a human word

`node scripts/check-empty-changeset.mjs --base origin/main` — **exit
1**, on carrier 4:

> `.changeset/19489-oauth-private-host-transport-rule.md` present on the
merge base and CHANGED by this PR

This is the gate's **DELIBERATE CORRECTION** class, not the COLLISION
class. Its own text says the remedy is *not* to restore the file —
restoring it from the base republishes a sentence this PR makes false —
and that correcting a pending release note "is a decision about a
release rather than a refactor — say so on the PR, naming the note and
what changed under it, and get it confirmed". Naming it here: the note
is `19489-oauth-private-host-transport-rule.md`, and what changed under
it is the D1 branch above. The gate stays red until a person confirms.

## Verification

| check | result |
|:---|:---|
| `pnpm --filter @objectstack/plugin-auth test` | 114 files / **2439
passed** |
| `pnpm --filter @objectstack/plugin-auth run typecheck` | exit 0 (after
building the package; `tsconfig.examples.json` resolves through `dist`)
|
| `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 |
| `pnpm --filter '@objectstack/plugin-auth^...' build` | exit 0 |
| derived gate families (`scripts/pm/dispatch-gates.mjs --commands`), 66
commands | 63 green · 1 red by design (above) · 2 `PREREQUISITE NOT MET`
|

`pnpm check:platform-checklist`, `check:nul-bytes`,
`check:doc-authoring`, `check:test-source-alias`,
`check:cross-package-test-inputs`, `check:published-files`,
`check:type-check-coverage`, `check:adr-0087-registration`,
`check:changeset-no-major` all exit 0.

NOT MEASURED (each prints `PREREQUISITE NOT MET` — explicitly "not a
pass and not a finding" — and needs a full workspace build, which is
CI's): `pnpm check:dual-build-cjs-loads` (exit 3), `pnpm
check:type-check-debt` (exit 3). This diff changes no `exports`, no
`package.json` and no build shape.

### Reverse verification (both legs from the committed state; restored
byte-identically)

Run through `scripts/ablation-replace.mjs`, which proves the mutation
landed on disk by blob hash and proves the restore by `git diff HEAD`
being empty. The subject resolves from `src` through a relative import,
so no `dist` leg applies.

1. **The new branch can fail.** `} else if (servedOverPlainHttp) {` → `}
else if (servedOverPlainHttp && false) {`. Blob `17bd405104b2` →
`eb7d11ae9d01`. Result: **9 failed | 6 passed (15)** — every public-face
assertion red. Restored: blob back to `17bd405104b2`, `git diff HEAD` 0
bytes.
2. **The D2 pin can fail.** The CJK prefix re-added to the accepted
line. Blob `17bd405104b2` → `c7aba397fff2`. Result: **1 failed | 14
passed (15)** — `fires on a private-address deployment` red on the
no-CJK assertion. Restored: `ok restored: blob == HEAD (17bd405)
and git diff HEAD is empty`.

Predicted direction was RED for both, and RED is what was observed.

## Acceptance notes

- The comment now states explicitly that whether the `.well-known`
discovery routes should be mounted at all on a refused origin is
pre-existing `main` behaviour neither ruling touched, and is not decided
at that call site — per the ruling, that would be a card on its own
evidence, ⛔ not folded here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s card (objectstack-ai#19895)

Fixes objectstack-ai#19883
Clause-②: no

## 维护者速读(草稿)

**改了什么**:三份 PM 协议细则文件,共 +5 / −3 行。① 采纳了 fork PR 的卡,正文恒带一行行首 `Adopts:
#M`(M = 那个 fork PR 的号),立卡那一笔就写;② 落地收口清单(确认
MERGED、给卡收口的那一笔)加一行:卡带这一行,就在同一动作里给 fork PR 贴致谢 + 落地链接并关闭它;需求被拒(卡关
not_planned)时,关卡那一笔同样贴致谢与谢绝理由并关闭;③ 分诊席每次读的职责文件加一行:每次 fire 扫开着的 fork
PR,没卡就立卡,卡已关而 fork PR 还开着就补关。两份有行数上限的文件各加一行、各退一行同文件里已原样写着的重复内容,上限不动。

**为什么改**:维护者原话「这种pr应该写个评论直接关闭。」「"这种 PR"都应该这样处理,开一个 skills 卡片」。外部贡献者的 PR
在需求已由内部 PR 落地后被晾了两天没人关:这条规则只写在一份落地时没有席位会读的文件里,落地收口清单里没有这一步,卡上指向 fork PR
的只是一段散文。另外核实发现,「分诊每次 fire 扫 fork PR」这一步本身也不在分诊席每次读的职责文件里,只在那份没人读的文件里。

**风险与代价(含回滚)**:纯文本改动:不加门禁、不加脚本、不加巡查行、不动行数上限。代价是分诊每次 fire 多一次「列出开着的
PR」的读取(当前 23 个开放 PR,0 个 fork),以及卡正文多一种行首拼写
`Adopts:`。退掉的两行,其每一句在同一文件里都还在(逐条对照见下文表格)。回滚 = revert 这一个 squash commit。

**席位意见**:

**你要做的**:无需点击。本 PR 只碰 `.claude/**`,属 Tier S,由 skills 席在席内契约复核 PASS
后走队列落地。若不希望分诊每次 fire 都扫 fork PR(只保留落地那一笔),在本 PR 评论一句即可,删掉 triage 那一行。

## What this PR does

Three files under `.claude/skills/pm-dispatch/references/`, +5 / −3.

1. **`external-contributions.md`** (the fork-PR playbook)
- Step ①: the card behind a fork PR always carries a line-start `Adopts:
#M` (M = the fork PR's number). It is written in the stroke that files
the card, or that attaches the fork PR to a card that already exists.
The landing closeout and the triage catch-up read only this line.
- Step ③: the line "after the internal PR lands, close the fork PR with
thanks and the landing link" becomes: the stroke that closes the card
also closes the fork PR its `Adopts:` line names. On `completed` the
closing comment carries thanks and the landing link. On `not_planned` it
carries thanks and the reason the need was declined. The declined case
had no text before.
- The sentence that limits a seat's comments on a fork PR is rewritten,
not worked around. It used to say "only these two kinds" after naming
review comments and the close. It now names them: apart from step ①'s
fixed comment, a seat writes review comments and the closing comment,
and nothing else. The declined form is the closing comment's second
form, not a third kind.
2. **`landing-operations.md` §B, the MERGED closeout**, the home of "the
landing stroke": one line directly after the `Part of` / `Fixes`
closeout line. A card closing `completed` with an `Adopts:` line means
the same action posts thanks and the landing link on that fork PR and
closes it.
3. **`triage-duties.md`**: one line at the end of the backlog-sweep
block. Every fire also scans open fork PRs. No card: file one per step
①. Card already closed: close the fork PR per step ③.

## Why this shape — the four axes

The card offers two candidates, A (at the landing step) and B (in the
triage sweep), and leaves the shape to the seat. `SKILL.md`'s order for
a failure fix is: remove the construct that allows the error, make the
right form the only spelling, and only then add a check.

- **Remove the construct.** The miss was allowed because the close was a
separate, later act. It was written only in `external-contributions.md`,
and no seat reads that file at MERGED. The landing seat reads
`landing-operations.md`, whose closeout list had no fork-PR item. The
card named its fork PR only in free prose: an "Adoption of PR" section
and a promise to close it "in the landing stroke". Shape A removes the
construct: the close is now an item of the closeout list itself, in the
same action that closes the card.
- **One spelling.** `Adopts: #M`, line-start and undecorated, the same
form as `Blocked-by:`. It has three named readers: the landing closeout,
the triage catch-up, and step ①'s "does this fork PR already have a
card". Measured on the one case: the fork PR's timeline carries **10**
cross-references from other issues and PRs, one of them its card. The
line is what picks the card out of the ten.
- **Check.** None is added: no gate, no half-state row, no ratchet.
Neither ruling sentence names one, and a new gate defaults to no. B is
one prose duty line on a scan the triage seat already owes by step ①. It
is not a patrol row.
- **Why B at all, with A in place.** The declined case has no other
reader. A `not_planned` close happens in several seats' flows: a triage
first-touch close, a decision-box answer, a falsified premise. The one
file every seat reads is `SKILL.md`, which sits at 319 / 319 and is held
by PR objectstack-ai#19890. The triage fire is the one reader that sees every closed
card's fork PR, and it is also the backstop if a landing stroke misses
one.
- **Real business need (measured).** Fork PRs are rare: 1 among the 500
most recent closed PRs (created 2026-09-17T18:37Z .. 2026-09-23T14:41Z).
That one is the fork PR that was missed. 0 of 23 open PRs are forks at
2026-09-23T15:29:57Z. The maintainer's word covers every such PR, and
the one miss cost an outside contributor two days of silence.
- **Startup focus.** +2 lines net across the corpus. Both ceilinged
files stay at their ceilings: `landing-operations.md` 101 / 101,
`triage-duties.md` 120 / 120. `external-contributions.md` goes 16 → 18;
that file is not in the ceiling map.
- **Rejected alternative.** Teach `scripts/pm/close-cards.mjs` to read
`Adopts:` and refuse a card close that does not also close the fork PR.
That is structurally the strongest shape, but it is a new mechanical
check that no maintainer word names, and it is outside this card's
claimed file surface.

## Premise check, on `origin/main` at `dabf8d7`

- **Confirmed.** The rule's only carrier was `external-contributions.md`
(the old line 13), as prose. Fork PR objectstack-ai#19342 stayed open from the
internal landing to 2026-09-23T14:42:37Z. Card objectstack-ai#19489 is `closed` /
`completed` (2026-09-21T10:32:18Z). PR objectstack-ai#19534 merged
2026-09-21T10:32:16Z as `2aac821a8c`.
- **Correction to one reading in the dispatch.** `triage-duties.md` had
**no** fork-PR scan line. `git grep -c -i fork dabf8d7 --
…/triage-duties.md` exits 1 (zero hits). The control on the same tree
and term, `…/external-contributions.md`, hits 8 and exits 0. Step ①'s
scan lived only in `external-contributions.md`, and `SKILL.md`'s
phase-file index names no reader for that file. The pre-relocation
`SKILL.md` (`f151ef2^`) pointed at it only from the enqueue-and-landing
section. So candidate B's premise, "step ① already scans open PRs on
every fire", held on paper only. The new triage line is what gives step
① a reader.
- The dispatch was cut at `e9eb2244d5`; this worktree starts at
`dabf8d7`. `git diff --stat e9eb224 dabf8d7 --
.claude/skills/pm-dispatch/` is empty.

## Retired lines: every clause is still stated in the same file

| retired | where each clause still stands |
|---|---|
| `landing-operations.md` old :19, "follow to MERGED; after enqueue the
watch belongs to the lane PM's landing window: every round read the
queue branch and `origin/main`" | §B heading 「跟到 MERGED 为止;入队后的看护归车道 PM
落地窗口」 and §B 「确认 MERGED 要两个读数:每轮同时读队列分支与 `origin/main`。」 |
| `triage-duties.md` old :8, "the tool-loading discipline binds only the
triage fresh session's opening; execution seats and devs are not bound"
| line 3: the file is the triage seat's alone. Line 6 「fire
开局只按名加载…判定本轮有活才加载其余」 and line 7 「⛔ 分诊 fresh session 开局不做泛关键词 ToolSearch」
are both scoped to the opening already. |

Neither retired line is quoted anywhere else in the tree (`git grep`
over both texts: the only hits are the lines themselves).

## Gates, on `4a3242c`

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 18 families from the 3 changed
paths, merge base `dabf8d795`. All 18 were run, each exit captured
before any pipe. `--ran` verdict: `✓ dispatch-gates --ran: 18 derived
famil(ies) accounted for — 18 run, 0 NOT-MEASURED (a DERIVED zero — all
18 recorded an exit code and none of them is 3).`

- `pnpm check:pm-skill-ratchet`: exit 0. `landing-operations.md is 101
lines (ceiling 101; headroom 0)`; `triage-duties.md is 120 lines
(ceiling 120; headroom 0)`. Every changed line is 120 bytes or less.
- `pnpm check:pm-skill-id-lint`: exit 0, `34 file(s) clean (pattern
/#[0-9]{3,}/g)`. No rule line cites a tracker number.
- `pnpm check:pm-governed-prose` (named by the dispatch, outside the
derivation): exit 0.
- `pnpm check:nul-bytes`: exit 0, `no raw ASCII control bytes`.
- `pnpm check:skill-frame-sync`: exit 0.
- `pnpm check:pm-half-states`: exit 0, `4912 cases pass`.
- `pnpm check:pm-governed-merges`: exit 0.
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`:
the first run exited 3 (PREREQUISITE NOT MET, `@objectstack/formula` and
`@objectstack/lint` not built). After `turbo run build` for those two
packages under the verify lock (VERDICT command-exit 0), the re-run
exited 0.
- The other 10 derived families (closing-keyword parity ×2, comment-mask
corpus, harness-current, agent-test spelling, cross-package test inputs,
doc authoring, driver-memory census, gitlink, refd-timer probe,
watch-hint literal): exit 0.

No package is touched, so there is no build closure and no package test
to run. Nothing here is published (`.claude/**`), so this PR carries
`skip-changeset`.

## Acceptance notes

- `SKILL.md`'s phase-file index names no reader for
`references/external-contributions.md`. After this PR both executing
seats' files point at it (`landing-operations.md` line 7,
`triage-duties.md`), so no index row is owed. Carrier: none.
- `references/external-contributions.md` is not in the line ratchet's
ceiling map, while the other `pm-dispatch` reference files are. Stated
as a fact, not a gap. Carrier: none.
- A fork-PR card that closes as `duplicate` is not covered: its
`Adopts:` line would need to move to the surviving card. There are 0
instances. Carrier: none.
- `check-half-states.mjs`'s H65 message quotes `triage-duties.md` as
「选层按 fire 时刻,⛔ 不用计数器;简报写明本轮跑的层」, but the file says 「选层按 fire 时刻 ⛔
不用计数器,简报写明层」. This is report-only message text. Carrier: none.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq)_

Co-authored-by: Claude <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

2 participants