Skip to content

feat(plugin-auth, plugin-audit): a sign-in the IP allow-list refuses writes one login_failed row, bounded per address - #31249

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-v9d4f2
Nov 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-v9d4f2

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

domain:server#6 · os-project-manager · session_01FEkQ5pfyn4wSGLdTuVZctK

Fixes #30924
Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:因为 IP 不在允许范围而被拒的登录,现在每次在审计日志里写一条 login_failed,带上 reason: IP_NOT_ALLOWED、rule: ip_allow_list、客户端地址、能认出的账号,以及组织(组织自己的名单拒绝时)。部署级名单和组织级名单两道门都记。
  • 为什么改:企业版「谁、从哪里、因为哪条规则被拦」的报表(cloud#3668)要读这份数据;之前部署级名单拒绝时什么都不写。这是本卡两个 PR 的第二个,第一个(MFA 挂起)已合并。
  • 风险与回滚:扫描器狂刷时有上限:同一地址 10 分钟内只在第 1、10、100 次各写一行,全局每 10 分钟最多 1000 行,超出只打一行告警。部署级名单的写入不阻塞拒绝响应,响应内容和耗时与账号是否存在无关。回滚就是 revert。
  • 先看:① ip-allow-list-refusal-audit.ts(限流规则和行的形状);② auth-plugin.ts 的 auditAllowListRefusal(部署级名单);③ auth-manager-database-hooks.ts 的 refuseUnadmittedScoping(组织级名单,切换组织不记)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/ip-allow-list-refusal-audit.test.ts src/ip-allow-list-refusal-audit-bound.test.ts,预期 17 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

A sign-in refused by the ADR-0069 D5 IP allow-list now writes one login_failed row, at both doors that refuse a sign-in by address. The row follows the incumbent shape of a refused sign-in (signInFailureEventFor, auth-session-audit.ts): action: 'login_failed' with metadata.endpoint and metadata.reason. It adds metadata.rule: 'ip_allow_list', the way an MFA-held login row names mfa_required (PR #30950), and metadata.attempts (the bound, below). The client address and user agent come from signInClientFingerprint, the rule every login row's address is read by. The lane kept the incumbent login_failed action rather than a dedicated one: a reader counting refused sign-ins already reads it, and rule separates the policy refusals from the password ones.

  • The deployment's list (auth-plugin.ts, the auth-route middleware, new private auditAllowListRefusal). It refuses before better-auth resolves any account. The row names the account when the request's JSON email resolves to one, through the new public forwarder AuthManager.resolveRefusedSignInAccountId(email) (open question 1, option A: the password sign-in's own findSignInAccount question). Otherwise the row is written with no subject (ruling 1A). Only /sign-in/* endpoints write a row (ruling 4); a refused get-session or sign-out does not.
    • The answer does not change. The bound is judged synchronously. The body read, the account lookup and the write run detached from the response (recordDeploymentListRefusal), so the 403 IP_NOT_ALLOWED status, body and timing do not depend on whether the identifier names an account. That keeps the refusal from becoming an account-existence oracle for a client outside the ranges. The body read is capped at 16 KiB; a larger or unparsable body writes the row with no subject.
  • An organization's own list (auth-manager-database-hooks.ts, refuseUnadmittedScoping, the SESSION_SCOPING_REFUSAL site). The credential check has passed there, so the row names the account and the organization (tenant_id), and impersonated_by on an impersonation session. Only the session's creation writes the row. A refused switch of the active organization (session.update.before) is not a sign-in and writes none (open question 2, option A).
    • No second row. On main the password sign-in's outcome already wrote a login_failed for this refusal (reason IP_NOT_ALLOWED, no rule, no organization, unbounded), because better-auth hands the after-hook the refusal the session hook threw. signInFailureEventFor now answers null for IP_NOT_ALLOWED (REFUSALS_RECORDED_AT_THEIR_DOOR), so the door's own row is the only one. That code reaches the after-hook only from this door; the deployment gate refuses before better-auth runs.
  • The bound (IpRefusalAuditBound, ruling 5). Per client address, a 10-minute window from its first refusal: rows at the 1st, 10th and 100th refusal, each carrying attempts. Across all addresses, at most 1000 rows per 10-minute window, and one warn line per window once the cap drops a row. An undetermined address counts as one address. The tracked addresses are capped at 10,000 (expired first, then the stalest), so rotating addresses cannot grow memory. Both doors share one bound per audit sink.
    • Across replicas: the bound lives in process memory, so N server processes write up to N times as many rows. No shared store is used, and none was ruled.
  • plugin-audit (auth-event-audit.ts): AuthSessionAuditEvent.userId is optional. A login_failed with no subject is written with user_id, record_id and actor null. It is a row about no record, read under the ledger's own grant (audit-log-read-visibility.ts); with no organization it is served to platform administrators only. Every other event with no subject still writes nothing.
  • plugin-auth's AuthEventAuditSurface: the event's userId is optional to match. Clause-②: yes (widening).
  • ⛔ No row carries the credential. The identifier is read only to find the account and is never recorded; the password is never read.
  • Docs: content/docs/kernel/runtime-services/audit-service.mdx now shows the optional userId, reason/rule on an MFA-held login row and on an IP refusal, and the bound.
  • Changeset: @objectstack/plugin-auth and @objectstack/plugin-audit, patch.

For the cloud#3668 consumer: read IP refusals as action = 'login_failed' with metadata.rule = 'ip_allow_list'. Use user_id (may be null), tenant_id (set when an organization's list refused), ip_address, user_agent, created_at and metadata.attempts. A row with attempts = 100 means at least a hundred refusals from that address in its window.

Why

#30924: plugin-auth wrote no audit row when policy refused a sign-in, so the Enterprise enforcement report (objectstack-ai/cloud#3668) had nothing to read. PR 1 (#30950) recorded the MFA hold on the held sign-in's login row. This PR 2 carries the IP allow-list rows, after #30922 (PR #31013) added the organization's own list.

Tests

  • ip-allow-list-refusal-audit.test.ts (new). The deployment half mounts the real AuthPlugin.registerAuthRoutes on a real Hono app in front of a real AuthManager. The organization half goes through AuthManager.handleRequest with the transport peer.
    • deployment, existing account: 403 IP_NOT_ALLOWED; one event with the account id, the address, the user agent, {endpoint, reason, rule, attempts: 1}; neither the password nor the email appears.
    • deployment, unknown identifier: one event, no userId.
    • get-session / sign-out refused: no event; /sign-in/magic-link refused: an event.
    • control: an admitted sign-in writes no refusal event.
    • flood: 120 refusals from one address give rows at attempts 1, 10, 100; a second address starts at 1.
    • the answer does not change: same status and body with and without the sink, and the response arrives before a 150 ms account lookup settles.
    • no audit plugin: the forwarder is never called.
    • organization, refused at creation: one event with the account, organizationId, the address and the rule (one, not two).
    • control: an admitted sign-in writes only its login.
    • a refused switch of the active organization: 403 IP_NOT_ALLOWED, no event.
    • 12 organization refusals from one address: attempts 1 and 10.
  • ip-allow-list-refusal-audit-bound.test.ts (new): the ruled constants; the per-address window restart; the undetermined address; the global cap with one warn per window; the tracked-address cap; the event shape.
  • plugin-audit/src/auth-event-audit.test.ts: a subject-less login_failed lands about no record, credits nobody, and keeps the address and the rule. Control: login, logout and account_locked with no subject write nothing.
  • Dogfood auth-session-audit-trail.dogfood.test.ts, new describe on a booted showcase with AuditPlugin. auth.allowed_ip_ranges is set through the settings service. Twelve /auth/sign-in/email requests answer 403 IP_NOT_ALLOWED and leave exactly two sys_audit_log rows for the account (attempts 1 and 10), each about sys_user, with the rule, the reason and the user agent, and no credential.

Acceptance notes

  • Found while measuring, not fixed here (reported to the seat): on main, a sign-in refused by an organization's own IP list counts toward the ADR-0069 D2 lockout, although the password was right. Measured with lockoutThreshold: 3: three /sign-in/email attempts with the correct password from outside Alpha's list answer 403 IP_NOT_ALLOWED, and the fourth, from inside the list, answers 403 ACCOUNT_LOCKED (failed_login_count 3). The cause is the password after-hook (auth-manager-after-hook.ts) reading every returned error as a failed credential. That file is outside this card's surface, so it goes to the seat to file.
  • Across replicas: the bound is per process (module note in ip-allow-list-refusal-audit.ts). N replicas write up to N times as many rows; no shared store was ruled.
  • Detached write: the deployment gate's row is written after the 403 is returned. A runtime that ends the process right after the response could lose that row. The Node server this ships on does not.
  • Which sign-ins name an account at the deployment gate: only a JSON body with an email (/sign-in/email, /sign-in/magic-link, /sign-in/email-otp). Social, SSO, passkey and username sign-ins are written with no subject. All refusals whose address could not be established count as one address in the bound, and their rows carry no ip_address.
  • A published function changes behaviour: signInFailureEventFor (exported from auth-session-audit.ts) now answers null for reason: 'IP_NOT_ALLOWED'. The door that refused writes that row.
  • File surface: the new module ip-allow-list-refusal-audit.ts and its two test files sit in the claimed packages/plugins/plugin-auth/src/, package-internal (not re-exported). organization-access-policy.ts is not touched; the refusal site edited is in auth-manager-database-hooks.ts.
  • Merged origin/main once (2aeb9e05): fix(plugin-auth): a refused enterprise OIDC sign-in names its cause #31194 (SSO refusal codes) touched auth-manager.ts in a different region and the merge was clean. The plugin-auth suite was run again on the merged head.

Verification

Readings on head 2aeb9e05 (the implementation 148bac5f plus one merge of origin/main at 065bba72) unless named, in this checkout.

Red first, on origin/main 658d84f (production sources restored, tests kept)
  • ip-allow-list-refusal-audit.test.ts: 8 failed | 3 passed. The deployment cases fail with expected [] to have a length of 1 (the sink receives nothing), and the flood case with expected [] to have a length of 3. The forwarder cases fail with resolveRefusedSignInAccountId is not defined. The organization case receives the password outcome's row, {endpoint, reason: IP_NOT_ALLOWED}, with no rule, no organizationId and no attempts. Twelve organization refusals give twelve rows. The three controls pass: an admitted sign-in, the refused switch, and the bound file not loaded.
  • plugin-audit auth-event-audit.test.ts: 2 failed | 17 passed (the subject-less row is dropped).
Ablations (scripts/ablation-replace.mjs, each restored: blob == HEAD, git diff HEAD empty)
# Mutation Red
A1 the gate's auditAllowListRefusal call deleted 5 deployment cases
A2 the per-address marks ignored 4 (flood, organization bound, 2 bound unit cases)
A3 the switch hands creating in the switch case (negative pin)
A4 the IP_NOT_ALLOWED suppression in signInFailureEventFor deleted 2 (two rows instead of one)
A5 isSignInEndpoint admits every path the get-session / sign-out case (negative pin)
A6 the write awaited before the 403 the answer-does-not-wait case (negative pin)
B1 plugin-audit refuses a subject-less login_failed 2
B2 plugin-audit admits any subject-less event 2 (negative pin, incl. the #8144 case)
  • A6's first attempt was a no-op: an unbalanced replacement failed to parse, the integration file did not load, and the reading was discarded. It was re-run as three held mutations (async method, await call, await at the gate) and read red.
  • Dogfood, on dist/: A1 rebuilt. The DTS step failed TS6133 (the method was left unused) after tsup wrote the mutated JS. ablation-dist-preflight.mjs --absent showed the marker gone from all 14 built files, and the new dogfood case failed (1 failed | 6 skipped). Restored and rebuilt: marker present in 2 built files, dogfood 7 passed (7).
Suites, types, lint, gates
  • @objectstack/plugin-auth full suite on 2aeb9e05: 236 of 237 files pass, 4199 passed | 1 failed | 10 skipped. The one red is organization-access-policy.test.ts "the absolute limit is judged too…", the known exact-60-minute boundary flake that plugin-auth test: organization-access-policy.test.ts sets a session exactly 60 minutes old against a one-hour limit and expects admitted, so it goes red whenever a millisecond passes (main red on 56baf78e) #31175 carries. Re-run alone: 19 passed (19), three times out of three. On 148bac5f before the merge: 236 files, 4185 passed | 10 skipped.
  • @objectstack/plugin-audit: full suite 1203 passed (1203) on 148bac5f; auth-event-audit.test.ts 19 passed (19) on 2aeb9e05.
  • Dogfood auth-session-audit-trail.dogfood.test.ts: 7 passed (7) on 2aeb9e05.
  • Typecheck (source + tests): @objectstack/plugin-auth exit 0, @objectstack/plugin-audit exit 0 on 2aeb9e05; @objectstack/dogfood exit 0 on 148bac5f.
  • eslint --no-inline-config on the 10 changed .ts files: exit 0.
  • Build: turbo run build under os-verify-lock, 120 successful, 120 total (VERDICT command-exit 0).
  • scripts/pm/dispatch-gates.mjs --ran on 2aeb9e05: 99 derived, 99 run, 0 NOT-MEASURED, all 99 exit 0. Three families first answered exit 3 (PREREQUISITE) and were re-run after their prerequisites: check-plugin-teardown-shape --self-test after fetching its fixture commit; check:skill-examples and check:dual-build-cjs-loads after the full build.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; eslint --no-inline-config passes on the changed files. (CI owns the repo-wide lint run.)
  • 4. One issue, one branch off an up-to-date main, one pull request (PR 2 of the claimed two-PR series).
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

Generated by Claude Code

…writes one login_failed row, bounded per address

Both doors that refuse a sign-in by client address (ADR-0069 D5) now record
it: the deployment's list in the auth-route middleware, written detached from
the 403 so the answer never depends on the account lookup, and an
organization's own list at session creation, naming the organization. The row
is the incumbent login_failed shape with reason IP_NOT_ALLOWED, rule
ip_allow_list and attempts. Rows are bounded per address (1st, 10th and 100th
refusal in a 10-minute window) and globally (1000 per window, one warn line).
A refused switch of the active organization writes no row, and the password
outcome no longer writes a second row for the organization's refusal.

plugin-audit admits a subject-less login_failed (user_id and record_id null);
every other event still needs a subject. AuthManager gains one public
forwarder, resolveRefusedSignInAccountId, used only for that row.

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

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-audit, @objectstack/plugin-auth, touching 44 documentable anchor(s).

37 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0d0c377beb988b990e77aa4763fd5e4b429d8de4.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: userId (5 routes)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 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 0d0c377beb988b990e77aa4763fd5e4b429d8de4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a6fcc3f437d60e891c01188b01c74e89b4b3053 — the merge of head 5c0974f9e7ad8b9bef9b1a7261e1d65ed4e814c9 into base 0d0c377beb988b990e77aa4763fd5e4b429d8de4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5a6fcc3f437d60e891c01188b01c74e89b4b3053 && git checkout 5a6fcc3f437d60e891c01188b01c74e89b4b3053
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d0c377beb988b990e77aa4763fd5e4b429d8de4 5c0974f9e7ad8b9bef9b1a7261e1d65ed4e814c9 && git checkout -B drift-repro 0d0c377beb988b990e77aa4763fd5e4b429d8de4 && git merge --no-ff 5c0974f9e7ad8b9bef9b1a7261e1d65ed4e814c9

node scripts/docs-audit/affected-docs.mjs --json 0d0c377beb988b990e77aa4763fd5e4b429d8de4

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0d0c377beb988b990e77aa4763fd5e4b429d8de4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 2aeb9e05f8037a63cc7dc768d15cee6cff20b7fd
Local-runs: none

Face: declaration. The committed tree touches no packages/spec/src/** and no governed path. The .changeset/ and content/docs/** files are prose surfaces. The review is owed only because of the Clause-②: yes line, so the tier is TIER_DEFAULT. Inputs read: card #30924 (its body and all 8 comments, including the PR 1 report 6686260557, the hand-off 6688338299 naming rulings 1A/3/4/5, claim 6710542762 and dev report 6712181248), the body and 12-file list of PR #31249, the net diff origin/main...2aeb9e05, and the 47 check-runs on the head.

Check-runs on 2aeb9e05 when read: 19 success, 12 skipped, 16 in progress, 0 failed. Among the green ones: Check Changeset, PR-body guards, Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · ESLint, Type Check · source gates, Dogfood Regression Gate, Spec property liveness. This record judges the contract only; the in-progress gates still have to read green before ACCEPT.

① Derived judgments

Published reach: both packages export only . (plugin-auth also ./rate-limit-storage and ./client-address). plugin-auth's index.ts runs export * from './auth-session-audit.js' and exports AuthManager. plugin-audit's index.ts exports createAuthEventAuditSink and the types AuthSessionAuditEvent, AuthEventAuditSink and AuthEventAuditSinkOptions.

  1. plugin-audit AuthSessionAuditEvent.userId: string becomes optional. Right. For the caller the accept-set widens (an event may leave out userId). For an implementer or reader userId is now possibly undefined: a new obligation, not a narrowing. recordAuthEvent is declared with method syntax, so its parameter is checked bivariantly and an existing annotated implementation still type-checks. The only in-repo implementer, createAuthEventAuditSink, is updated. At runtime a subject-less login_failed is now written with user_id, record_id and actor null; every other subject-less event still writes nothing (ablation B2). The row falls in isLedgerRowAboutNoRecord, served under the ledger grant; with a null tenant_id only the platform administrator sees it. user_id, record_id and actor are required: false on sys_audit_log.
  2. plugin-auth AuthEventAuditSurface.recordAuthEvent event userId?, and the derived AuthSessionAuditEventInput. Right. Same analysis as item 1. At runtime it fails safe: emitAuthSessionAuditEvent swallows what a sink throws, so an older sink (including an older plugin-audit with a newer plugin-auth) loses only the subject-less rows. audit-service.mdx now shows userId? and says when it is absent. No value accepted before is refused now.
  3. New public AuthManager.resolveRefusedSignInAccountId(email). Right. Additive widening of an exported class, an in-process lookup, the forwarder ruled Q1-A; no new reachable type.
  4. Exported signInFailureEventFor now returns null for reason: 'IP_NOT_ALLOWED'. Right in-repo; disclosure incomplete. Its accepted input and declared return type (| null) are unchanged; what shrinks is the set of events it produces, so no (narrowing) arm is owed. In-repo IP_NOT_ALLOWED reaches the after-hook only from the organization door, which now writes the richer row; without the suppression there would be two rows (ablation A4). An out-of-repo host mapping outcomes through this helper would silently lose that row: the PR body says so under Acceptance notes, the changeset does not. Should-fix S1.
  5. New module ip-allow-list-refusal-audit.ts. Right. No subpath or entry type graph reaches it; auditAllowListRefusal is private. Nothing new is published.
  6. Row data contract seen by cloud#3668. Right. login_failed rows now carry metadata.rule: 'ip_allow_list', metadata.attempts, metadata.reason: 'IP_NOT_ALLOWED', plus tenant_id when an organization's list refused. Free-form context keys, no schema change, no new error code.
  7. Behaviour against the card and its rulings. Right. Deployment gate rows on /sign-in/* only (ruling 4); a row even when no account resolves (ruling 1A); organization door writes on session creation only, a refused switch writes none (Q2-A); bound 1st/10th/100th per address per 10 minutes, cap 1000 per window with one warn line, tracked addresses capped (ruling 5, Q3-A). The detached body read and lookup keep the 403's status, body and timing independent of whether the account exists (150 ms lookup case, ablation A6). No credential reaches a row. Both doors resolve the same audit object, so they share one bound. Sign-in runs with no native transaction, so a row written before the throw is not rolled back.

② Semver level

  • Changeset level is right. patch for @objectstack/plugin-auth and @objectstack/plugin-audit, Metadata-protocol: none (nothing an author writes changes). Both declaration lines start a line in the PR body and in the changeset; Check Changeset is green.
  • Declared arm Clause-②: yes (widening) is right. The diff widens published accept-sets (items 1, 2) and the public surface (item 3) and narrows no published accept-set: the implementer burden in items 1–2 comes from widening the input domain and does not break assignment; item 4 shrinks what a helper produces, not what it accepts. No BREAKING banner, migration or ADR-0087 marker is owed. Matches claim 6710542762 and forecast 6685098880.

③ Boundary flags

  • Dev report 6712181248: open_questions empty. PR 1's three open questions (6686260557) were ruled in 6688338299 and this diff follows each (Q1-A item 3; Q2-A pinned by A3; Q3-A pinned by the bound tests).
  • Out-of-scope 1 (class a, public door POST /api/v1/auth/sign-in/email). Escalated. An organization-list IP_NOT_ALLOWED refusal counts toward the ADR-0069 D2 lockout with the right password (auth-manager-after-hook.ts passes any returned APIError to recordSignInOutcome(email, false)). Pre-existing on main, outside this card's surface, not blocking this contract; this PR leaves the counting as it was (the lockout path still writes account_locked; only the duplicate login_failed is suppressed). The seat files it before ACCEPT; that card's fix keys on the same refusal code and must keep REFUSALS_RECORDED_AT_THEIR_DOOR.
  • Out-of-scope 2, the exact-60-minute flake: plugin-auth test: organization-access-policy.test.ts sets a session exactly 60 minutes old against a one-hour limit and expects admitted, so it goes red whenever a millisecond passes (main red on 56baf78e) #31175 carries it. Nothing owed.
  • Residuals declared and accepted as ruled: per-process bound (N replicas, up to N times the rows); the deployment row is written detached after the 403, so a runtime ending right after responding can lose it; only a JSON email names an account at the deployment gate.
  • Not blocking: the organization-list row is pinned through AuthManager.handleRequest with a sink double plus plugin-audit's own unit case; the dogfood pin covers only the deployment list.

Implemented-by: claude/project-thread-v9d4f2
Reviewed-by: session_01FEkQ5p3T5YWhLjurajudnQ

VERDICT: PASS

Should-fix S1 (relayed to the dev): the changeset should also say that the exported signInFailureEventFor now answers null for reason: 'IP_NOT_ALLOWED' (the refusing door writes that row), and that a sink registered on the audit slot now receives login_failed events with no userId. Out-of-scope 1 is filed as #31251.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

席位核对 · ACCEPT · 2026-11-07T20:22Z · head 5c0974f9e7ad8b9bef9b1a7261e1d65ed4e814c9

Seat domain:server#6 reviewed the diff of its own dispatch (claim 6710542762 on #30924, card thread session_01FEkQ5pfyn4wSGLdTuVZctK, report 6712181248). Read against GitHub, not the report.

Regen-provenance: 6712253318 · 2aeb9e05f8037a63cc7dc768d15cee6cff20b7fd → 5c0974f9e7ad8b9bef9b1a7261e1d65ed4e814c9 · git diff --name-only → .changeset/30924-ip-allow-list-refusal-audit-rows.md

  1. Shape: 12 files, +1082 / -25 against main at 2aeb9e05, plus one prose-only commit. A sign-in the ADR-0069 D5 IP allow-list refuses writes one login_failed row (reason: IP_NOT_ALLOWED, rule: ip_allow_list, attempts): the deployment list's row from the auth-route gate on /sign-in/* only, written detached from the 403 so the answer's status, body and timing do not depend on whether the identifier names an account; an organization list's row at session creation, naming account and organization, none on a refused switch. Bound per address (1st / 10th / 100th in 10 minutes) and per window (1000 rows, one warn), per process. userId optional on the audit event for that one event; signInFailureEventFor gives way so no duplicate row. Body says Fixes #30924; no other closing keyword.
  2. Premise: on origin/main neither door wrote any row (card body, PR 1 report 6686260557); the dev measured on main that the organization door's refusal was already written once by the password after-hook without rule, now one row.
  3. Gates / check-runs: 50 runs on 5c0974f9, 38 success, 12 skipped, 0 red, including Test Core 1–8/8, Type Check · workspace and Lint. mergeable_state clean. Dev: 99/99 derived gates exit 0, dogfood 7/7, eight unit ablations plus one dogfood dist ablation red and restored. The aggregate red on 2aeb9e05 is the superseded head's cancelled lanes.
  4. Contract review: Clause-②: yes (widening) reviewed at TIER_DEFAULT: PASS 6712253318 on 2aeb9e05 (optional userId judged a widening of the input domain, not a narrowing; method-parameter bivariance keeps old sinks assignable). Its should-fix S1 (the changeset names the signInFailureEventFor change and the subject-less event a custom sink now receives) is the one prose commit above, read sentence by sentence against the diff: both sentences are true of the code.
  5. Out of scope / Skills-docs: the dev's lockout finding is filed as plugin-auth: a correct-password sign-in refused by an organization IP list counts toward the account lockout #31251 and dispatched on its own thread; the 60-minute flake was plugin-auth test: organization-access-policy.test.ts sets a session exactly 60 minutes old against a one-hour limit and expects admitted, so it goes red whenever a millisecond passes (main red on 56baf78e) #31175 (merged). audit-service.mdx updated in the diff. Metadata-protocol: none; Skills-docs: not owed.
  6. Landing: check-governed-merges --pr 31249: not governed, 1107 changed lines. No packages/spec/src path. Changeset patch for both packages holds. Landing on the maintainer's standing words 「按照 pm-dispatch 规则复核过的都授权」 (2026-10-25T11:35Z) and 「你应该使用 pm-dispatch 技能审核和落地pr」 (2026-11-07T16:59Z). Relay pr_ready, then automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 7, 2026 20:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 7, 2026 20:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

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

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

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

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

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

    @objectstack/example-todo:test:  FAIL  test/loop-body-iteration.test.ts > #19206 — the loop body runs and binds `currentTask` > ESCALATION: the run reaches `notify_owner` and the notice renders the re
      ↳ 失败原因: @objectstack/example-todo:test: Error: Test timed out in 5000ms.
    

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

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

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

  • test/loop-body-iteration.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

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

分诊清单:

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

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

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ

Queue triage · 2026-11-07T20:49Z

The merge queue removed this PR at 2026-11-07T20:46Z on a red Test Core (8/8) in its merge group (run 42410962891, job 128519296820): examples/app-todo/test/loop-body-iteration.test.ts › "ESCALATION: the run reaches notify_owner and the notice renders the real day count" timed out at 5000 ms, beside repeated Acquire connection error: Error: aborted lines in the app-todo and showcase legs. Not this PR's: the diff touches no flow, automation or example path (plugin-auth / plugin-audit sign-in refusal rows only), and the same leg was green on this PR's own head 5c0974f9. Re-queued once, unchanged; a second red on the same test is treated as real and goes to the lane that owns the flow loop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth writes no audit row when policy refuses a sign-in (IP allow-list 403, MFA_REQUIRED hold), so no enforcement report can be built

1 participant