Skip to content

feat(plugin-auth): an SSO sign-in grants the permission set its IdP group is mapped to - #31657

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-dwuroz
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-dwuroz

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #30967

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

What changes

Item 3 of #30967 (IdP group → permission set on SSO sign-in), the last open item, under maintainer ruling A (record 6718355623): it follows #30959's ruled model, the same sys_idp_group_mapping object, no second model. Built on what PR #31443 landed; nothing in it is duplicated.

  • Shared projection, two sources (scim-group-projection.ts). The boundary admission and the reconcile are lifted into createMappingAdmission(deps, kind) and reconcileMappedGrants(engine, { userId, reason, mappingIds }, admits); the SCIM projection now calls them (its own behaviour, refusal text and marker are unchanged, and its suite passes unchanged). A sourceMappings(engine, kind, sourceId) read and an SSO marker ssoGroupProjectionReason(providerId) (sso-group-projection: provider 'ID') are added.
  • The sign-in hook (sso-group-projection.ts, wired in auth-manager-plugin-list.ts). sso({ provisionUser, provisionUserOnEveryLogin: true }): every sign-in reads userInfo.groups, which the provider's own mapping.extraFields.groups fills (a string or a list), matches each value against the provider's rows with the SCIM half's matcher (group_external_id when the row names one, else exact group_display_name), and reconciles the user's grants carrying this provider's marker. A sign-in with no groups desires nothing: it removes only grants this provider made. Writes go through the engine, so the break-glass guard judges them. A failure never fails the sign-in; it is logged at error once, naming the consequence and the remedy.
  • The save door (idp-group-mapping-admin.ts). POST /admin/scim/set-group-mapping accepts providerId in place of connectionId (exactly one). The organization is the provider row's organization_id; unknown provider → reference_not_found, provider with no organization → rule_violation, both on providerId; SSO off → 503. The same boundary judgement and the same 503 without a security service. An SSO save applies at each member's next sign-in (reprojected: false). delete-group-mapping is source-aware: deleting an SSO mapping removes at once the grants its provider made of that set in that organization, unless another of its mappings still names it.
  • The object (platform-objects). sys_idp_group_mapping.source_kind gains the option sso (source_id = the provider id); three field descriptions name both sources; translations for all nine locales (the en bundle regenerated, the eight others translated by hand).
  • Route ledger. The set row's note describes both sources and drops requires: 'scim' (the mount is unconditional, and the door now serves SSO with SCIM off); the delete row's note describes the SSO revoke.
  • Tenant-audit census (content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md). The delete-revoke is one new engine write site (275 → 276); the generated regions are regenerated and the page's prose figures updated to the census. Gate-required, not a behaviour change.

Tests

New top-level describe in the existing OIDC SSO harness, sso-oidc-refusal-cause.test.ts ("an SSO sign-in grants the permission set its IdP group is mapped to"): the real AuthManager with the real sso() plugin, driven /sign-in/sso → a stubbed IdP → /sso/callback, on a real ObjectQL engine over better-sqlite3 with the break-glass guard registered, the mapping saved through the door's own function, judged by plugin-security's real organizationBoundaryFailure.

  1. A mapped group grants the set in the provider's organization, by display name and by external id (a list claim and a string claim).
  2. Leaving the group revokes the projected grant at the next sign-in; a grant made by hand stays.
  3. CONTROL: an unmapped group grants nothing.
  4. CONTROL: a sign-in with no groups claim lands as before, writes nothing to the grant table and logs nothing.
  5. A mapping naming an administrator set, or a set outside the organization boundary, is refused at save (400 VALIDATION_FAILED, permissionSet, rule_violation) and stores nothing.
  6. A set redefined outside the boundary after its mapping was saved grants nothing at sign-in.
  7. With no security service, the save is refused 503, and a stored mapping grants nothing at sign-in (fail closed).
  8. The break-glass guard still refuses through the sign-in: the last administrator's grant is not removed, and the sign-in still lands (one error line).
  9. Deleting the mapping removes the grants it projected at once.
  10. The door names exactly one source; an SSO source must be a provider that belongs to an organization.

Forced test edits: none. The only edit to existing harness code is additive: TokenShape gains an optional claims member that idToken() spreads into the signed claims.

Acceptance notes

  • Group membership changes reach the platform only at a sign-in: an SSO provider pushes nothing between sign-ins, so a user removed from an IdP group keeps the set until their next sign-in through that provider (SCIM is the push channel for prompt revocation). A deleted mapping's grants are removed at once.
  • An absent groups claim is read as "no groups": the provider's own earlier grants are removed. A provider whose mapping does not fill groups therefore never grants and never removes anything but its own marked rows, of which it has none.
  • A set held through any other row (by hand, by SCIM) is not written again; whichever source wrote first owns the row.
  • The door's path keeps the scim segment while now serving both sources (seat ruling: no /admin/sso/* mounts); listed in open questions as a naming note.
  • content/docs/permissions/sso.mdx does not yet describe group mapping (outside this card's file surface); the changeset and the route-ledger notes carry the operator-facing text.
  • SAML: the vendor fills userInfo from mapping.extraFields the same way (SAML attribute values may be lists, which the reader takes); the end-to-end pins drive OIDC only.

Verification

Head: 70db6d0601c6ca0f3d64750fcd93c9ce95b87a19 (origin/main 8784ccbc merged in, no rebase).

Red first on main

The new describe run against the base before any source change: 10 of 10 red (the door refused providerId as an unknown field, and no sign-in granted anything).

Tests on the head
  • pnpm exec vitest run src/sso-oidc-refusal-cause.test.ts (plugin-auth): 1 file, 32 passed.
  • plugin-auth full suite under the verify lock: Test Files 244 passed (244), Tests 4372 passed, 10 skipped.
  • platform-objects full suite: Test Files 120 passed, Tests 1886 passed.
  • tsc --noEmit for plugin-auth and platform-objects: exit 0. eslint --no-inline-config on both packages: exit 0.
  • pnpm install --frozen-lockfile && pnpm build under the verify lock: exit 0.
Ablation: each pin goes red when its behaviour is removed

Each mutation was applied on disk to the source, the describe run, then restored (git diff HEAD empty after each).

Mutation Red tests
group matcher always matches 3
sign-in admission always admits 2
save-time boundary refusal removed 1
save-time fail-closed (no security service) removed 1
reconcile removes grants outside the engine's guard 1
a projection failure fails the sign-in 1
reconcile ignores the provider marker 1
provisionUserOnEveryLogin off 2
delete does not remove the projected grants 1

One first run was a no-op (its replacement still contained the anchor, so nothing changed); it was re-run with a mutation that moved the anchor, which went red. It is the "a projection failure fails the sign-in" row.

Derived gates

node scripts/pm/dispatch-gates.mjs --commands on the head derived 108 commands; all 108 ran with exit 0, and --ran reconciled: 108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass check-tenant-audit-census found the new delete-revoke write without a tenant context: it now carries the grant's organization as its tenant, and the census page and ledger counts are regenerated (tenant-audit-census.mjs --write) with the page's prose figures updated to match (276 sites; the provable no-context figure stays 3). check:i18n and check:i18n-stale-fill pass with the regenerated en bundle.


Generated by Claude Code

…ough the provider first

Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n
Co-authored-by: Claude <noreply@anthropic.com>
…ource; translations; changeset

Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n
Co-authored-by: Claude <noreply@anthropic.com>
… logger's error channel

Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n
Co-authored-by: Claude <noreply@anthropic.com>
…n as its tenant

The grant removal on deleting an SSO group mapping now carries the system
context with the grant's organization as tenant, as the sign-in's own grant
writes do. The tenant-audit census and its page are recounted for the new
write site.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n
@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 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

45 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 d4a843911d50eb06ef045adc4035c9f6641f85c7.

⛔ 15 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)
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 39 pages)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 231 client-bound route-ledger rows — the other 166 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 166: 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; 107 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 — 24 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 d4a843911d50eb06ef045adc4035c9f6641f85c7 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 d4a843911d50eb06ef045adc4035c9f6641f85c7 → 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#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 70db6d0601c6ca0f3d64750fcd93c9ce95b87a19
Local-runs: none

① Derived judgments

  • Face: the Clause-②: yes (widening) declaration alone; no published schema, no governed path (check-governed-merges --pr 31657: 0 of 21 paths on the register, 1029 changed lines).
  • Widenings, nothing narrowed: sys_idp_group_mapping.source_kind gains 'sso' (source_id = the SSO provider id); POST /admin/scim/set-group-mapping takes providerId as the alternative to connectionId (exactly one); delete-group-mapping is source-aware. SCIM behaviour is unchanged. No new mount, no new provider setting: the claim rides the vendor's per-provider mapping.extraFields.groups.
  • The SSO projection reuses the SCIM half's matcher, organization-boundary admission (fail-closed with no security service) and marker-scoped reconcile; it removes only rows carrying its own per-provider marker, so a hand or SCIM grant is never touched.

② Semver level

  • patch on plugin-auth and platform-objects matches check-changeset-no-major under Metadata-protocol: none; every change is additive.

