fix(security): OAuth-connected MCP agents run at the delegator's recorded scope, and a narrowed delegated read says so - #17332
Conversation
…ADR-0090 D10) WIP checkpoint before build/test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
#16549) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ed reads say so Ruling batch #81 item 1 (option 1). A silent agent ceiling declares no record depth, so the D10 intersection takes the delegator's; the ceiling keeps every other subtraction, written down beside the sets. MCP query_records carries the D10 statement when a delegated read was narrowed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The published-surface delta this card adds, measured by the repo's own tools: `@objectstack/spec` ./contracts gains one entry, `DelegationNarrowing (interface)` — 0 breaking, 1 added. The census gains one elevation read (the diagnostic's `context.isSystem` guard). Types the runtime's new security-slot lookup against `Partial<ISecurityService>` so the slot-lookup ratchet stays at its baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…owcase Human leg over the REST door with a real admin token vs the OAuth agent leg through the engine with the producer's own context — same person, same object. Plus the control that the widening reaches a viewAllRecords profile and nobody else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift CheckThis PR changes 4 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 148 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2abe5b261f76856401047d3179abfd2246426c98 && git checkout 2abe5b261f76856401047d3179abfd2246426c98
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 92242041e738eec91b2ccd4e29c845682b53bb4a 63a63e4592c172bc828b164fb2f56274df880fbb && git checkout -B drift-repro 92242041e738eec91b2ccd4e29c845682b53bb4a && git merge --no-ff 63a63e4592c172bc828b164fb2f56274df880fbb
node scripts/docs-audit/affected-docs.mjs --json 92242041e738eec91b2ccd4e29c845682b53bb4a
|
…uth-agent-runs-as-the-user-r2
The census page's six declared counts are written by `pnpm gen:system-context-census`, never by hand. Both sides of the merge had independently raised them 108 -> 109 for their own new elevation read, so the merge kept one 109 while the union is 110 — the mechanical `[declared-count]` mismatch the gate reports. Regenerated wholesale after the merge commit: headline-sites, sharing-total, table-reads, table-elevation-reads, table-behaviour-bearing and ruling-sites, each +1. No anchor rewrite (this page carries no line numbers) and no human-edit-class problem was reported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
|
| direction | after release |
|---|---|
| ship optional, later tighten to required | BREAKING |
ship discriminated, later loosen (add a union member, or an optional field to the true arm) |
non-breaking |
⇒ Under permanence the two shapes are not symmetric. Shipping the loose one buys nothing and forecloses the tightening; shipping the tight one costs nothing now and keeps every widening open. That is the whole of my answer to "is this the shape ./contracts should permanently carry": not quite — this one.
statement is the sentence an AI consumer renders; with it optional, a consumer that forgets the narrowed check silently renders undefined — the same shape as the total: 0 silence that #16549 measured.
3. Advisory — the unnamed 5-member scope union ⚠️ weaker, your call
'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' is inlined once on origin/main (security-service.ts:468, the existing Promise<…> return). This PR takes it to three, all on the published surface. Measured with a dark control (a fabricated member reads 0).
⛔ There is no existing alias to reuse, and the near-miss is a trap: sharing-service.ts:633 exports HierarchyScope = 'unit' | 'unit_and_below' | 'own_and_reports' — a different, three-member set. Reusing it would be a silent narrowing. ⛔ Do not.
A new exported alias would replace three permanent anonymous shapes with one permanent named one, and make a future added member a one-site edit consumers inherit.
What I did not find
No duplicate of this type on the published surface, no naming-convention violation, no collision with DelegableScope's meaning, and the ?-optional method with feature detection (typeof svc.describeDelegationNarrowing === 'function') is the right call for an interface older implementations must still satisfy — that part I'd keep exactly as written.
Process note, ⛔ not a condition on this verdict
A cross-seat request is work, and the protocol says it lands as a card in the target lane's queue — a seat-post or PR comment is an accelerator, not the record. I am answering here because your framing was bounded and already carried its measurements, so bouncing it would have cost a round for nothing.
packages/spec routes to the domain:spec seat regardless of who needs it. This PR touches packages/spec/src/contracts/security-service.ts. ⛔ That is a routing observation for the triage seat, ⛔ not a demand to move a finished PR.
Generated by Claude Code
…rrowed` The three fields were documented as "present only when `narrowed`" while the type left them optional, so every consumer had to read the discriminant and then non-null-assert three fields the compiler still believed might be absent. The invariant lived in prose, where nothing enforced it. `statement` is the sentence an AI consumer renders. Left optional, a consumer that forgets the `narrowed` check renders `undefined` — the same silence this diagnostic exists to remove. The union makes the compiler enforce it. The two shapes are also asymmetric under permanence: shipping optional and later tightening to required is breaking, while shipping discriminated and later loosening (a new member, or an optional field on the `true` arm) is not. The five-member scope ladder the type spelled inline twice — and `resolveWriteScope` a third time — now names the alias that already exists for it: `ObjectAccessScope` (`security/permission.zod.ts`, ADR-0057 D1), the Zod enum the permission-set authoring schema itself uses. Same five members, so no narrowing; zero new exported names. Same information, same runtime values. Baselines regenerated with the repo's own generators. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…uth-agent-runs-as-the-user-r2
The body called `DelegationNarrowing` a new exported interface; it is now a discriminated union, so the word was wrong. States the permanence asymmetry that decides the shape, and that the five-member ladder names the existing `ObjectAccessScope` rather than a second declaration of one ladder — so the published surface still gains exactly one name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The prose spelled the cost of one direction as a bold-emphasised word, which is byte-identical to this repo's breaking-change declaration marker, so `check-adr-0087-registration` read the changeset as declaring a breaking change and asked for an ADR-0087 disposition. There is none to give: this PR is `minor` and purely additive — the api-surface baseline gains one line and removes none, and the sentence was describing a hypothetical FUTURE direction, not this change. A table says the same thing without minting the marker. ⛔ Not an evasion of a real declaration: nothing here is breaking, so no disposition is owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
PM 裁决 ·
|
| 项 | 读数 |
|---|---|
DelegationNarrowing 形状 |
:217 起,两臂判别联合:{ narrowed: false } 不携带任何字段;{ narrowed: true } 三字段全部必填 ✓ |
| 别名来源 | :59 import type { ObjectAccessScope, PermissionSet } from '../security/permission.zod.js',用于 :231/:233 ✓ |
| 梯子同一性 | 规范源五成员同序 ['own','own_and_reports','unit','unit_and_below','org'] ⇒ 无静默收窄 ✓ |
| 陷阱对照 | HierarchyScope 在全 diff 中 0 命中 ✓ |
| 变更面 | 14 个文件,与 PR 自报一致 ✓ |
⭐ 反向验证的 leg B —— TS2339: Property 'statement' does not exist on type '{ narrowed: false; }' —— 就是本卡片的故障模式本身:那条会渲染出 undefined 的路径现在在编译期写不出来。这比任何测试都更接近 #16549 的诉求。
条款② 与放行前检三条
- ① 本轮为席内复核:契约复审层级自
c47edbb21(pm-dispatch: reserve the contract-review tier for the skills seat and the spec seat’s clause-② review; default tier elsewhere; the director’s tier is the maintainer’s per-summon choice #17294)起收归 skills 席与 spec 席;余席按默认层级 = 席内自审 + 门禁。本条即该 PASS 的在案记录。 - ② 条款② 判定 yes(
export *经contracts/index.ts:51上架./contracts),已在 PR 正文首段以固定拼写声明;两侧载体均无needs:contract-review。 - ③ 入队资格 = 每一项 check 全绿(不是必需子集)。以
63a63e459上的收敛读数为准 —— 截至此刻 46 项 check、0 红,仅Lint & Repo Gates仍在跑;⛔ 未收敛不入队。
两处我接受的诚实记账(⛔ 不当作绿)
- ⊘ 1 项 NOT MEASURED:
check:dual-build-cjs-loadsexit 3PREREQUISITE NOT MET(8 个包无dist/,均不在本 PR 触及范围)。exit 3 永远不算通过;它由 CI 的Build Core在有完整构建闭包处测量。席位没有把 runner 自报的0 NOT-MEASURED当成测量结果 —— 这点做对了。 ⚠️ 本轮自造的一条红并自行修复:Check Changeset在0f0ff0bc8变红(11e1b73de时为绿),起因是 changeset 里一处加粗恰好与 ADR-0087 的破坏性声明标记字节同形。改成表格而非断言一个not-required处置 —— 后者会把一个假答案写进那道门禁唯一维护的账本。判断正确。- 它顺带暴露的那件事更有价值:该门禁在 110 条扫描的第 3 位跑过,早于 changeset 那次编辑存在 ⇒ 全绿账里混着一次陈旧读数。席位据此把凡涉
.changeset的 11 个 family 在最终 head 重跑(全 exit 0)。
我这一侧的错误,一并记在案
派工第 2 条的前提「引入一个新的导出别名」为假 —— 可复用的规范名早已存在且已发布。是席位的测量纠正了我,不是我先发现的。
Generated by Claude Code
Fixes #16549
Clause-②: yes — the diff addsDelegationNarrowingtopackages/spec/src/contracts/security-service.ts, whichpackages/spec/src/contracts/index.ts:51republishes byexport *, so it lands on the published./contractsentry point. Two new optional interface members ride with it:ISecurityService.describeDelegationNarrowingandMcpDataBridge.diagnoseDelegation.plugin-security's ownintersectDelegatedScope/d10NarrowingStatementare module-level only —packages/plugins/plugin-security/src/index.tsis named-re-export only, noexport *, so they are not published. Measured by the repo's own tools:check:api-surface→'./contracts + DelegationNarrowing (type)', 0 breaking (removed/narrowed), 1 added (it was(interface)when this line was first written; round 2 below made it a discriminated union — the count is unchanged);export-origins/contracts.jsongained exactly one line.What was wrong
Batch #81 item 1, ruled option 1. An OAuth-connected MCP agent read less than the person who delegated to it, silently: the delegated principal's own leg contributed an owner narrowing even when every granting permission set was silent about depth, so
agent ∩ usercame out narrower thanuser.The fix
PermissionEvaluator.getDeclaredScopenow answers the depth a permission set actually declares —undefinedwhen every granting set is silent — andintersectDelegatedScopereads that silence as no opinion. The delegator's depth therefore stands, andagent ∩ user = userfor visibility. The explain engine'sdepthlayer folds through the same function, so the explanation and the enforcement cannot drift.⛔ Deliberately not
narrowerScope(ceiling, delegator): the OWD/sharing owner-match is identity-scoped —__readScopebounds the agent identity,__delegatorReadScopethe delegator's, and the true intersection is the AND of the two filters. Folding the minimum into one leg would have hidden rows the delegator owns.A second, independent leg: optional
ISecurityService.describeDelegationNarrowing+ optionalMcpDataBridge.diagnoseDelegation, and MCPquery_recordsnow serves a narrowed delegated result withdelegationNarrowed: trueand awarningnaming the ADR-0090 D10 intersection — mirroringlist_objects' existingpartial/warningshape rather than inventing a second one.Each ceiling's remaining subtraction is now written down beside the sets, per the ruling's own instruction that "a subtraction nobody wrote down is the next card".
Two things the dispatch expected that measured otherwise
resolveWriteScope/hasWriteBypassare not landing points. Their only consumers areISharingService.canManageSharesand the attachment/comment hooks — that is share-management authority, not data visibility. They were left untouched and their on-behalf-of fail-closed stance recorded in the subtraction table instead.viewAllRecords/modifyAllRecordson the ceiling is fenced:MODIFY_ALL_WRITE_KEYScoversallowTransfer, and the superuser wildcard reachesprivate-posture objects. The intersection route was the only one inside the fence.rls-compiler.tsuntouched; the hot-file fences held; nothing outside what the ruling names was widened.Evidence (measured at
a76e26e8d, pre-suspension)Suites — plugin-security 107 files / 2051 tests; plugin-sharing 37 / 910 (the downstream consumer of the
__readScopestash this change touches); spec 470 / 13218; runtime 249 / 3516; mcp 27 / 297; the showcase dogfood ceiling file 7 / 7. Typecheck clean on plugin-security, spec, mcp, runtime.New pins — 6 enforcement + 5 diagnostic inside the existing ADR-0090 D10
describe(reusing that harness, ⛔ not a second one); 5 inpackages/mcp/src/query-records-delegation-diagnostic.test.ts; 2 end-to-end in the showcase dogfood ceiling file. The pre-existing depth pin (agentviewAllRecords+ delegatorreadScope:'own'→'org'/'own') is untouched and still green — the evidence that the identity-scoped stash survived.Whole-repo lint —
eslint . --no-inline-config: exit 0, zero findings. No narrowing claimed because none was needed.Ablation — four legs, each predicted in writing first, each mutate-and-measure in one shell under
trap … EXIT INT TERM, restored viagit checkout HEAD -- <abs>and proven by state:'own'againnarrowedByCeilingforced falsedist/(qa/dogfood resolves plugin-security byexports)expected [] to include …, the defect verbatimGates — 109 families derived, all 109 run.
--ranprinted0 NOT-MEASURED; that zero is the runner's claim and is not inherited: 2 are NOT MEASURED —check:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3PREREQUISITE NOT MET(they read a full build closure; 36 packages had nodist/). Honest tally: 109 derived / 109 run / 107 measured green / 2 NOT MEASURED / 0 UNRUN / 0 red. Three gates went red on the first sweep and were fixed, not waived:check:slot-lookup(ananyerasure in the new security-slot lookup — now a typedPartial<ISecurityService>),check:api-surface+check:export-origins(regenerated),check:system-context-census(regenerated; +1 elevation read, the diagnostic'scontext.isSystemguard).Changeset
minoron plugin-security / spec / mcp / runtime — argued in the changeset body. ⛔ Notmajor, ⛔ notprotocol:breaking: no member removed, no required member added, no wire shape retired.Known residual subtractions (recorded, ⛔ not fixed here)
private-posture object its delegator can read — a'*'wildcard without a superuser bit does not reach one, so the agent is denied outright. That is a loud refusal, not the silent shrinkage this card is about, and widening it is exactly what the ruling fenced off. It is an explicit row in the subtraction table.hasWriteBypassreturns false andresolveWriteScopereturns'own'for any on-behalf-of context, socanManageSharesrefuses for a delegated manager who could manage shares herself. Left in place (the ruling names visibility) and likewise written into the subtraction table.Round 2 — the
domain:specseat's shape review (#issuecomment-5617462625)Bounded patch round acting on the spec seat's verdict: name accept · shape one change · one advisory. ⛔ Correctness, clause-② classification, semver level and non-breaking-ness were not re-opened — they stand as measured at
#issuecomment-5616116189. Appended on top of the reviewed head11e1b73de; ⛔ no rebase, no amend, no force-push.origin/mainwas merged in (⛔ never rebased) viascripts/pm/os-regen-merge.sh.1.
DelegationNarrowingis now a discriminated union — the seat's one required changeSame information, same runtime values. The three fields were documented as "present only when
narrowed" while the type left them optional, so the invariant lived in prose. It is now the compiler's.Under permanence the two shapes are asymmetric:
truearm)The loose shape buys nothing and forecloses the tightening. And
statementis the sentence an AI consumer renders — left optional, a consumer that forgets thenarrowedcheck silently rendersundefined, which is the same silence #16549 exists to remove.⛔ The
?-optional method with feature detection (typeof svc.describeDelegationNarrowing === 'function') is untouched, exactly as the seat asked.2. The scope alias — taken, but⚠️ NOT as a new export, and this is a reportable deviation
The dispatch said to mint one new exported alias and use it at all three sites. Measurement contradicted the premise that there is nothing to reuse, so per the dispatch's own "if your measurement contradicts this, STOP and report rather than forcing the alias" I did not mint a second name. Reported here rather than decided silently.
packages/spec/src/security/permission.zod.ts:17-18already declares, and already publishes on the./securityentry:HierarchyScopetrap the seat flagged: that one is a different, three-member set and would have been a silent narrowing. This one is the identical ladder.readScope/writeScopeon a permission set are authored against — which is exactly what these three sites report.z.inputvsz.inferidentity is machine-pinned for this schema (type-alias-convention.pin.test.tsIso480), so using it as a return type is safe.contracts/security-service.tsalready imports type-only from that module (PermissionSet), so no new dependency edge.⭐ This is strictly better on the PM's own startup-focus axis, not a shortcut. Minting
ScopeDepthbesideObjectAccessScopeSchemawould have created a second declaration of one ladder — a hand-written union that nothing keeps in sync with the Zod enum, against Prime Directive #1 (Zod First). Reusing the existing name gets the whole benefit the advisory was after — one named thing instead of three anonymous copies, and a future member as a one-site edit at the enum that both the authoring schema and these contract sites inherit — at a cost of zero new permanent exports../contractscarry its own name for the ladder, adding one later is a one-line, non-breaking addition.3. Public-surface reading — the repo's own tools, not an assertion
pnpm --filter @objectstack/spec run check:api-surface→ exit 0,@objectstack/spec public API surface + factory signatures unchanged ✓.Whole-PR delta vs
origin/main, both baselines regenerated wholesale withcheck:generated --fix(⛔ nothing hand-edited):One added line each, zero removed, zero narrowed — unchanged in count from the pre-review PR. The only movement inside this round was
(interface)→(type)on the line this PR was already adding. ⇒ The advisory cost no public-surface addition; the PR still adds exactly the one export it always declared.pnpm --filter @objectstack/spec check:generated: 15 of 15 artifacts current on the merged tree, includingcheck:api-surface,check:export-origins,check:authorable-surfaceandcheck:docs.4. No non-null assertion was re-added anywhere
The seat's stated payoff is that consumers only delete assertions. Measured: this round's commit adds none, and the PR's four security-touching files add none vs
origin/main(security-service.ts,security-plugin.ts,runtime/src/domains/mcp.ts,mcp-http-tools.ts). The one producer-side cast that referenced the old indexed access (DelegationNarrowing['effectiveScope'], which the union makes ill-formed) becameas ObjectAccessScope— a rename of an existing cast, not a new escape hatch.packages/mcp's local bridge port is structurally typed ({ narrowed: boolean; statement?: string }) and the union is assignable to it, so the transport needed no change at all.5. Suites — new numbers at the final head
63a63e459, ⛔ not confirmationsEvery one re-run after the
origin/mainmerge, underscripts/pm/os-verify-lock.sh(VERDICT command-exit 0on each):@objectstack/plugin-security@objectstack/mcp@objectstack/spec@objectstack/runtimeTypecheck:
pnpm --workspace-concurrency=2 --filterover spec · plugin-security · mcp · runtime · dogfood →Scope: 5 of 81 workspace projects, all fiveDone, 0error TS.check:test-typecheckreports plugin-security's test layer at 0 files / 0 errors, i.e. the suite that exercises the union compiles undertsconfig.test.json.6. Reverse verification —⚠️ required for a cross-package type change, and it is the point of the shape
⛔ No ablation is owed — this round adds no new guard, so none was staged. What is owed is a reverse verification: prove the rebuilt
.d.tsis what the consumers actually read. Two legs, each mutate → prove on disk (marker count andgit hash-objectdiffering from the HEAD blob) → typecheck → restore viagit checkout HEAD -- ABSOLUTE_PATH(a real absolute path, spelled as a placeholder here) → prove restored (blob hash equal to HEAD's andgit diff HEADempty), all undertrap … EXIT INT TERM. Direction predicted in writing first: both turn red.statement:on thenarrowed: truereturnerror TS2353 … does not exist in type '{ narrowed: true; statement: string; effectiveScope: "own" | "org" | "unit" | "own_and_reports" | "unit_and_below"; delegatorScope: … }'.statementoff aDelegationNarrowingwithout checkingnarrowederror TS2339: Property 'statement' does not exist on type '{ narrowed: false; }'⭐ Leg B is the card's failure mode, now refused at compile time: the render path that produced
undefinedcan no longer be written. Leg A's message also independently re-proves the alias is not a narrowing — the compiler prints the resolved arm with all five members.Both restored byte-exact (
57b8a8e2c20751e734b9b13880efb2dfc17fe3f2before and after each leg); working tree clean.7. Gate reconciliation — honest, ⛔ exit 3 is never a pass
node scripts/pm/dispatch-gates.mjs --commandsre-derived on the merged tree (noSTALE TREEwarning; change set 14 paths vs merge base) → 110 commands, all 110 run with the exit code captured before any pipe.pnpm check:dual-build-cjs-loads,exit 3=PREREQUISITE NOT MET: "this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured" (8 unbuilt packages, none of them touched by this PR). CI'sBuild Corebuilds the closure and measures it there.pnpm --filter @objectstack/spec run check:skill-examplesfirst exited 1 — which is a prerequisite refusal, not a red about this diff (it reports one as exit 1, not 3):@objectstack/client-reacthad nodist/. Built that package and re-ran it to a real green:✅ 258 prose examples type-check across 3 surface(s).--ranrunner's own zero is a claim, not a measurement; the two exit codes above are annotated here from the recorded codes rather than inherited.node scripts/pm/dispatch-gates.mjs --ranover the recorded codes, verbatim:Honest tally: 110 derived / 109 measured green / 1 NOT MEASURED / 0 UNRUN / 0 red. ⛔ The NOT-MEASURED count is derived from the exit codes, not claimed — the codes were captured before any pipe precisely so this class could not be forgotten.
⛔ Repo-wide scans (
eslint . --no-inline-configand the rest of the lint farm) are CI's run, not re-claimed here; the whole-repo lint figure in the pre-review section above was measured ata76e26e8dand is not restated for this head.8.⚠️ One red this round produced, and it was fixed rather than waived
Check Changeseton CI went red on the first push of this round — green at11e1b73de, red at0f0ff0bc8, so ⛔ this round caused it. Named honestly rather than left for the PM to find.Cause. The changeset paragraph added in §1 spelled one direction's cost as a bold-emphasised word, and
**immediately followed by that word is byte-identical to this repo's breaking-change declaration marker (check-adr-0087-registration.mjs,/\*\*BREAKING/i). The gate correctly read the changeset as declaring a breaking change and demanded an ADR-0087 disposition.Fix — and why it is not the evasion that gate warns about. The gate's own text says ⛔ "Do NOT reach for it by dropping the token instead." That warning is aimed at a changeset that genuinely breaks consumers and is dodging the ledger. This one does not: the PR is
minor,check:api-surfacereports the surface unchanged, the baseline gains one line and removes none, and the sentence was describing a hypothetical future direction, not this change. Asserting anot-requireddisposition would have written a false answer into the one ledger that gate keeps true. ⇒ The same content is now a table, which mints no marker.check-adr-0087-registration --base origin/main→ exit 0: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)".What it exposed about the sweep. That family ran at position 3 of the 110-command sweep, i.e. before the changeset edit existed — a stale reading inside an otherwise-green tally. ⇒ Every family whose gate source includes
.changesetwas re-derived from the tool's own attribution and re-run at the final head63a63e459:check-adr-0087-registration(both legs),check-changeset-no-major(both),check-empty-changeset(both),release-rehearsal-clone --self-test,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-changeset-deadline-census,check:published-files— 11 families, all exit 0.check:changeset-fixed,check:nul-bytesandcheck:closing-keyword-paritywere re-run at the final head too, all exit 0.Generated by Claude Code