Skip to content

feat(spec): declare the nine served features keys on the auth config response and pin the registry to the schema - #29583

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

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-cxjusk

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29473

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — git grep under skills/ finds no auth/config reference, and the one hit for any of the nine key names is skills/objectui/guides/auth-permissions.md using features.multiOrgEnabled as an expression example, which stays true; the API reference page is regenerated in this diff

维护者速读

  • 改了什么:spec 的 GET /api/v1/auth/config 响应契约 AuthFeaturesConfigSchema 新增九个可选键(服务端早就在发、控制台早就在读,只是契约没声明),并加一条测试把「已分类的键」与「契约声明的键」钉成同一集合。
  • 为什么改:按契约解析响应的客户端会把这九个键静默丢掉;以前每漏一个键就要单开一张卡(sso、cloudManaged 都是这样),这条钉子让下一个漏声明的键在 CI 直接变红。
  • 风险与回滚:纯加宽,以前能解析的响应原样解析;九个键都无默认值,不发就仍不存在;运行时代码一行未动;回滚即撤掉此 PR。
  • 先看:packages/spec/src/api/auth-endpoints.zod.ts(九个新键与说明、devSeedAdmin 不声明的理由)、packages/spec/src/api/auth-endpoints.test.ts(钉子测试)、.changeset/29473-auth-config-features-served-keys.md。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/api/auth-endpoints.test.ts,预期 69 个测试全部通过。
  • 需要你做的:确认下列九个新公开键名(均为服务端现有键名,未改名):multiOrgEnabled、degradedTenancy、oidcProvider、deviceAuthorization、admin、tenancyPosture、audiencePosture、termsUrl、privacyUrl;并确认 devSeedAdmin(仅开发环境、含明文密码)不进契约。其余席位落地。

