Skip to content

test(plugin-auth): register the authz objects two sign-in fixtures drive - #17982

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-17897-permission-set-refused-reads
Sep 13, 2026
Merged

os-project-manager merged 2 commits into
mainfrom
claude/issue-17897-permission-set-refused-reads

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #17897

Clause-②: no

Two plugin-auth test fixtures boot a real ObjectQL over a real SqlDriver and register
only plugin-auth's own authIdentityObjects. Both then drive reads against
plugin-security-owned tables that were never provisioned, and the driver refused every one
of them. tryFind classifies a missing table as "not provisioned" and answers [], so
nothing went red: the suites reported a green they had not earned, plus seven
DATABASE_ERROR lines of noise per package run.

This registers the missing objects locally, with only the columns those paths read,
following the find-envelope-limb-removal.test.ts precedent — so no dependency edge from
plugin-auth to plugin-security is added. No product code changes.

file objects added path that drives the read
account-issuer-upgrade-path.test.ts sys_user_position, sys_user_permission_set, sys_position real sign-in through AuthManager.handleRequest -> session-payload callback -> core's resolveUserAuthzGrants -> resolve-authz-context.ts tryFind
signup-existing-address-refusal.test.ts sys_user_permission_set settleSelfRegistrationGrant's own existence read before it inserts the declared self-registration grant

The measurement

Both runs are on the same tree; the only thing between them is this PR's diff. Baseline at
origin/main = 225197cdb, after at 7222252f8.

The fenced two-file command:

pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=1 \
  src/account-issuer-upgrade-path.test.ts src/signup-existing-address-refusal.test.ts
tests exit DATABASE_ERROR total of which sys_user_permission_set
before 12/12 passed 0 7 3
after 12/12 passed 0 0 0

The WHOLE package — pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2:

test files tests exit DATABASE_ERROR total of which sys_user_permission_set
before 108/108 2287/2287 0 7 3
after 108/108 2287/2287 0 0 0

The card measured 2283 tests at a61ae59f9; this tree carries 2287 at 225197cdb. The
DATABASE_ERROR counts are unchanged from the card's at both scopes.

The other four lines: same root cause, same two files — declared, not chased quietly

The card flagged 4 of the 7 package-wide lines as unattributed and explicitly NOT MEASURED.
They are now measured, per file:

src/account-issuer-upgrade-path.test.ts     -> 6 lines: 2x sys_user_position
                                                        2x sys_user_permission_set
                                                        2x sys_position
src/signup-existing-address-refusal.test.ts -> 1 line:  1x sys_user_permission_set

7 of 7. The two-file run and the whole-package run produce the same multiset, so these two
files account for 100% of the package's DATABASE_ERROR lines — there is no third site
in this package.

The other four are not a different defect: they are the same fixture gap, in the same file,
on the same resolveUserAuthzGrants leg. sys_user_position and sys_position are read by
the same Promise.all / position block as the sys_user_permission_set read the card
names, twice each for the suite's two sign-in cases. Registering only the card's three
occurrences would have left the same fixture half-provisioned and the same resolver leg
half-exercised, so all three objects are registered together. This is stated here rather
than done quietly: if the PM wants the extra four split out, they are one git revert of
two registerObject lines away.

Why the count falls because the read SUCCEEDS

No log line is silenced, filtered or re-levelled — the diff is two test files, +99 lines,
zero product code. The positive proof is that the previously-refused read now completes and
its follow-on write lands. A one-off, non-committed assertion on case ③ of
signup-existing-address-refusal.test.ts (injected, run, restored to byte-identical HEAD
bytes, git diff HEAD empty):

ONEOFF-17897 ups rows = [{"id":"ups_mtzjq2xbofgynv6d", ...,
  "user_id":"g3XNGi1sDEs8lszNnwXog11y3PIxYhoR",
  "permission_set_id":"ps_member_default","organization_id":null}]
 Test Files  1 passed (1)
      Tests  8 passed (8)

Before this PR that read was refused, settleSelfRegistrationGrant caught the refusal and
reported "admitted but NOT granted" — so the admitted-registrant control was passing over a
grant path that never completed. It completes now.

Checks

  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0 (tsc --noEmit, the examples
    project, and check:test-typecheck).
  • 53 derived gate commands from node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
    — all exit 0. Two first answered exit 3 (PREREQUISITE NOT MET — check:dual-build-cjs-loads,
    check:type-check-debt); the workspace build they name was run
    (turbo run build --filter='./packages/*' --filter='./packages/*/*', 72/72 tasks) and both
    then exit 0.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0.
  • grep -naP control-character scan over both edited files — no matches.

No changeset — measured, not assumed

@objectstack/plugin-auth ships files[] = ["dist","README.md","CHANGELOG.md"]. After a
build, grepping the shipped path for the symbols this diff introduces:

sysUserPosition              in dist/ -> 0 files
account-issuer-upgrade-path  in dist/ -> 0 files
authIdentityObjects          in dist/ -> 2 files   (POSITIVE CONTROL: a real published symbol)
compiled test files in dist/ -> 0

Nothing published moves, so this PR carries the skip-changeset label rather than a
changeset.

Clause-② re-determination from the delivered diff: no. The diff adds no exported
symbol reachable from the published entry and no new key on an already-published payload —
it adds three const object literals and four registerObject calls inside two
*.test.ts files, none of which reach dist/. The measurement above is the same evidence.

Acceptance notes


Generated by Claude Code

`account-issuer-upgrade-path.test.ts` and `signup-existing-address-refusal.test.ts`
boot a real ObjectQL over a real SqlDriver and register only plugin-auth's own
`authIdentityObjects`. Both then drive reads against plugin-security-owned
tables that were never provisioned:

  - real sign-ins reach core's `resolveUserAuthzGrants`, whose `tryFind` reads
    `sys_user_position`, `sys_user_permission_set` and `sys_position`;
  - `settleSelfRegistrationGrant` reads `sys_user_permission_set` before
    inserting the declared self-registration grant.

The driver refused every one of them. `tryFind` classifies a missing table as
"not provisioned" and answers `[]`, so nothing went red — the resolver leg and
the grant-write leg simply went unexercised while the suites reported green.

Declares the missing objects locally with only the columns those paths read,
following the `find-envelope-limb-removal` precedent, so no dependency edge
from plugin-auth to plugin-security is added. No product code changes; the
refused reads now succeed.

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

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Auto Label is red on a write that SUCCEEDED — standing down, with one re-run

Read by job id (103702274452), ⛔ not by the check's name. The only failing step is #3 Label based on changed files (additive POST), and its log says the whole thing:

