test(plugin-auth): register the authz objects two sign-in fixtures drive - #17982
Conversation
`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>
|
| 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
|
| 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
Contract reviewHead reviewed: Implemented-by: ① Clause-② — re-derived in-seat from the DELIVERED diff
⇒ Clause-②: ⭐ The delivery measured the same thing one level deeper, against the BUILT tree: after a full build, ② 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 returns — ⛔ ③ Both scopes reported, and ⛔ not interchangedFenced two files: ⭐ They coincide, and the delivery says why instead of letting the coincidence pass: the two fenced files account for 100% of the package's All seven lines are now attributed (6 to ④ The declared widening — checked against the four conditions, ⛔ not waved throughThe card names only the 3
✅ 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 ⭐ 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 Gates53 derived commands, all exit 0. Two first answered exit 3 (PREREQUISITE NOT MET) — The open question — settled: AThe delivery asked whether the "one package only" boundary should be swept. Yes — successor filed. The static pointer is large (73 test files outside Verdict: PASS at
|
📓 Docs Drift CheckNothing 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
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
⛔ Not landable yet — and "0 failing" was hiding itEvery check run on
⭐ 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 Disposition:
⇒ the runs API reports it ⭐ This is a platform incident, not this PR's. Measured: every run of
⇒ 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 |
Contract review — re-issued for the moved headHead reviewed: Reviewed-by: Why the head moved
The head was moved by merging the current base branch — Result: The wedge was invisible at the check-run levelAt
⇒ the completeness limb has to be read at the workflow-run level, and the check-run level cannot substitute for it in either direction.
|
| 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 7222252f8 → b4246a6bf 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 17982 → exit 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.)
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
CI, Lint & Type Check and Governed Surface Guard are in_progress, so the limb is NOT MEASURED, ⛔ not a pass.
Generated by Claude Code
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34758691525 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
🔴 Ejected from the merge queue — not this PR's failure, standing down with evidenceDequeued Failing check: Why it is not this PR's — three readings, not an opinion
⛔ "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 Does a fix exist to port? No — measured, not assumed
Action: the one sanctioned re-queue, spent nowRe-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 Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34759517090 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Ejected a second time — different base, same failure. ⛔ No third re-queue.Build The decisive new fact: the re-queue landed on a different merge base (
This PR stays out of the queue until #18032 is resolved. Nothing about its diff has changed; the contract review at Generated by Claude Code |
|
| 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
…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>
…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>
…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>
Fixes #17897
Clause-②: no
Two
plugin-authtest fixtures boot a realObjectQLover a realSqlDriverand registeronly plugin-auth's own
authIdentityObjects. Both then drive reads againstplugin-security-owned tables that were never provisioned, and the driver refused every one
of them.
tryFindclassifies a missing table as "not provisioned" and answers[], sonothing went red: the suites reported a green they had not earned, plus seven
DATABASE_ERRORlines 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.tsprecedent — so no dependency edge fromplugin-auth to plugin-security is added. No product code changes.
account-issuer-upgrade-path.test.tssys_user_position,sys_user_permission_set,sys_positionAuthManager.handleRequest-> session-payload callback -> core'sresolveUserAuthzGrants->resolve-authz-context.tstryFindsignup-existing-address-refusal.test.tssys_user_permission_setsettleSelfRegistrationGrant's own existence read before it inserts the declared self-registration grantThe measurement
Both runs are on the same tree; the only thing between them is this PR's diff. Baseline at
origin/main=225197cdb, after at7222252f8.The fenced two-file command:
DATABASE_ERRORtotalsys_user_permission_setThe WHOLE package —
pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2:DATABASE_ERRORtotalsys_user_permission_setThe card measured 2283 tests at
a61ae59f9; this tree carries 2287 at225197cdb. TheDATABASE_ERRORcounts 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:
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_ERRORlines — there is no third sitein this package.
The other four are not a different defect: they are the same fixture gap, in the same file,
on the same
resolveUserAuthzGrantsleg.sys_user_positionandsys_positionare read bythe same
Promise.all/ position block as thesys_user_permission_setread the cardnames, 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 revertoftwo
registerObjectlines 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 HEADbytes,
git diff HEADempty):Before this PR that read was refused,
settleSelfRegistrationGrantcaught the refusal andreported "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 examplesproject, and
check:test-typecheck).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 boththen exit 0.
pnpm lint(eslint . --no-inline-config, whole repo) — exit 0.grep -naPcontrol-character scan over both edited files — no matches.No changeset — measured, not assumed
@objectstack/plugin-authshipsfiles[] = ["dist","README.md","CHANGELOG.md"]. After abuild, grepping the shipped path for the symbols this diff introduces:
Nothing published moves, so this PR carries the
skip-changesetlabel rather than achangeset.
Clause-② re-determination from the delivered diff:
no. The diff adds no exportedsymbol reachable from the published entry and no new key on an already-published payload —
it adds three
constobject literals and fourregisterObjectcalls inside two*.test.tsfiles, none of which reachdist/. The measurement above is the same evidence.Acceptance notes
packages drive the same resolver against a narrower object set was never swept, for this
card or for its predecessor [finding]
find-envelope-limb-removal.test.tsdrives a read onsys_user_permission_setagainst an engine that never registered it — the last refused read left in plugin-auth after #14846 #16315. A static pointer only, not a measurement: 73 testfiles outside
plugin-authnameresolveUserAuthzGrants/resolveAuthzContext(control:the same grep finds 6 inside
plugin-auth), and 4 constructAuthManager. Sweeping themmeans running those packages' suites; that is the PM's call, not this card's.
probe triage suggested — re-run the two-file command at
519a76915— cannot be run fromthis checkout: that object does not resolve here at all (
git cat-file -t 519a76915->fatal: Not a valid object name). The control leg proves the reading vacuous rather thaninformative:
manifest.ts, a file certainly present at every ref, also reads ABSENT at519a76915, while both target test files read PRESENT ata61ae59f9and225197cdb.Nothing here implies an answer in either direction.
sys_position_permission_setandsys_permission_setare deliberately NOT registered inaccount-issuer-upgrade-path.test.ts: the resolver reaches them only once asys_positionrow resolves and a permission-set id is collected, and these cases seed neither. Recorded
in the fixture comment so the next author does not read the omission as an oversight.
valid_from/valid_until) are absent from the newobjects for the same reason
sys_memberlacks them today:isGrantActivereads an absentbound as unbounded.
back-channel logout planning failed … no such table: sys_oauth_access_token) #14615 — deciding that a read of an absent table is a benigndiscriminated case and therefore no longer worth logging — is untouched here, as it was
untouched by plugin-auth sso-register harness never registers sys_position / sys_user_position, so the platform-admin standing resolver logs 8 DATABASE_ERROR lines on every green run #14846 and by the predecessor card.
Generated by Claude Code