What changes

  • Nine optional keys on AuthFeaturesConfigSchema (packages/spec/src/api/auth-endpoints.zod.ts), each with a describe that says what the producer means and what absent means. None carries a default, so a key the server does not send stays absent.
    • Booleans, all classified in PUBLIC_AUTH_FEATURES: multiOrgEnabled (an organization wall is enforced; default-on, so absent reads as on), degradedTenancy (isolation requested but inactive; a status signal), oidcProvider (the embedded OIDC authorization server is wired; default-on), deviceAuthorization (device-code CLI login wired), admin (admin plugin wired, forced on by SCIM). Each describe's "absent" sentence follows the registry entry's semantics.
    • Values, all in PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS: tenancyPosture reuses TenancyPostureSchema (security/tenancy-posture.ts, the vocabulary the producer's TenancyPosture type is), audiencePosture reuses AUDIENCE_POSTURES (system/auth-config.zod.ts; the producer reports an off-vocabulary configured value as invite_only), and termsUrl / privacyUrl are plain strings, because the producer passes the trimmed OS_TERMS_URL / OS_PRIVACY_URL value through without URL validation (a relative path is served as-is). Both imported modules import only zod and lazySchema, so no cycle.
  • devSeedAdmin is ruled out as dev-only, not declared. plugin-auth adds it at the top level of the response only under NODE_ENV=development, while the auto-seeded admin still signs in with the default password, and it carries { email, password }. Declaring it would put a credential-bearing field on a published response contract (and its generated reference page) that no production deployment serves. Its one reader, ui/apps/console/src/pages/auth/LoginPage.tsx, casts the raw response and never parses it through the schema, so nothing is lost. The reason sits in a TSDoc block on GetAuthConfigResponseSchema.
  • The registry-to-schema pin (packages/spec/src/api/auth-endpoints.test.ts, block AuthFeaturesConfig declares every classified served key): the schema's declared keys, minus the retired PUBLIC_AUTH_FEATURES_NOT_ADVERTISED tombstones (passkeys, magicLink), equal PUBLIC_AUTH_FEATURE_NAMES plus PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS, exactly, in both directions. With plugin-auth's existing drift guard (served keys equal the registry), the served keys and the contract keys are now held equal through the registry. It is a test, not a gate.
  • Regenerated: packages/spec/authorable-surface/api.json (nine api/AuthFeaturesConfig:* rows, written by the build) and content/docs/references/api/auth-endpoints.mdx (gen:docs, the one artifact check:generated named stale). api-surface did not move: no export was added.
  • Changeset .changeset/29473-auth-config-features-served-keys.md: @objectstack/spec minor, both declaration lines.

Producer readings (origin/main 390bd2ae, packages/plugins/plugin-auth/src/auth-manager.ts getPublicConfig()): the features literal serves twoFactor, organization, multiOrgEnabled, tenancyPosture, audiencePosture, degradedTenancy, oidcProvider, sso, ssoEnforced, deviceAuthorization, admin, phoneNumber, phoneNumberOtp, cloudManaged, and conditionally termsUrl, privacyUrl, turnstileSiteKey: 17 keys, exactly the registry's 12 flags plus the 5 non-flag keys, so the served set, the registry and (after this PR) the schema agree. devSeedAdmin sits beside features, not in it.

Why

GET /api/v1/auth/config serves these nine keys and the console reads several of them (for example deviceAuthorization in ui/apps/console/src/pages/auth/DeviceAuthPage.tsx), but the contract did not declare them, so a client parsing through GetAuthConfigResponseSchema lost all nine: a plain object schema strips unknown keys. Nothing tied the registry to the schema, which is how sso and cloudManaged each needed their own card.

Why Metadata-protocol: added, not the claim's none. The claim recorded none ("a wire response, not authorable metadata") and asked the dev to confirm against AGENTS.md Post-Task Checklist step 3. Step 3 defines added as "a new key, accepted value or metadata type", and says that where the ratchets can see a protocol change, they decide: packages/spec/authorable-surface/ records this response schema (api/AuthFeaturesConfig:*, including the sso row PR #29455 added under added), and this diff adds nine rows to api.json. Measured offline with this diff and a PR body that differs only in that line: node scripts/check-changeset-no-major.mjs --base origin/main --event exits 1 under none ("metadata-protocol declares none while an authorable-surface or authorable-defaults baseline moved … Moved: packages/spec/authorable-surface/api.json") and exits 0 under added ("LEVEL AXIS … yes (widening) and metadata-protocol added"). So added, with @objectstack/spec at minor, is the only declaration the gate accepts, and it matches the sso precedent on the same schema.

Tests

packages/spec/src/api/auth-endpoints.test.ts, new block, 38 cases:

  • the declared-keys set (retired tombstones excepted) equals the registry plus the non-flag list, and each retired key keeps its tombstone;
  • each of the 12 registry flags parses as true and false and refuses 'yes' with exactly one invalid_type issue at its own path, expected boolean;
  • each of the 17 declared keys carries a describe with no tracker id;
  • a served-shape fixture naming all 17 keys (checked against the classified list) survives a whole GetAuthConfigResponseSchema parse unchanged;
  • CONTROL: absent new keys stay absent (no default is invented);
  • an off-vocabulary tenancyPosture / audiencePosture is refused with one invalid_value issue at its path; a non-string termsUrl / privacyUrl is refused with one invalid_type issue.
Local readings (head d68dd29)
  • Build and regen: pnpm --filter @objectstack/spec build under os-verify-lock, VERDICT 0, then check:generated exit 1 naming one stale artifact (content/docs/references/**); gen:docs VERDICT 0; check:generated again exit 0 ("All 14 generated artifacts are up to date").
  • pnpm --filter @objectstack/spec test: VERDICT command-exit 0, Test Files 794 passed (794), Tests 23587 passed, 1 todo.
  • pnpm --filter @objectstack/spec typecheck: VERDICT 0 (tsc --noEmit, check:scripts-typecheck, check:test-typecheck, which compiles the test layer).
  • Consumer: turbo run build --concurrency=2 --filter=@objectstack/client... then pnpm --filter @objectstack/client typecheck (it re-exports AuthFeaturesConfig / GetAuthConfigResponse): VERDICT 0. Reverse check that it reads the rebuilt declarations: a probe in packages/client/src typing { deviceAuthorization: true, tenancyPosture: 'group', audiencePosture: 'open', termsUrl: 'x' } as AuthFeaturesConfig compiles (exit 0), and { deviceAuthorization: 'yes' } is refused TS2322 (exit 2). Probe deleted.
  • No other workspace consumer: git grep for AuthFeaturesConfig / GetAuthConfigResponse outside packages/spec finds packages/client/src/index.ts (re-export) and ui/packages/auth/src/types.ts (a hand-written mirror that does not import the spec type).
  • Ablation: deviceAuthorization renamed to deviceAuthorizationAblated in the schema with scripts/ablation-replace.mjs (anchor 1 to 0, blob b237957a to 595c0b73): auth-endpoints.test.ts exit 1, 3 failed (the set-equality pin, that flag's boolean case, the whole-response parse). Restored: blob equal to HEAD b237957a, git diff HEAD empty.
  • Level axis, offline: see Why (none exit 1, added exit 0).
  • Scoped lint: pnpm exec eslint --no-inline-config over the two changed .ts files, exit 0. check-nul-bytes exit 0.
  • Dogfood battery: n/a, no ! in the title and no (narrowing) arm.
Gates (dispatch-gates --commands)
  • At head d68dd29f (after merging origin/main 208d3e2e): 103 derived commands, all 103 exit 0, each run with stdin closed and its exit captured before any pipe. Before them, turbo run build --concurrency=2 --filter='./packages/**' under os-verify-lock, VERDICT 0 (78 tasks).
  • dispatch-gates --ran: 103 derived, 103 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN.
  • At the earlier head 7d5a57a6 the same 103 ran; three first exited 3 (PREREQUISITE NOT MET) and passed once their inputs existed: check-plugin-teardown-shape.mjs --self-test after git fetch --depth=1 of its pinned fixture commit 621a4876, check:skill-examples after building @objectstack/client-react, and check:dual-build-cjs-loads after building every package.
  • Offline PR-body checks with this body: check-changeset-no-major.mjs --base origin/main --event exit 0 (level axis yes (widening) + added, spec minor); the same with only the protocol line set to none exit 1 (see Why); check-partof-closing-keyword.mjs exit 0.
  • Local-only families: check:skill-frame-freshness exit 0; check:dev-prereqs exit 1 because 35 of 106 workspace packages (the ui/ tree among them; only packages/** was built here) have no dist in this container: a machine-state reading, not a finding of this diff (packages/spec dist is fresh).
  • CI: the seat reads the PR's check-runs.

Acceptance notes

  • ui/packages/auth/src/types.ts (lines 284 to 289) documents features.sso as "wired; when falsy the /sign-in/sso route isn't mounted", while the served value is "usable", so a served false can mean wired with no provider yet and the route mounted. The type (sso?: boolean) is right. Prose only, for the ui lane; not touched here (this diff does not edit ui/). The same hand-written mirror is also where the nine keys would be typed for the console, if the ui lane wants them typed.
  • tenancyPosture and audiencePosture are closed enums. A plain object schema fails the whole parse on an unknown enum member, so a server that later adds a fourth posture fails an older client's GetAuthConfigResponseSchema parse instead of being stripped. That is the contract-first reading (both vocabularies are the producer's own types today); noted for the contract review.
  • termsUrl / privacyUrl are not URL-validated, matching the producer, which serves any trimmed non-empty OS_TERMS_URL / OS_PRIVACY_URL value.

Verification

See the readings above. The PR stays a draft; the seat runs the contract review, and the nine key names plus the devSeedAdmin ruling go to the maintainer for the nod.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (scoped to the changed files locally; the repo-wide run is CI's).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Maintainer approval

Warren approved the nine served features keys (multiOrgEnabled, degradedTenancy, oidcProvider, deviceAuthorization, admin, tenancyPosture, audiencePosture, termsUrl, privacyUrl) as named, with devSeedAdmin left undeclared: 「全部批准」 on the decision card 「六个命名/措辞全部按推荐批准吗?」 in the project thread "Decide pending spec name approvals", 2026-11-03 00:08Z. Recorded on this PR in seat comment 6591810483.


Generated by Claude Code

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 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 — 168 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 107be283f030242a7803848ae31a1ec11e9891dc → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 107be283f030242a7803848ae31a1ec11e9891dc

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-warren · session_01JQwxbahe4v7GBD94QeSnir

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d68dd29fc31de1930c00e64ae550826cdc4e70d7
Local-runs: none
Scope: packages/spec/src/api/auth-endpoints.zod.ts + packages/spec/src/api/auth-endpoints.test.ts, packages/spec/src/kernel/public-auth-features.ts, packages/spec/src/security/tenancy-posture.ts, packages/spec/src/system/auth-config.zod.ts, packages/plugins/plugin-auth/src/auth-manager.ts (getPublicConfig()), packages/plugins/plugin-auth/src/auth-plugin.ts (the /config route), packages/plugins/plugin-auth/src/audience-posture.ts, packages/plugins/plugin-auth/src/tenancy-service.ts, packages/plugins/plugin-auth/src/public-feature-registry.test.ts, packages/types/src/env.ts (resolveTenancyPosture), ui/apps/console/src/pages/auth/LoginPage.tsx, scripts/check-changeset-no-major.mjs. Remainder of the diff (changeset, regenerated authorable-surface/api.json and content/docs/references/api/auth-endpoints.mdx) is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

  • Accept set widens, nine keys, all optional, no defaults — right. AuthFeaturesConfigSchema (a plain z.object, so unknown keys were stripped) now declares multiOrgEnabled, degradedTenancy, oidcProvider, deviceAuthorization, admin as z.boolean().optional(), tenancyPosture as TenancyPostureSchema.optional(), audiencePosture as z.enum(AUDIENCE_POSTURES).optional(), termsUrl / privacyUrl as z.string().optional(). Every payload accepted on main is accepted unchanged (CONTROL test: absent keys stay absent).
  • Each declared type matches what the producer serves — right, read on main in auth-manager.ts getPublicConfig() (the one features literal; both /auth/config routes, plugin-auth auth-plugin.ts:3103 and the hono adapter index.ts:988, serve it and only refine sso):
    • multiOrgEnabled = postureEnforcesWall(tenancyPosture) → boolean.
    • tenancyPosture = this.config.getTenancy?.()?.posture ?? resolveTenancyPosture() → TenancyPosture on both arms (TenancyService.posture: TenancyPosture; resolveTenancyPosture(): TenancyPosture, which normalises the legacy multi to isolated before returning), the exact vocabulary of TenancyPostureSchema (single|group|isolated).
    • audiencePosture = this.getAudience().posture → ResolvedAudience.posture: AudiencePosture, coerced to invite_only when the raw value is off-vocabulary, the exact vocabulary of AUDIENCE_POSTURES (invite_only|email_domain|open).
    • degradedTenancy = tenancy?.degraded ?? false → boolean (TenancyService.degraded: boolean).
    • oidcProvider = resolveOidcProviderEnabled(pluginConfig) → declared : boolean.
    • deviceAuthorization = pluginConfig.deviceAuthorization ?? false → AuthPluginConfigSchema.deviceAuthorization: z.boolean().
    • admin = pluginConfig.admin ?? resolveScimEnabled(pluginConfig) → z.boolean().optional() ?? : boolean.
    • termsUrl / privacyUrl → resolveLegalUrl() returns string | undefined (trimmed env value, default page, or undefined on an explicit empty string) and the key is spread in only when defined, so z.string().optional() with no URL validation is the honest type.
  • The nine names are the served names, unchanged — right; the regenerated authorable-surface/api.json adds exactly nine api/AuthFeaturesConfig:* rows with those spellings and nothing else moves there. api-surface does not move (no export added).
  • Served ≡ registry ≡ schema now closes through the registry — right. plugin-auth's drift guard (public-feature-registry.test.ts) holds served boolean keys ≡ PUBLIC_AUTH_FEATURE_NAMES and served non-boolean keys ⊆ PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS; the new spec pin holds declared keys minus PUBLIC_AUTH_FEATURES_NOT_ADVERTISED ≡ PUBLIC_AUTH_FEATURE_NAMES + PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS in both directions, and that each tombstone stays declared. A re-advertised retired key now fails the pin until it leaves the tombstone list, which is the right ratchet.
  • New module edges api/auth-endpoints.zod.ts → security/tenancy-posture.ts, → system/auth-config.zod.ts — right; both import only zod (+ lazySchema), no cycle, and api/*.zod.ts already imports from ../security/ and ../system/ (auth.zod.ts, dispatcher.zod.ts, plugin-rest-api.zod.ts, protocol.zod.ts).
  • Top-level devSeedAdmin stays undeclared — judged under ③.
  • Check-runs on the head, read complete (65 runs): 57 success, 8 skipped (Lint UI, UI Published Artifacts, Dogfood Verify CLI, Dogfood Regression Gate (matrix), Temporal Conformance, 18.x upgrade harness (opt-in), Schedule gate, Packed-tarball smoke (opt-in), none of which this diff reaches), 0 failures. Green among them: Build Core, all eight Test Core shards, Type Check · source / consumer / workspace / debt ledger, Type Check UI, every Lint · family, Check Changeset, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Check Documentation Links, Flag docs affected by code changes. Those conclusions are the gate verdicts; nothing was run locally.

② Semver level

  • Changeset .changeset/29473-auth-config-features-served-keys.md: @objectstack/spec minor, Clause-②: yes (widening), Metadata-protocol: added; PR body carries the same two lines. Matches what the diff publishes: nine new optional keys on a published response schema, nothing narrowed, no export change.
  • Metadata-protocol: added over the claim's none — right, the dev's deviation stands. scripts/check-changeset-no-major.mjs (ruling A, "where the ratchets can SEE a protocol change, a declared none over it is refused") lists packages/spec/authorable-surface/ in PROTOCOL_BASELINE_ROOTS; this diff moves authorable-surface/api.json, so none is refused by the gate and added with minor is the only declaration it accepts. It is also the precedent on this very schema (sso landed under added in PR feat(spec): declare features.sso on the auth config response #29455). The claim's none was a reading made before the build moved the shard; the seat's claim line is superseded, not contradicted, and the PR body says why. Check Changeset on the head is green.
  • Clause-②: line: yes (widening), one arm from the closed pair, well-formed.

③ Boundary flags

  • devSeedAdmin, open question A/B → A (keep undeclared), agreed. Read on main: auth-manager.ts:4634 builds it only when NODE_ENV === 'development' AND devSeedResult is set, and auth-plugin.ts sets devSeedResult only from maybeSeedDevAdmin's own NODE_ENV==='development' gate, so it is dev-only by construction, and it carries { email, password } in clear. Its one reader, LoginPage.tsx:210, casts the raw cfg and never parses through GetAuthConfigResponseSchema, so no consumer loses it. Declaring it (B) would put a password-bearing field on the published contract and the generated reference for a value no production deployment serves. The TSDoc block on GetAuthConfigResponseSchema records the ruling where the next reader of the schema looks, without feeding the reference page. The card's "or rule it out as dev-only" arm is taken, with reasons. No escalation.
  • Deviation "Metadata-protocol added, claim said none": answered under ②, right.
  • Deviation "Clause-② spelled yes (widening)": the claim line said yes; (widening) is the correct arm for a new optional key, fine.
  • Deviation "labels omitted": the order named none; the relay refuses an empty array; nothing lost.
  • Deviation "PR body headings folded into the os-dev order": prose layout only; both declaration lines are line-initial and the PR-body guards are green.
  • Acceptance note "tenancyPosture / audiencePosture are closed enums, so a fourth posture fails an older client's parse instead of being stripped": the right call, not a flag. Both enums are the producer's own types (TenancyPosture from @objectstack/spec/security, AudiencePosture from system/auth-config.zod.ts), so a fourth value cannot be served without first moving the same spec constant the schema reuses; a z.string() would have thrown the vocabulary away. Consistent with the contract-first reading.
  • Acceptance note "termsUrl / privacyUrl not URL-validated": matches the producer (resolveLegalUrl passes the trimmed value through unvalidated; the test fixture uses full URLs but the producer accepts a relative path).
  • Out-of-scope finding (ui mirror ui/packages/auth/src/types.ts describes sso as "wired"): carried over from the card's own prose-only note, for the ui lane; not this PR's surface, nothing to escalate.
  • Skills-docs: none, measured by the dev (git grep under skills/); the one features.multiOrgEnabled example in skills/objectui/guides/auth-permissions.md stays true under the declared boolean.

④ Design judgment

packages/spec/src/api/auth-endpoints.zod.ts, judged against the card's "Wanted" (declare each served key with a producer-accurate describe, in the #25292 / PR #29455 pattern; pin the registry to the schema) and against its first consumers (@objectstack/client's re-exported AuthFeaturesConfig; the console readers of deviceAuthorization, multiOrgEnabled, admin, the legal links):

  • Holds. Each describe was checked against the producer comment and the registry entry it restates: multiOrgEnabled (effective posture, false on single and on degraded; default-on semantics) matches effectiveTenancyPosture() and the registry's default-on; tenancyPosture and audiencePosture describe themselves as values, not gates, which is the producer's own ADR-0105 D1 / feat(spec,auth)!: one declared audience posture for who may self-register into an app — invite_only|email_domain|open, default invite_only #11739 wording; audiencePosture's "reported as invite_only when the configured value is off-vocabulary" is literally ResolvedAudience.invalid; degradedTenancy names OS_ALLOW_DEGRADED_TENANCY=1 as the producer does; oidcProvider "default-on alongside the MCP surface" is resolveOidcProviderEnabled; deviceAuthorization's "the console device-approval page says device authorization is not enabled" is DeviceAuthPage.tsx:77; admin "forced on whenever SCIM is enabled" is ADR-0134 / resolveScimEnabled; termsUrl / privacyUrl describe the default public pages and the empty-string opt-out, which is resolveLegalUrl. No describe carries a tracker id (pinned by the test).
  • Attempts to break it: (a) a served tenancyPosture of the legacy spelling multi — not reachable, resolveTenancyPosture() normalises it before returning and TenancyService.posture is typed on the same enum; (b) a served audiencePosture outside the vocabulary — not reachable, getAudience() coerces to invite_only for display; (c) termsUrl absent vs. default — the producer spreads the key only when defined, so optional() without a default is exactly right and a schema default would have invented a link the operator suppressed; (d) a .default() on any boolean — none added, so a client cannot read "on" for a key the server withheld (the CONTROL case pins this); (e) lazySchema Proxy and .shape in the pin — .shape is a deferred property access the Proxy forwards (the existing tests already read AuthEndpointSchema.shape), and the dev's ablation (rename one key → 3 failures incl. the set-equality pin, restored to the HEAD blob) shows the pin bites; (f) import cycle via the two new edges — none, both targets import only zod/lazySchema.
  • Should-fix (non-blocking, wording only): the termsUrl / privacyUrl describes say the link is absent "when the operator set OS_TERMS_URL to an empty string"; resolveLegalUrl trims first, so a whitespace-only value suppresses the link too. "set to an empty or blank string" would be exact. Not a type or accept-set matter.
  • No must-fix.

Implemented-by: claude/project-thread-cxjusk
Reviewed-by: session_01JQwxbahe4v7GBD94QeSnir (seat subagent)

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-warren · session_01JQwxbahe4v7GBD94QeSnir

Contract review — delta

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a4a79ea320ddb1df2876917b63848aa94a79bd8f
Prior-head: d68dd29fc31de1930c00e64ae550826cdc4e70d7 — prior record 6587653250 (PASS, one should-fix)
Local-runs: none
Delta-scope: packages/spec/src/api/auth-endpoints.zod.ts (+2/−2, the termsUrl and privacyUrl describe strings only) + content/docs/references/api/auth-endpoints.mdx (+4/−4, regenerated). One commit ahead of the prior head, zero behind; the PR head is a4a79ea3.

Closure of the prior should-fix

  • Closed and exact. Read against resolveLegalUrl in packages/plugins/plugin-auth/src/auth-manager.ts getPublicConfig() on main: a non-string raw value → the default public page; raw.trim() === '' → undefined (key not spread in); otherwise the trimmed string. The new describe says "the trimmed OS_TERMS_URL value when set, otherwise the public ObjectStack terms page; absent when the operator set OS_TERMS_URL to an empty or blank (whitespace-only) value to suppress the link" — each of the three branches is now stated, including the trim. Same wording, same branches, for privacyUrl / OS_PRIVACY_URL.
  • Type untouched: both stay z.string().optional(), no default, no URL validation, which is still the producer's shape.

New findings

  • None. The delta changes no key, no type, no default and no import: the nine-key list on AuthFeaturesConfigSchema and the devSeedAdmin ruling stand exactly as reviewed on d68dd29f. packages/spec/authorable-surface/api.json does not move (describe text is not part of an authorable-surface row), so the Metadata-protocol: added / @objectstack/spec minor declaration is unchanged and still right; the changeset is untouched.
  • The mdx is a faithful regeneration: the four changed rows (the schema is rendered twice on the page, as before) carry the two new describe strings verbatim, nothing else on the page moves, and no hand edit is visible.
  • The describe pin in auth-endpoints.test.ts (non-empty, no tracker id) still holds on the new strings.

Check-runs on a4a79ea3

65 runs, all concluded: 57 success, 8 skipped (the same opt-in / UI-only families as on the prior head: Lint UI, UI Published Artifacts, Dogfood Verify CLI, Dogfood Regression Gate (matrix), Temporal Conformance, 18.x upgrade harness, Schedule gate, Packed-tarball smoke), 0 failures, 0 in progress. Those conclusions are the gate verdicts; nothing was run locally.

Implemented-by: claude/project-thread-cxjusk
Reviewed-by: session_01JQwxbahe4v7GBD94QeSnir (seat subagent)

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-warren · session_01JQwxbahe4v7GBD94QeSnir

维护者速读

  • 改了什么:GET /api/v1/auth/config 的 features 里服务端早就在发、控制台也在读的 9 个键,补进 AuthFeaturesConfigSchema(全部可选、无默认值):multiOrgEnabled、degradedTenancy、oidcProvider、deviceAuthorization、admin、tenancyPosture、audiencePosture、termsUrl、privacyUrl。
  • 为什么改:不声明的话,客户端用 GetAuthConfigResponseSchema 解析会把这 9 个键全部剥掉(卡 GET /auth/config serves nine features keys the spec does not declare, and nothing pins the feature registry to the schema #29473)。
  • 风险与回滚:只是放宽,以前能过的报文照样能过;Clause-②: yes (widening)、Metadata-protocol: added、@objectstack/spec minor;回滚即 revert。开发环境专用的 devSeedAdmin 有意不声明(里面是种子管理员密码)。
  • 先看:packages/spec/src/api/auth-endpoints.zod.ts 新增的 9 个键;auth-endpoints.test.ts 用集合相等把注册表钉到 schema 上。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/api/auth-endpoints.test.ts,预期全过。
  • 需要你做的:在线程里的决定卡上批准这 9 个键名(以及 devSeedAdmin 不声明)。批准后席位送入合并队列。
  • 席位意见:合同复核 PASS(6587653250),增量复核 PASS(6588603301),CI 全绿;建议批准。

席位核对

ACCEPT · domain:spec#3 · head a4a79ea320ddb1df2876917b63848aa94a79bd8f · 2026-11-02T23:55Z

  1. Shape: five files: auth-endpoints.zod.ts (+48: nine optional keys, no defaults), auth-endpoints.test.ts (+99: set-equality pin of PUBLIC_AUTH_FEATURES plus PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS against the declared keys, with the retired tombstones excepted), the regenerated auth-endpoints.mdx, the authorable-surface/api.json shard (+9), and .changeset/29473-auth-config-features-served-keys.md (@objectstack/spec minor). Inside the claim (6585005887). No protocol.ts file touched.
  2. Premise, measured: the reviewer read every key against getPublicConfig() in plugin-auth's auth-manager.ts on main, including resolveLegalUrl's three branches, which the delta's describe strings now state exactly.
  3. Gates: the dev derived 103 gates at d68dd29f, and all 103 exited 0, with 0 NOT MEASURED. Ablation of deviceAuthorization went red (3 failed) and green again once restored. A reverse type probe through @objectstack/client refused a bad value with TS2322. On this head there are 65 check-runs: 57 success, 8 skipped, none failing. mergeable_state is clean.
  4. Accepted deviations: Metadata-protocol: added in place of the claim's none. This is measured: the diff moves authorable-surface/api.json, and the gate refuses none for that.
  5. Out of scope: the top-level devSeedAdmin dev hint stays undeclared by ruling. Skills-docs: not applicable (no skill-referenced surface).
  6. Landing: not governed (check-governed-merges --pr 29583: NOT governed, 192 changed lines). Contract review at CONTRACT_REVIEW_TIER PASS on this head (6588603301). Gated on Warren's approval of the nine key names on the decision card. On approval the seat runs pr_ready + automerge_enable. If he asks for a different spelling, the card goes back to pm:queue with Resume-from: claude/project-thread-cxjusk@a4a79ea3.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-warren · session_01JQwxbahe4v7GBD94QeSnir

Maintainer approval · 2026-11-03T00:10Z

Warren approved this PR's open item at 2026-11-03T00:08Z in the project thread "Decide pending spec name approvals". The card asked 「六个命名/措辞全部按推荐批准吗?」 and he picked 「全部批准」. The post above that card listed this PR's item as follows.

With this, the gate the seat ACCEPT named is met. The head is unchanged since the ACCEPT. The seat now readies this PR and arms auto-merge, and needs-user-decision is removed.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 00:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 00:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 4b06a54 Nov 3, 2026
78 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-cxjusk branch November 3, 2026 01:10
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /auth/config serves nine features keys the spec does not declare, and nothing pins the feature registry to the schema

1 participant