pr-labels: 2 changed file(s) match: tests
pr-labels: labels on PR #17982 right now: size/s, skip-changeset
pr-labels: POST /issues/17982/labels -- add path label(s) tests
pr-labels: POST https://api.github.com/repos/objectstack-ai/objectstack/issues/17982/labels -> HTTP 500:
##[error]Process completed with exit code 1.

a GitHub API 500 on a label POST. It names no code this diff touches — the diff is two *.test.ts files, +99/-0, no product source.

And the write it reports as failed actually landed. This PR's label set now reads size/s, skip-changeset, teststests is exactly the label step #3 was POSTing when it got the 500. ⛔ This seat did not add it; the labeler did, and only its response failed. ⇒ the job's work is complete and the red is the response, not the effect.

Corroboration that the instability is GitHub's and not this PR's, measured rather than assumed — three independent 500s in the same window, on three different endpoints, from two different clients:

when who call result
09:12:56Z the labeler (CI) POST /issues/17982/labels 500 — yet the label landed
earlier the implementing dev create_pull_request 500, created nothing (it retried; #17982 is the retry)
09:1xZ this seat GET /issues/17982 500, then succeeded on retry with backoff

⇒ not a flake hypothesis — a measured window of API instability that this PR's diff cannot reach.

Action: one re-run of this job, which is the single re-run this failure is allowed. ⛔ No empty commit, ⛔ no close-and-reopen, ⛔ nothing skipped, disabled or quarantined. If it fails a second time, that is real and will be root-caused as this PR's.


Generated by Claude Code

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

⚠️ Correction to the comment above — the re-run is OWED, not done

The previous comment ended "Action: one re-run of this job." This seat could not perform it, and that sentence should not stand uncorrected.

Measured, two channels:

channel result
POST /actions/jobs/103702274452/rerun (repo-scoped REST) 403 — that token has no actions: write
POST /actions/runs/34749065948/rerun-failed-jobs (MCP) 500, twice

⇒ the second channel has the permission; GitHub's Actions API is simply still answering 500 in this window — the same window that 500'd the label POST, the dev's create_pull_request, and a plain GET /issues/17982 from this seat.

⭐ And the re-run genuinely did not land: run_attempt on run 34749065948 still reads 1, status completed / failure. ⛔ That check matters, because the label POST in this same window returned 500 while its write succeeded — so a 500 here could have meant a re-run was already queued, and stacking a second one would have been the wrong move. It was verified before retrying, and again after.

Nothing about the standing-down analysis changes: the failing step is a GitHub API 500 on a label POST, it names no code this diff touches, and the label it was writing (tests) is on this PR. The job is red for work that completed.

⇒ the one re-run stays owed and scheduled, ⛔ not spent and ⛔ not abandoned. This PR stays watched until it is green and merged, or until a second failure proves the reading wrong. ⛔ No empty commit, ⛔ no close-and-reopen, ⛔ nothing skipped or disabled.


Generated by Claude Code

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Contract review

Head reviewed: 7222252f839d4dd9fd37c6919beccf7d22f15139

Implemented-by: claude/issue-17897-permission-set-refused-reads (mode:subagent — the branch, not a session)
Reviewed-by: session_01URLHobLUJB9K1ABV6ofdjj (domain:services execution seat)

① Clause-② — re-derived in-seat from the DELIVERED diff

reading result
diff shape 2 files, both *.test.ts, +99/-0; git diff --name-only minus *.test.tsempty
^export lines added 0
src/index.ts references to either file 0
files[] ["dist","README.md","CHANGELOG.md"]src/** is not published

Clause-②: no, and below the patch floor — hence skip-changeset, not a changeset.

⭐ The delivery measured the same thing one level deeper, against the BUILT tree: after a full build, dist/ carries 0 compiled test files, sysUserPosition hits 0 files and account-issuer-upgrade-path 0, while the positive control authIdentityObjects — a genuinely published symbol — hits 2. ⛔ A zero whose control also reads zero is not evidence; this one has its control, and it agrees with the source-level trace.

② The fences — held, and one of them proved POSITIVELY

The log line was not silenced, filtered, or re-levelled. This is the fence that mattered, because "make the count reach zero" has an illegitimate solution, and the delivery ruled it out by positive proof rather than by assertion: a one-off, non-committed assertion injected into the fixture printed the rows the previously-refused read now returnsups rows = [{"id":"ups_mtzjq2xbofgynv6d", … "permission_set_id":"ps_member_default"…}] — with 8/8 passing, so settleSelfRegistrationGrant's insert lands. The injection was proved on disk before the run (marker occurrences = 2) and restored after with git checkout HEAD -- <path>, verified by git hash-object == the HEAD blob and an empty git diff HEAD, under a trap on EXIT/INT/TERM. ⇒ the count fell because the read succeeds.

packages/spec untouched · ⛔ content/docs/releases/** untouched · ⛔ no product code changed · ⛔ no plugin-auth → plugin-security dependency edge added (the objects are declared locally with only the columns the reading path touches, following the find-envelope-limb-removal.test.ts precedent).

③ Both scopes reported, and ⛔ not interchanged

Fenced two files: DATABASE_ERROR 7 → 0 (of which sys_user_permission_set 3 → 0), 12/12 passing both times. Whole package: 7 → 0, 108 files / 2287 tests passing both times.

⭐ They coincide, and the delivery says why instead of letting the coincidence pass: the two fenced files account for 100% of the package's DATABASE_ERROR lines — stated as a measured result, ⛔ not an assumption. That distinction is the entire reason #16315 was closed not_planned: a fenced reading was mistaken for a package one. Repeating it here would have been the same error twice on the same symptom.

All seven lines are now attributed (6 to account-issuer-upgrade-path.test.ts, 1 to signup-existing-address-refusal.test.ts), method stated: each file alone at --maxWorkers=1, counted with grep -o ... | sort | uniq -c.

④ The declared widening — checked against the four conditions, ⛔ not waved through

The card names only the 3 sys_user_permission_set lines; the diff also fixes the 4 others (sys_user_position, sys_position). Against this lane's in-place-fix exemption:

condition reading
same defect class ✅ same fixture gap, same resolveUserAuthzGrants leg, same tryFind
mechanical ✅ two further registerObject lines
unclaimed by others
same gate family

✅ And it was declared in the PR body, ⛔ not done quietly — which is the part the exemption actually turns on. The delivery even notes it is "one revert of two registerObject lines away" if this seat wanted it split. It does not: splitting would leave four known-broken reads in a file being fixed for the same cause.

⭐ The finding is larger than the card, and the card should say so

#17897 was filed about 3 lines of log noise. What the delivery establishes is that tryFind classifies a missing table as "not provisioned" and answers [] — so both suites were passing on reads that never happened. ⇒ the noise was the visible symptom of a latent false green. That is a different and worse fact than the one the card was graded on.

Gates

53 derived commands, all exit 0. Two first answered exit 3 (PREREQUISITE NOT MET)check:dual-build-cjs-loads and check:type-check-debt — and were not excused: the workspace build they name was actually run (turbo run build, 72/72 tasks) and both then returned exit 0. ✅ That is the correct handling of exit 3: build the closure it names and re-run to a real verdict, ⛔ never a pass and ⛔ never an excuse.

The open question — settled: A

The delivery asked whether the "one package only" boundary should be swept. Yes — successor filed. The static pointer is large (73 test files outside plugin-auth name resolveUserAuthzGrants / resolveAuthzContext; control: 6 inside), the instrument is built and cheap to re-point, and the class is a latent false green rather than mere noise. ⚠️ 73 is a STATIC pointer, ⛔ not a measurement — it names candidates, not sites, and the successor card says so.

Verdict: PASS at 7222252f8

⚠️ Binds to the head it names. ⚠️ Landing still blocked on pre-check ③: at this writing the head carries 30 distinct check names against this repo's reference of 34, with 5 in progress ⇒ NOT MEASURED, ⛔ not a pass (correction 161). Auto Label's transient failure was re-run once and is now green at run_attempt 2.


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 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.

Coarse fallback — 0 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 2f1a6f696816a393d6176a3bc2b29e5d9d733655packageMentionDocs.

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

⛔ Not landable yet — and "0 failing" was hiding it

Every check run on 7222252f8 is green or skipped (29 success / 3 skipped / 0 failure). That reading is true and insufficient, because two workflows produced no check runs at all:

workflow state the check it owes
Docs Drift Check (34749065999) startup_failure Flag docs affected by code changes
Closing-Target Claim Guard (34749065945) queued since 09:10:27Z, updated_at never moved The card this PR closes must claim this branch

A workflow that never starts emits no failing check. So a check-runs-only view reports a clean board while two gates are simply absent — including a governance gate. Caught by diffing this head's check-name set against two landed PRs (#17941 and #17937, the second also test-only): both ran all three of Flag docs affected by code changes, The card this PR closes must claim this branch, and Close issues referenced in other repositories. ⛔ Not a path-filter difference.

Disposition:

  • Docs Drift Checkre-run, and it took: run_attempt 2, now in progress. ✅
  • Closing-Target Claim Guard — ⛔ unrecoverable through the API. Both verbs are refused, with mutually contradictory reasons:
POST /actions/runs/34749065945/rerun   -> 403 "This workflow is already running"
POST /actions/runs/34749065945/cancel  -> 409 "Cannot cancel a workflow run that has not been queued yet"

⇒ the runs API reports it queued; the cancel endpoint says it was never queued. It is in a limbo state on GitHub's side, created 09:10:27Z — inside the window that also produced a 500 on the label POST (with its write landed), a 500 on create_pull_request, two 500s on rerun-failed-jobs, and 503s on plain reads.

This is a platform incident, not this PR's. Measured: every run of Closing-Target Claim Guard created after 09:15Z completed successfully (09:15:21, 09:17:58, 09:21:56, 09:23:09, 09:26:14, 09:27:44, 09:35:39, 09:36:23). Exactly two are stuck, both created in that window — this one, and run 34749128715 on PR #17983, another seat's PR. ⛔ Not concurrency, ⛔ not runner scarcity, ⛔ not this diff.

⚠️ This seat will not substitute its own reading for the gate. The gate asks whether the card claims this branch; card #17897's claim comment does name claude/issue-17897-permission-set-refused-reads. ⛔ That is not a pass — verifying a gate's subject by hand while the gate itself never ran is 自查放行, and the whole point of the gate is that it is not this seat's word.

the PR stays open, unlanded and watched until the guard reports or is recoverable. ⛔ No empty commit, ⛔ no close-and-reopen, ⛔ no landing around a gate that did not run.


Generated by Claude Code

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Contract review — re-issued for the moved head

Head reviewed: b4246a6bf8c3c96c1ee24e37d66a595386f2272f
(supersedes the binding in comment 5652418674, which named 7222252f8)

Reviewed-by: session_01URLHobLUJB9K1ABV6ofdjj (domain:services execution seat)

Why the head moved

Closing-Target Claim Guard run 34749065945 sat in queued from 09:10:27Z with updated_at frozen at the same instant — 3 h 21 min, never starting. Both re-run endpoints refused (403 Resource not accessible by integration); the earlier attempts refused with a contradictory pair (rerun → 403 "already running", cancel → 409 "has not been queued yet").

The head was moved by merging the current base branchmain was 21 commits ahead of this PR's base (45b90b6a92f1a6f696, comparestatus: ahead, ahead_by: 21). That is ordinary staleness hygiene that happens to re-fire the workflow set; ⛔ it is not an empty commit and ⛔ not a close-and-reopen, neither of which is available to this seat as a way to kick CI.

Result: Closing-Target Claim Guard run 34757373684 on the new head — completed / success.

The wedge was invisible at the check-run level

At 7222252f8 the check-run level read 37 check runs, 30 success + 7 skipped, 0 failing, none pending. It looked finished. The guard's check run — The card this PR closes must claim this branch — was simply absent from the set, because a workflow run that never starts emits no check run at all.

level reading at 7222252f8
check runs 37 total · 30 success · 7 skipped · 0 failing
workflow runs 13 total · Closing-Target Claim Guard = queued, forever

⇒ the completeness limb has to be read at the workflow-run level, and the check-run level cannot substitute for it in either direction.

⚠️ A measurement trap, recorded so it is not re-walked

GET /actions/runs?head_sha=<SHORT_SHA> answers total_count: 0 — not an error, not a 422. It is byte-for-byte indistinguishable from "this commit has no runs", which here would have read as the guard still never ran. The filter matches only the full 40-character sha. Measured both ways on this exact head:

head_sha=b4246a6bf                                 -> total_count 0
head_sha=b4246a6bf8c3c96c1ee24e37d66a595386f2272f  -> total_count 11

The review above still binds — proved, not assumed

The base merge changed no file this PR owns:

reading result
git diff --name-only <merge-base> <head> old vs new identical file set (2 files)
account-issuer-upgrade-path.test.ts blob git rev-parse 7222252f8:<path> == b4246a6bf:<path>
signup-existing-address-refusal.test.ts blob ==, same method
whole content diff 7222252f8b4246a6bf minus the merge empty

So ①–④ and the settled open question in comment 5652418674 carry unchanged; only the head binding is re-issued.

Carrier gate, re-read after the head move

node scripts/pm/check-clause2-carriers.mjs --pair 17982exit 0, captured before any pipe. (Correction 160: a review re-issued for a moved head is necessary and not sufficient — the gate reads the label event stream, so it is re-read, not inferred.)

⚠️ The gate also returned an advisory this seat owns: "ATTRIBUTION NOT VERIFIED: the governing claim comment carries no Session: line". That is a defect in this seat's claim template, not in the delivery — SKILL.md 〈模板与表〉 requires it. Every claim this seat writes from here carries the line; the already-posted claims are not edited, per the same mechanism that forbids filling a declaration in on a seat's behalf.

Verdict: PASS at b4246a6bf

⚠️ Binds to the head it names. Landing waits on pre-check ③ at the workflow-run level — at this writing CI, Lint & Type Check and Governed Surface Guard are in_progress, so the limb is NOT MEASURED, ⛔ not a pass.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 13, 2026 13:00
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

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

    @objectstack/cli:test:  FAIL   integration  test/format-zod-union.test.ts > [#5341] `os validate` delivers a union branch prescription > the specimen fails on exactly one issue, and that issue is the 
      ↳ 失败原因: @objectstack/cli:test: AssertionError: expected [ …(2) ] to have a length of 1 but got 2
    @objectstack/cli:test:  FAIL   integration  test/format-zod-union.test.ts > [#5341] `os validate` delivers a union branch prescription > leaves the `--json` payload exactly as it was — full, and neste
      ↳ 失败原因: @objectstack/cli:test: AssertionError: expected [ …(2) ] to have a length of 1 but got 2
    

↳ 失败原因 是判读的关键:超时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 队列共有 2 个失败构建(不含本次)。

分诊清单:

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

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

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

🔴 Ejected from the merge queue — not this PR's failure, standing down with evidence

Dequeued 13:14:48Z, reason: CI_FAILURE. Queue build 34758691525, branch gh-readonly-queue/main/pr-17982-1e20f816e…, job Test Core (3/6).

Failing check: CITest Core (3/6)Run this shard's tests

FAIL  integration  packages/cli/test/format-zod-union.test.ts
  > [#5341] `os validate` delivers a union branch prescription
AssertionError: expected [ …(2) ] to have a length of 1 but got 2   (lines 205 and 227)

Why it is not this PR's — three readings, not an opinion

  1. This PR does not touch that package. Its diff is 2 files, both packages/plugins/plugin-auth/src/*.test.ts. packages/cli hits: 0. There is no mechanism by which adding two plugin-auth test files changes how os validate counts zod union issues.
  2. An unrelated PR hit it identically. 34756944488fix(cli): one column width for the file family in both os generate migration formats #18014, different lane, ~40 min earlier — failed in the same job, on the same two assertions, at the same lines, with the same AssertionError. The repo's own merge-queue-triage workflow filed Queue-flake anchor: test/format-zod-union.test.ts #18032 over exactly these two builds and records them as "2 independent hits once GitHub's speculative stacking is accounted for."
  3. This PR's own branch CI is green. At b4246a6bf: 34 check runs, 30 success + 4 skipped, 0 failing; workflow-run level 11/11 completed and clean.

"Flake" is not the diagnosis either, and I did not reach for it. Two independent reproductions is the opposite of a flake, and the reason line is an AssertionError, ⛔ not a timeout — which is the discrimination #18032 asks each victim to supply. It is supplied there, with the main-is-green control and the cheapest next probe.

Does a fix exist to port? No — measured, not assumed

search_pull_requests for an open PR touching this: 0 results. So there is nothing to port into this PR, and ⛔ this seat does not write the fix itself: packages/cli is domain:cli and this (domain:services) seat holds zero of it. ⛔ No test is skipped, quarantined or weakened here — #18032's own text says weakening a gate stays a human act, and this seat agrees.

Action: the one sanctioned re-queue, spent now

Re-queued once. If the cause is a semantic conflict between stacked PRs, a different stack may carry it; if it ejects again, that is a second independent reproduction and the evidence goes to #18032, ⛔ not into another attempt. ⛔ No empty commit, ⛔ no close-and-reopen.

This PR stays watched until it is MERGED or closed. The contract review at b4246a6bf (5653296320) is unchanged — nothing about the diff moved.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queue Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

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

    @objectstack/cli:test:  FAIL   integration  test/format-zod-union.test.ts > [#5341] `os validate` delivers a union branch prescription > the specimen fails on exactly one issue, and that issue is the 
      ↳ 失败原因: @objectstack/cli:test: AssertionError: expected [ …(2) ] to have a length of 1 but got 2
    @objectstack/cli:test:  FAIL   integration  test/format-zod-union.test.ts > [#5341] `os validate` delivers a union branch prescription > leaves the `--json` payload exactly as it was — full, and neste
      ↳ 失败原因: @objectstack/cli:test: AssertionError: expected [ …(2) ] to have a length of 1 but got 2
    

↳ 失败原因 是判读的关键:超时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 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 3 个失败构建(不含本次)。

分诊清单:

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

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

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Ejected a second time — different base, same failure. ⛔ No third re-queue.

Build 34759517090, same job, same two assertions.

The decisive new fact: the re-queue landed on a different merge base (a0dd872c1 vs 1e20f816e, with #17990 and #18018 having gone green and moved ahead in between), and this PR's content was byte-identical between the two attempts — no push happened. ⇒ the "semantic conflict with the PRs it was stacked with" hypothesis my previous comment carried is ruled out.

⚠️ It also puts my own control in doubt, and I would rather say so than let it stand: I offered "main is green on this test". The queue runs the full suite while branch CI runs only the affected subset — so if packages/cli is outside that subset, that green is a run that never asked the question, and this is a regression already on main rather than a queue quirk. ⛔ I have not resolved which. The evidence and the one check that settles it are on #18032.

This PR stays out of the queue until #18032 is resolved. Nothing about its diff has changed; the contract review at b4246a6bf still binds. ⛔ No empty commit, ⛔ no close-and-reopen, ⛔ no touching that test.


Generated by Claude Code

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

⚠️ Correction to the comment above — the doubt I raised is resolved, the other way

I wrote that my "main is green on this test" control might be void, because the queue runs the full suite while branch CI runs only the affected subset. I checked rather than leaving it hanging. It is not void: the test really ran on main and really passedcheck-test-completeness on that shard reads 13 of 13 scheduled package(s) … 9 703 test(s).

⇒ ⛔ This is NOT a regression sitting on main. Scratch that reading.

What the same measurement turned up instead is a sharper and much more tractable lead — the failure tracks shard composition, not any PR's content:

build packages in shard 3/6 result
main push CI 13 passed
queue, this PR 5 failed
queue, #18014 1@objectstack/cli alone failed

⇒ the package fails when it runs alone or near-alone in a shard and passes when it runs among twelve others, which makes it reproducible locally on plain origin/main with no PR involved: pnpm --filter @objectstack/cli test --shard=1/2. Full evidence and the probe are on #18032.

Nothing else changes for this PR: it stays out of the queue until #18032 is resolved, its diff is untouched, and the contract review at b4246a6bf still binds.


Generated by Claude Code

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 13, 2026
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit a249786 Sep 13, 2026
36 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-17897-permission-set-refused-reads branch September 13, 2026 15:57
baozhoutao pushed a commit that referenced this pull request Sep 14, 2026
…he response

`Auto Label` went red on PR #17982 for work it had COMPLETED: the `--paths`
step POSTed `tests`, the API answered HTTP 500, the script exited 1 — and the
PR's label set read `size/s, skip-changeset, tests` immediately afterwards.
A 500 is not evidence the write failed.

The job's red said "the response failed"; every reader takes it to mean "the
label is missing". Those are different facts, and that gap — not a missing
retry — is the defect. A bounded 5xx retry with exponential backoff has been in
this file since #10777 and did not close it.

Failures are now classified: 5xx and a thrown fetch are INDETERMINATE (the
server may have acted before the answer was lost) and are settled by re-reading
the PR's labels and judging the step's post-condition; 4xx including 429 stays
DETERMINATE, fatal and loud, even when the board happens to satisfy the
post-condition — a 403 is a broken token and a 422 is a label that does not
exist in the repo. A settling re-read that itself fails settles nothing: the
write is reported UNVERIFIED and the original error is raised.

`failureIsIndeterminate`, `postconditionOf` and `settleWriteFailure` are pure
and pinned by a new 16-case `--self-test` battery covering both directions.

Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…n-lane fixtures now provision the resolver's tables (objectstack-ai#18067)

Fixes objectstack-ai#17985

Clause-②: no

The card handed this lane a **static pointer** — 73 test files outside
`plugin-auth`
naming `resolveUserAuthzGrants` / `resolveAuthzContext` — and asked for
a measurement.
Here is the measurement, the instrument's own blind spot (which it has,
and which the
pointer could never have shown), the five in-lane sites it found, and
the out-of-lane
ones this lane reports rather than touches.

## The population, and how it was derived

Reproduced at the branch point `2f1a6f696`, same greps the card used:

| reading | count |
|---|---|
| `*.test.ts` outside `plugin-auth` naming the resolver | **73** —
exactly the card's number |
| control, the same grep inside `plugin-auth` | 5 (the card's 6 counts
all `*.ts`, not just tests) |
| `*.test.ts` outside `plugin-auth` constructing `AuthManager` | 4 |
| **union, de-duplicated** | **77 files across 18 packages** |

The card's method is "run the **candidate packages'** suites". That was
done — all 18 —
and then extended to every other package this lane owns, because a
candidate list built
from a grep cannot name a fixture that drives the resolver without
mentioning it (and
one of the five sites below is exactly that). **44 package suites in
total**, each a full
`vitest run --maxWorkers=2`, ~34k tests, all green.

## The instrument has a blind spot, and it is the one that mattered

Counting `refused a read on '...'` in the shared log **under-reads**.
Four fixtures route
their refusals through `captureExpectedReadRefusals` (objectstack-ai#10629 / objectstack-ai#11081),
which **withholds**
the driver line and **asserts** the count instead. Against those, a grep
of the log reads
a clean zero while every read is still being refused.

A complete census of that channel: **24 `captureExpectedReadRefusals`
call sites** in the
repo; **4** of them declare the authz resolver's tables as
expected-absent. Two are in
this lane, two are not. Both of this lane's two are real sites of the
class, and neither
would have appeared in a log-grep sweep.

That is not a defect in objectstack-ai#10629 — the channel names this outcome itself:

> a table that started resolving means the fixture now provisions it

## How a "site" is told apart from any other refused read

`resolve-authz-context.ts` issues eight reads with fixed filter shapes
and limits, so a
refusal is attributed to the resolver by the `(table, filter, limit)`
triple in the logged
statement — `sys_position ... where name in (...) limit 200` is the
resolver's; the
`where name = ? limit 1` on the same table is not.

The discriminator earns its keep: `plugin-security`'s suite emits
**585** refused reads and
**0** of them are resolver-class (they are `sys_permission_set` /
`sys_position` /
`sys_capability` reads **by name**, a different fail-soft path), while
`client`'s 264
contain 227 that are.

## Controls — so the zeros mean something

1. **Positive control, same tree, same command shape.** `plugin-auth`'s
two known fixtures
at `2f1a6f696`: **7** lines, `sys_user_position` 2 /
`sys_user_permission_set` 3 /
`sys_position` 2 — byte-for-byte the baseline PR objectstack-ai#17982 measured
independently. The
   instrument detects the class when the class is there.
2. **Discrimination control.** The same classifier reads 0
resolver-class out of
`plugin-security`'s 585 and 227 out of `client`'s 264 — it is not "any
refusal".
3. **Blind-spot control.** The withheld sites were measured through the
capture's own
   `refusals` counter, not through the log.

## The five in-lane sites — before and after

Before is at `2f1a6f696`; after is this branch. Per file,
`--maxWorkers=1`.

| file | before | after |
|---|---|---|
| `triggers/trigger-record-change/src/record-change-integration.test.ts`
| **75** (withheld) | **0** |
|
`plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts`
| **21** (withheld) | **0** |
|
`triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts`
| **10** | **0** |
| `triggers/trigger-record-change/src/reentrant-start-condition.test.ts`
| **10** | **0** |
| `services/service-automation/src/paused-run-visibility.test.ts` |
**5** | **0** |

**121 refused authz reads**, every one of them a grant resolution that
answered `[]` because
the table was missing rather than because the state was empty.

Actual output. The two withheld ones, read off the capture's own counter
by a one-off
`console.log` of `noise.refusals` (injected, run, restored):

```
BEFORE  status-mirror-cascade      {"sys_organization":3,"sys_approval_delegation":2,
                                    "sys_user":5,"sys_member":4,"sys_user_position":4,
                                    "sys_user_permission_set":4,"sys_position":4}
AFTER   status-mirror-cascade      {"sys_organization":3,"sys_approval_delegation":2}

BEFORE  record-change-integration  {"sys_organization":8,"sys_user":15,"sys_member":15,
                                    "sys_user_position":15,"sys_user_permission_set":15,
                                    "sys_position":15}
AFTER   record-change-integration  {"sys_organization":9}
```

The three visible ones, counted from the log:

```
BEFORE  before-update-flow-payload-reach  total=11  RESOLVER-CLASS=10  other={sys_organization:1}
AFTER   before-update-flow-payload-reach  total=1   RESOLVER-CLASS=0   other={sys_organization:1}
BEFORE  reentrant-start-condition         total=11  RESOLVER-CLASS=10  other={sys_organization:1}
AFTER   reentrant-start-condition         total=1   RESOLVER-CLASS=0   other={sys_organization:1}
BEFORE  paused-run-visibility             total=7   RESOLVER-CLASS=5   other={sys_organization:2}
AFTER   paused-run-visibility             total=2   RESOLVER-CLASS=0   other={sys_organization:2}
```

Whole-suite, after: `plugin-approvals` 45 files / 738 tests / 32
refusals, 0 resolver-class ·
`trigger-record-change` 10 / 101 / 3, 0 resolver-class ·
`service-automation` 134 / 1581 / 8,
0 resolver-class. All `sys_organization` and one
`sys_metadata_activation` — the
`probeInstallOrganizations` class, not this card's.

## The count falls because the read SUCCEEDS

⛔ No log line is silenced, filtered or re-levelled: the diff is five
test files, zero
product code. The declared-absent lists **shrink** — the tables leave
them because the
fixture now provisions them, and `sys_organization` /
`sys_approval_delegation` stay
declared because those probes are still genuinely unprovisioned.

The positive proof, PR objectstack-ai#17982's shape: a one-off, non-committed
assertion that seeds a
`sys_user_position` row and then asks the resolver for that principal's
grants. Before the
fix it cannot even reach the table; after it, the row comes back and the
resolver's answer
carries it.

```
BEFORE  SqliteError: insert into `sys_user_position` ... - no such table: sys_user_position
        (both files; 1 failed | N passed)

AFTER   ONEOFF-17985 sys_user_position rows = [{"id":"oneoff17985", ...,
          "user_id":"approver","position":"oneoff_role","organization_id":null}]
        ONEOFF-17985 resolved positions = ["oneoff_role","everyone"]
```

`oneoff_role` reached `grants.positions` only because the resolver's
`sys_user_position`
read returned a real row — the leg that answered `[]` before.

Both legs are anchored on `HEAD` (which carries the implementation),
restored with
`git checkout HEAD -- ...`, and verified by `git hash-object` against
the HEAD blob plus an
empty `git diff HEAD`:

```
RESTORE-PROOF ok packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts:
  HEAD blob cc4d10e == working tree cc4d10e
RESTORE-PROOF ok packages/triggers/trigger-record-change/src/record-change-integration.test.ts:
  HEAD blob 7d0efb7 == working tree 7d0efb7
RESTORE-PROOF ok: git diff HEAD is EMPTY
```

Each injection was proved to have landed on disk before the run (marker
count grepped, not
inferred from the editor's exit code), and every mutation ran under a
`trap ... EXIT INT TERM`.

## The remedy, and the edge it does not add

Each fixture declares the objects **locally**, with only the columns the
reading path
touches, so ⛔ no dependency edge onto `plugin-auth` or `plugin-security`
is added — the
`find-envelope-limb-removal.test.ts` precedent PR objectstack-ai#17982 applied:

| object | columns declared | why |
|---|---|---|
| `sys_user` | id, email | `id` filter; `email` is the RLS owner-email
fallback |
| `sys_member` | id, user_id, organization_id, role | both membership
reads |
| `sys_user_position` | id, user_id, position, organization_id |
ADR-0057 D4 role assignments |
| `sys_user_permission_set` | id, user_id, permission_set_id,
organization_id | user-scoped grants |
| `sys_position` | id, name, active, organization_id | name filter,
`isRowActive`, tenant scope |

⛔ `sys_position_permission_set` and `sys_permission_set` are
deliberately absent: the
resolver reaches them only once a `sys_position` row resolves and a
permission-set id is
collected, and none of these fixtures seeds either. The ADR-0091
validity columns are absent
for the reason `sys_member` lacks them today — `isGrantActive` reads an
absent bound as
unbounded, so declaring them would change no verdict.

## Out of lane — REPORTED, not touched

🔴 The population is repo-wide by construction and this lane owns part of
it. These are
measured and left alone for routing.

| package | lane | resolver-class refusals | shape |
|---|---|---|---|
| `@objectstack/client` | `domain:cli` | **227** | visible in the log |
| `@objectstack/runtime` | `domain:cli` | 0 visible | 2 fixtures declare
the 5 authz tables as expected-absent, so the refusals are withheld and
asserted |

`client`, per file — these seven account for 100% of the package's 227,
so there is no
eighth site in it:

```
src/client.metadata-prefix.test.ts      95
src/client.hono.test.ts                 35
src/client.data-prefix.test.ts          25
src/client.batch-transaction.test.ts    25
src/auth-get-session-envelope.test.ts   21
src/client.environment-scoping.test.ts  20
src/auth-login-register-envelope.test.ts 6
```

`runtime`, by file: `src/notifications.hono.integration.test.ts` and
`src/notification-schema-conformance.integration.test.ts`, each
declaring

`['sys_user','sys_member','sys_user_position','sys_user_permission_set','sys_position','sys_setting']`
as `ABSENT_AUTHZ_TABLES`.

Every other swept package reads **0** resolver-class, including `core`,
`spec`, `rest`,
`mcp`, `dogfood`, `verify`, `cloud-connection`, `plugin-hono-server`,
`organizations`,
`plugin-security`, `plugin-sharing` and all 16 `services/*`, 4
`connectors/*` and the other
two `triggers/*`.

## Checks

- 55 gate commands derived by `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack`
from the delivered change set at this head — see the report comment on
objectstack-ai#17985 for the verdict line.
`check:type-check-debt` first answered **exit 3 (PREREQUISITE NOT MET,
heap OOM)**, which is
not a pass; re-run under the `--max-old-space-size=6144` ceiling its own
script pins, it
exits 0 with a real verdict (`5 ledger entries re-measured, none above
its recorded number`).
- `pnpm --filter @objectstack/plugin-approvals --filter
@objectstack/trigger-record-change --filter
@objectstack/service-automation typecheck` — exit 0, all three `Done`.
- Lint, as a **declared narrowing** with the three readings it needs:
population **6725**
tracked files matching `eslint.config.mjs`'s own
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`
minus its only global `ignores` (`NEVER_LINTED`); **5** files linted,
counted from
`--format json`, **0 errors / 0 warnings**; and the invariance that
makes the narrowing a
measurement rather than a skipped run — this repo's config **never
enables type-aware
linting** (no `parserOptions.project`, no typed `@typescript-eslint`
rules, stated and
measured with a positive control at `eslint.config.mjs:327`), so no edit
in this diff can
move the verdict on a file it does not contain. The full-repo scan is
CI's.
- `grep -naP` control-character scan over all five edited files — no
matches.

## No changeset — measured, not assumed

All three packages ship `files[] = ["dist","README.md","CHANGELOG.md"]`.
After a build,
grepping the shipped path for every symbol this diff introduces:

```
authzResolverObjects       in dist/ -> 0 files   (x3 packages)
status-mirror-cascade      in dist/ -> 0 files
paused-run-visibility      in dist/ -> 0 files
reentrant-start-condition  in dist/ -> 0 files
compiled test files        in dist/ -> 0

POSITIVE CONTROL — a real published symbol per package:
ApprovalService            in plugin-approvals/dist       -> 4 files
RecordChangeTriggerPlugin  in trigger-record-change/dist   -> 4 files
AutomationServicePlugin    in service-automation/dist      -> 4 files
```

Nothing published moves, so this carries `skip-changeset` rather than a
changeset.

**Clause-② re-determination from the delivered diff: `no`.** The diff is
five `*.test.ts`
files. It adds no exported symbol reachable from any published entry
(measured above: every
introduced identifier is absent from all three `dist/` trees while a
real published symbol
is present in four files of each), and no new key on an
already-published payload — the
object literals it adds are fixture-local `const`s consumed only by
`registry.registerObject`
inside the same file.

## Acceptance notes

Observations from the sweep, noted and **not filed** — none is in this
card's class, and
each already has a home or no one to hand it to:

- `plugin-security` emits **585** refused reads, **0** resolver-class:
`sys_permission_set`
(471), `sys_organization` (81), `sys_position` (24), `sys_capability`
(7),
`sys_audience_binding_suggestion` (2). The `sys_*` ones are reads **by
name** from a
different fail-soft path; 14 more are a deliberate connection-abort
fixture. Carrier: the
`domain:services` seat already owns the file surface, but no PR in
flight touches it.
- The `probeInstallOrganizations` / seed-loader / metadata classes are
the bulk of what is
left repo-wide (`rest` 362 `sys_metadata`, `dogfood` 126
`sys_migration`, `verify` 22
`sys_migration`, and `sys_organization` across ~10 packages). That
population is objectstack-ai#10629's,
  not this card's.
- `trigger-record-change/src/record-change-integration.test.ts` emits
one refusal on
`sys_metadata_activation` that its `captureExpectedReadRefusals` list
does not declare —
  unchanged by this diff, present in both legs.
-
`service-automation/src/notify-zero-delivery-visibility.integration.test.ts`
exits 1 when
run as a single file under `--maxWorkers=1` and passes in the package
run. Pre-existing,
  untouched by this diff. Carrier: none identified.

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

---------

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

Closes objectstack-ai#17984

`Clause-②: no`

## The defect, and the premise that turned out to be false

On PR objectstack-ai#17982 `Auto Label` went red for work it had **completed**. Step
objectstack-ai#3 POSTed `tests`, the API answered HTTP 500, the script exited 1 — and
the PR's label set read `size/s, skip-changeset, tests` immediately
afterwards. A 500 is not evidence the write failed.

The job's red said *"the response failed"*; every reader takes it to
mean *"the label is missing"*. Those are different facts, and that gap
is the defect.

⚠️ **One premise in the card is false, and it is worth stating
plainly.** The card reads "**No retry on 5xx.** A 500 from the GitHub
API is transient by definition". `scripts/pr-labels.mjs` has had a
**bounded exponential-backoff retry on 5xx since the day the file
landed** (objectstack-ai#10777, 2026-08-22) — 4 attempts, `2 ** attempt * 500` ms,
with 4xx-other-than-429 breaking out as fatal. Verified against the
tree: the file's last commit before the incident is 2026-09-06, and the
loop is present in the 2026-08-22 blob.

So **two of the card's three suggested-shape bullets were already
implemented** (retry-on-5xx, 4xx-stays-fatal). The card's incidental
observation that "the same window produced at least four" 500s is most
likely *those four retry attempts*: `runPlan` logs the POST once and
`ghRequest` reports only `lastError`, so four internal attempts render
as exactly the one-POST-one-500 log the card read.

The one bullet that was genuinely missing is the one the card itself
identified as the actual defect: **idempotency-awareness**. Nothing ever
re-read the board.

## What changed

One file, `scripts/pr-labels.mjs`. Failures are now **classified**
rather than uniformly fatal:

| class | statuses | treatment |
|:--|:--|:--|
| **INDETERMINATE** — the server may have acted before the answer was
lost | any `5xx`; a fetch that **threw** | re-read the PR's labels and
judge the step's **post-condition** |
| **DETERMINATE** — the server refused and did not act | `4xx`, **`429`
included** | fatal, loud, byte-identical error message to before |

Three new pure exports carry the decision:

- `failureIsIndeterminate({ status, threw })`
- `postconditionOf(step)` — reads the wanted state **off the step
itself** (`POST` ⇒ its labels present; `DELETE` ⇒ the one named label
absent), so it cannot drift from what the step asks for
- `settleWriteFailure({ step, liveLabels, indeterminate })`

`ghRequest` now tracks indeterminacy **stickily across attempts** — if
any attempt could have reached the server's state, the whole request is
indeterminate even when a later attempt came back with a clean 4xx.
`runPlan` routes a failed write through `settleOrRethrow`.

Two deliberate non-relaxations:

- a **determinate 4xx stays fatal even when the board satisfies the
post-condition**. A `403` is a broken token and a `422` is a label that
does not exist in the repo; the label being there by some other hand
does not make the token work.
- a **settling re-read that itself fails settles nothing** — the write
is reported `UNVERIFIED`, and the *original* error is raised (AGENTS.md
*Route & surface ownership* §3: absence must be loud, prefer failing to
falling back).

## The two-direction test, and its verdicts

`--self-test` gains a 16-case battery, `the objectstack-ai#17982 indeterminate write,
settled against the board`, pinned in `SELF_TEST_BATTERIES`;
`SELF_TEST_BATTERY_FLOOR` goes 6 → 7 so the new battery cannot be
silently deleted.

A battery that only proved the settle succeeds where the write landed
would be the same exit-0-by-construction shape this card is about. So
**both legs were ablated**, each proven to land on disk by an occurrence
count on the mutated anchor before the run:

| ablation | anchor before → after | self-test |
|:--|:--|:--|
| **A** — delete the 4xx-stays-loud leg (`if (!indeterminate)` → `if
(false)`) | 1 → 0 old, 1 new | **exit 1**, `FAIL a determinate 4xx is
NOT settled, even with the label present` · `FAIL …and it says so in
those words` |
| **B** — make 5xx read as determinate (`return Number(status) >= 500` →
`return false`) | 1 → 0 old, 1 new | **exit 1**, `FAIL a 500 is
indeterminate` · `FAIL so is a 503` |

Both restores were settled by `git hash-object` against the HEAD blob
(`24e4c5098d6b…`, matched) plus an empty `git diff HEAD` — ⛔ not by a
`trap`, and not by a restore command's exit code. Unmutated verdict:
`VERDICT: pr-labels self-test PASSED`.

## The sweep — population, criterion, controls

The card asked for the population, not a "I also checked others".

**Criterion counted by:** a tracked file that (a) names a GitHub API
host (`api.github.com` / `GITHUB_API_URL`) **and** (b) issues a non-GET
verb, then judged on whether its write failure handling **treats the
response status as the verdict with no post-condition re-read**.

**Population:** `git grep -lE "api\.github\.com|GITHUB_API_URL"` over
tracked files → **23** under `scripts/**`, **3** elsewhere
(`.claude/hooks/guard-governed-enqueue.{sh,selftest.sh}`,
`.claude/settings.json`), **0** under `packages/**`. Of the 23, **7**
contain a write verb.

**Controls.** Firing control: `scripts/pr-labels.mjs` — the known
positive, still present, 38 953 bytes — appears in both the population
and the write-verb narrowing. Nonsense control: the same probe for
`api.gitlab.com|GITLAB_API_URL` reads **0**.

| file | verdict |
|:--|:--|
| `scripts/check-whole-set-label-write.mjs` | **not a writer.** Its 8
verb hits are literals in its own detector vocabulary and fixtures.
Excluded with evidence. |
| `.claude/settings.json`,
`.claude/hooks/guard-governed-enqueue.selftest.sh` | **not writers.**
Verb hits are *matcher patterns* for a read-only guard hook (the hook
itself: 0 verb hits). |
| `scripts/pm/label-write.mjs` | ⭐ **already correct, and it is the
in-repo reference for this cure.** Nothing there throws on an HTTP
status; `classifyHttp` routes, and exit 0 requires *"the write landed
AND the read-back matched the target"*, with a second read-back after a
re-add. |
| `scripts/pm/sweep-stale-finding.mjs` | **deliberate, documented
no-retry design** — stops on the first `403`/`429` and prints a resume
cursor; carries a `read-back-mismatch` stop reason. Not this shape. |
| `scripts/pm/post-stamped.mjs` | **has the shape.** Bare `if (!res.ok)
throw` in `rest()`, no retry, no post-condition re-read; its existing
read-back verifies stored *bytes* on the success path only. |
| `scripts/pm/sweep-closed-cards.mjs` | **has the shape.**
Byte-identical `rest()` helper to the above. |
| `scripts/release-github-releases.mjs` | **has the shape** on
`POST`/`PATCH` release. Release-lane, Prime Directive objectstack-ai#15 territory. |
| `.github/workflows/**` inline (5 files) | `github.rest.issues.*` via
`actions/github-script`; **not swept further** — out of this card's file
surface. |

**What was fixed vs. left as a card candidate.** Fixed:
`scripts/pr-labels.mjs` only. ⛔ Deliberately **not** extracted into a
shared helper and swapped into the other three: they are **seat-invoked
CLI tools**, where a human or agent reads the output and re-runs, not
unattended CI jobs whose red blocks a PR — the severity that makes this
card worth fixing does not carry over, and `scripts/pm/label-write.mjs`
already shows the repo has the discipline where the stakes are highest.
The three are reported to the PM as a **card candidate**, not widened
into this PR.

## Reverse-read, both directions

- **Currently-true sentence this makes false:**
*"`scripts/pr-labels.mjs` fails the job on any non-2xx answer from the
labels API."* Now false for the 5xx/thrown class — false only when the
board proves the post-condition holds.
- **Currently-false sentence this makes true:**
*"`scripts/pr-labels.mjs` re-reads the PR's labels after a failed write
and judges the write by the board's state."*
- **A zero, reported as required:** the change moves **no** sentence
about 4xx behaviour. A `4xx` threw and exited 1 before, and rethrows the
**same error object with the same message** now. That invariance is
asserted by ablation A rather than asserted in prose.

## Scope, gates, publishing

- ⛔ **This PR does NOT touch `.github/workflows/**`.** The fix is
entirely inside the script, which both `lint.yml` and
`pr-automation.yml` already invoke via `--self-test`. No workflow arming
problem for the PM seat.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived
**32** families from the change set at the final tree; all 32 run, **all
exit 0**. `--ran` reconciliation with per-command exit codes: `✓ 32
derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED
zero)`.
- **Lint:** the full repo-wide union was run rather than narrowed —
`eslint . --no-inline-config --format json` over **6 751** files, **0
errors, 0 warnings**, exit 0, at `1bc65ff50e`. (`eslint.config.mjs`
declares no `parserOptions.project` and no typed rules, so the narrowing
question is moot in any case.)
- **`skip-changeset`, measured not asserted:** every
`pnpm-workspace.yaml` glob roots under `packages/*` / `apps/*` /
`examples/*`; npm `files[]` resolves relative to a package dir and
cannot reach outside it; `scripts/pr-labels.mjs` sits at the repo root
inside **no** package, and the root manifest is `private: true`.
Positive control: `packages/spec` is non-private with a real `files[]`,
so the probe can distinguish. ⇒ nothing publishes ⇒ label, not a
changeset.

## 验收备注

- noted, not filed: `ghRequest`'s 4-attempt / `2 ** attempt * 500` ms
budget is a hard-coded literal with no env override. Not a defect and
not in scope; whoever next tunes the labeler's patience will meet it.
承接者:无 — no queued PR touches this file.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU


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

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

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants