Skip to content

feat(settings,auth): expose the audience posture in the auth settings namespace - #12301

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-11768-auth-audience-settings-surface
Aug 25, 2026
Merged

feat(settings,auth): expose the audience posture in the auth settings namespace#12301
os-trump merged 3 commits into
mainfrom
claude/issue-11768-auth-audience-settings-surface

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#11768
Part of #11723

The audience posture shipped by #11739 (PR #11767) was switchable only from stack config at boot; the open-source operator surface had no channel. The auth settings namespace now carries an audience group — a select over the closed vocabulary (invite_only | email_domain | open), a domain-list textarea, and a permission-set-name field — and bindAuthSettings maps the three keys, one atomic declaration, to oneAuthManager.applyConfigPatch({ audience }). The patch replaces the whole audience object and validates the MERGED result (assertAudienceConfig, the same validator the constructor runs), so the settings path cannot reach a posture the config path could not, and every #11739 ruled invariant holds through the new door. Verified against the merged ref, not the card body: the card's premise was re-derived on 8064e6da1 (post-#11767, post-#12210).

The membership_policy precedent (#5152), followed exactly

  • Explicit-only application. Manifest defaults are UI defaults; only stored/env-explicit values reach the patch. A deployment's boot-config audience is never masked by the manifest's invite_only default (pinned).
  • Off-vocabulary refused loudly, never coercedisAudiencePosture at the binding, before the value ever reaches the patch; the refusal names the value, the standing posture that keeps ruling, and the vocabulary. The write API refuses off-table values too (SETTINGS_VALIDATION + invalid_option on setMany, pinned with the coded envelope), and the OS_AUTH_AUDIENCE_POSTURE env door is judged against the same option table by effectiveEnvOverride — with the binding's isAudiencePosture behind it as the boundary check the card requires. No new env channel was added; the generic settings env-override door covers these keys by construction.
  • Refusals fail closed. An invalid combination thrown by applyConfigPatch is caught inside applySettings (never escapes the subscribe callback) and reported at error level with the validator's remedy-bearing message; the standing config keeps ruling.

Design decisions worth review (Clause-② surface)

  1. The audience patch is a separate applyConfigPatch({ audience }) call, applied AFTER the main settings patch. The card sketched "catch and report per the existing applySettings catch"; the dedicated inner catch is that same path (inside applySettings, the outer catch remains the net) but reports at error with the audience-specific message — warn is the level the membershipPolicy 无法作为平台设置配置,且注册路径与回填路径读的是两个来源 #5152 precedent explicitly argues against for this class ("nothing looks broken afterwards"). Ordering matters: applying audience last makes the merged-result validation judge it against the emailAndPassword state the same pass just applied, so require_email_verification: false + posture open in one save is refused as the contradiction it is (pinned). The separate call also bounds the blast radius: a refused audience declaration cannot block session/lockout/rate-limit settings riding the same namespace (pinned).
  2. Composition rule: the declaration carries only the keys the explicitly selected posture reads (the spec marks the siblings "only read under" their postures). Concretely: switching BACK to invite_only sends { posture: 'invite_only' } alone, so leftover text in the posture-hidden domain-list field can never make CLOSING the wall refusable — that refusal would leave the previous, more open posture ruling, the one direction that must not fail (pinned). Missing required siblings are never guessed: the patch goes out without them and the validator refuses (refusing to OPEN fails closed). A sibling declared with no explicit posture is refused loudly, not completed.
  3. Security boundary check (the dispatch's STOP condition), verified negative:applyConfigPatch runs assertAudienceConfig(next.audience, next.emailAndPassword) whenever either key is patched (auth-manager.ts), the same assertion the constructor runs at entry — the settings channel converges on the same validator and the same getAudience() accessor the admission gate reads. No bypass exists; nothing here loosens the boundary, so the card implements rather than stops.

Verification (all at eb316a452, tree clean; heavy commands through scripts/pm/os-verify-lock.sh, verdict lines quoted)

  • pnpm --filter @objectstack/plugin-auth testTest Files 80 passed (80) / Tests 1631 passed (1631); typecheckVERDICT command-exit 0.
  • pnpm --filter @objectstack/service-settings testTest Files 29 passed (29) / Tests 518 passed (518); typecheck exit 0.
  • New pins: 12 in plugin-auth/src/audience-posture-setting.test.ts (the refusal set: empty domain list under email_domain, missing permission set, admin_full_access, verification contradiction, off-vocabulary, sibling-without-posture; plus explicit-only masking, live re-apply, fail-closed invite_only switch-back, blast-radius, and the dangling-name/getAudience() convergence pin — the admission-time 403 AUTH_CONFIG_ERROR for a dangling set is already pinned in audience-posture.test.ts). 3 in service-settings/src/manifests/auth.manifest.test.ts (closed three-value select, sibling visibility gating, setMany refusal with the coded envelope).
  • Dissolution verification (fix committed first; self-restoring trap script; mutations confirmed on disk by marker counts before each run):
    • Leg A — pass-through binding (direct config.audience write bypassing the validator): injected marker 1/1, deleted form 0/0 → Tests 5 failed | 7 passed (12), e.g. AssertionError: expected 'email_domain' to be 'invite_only' on the empty-domain-list pin — the suite can tell an enforcing binding from a pass-through.
    • Leg B — coerce instead of refuse (isAudiencePosture(raw) ? raw : 'invite_only' with the guards disabled): markers 3/3, deleted form 0/0 → Tests 2 failed | 10 passed (12), e.g. AssertionError: expected 'invite_only' to be 'open' on the off-vocabulary pin and expected { posture: 'invite_only' } to be undefined on the sibling-without-posture pin.
    • Both restore legs verified: markers back to 0, original forms back to 1, git status --porcelain empty (disk == index == HEAD). Mutated subject is same-package src/ resolved by vitest, so no dist/ staleness in either leg.
  • Path-derived gates (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, derivation stderr naming this tree at eb316a452): all 15 matched families green — check:auth-mount-ledger, check:route-envelope, check:slot-lookup (baseline key set verified against 071ec4e: no files added), check:test-source-alias, check:type-source-resolution, check:published-files, check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration (this PR adds no declared-breaking changeset), check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape, release-rehearsal-clone --self-test, and both docs-audit gates exit 0.
  • Convention-triggered families (new test files): check:type-check-debt32 ledger entr(ies) re-measured in 394.0s, 1843 raw tsc error(s) total, none above its recorded number (plugin-auth's zero-margin 97 holds with the new test file); check:type-check-coverage, check:engine-double-contract, check:cross-package-test-inputs (OK: 16 package(s) read outside themselves, all declared), check:where-matcher, check:query-options-erasure — all exit 0. check-nul-bytesOK (scanned 6804 text file(s)).
  • Two consumers the first run caught, fixed in-scope: the settings-manifest visible is declared ExpressionInputSchema (CEL) but evaluated by a non-CEL grammar — a CEL predicate there silently skips the save-time required gate #7169 visibility-corpus tripwire moved deliberately 94 → 96 (two new posture-gated predicates), and the gated locale bundles (zh-CN / ja-JP / es-ES; en for parity) cover the new group and keys.

Changeset

minor for @objectstack/service-settings + @objectstack/plugin-auth: three new authorable settings keys are what a host sees — a new console capability, no existing key or behaviour changes shape, nothing breaking (never major in the launch window).

Scope

Zero packages/spec changes (this lane's red line — the posture's spec shape landed in #11767 and is consumed as published). plugin-security/**, plugin-sharing/**, service-messaging/** untouched.

Generated by Claude Code


Generated by Claude Code

… namespace
Three new authorable keys in an audience group on the auth namespace
(audience_posture select over the closed vocabulary, a domain-list
textarea, a permission-set name field), mapped by bindAuthSettings to one
applyConfigPatch({ audience }) that replaces the whole audience object and
validates the merged result. Explicit-only application; off-vocabulary
postures refused loudly and never coerced (#5152 precedent); every #11739
invariant holds through the settings channel and refusals fail closed.
Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
Co-authored-by: Claude <noreply@anthropic.com>
…e the visibility-corpus pin
The three gated locale bundles (zh-CN / ja-JP / es-ES) gain the audience
group and its three keys (en updated for parity); the #7169 visibility
corpus tripwire moves 94 -> 96 for the two new posture-gated predicates.
Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 177: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 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.

Coarse fallback — 14 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 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 1b5f4cc0e6637838367dec223a0283ae12593db6 — the merge of head eb316a452e1d3d3d88fd43334a490b54409f2b3b into base 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a, 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 1b5f4cc0e6637838367dec223a0283ae12593db6 && git checkout 1b5f4cc0e6637838367dec223a0283ae12593db6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a eb316a452e1d3d3d88fd43334a490b54409f2b3b && git checkout -B drift-repro 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a && git merge --no-ff eb316a452e1d3d3d88fd43334a490b54409f2b3b
node scripts/docs-audit/affected-docs.mjs --json 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a

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

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Contract-review verdict: PASS (#11768)

Reviewed by the contract-review chain at CONTRACT_REVIEW_TIER. Tier fuse, machine reading this sub-round: get_session.external_metadata.last_served_model = claude-fable-5.

Reviewed: the binding and manifest patches in full, the epic lineage (#11723 ruled A; #11739's invariants; the #5152 precedent), and the seat's ACCEPT with its quoted auth-manager.ts verification.

The Clause-② question — can the settings door reach a posture the config door could not, or bypass a ruled invariant? — answers no, at three independent layers: the dev's measurement, the seat's own code read (the patch replaces the whole audience object and assertAudienceConfig — the constructor's own validator — runs on the merged result whenever either key moves, converging on the same getAudience() accessor the admission gate reads), and the binding read here. The seat's Clause-②-yes call overriding triage's expectation was correct on both limbs, and gating it was the right posture.

The contract shape is right, and the hard direction was reasoned:

  • Explicit-only (manifest defaults never mask boot config); off-vocabulary refused loudly at the binding and at the write API with the coded envelope (SETTINGS_VALIDATION + invalid_option), never coerced — membershipPolicy 无法作为平台设置配置,且注册路径与回填路径读的是两个来源 #5152's fail-open direction closed.
  • The composition rule is the subtle contract decision and it is correct: the declaration carries only the keys the selected posture reads, so closing the wall (invite_only switch-back) can never be refused by leftover sibling text — a refusal there would leave the more open posture ruling. Missing siblings are never guessed; refusing to open fails closed with the standing config ruling and an error-level report.
  • Domain entries are deliberately not shape-filtered at the binding — a malformed domain is refused loudly by the validator rather than silently dropped from the allowlist it was typed into. Sibling-without-posture refused, not completed.
  • Dissolution legs distinguish exactly the two failure modes that matter (pass-through binding: 5 red; coerce-not-refuse: 2 red on the two pins built for it); the admin_full_access refusal and the switch-back-immunity pin go beyond the brief in the right places; the settings-manifest visible is declared ExpressionInputSchema (CEL) but evaluated by a non-CEL grammar — a CEL predicate there silently skips the save-time required gate #7169 tripwire moves 94→96 for the two new gated predicates with the reason written beside the number.
  • Scope held: zero spec, zero overlap with the three fenced surfaces; changeset minor argued from what a host sees.

Landing completed by the chain in the same stroke per the maintainer's standing instruction (2026-08-25, verbatim: 「审核通过你应该直接负责合并吧,还要等原始的项目经理吗」): clearing needs:contract-review on this PR and #11768, flipping ready, arming the queue — the queue waits for the still-running CI, per the seat's own caution.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(settings,auth): expose the audience posture in the auth settings namespace (console switch surface for self-host)

2 participants

@os-trump@claude