③ Boundary flags

  • Security family: rules and counts only in the public surfaces; security_findings: none. The tenant-audit census page and its counts file moved 275 → 276 because the delete-revoke now carries the grant's organization as tenant (a gate finding fixed at the root). Author of record is objectstack-fleet[bot] (relay pr_create).

Implemented-by: claude/project-thread-dwuroz
Reviewed-by: session_01ADHkmQKZzp4DLtqiCkZZRM

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM

维护者速读

席位核对 — ACCEPT · domain:server#2 · Head-sha 70db6d0601c6ca0f3d64750fcd93c9ce95b87a19 · 2026-11-08T20:17Z

  1. Shape: draft to main, Fixes #30967 (item 3, the last open item); Clause-②: yes (widening) and Metadata-protocol: none at line start in the body and the changeset, matching claim amendment 6725074902. Patch changesets for plugin-auth and platform-objects.
  2. Premise measured: red on main 10/10 (no SSO source on the mapping row; the save door took only connectionId).
  3. Gates (check-runs on this head): Test Core, TypeScript Type Check, Lint & Repo Gates, Dogfood Regression Gate and Check Changeset success; 0 failed. Dev: derived gates 108/108 exit 0, reconciled; plugin-auth 244/244 files; 9 ablation legs red, each restored. check-governed-merges --pr 31657: not governed.
  4. Accepted deviations: content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md (275 → 276, provable figure still 3), forced by the census gate after the delete-revoke was given its tenant at the root; the route ledger row drops requires: 'scim' because the door now serves both sources.
  5. Out of scope: open question 4 filed as docs(sso): the SSO page does not describe mapping an identity provider's groups to permission sets #31658 (the SSO page documents group mapping). Skills-docs: not owed.
  6. Landing: seat rulings: (1) the scim path segment stays, no alias (one door, one name); (2) new public names accepted as named: providerId, source_kind: 'sso', the userInfo.groups / mapping.extraFields.groups convention, the marker sso-group-projection: provider 'PROVIDER_ID'; (3) an absent groups claim removes that provider's own earlier grants (fail closed: a provider whose claim is switched off stops granting). Contract review at the seat's default tier (declaration arm). Ordinary queue landing: pr_ready + automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 8, 2026 20:19
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 8, 2026 20:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit 8c9260f Nov 8, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-dwuroz branch November 8, 2026 20:49
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…the inherited admin cookie (objectstack-ai#31871)

<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi96VGP7vMPA9C54zyjz2LMch)_

Fixes objectstack-ai#31827

Clause-②: no
Metadata-protocol: none

## 维护者速读

- 改了什么:夜间 live 层的成员夹具改为只用成员自己的凭据发 API 请求(成员自己的会话 cookie 加它的
Bearer),成员登录也从空状态开始;`read-only-member-record.spec.ts` 只换成调用新助手,断言一条未动。
- 为什么改:在 `@playwright/test` 里,不指定 `storageState` 新建的请求上下文会继承配置里的管理员
cookie,于是成员请求同时带着管理员 cookie 和成员 Bearer。objectstack-ai#31469 起服务端拒绝这种「两个账号」的请求,数据路由回
401,夜间层因此变红。席位裁定:改夹具,不改产品。
- 风险与回滚:只动 `ui/e2e/live/` 下两个测试文件,不发布任何包,不动 `packages/`。回滚即 revert。
- 先看:`ui/e2e/live/member.ts` 的 `memberRequestContext` 和模块注释里的 ⛔ 段。
- 两分钟验证:起后端后跑 `OS_TEST_TIERS=nightly pnpm --filter
@objectstack/example-showcase exec vitest run
test/console-live-specs.live.test.ts`,预期 `28 passed`。
- 需要你做的:无,席位落地。

## What changes

- `ui/e2e/live/member.ts` gains `memberRequestContext(member)`: a
request context carrying the member's own session cookie and its Bearer,
with its `storageState` named so the config's administrator cookie is
not inherited.
- The member's sign-in context starts from an empty `storageState` (`{
cookies: [], origins: [] }`) instead of the inherited administrator
cookie, so the sign-in request names the member only.
- `read-only-member-record.spec.ts` builds its premise context with the
helper. That is its only change: the `playwright` fixture it no longer
uses leaves the destructuring, and no assertion moves.
- The module comment states the trap: inside `@playwright/test`,
`playwright.request.newContext()` and the imported
`request.newContext()` both start with the config's `use.storageState`.

No test was skipped, renamed or loosened. No change under `packages/`.

## Why

The nightly live tier went red at `6be71abc` on
`read-only-member-record.spec.ts:48`, premise step: the member's `GET
/api/v1/data/showcase_inquiry/<id>` answered 401 where 200 was expected.

Step 0 (seat ruling 6735238125):

- **Bisect.** Each commit measured on a fresh backend booted by
`ui/e2e/live/ci/start-backend.sh`, with the spec's request shape:

  | Commit | Result |
  |:--|:--|
  | `44a030bf` (last green nightly) | green, read 200 / write 403 |
  | `133f75aec0~1` | green, 200 / 403 |
  | `133f75aec0` objectstack-ai#31469 | **red**, 401 / 401 |
  | `ed4b83226b` objectstack-ai#31443 | red |
  | `59630d9357` objectstack-ai#31622 | red |
  | `8c9260f79c` objectstack-ai#31657 | red |
  | `f127b04e07` objectstack-ai#31686 | red |
  | `6be71abc` (red nightly) | red |

- **Cause.** The member's context was `playwright.request.newContext({
extraHTTPHeaders: { Authorization: 'Bearer <member>' } })`. Inside
`@playwright/test` that context inherits the config's
`use.storageState`, which is the global setup's administrator cookie.
Measured: the context's cookie equals the administrator's. So each
premise request carried two accounts' credentials. Before objectstack-ai#31469 the
bearer won. Since objectstack-ai#31469 the session read refuses such a request with
`CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`, and the data route answers its
own anonymous 401. objectstack-ai#31469's acceptance notes describe exactly that
data-route answer, so the product behaves as ruled and the fixture
relied on what objectstack-ai#31469 ruled out.

## Sweep (owed by the ruling)

Every live spec and helper under `ui/e2e/live/` that builds a request
context or sends a credential:

| Site | Accounts it sends | Action |
|:--|:--|:--|
| `read-only-member-record.spec.ts` premise context | admin cookie
(inherited) + member Bearer | **moved onto `memberRequestContext`** |
| `member.ts` sign-in context (`request.newContext()`) | admin cookie
(inherited) on the member's sign-in | **moved onto an empty
`storageState`** |
| `read-only-member-record.spec.ts` member browser context | member
`storageState` named explicitly | one account, unchanged |
| `global-setup.ts` sign-in context | none (global setup runs before any
`use` state exists) | one account, unchanged |
| `inline-edit-polish-2572.spec.ts` `request` + Bearer | admin cookie +
admin token from the same `state.json` | one account, unchanged |
| `studio-customize-journey.spec.ts` `request` override | `storageState`
passed by name (admin), no other credential | one account, unchanged |
| `studio-object-designer.spec.ts` in-page `fetch` | admin page's own
cookie + its own localStorage token | one account, unchanged |
| every other spec's `request.get/post/…` (cascading-options,
grid-file-upload, list-row-action-cel, master-detail,
record-form-direct-url, record-history-display) | admin `request`
fixture alone | one account, unchanged |

Measured for the two inherited sites: the imported
`request.newContext()` and `playwright.request.newContext()` each start
with 1 cookie under this config; with `storageState: { cookies: [],
origins: [] }` they start with 0.

## Verification

All heavy runs went through `scripts/pm/os-verify-lock.sh`. Head
`86082eb9b5`, backend closure and `pnpm console:build` rebuilt at the
head first.

- **Nightly live file, head:** `OS_TEST_TIERS=nightly pnpm exec vitest
run test/console-live-specs.live.test.ts` in `examples/app-showcase`:
Playwright `28 passed (1.3m)`, vitest `Test Files 1 passed (1)`, `Tests
1 passed (1)`.
- **Ablation:** `scripts/ablation-replace.mjs` deletes the helper's
`storageState` line in `member.ts` (anchor 1 → 0, blob `d4e1a39d33f5` →
`dc6c2ff0d686`), so the inherited administrator cookie is back. The same
run: `✘ read-only-member-record.spec.ts:48`, `Expected: 200`, `Received:
401`, `Test Files 1 failed (1)`. Restored: blob equals HEAD's
(`d4e1a39d33f5`), `git diff HEAD` empty, `git status --porcelain` empty.
- **Before the fix, on main `3487ccc1`:** the same nightly run was red,
1 of 28, the same 401.
- **Dispatch gates:** see the os-dev-report on objectstack-ai#31827 for each command's
exit code and the `--ran` reconciliation; `pnpm lint` and `eslint` over
the two touched files are reported there too.

No changeset: the diff publishes nothing (`ui/e2e/live/` only), so this
PR carries `skip-changeset`.

## Human Minimum

- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset. (Publishes
nothing; `skip-changeset`.)
- [x] 6. `packages/spec` touched: n/a.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English. (The 维护者速读 section is
the seat's required Chinese summary.)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.

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

https://claude.ai/code/session_01ADHkmPpwZNVKwJLQGotdJy

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

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

1 participant