Skip to content

fix(plugin-auth): declare the settings ordering edge so saved auth settings apply at boot - #11619

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-11579-auth-settings-prebind
Aug 24, 2026
Merged

fix(plugin-auth): declare the settings ordering edge so saved auth settings apply at boot#11619
os-sam merged 1 commit into
mainfrom
claude/issue-11579-auth-settings-prebind

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#11579

The defect

SettingsServicePlugin registers the settings service in init() but binds its data engine from a kernel:ready hook it registers in start(). Between those two moments the service is resolvable and answers reads — from an empty in-memory fallback and the manifest defaults, with source: 'default' — while the deployment's real sys_setting rows sit unread. Nothing distinguishes that from "no row exists".

AuthPlugin was reading inside that window. Its start()-registered kernel:ready hooks reach getService('settings') at depth 3 (runBackfillensureAuthSettingsBoundbindAuthSettings) and call getNamespace('auth') in the same tick. Handlers fire in registration order, registration order is start() order, and AuthPlugin declared dependencies: ['com.objectstack.engine.objectql'] and nothing about settings.

On the shipped composition the resulting order was wrong, not merely unconstrained: os serve does kernel.use(new AuthPlugin(...)) before the capability loop registers SettingsServicePlugin, and resolvePluginOrder preserves insertion order for plugins with no edge between them. So everything applySettings() derives was computed from DEFAULTS at boot — the ADR-0093 membership policy the D6 backfill runs under, and the google_* social-provider config — and settings.subscribe('auth', …) only re-applies on a later change. A workspace that configured auth in Setup and never touched it again kept booting with the wrong values: authored, stored, and silently not applied.

The repair

One declaration, the #10250 shape the three other shipped readers (plugin-email, service-sms, service-storage) already carry:

optionalDependencies=['com.objectstack.service.settings'];

SOFT, not hard — a kernel with no settings service must still boot auth, and bindAuthSettings already returns early when the service is absent. requiresServices would not have done it: it asserts the service is REGISTERED before init(), which it always is, and carries no start() ordering.

The acceptance test: the gate's own ledger shrinks

KNOWN_PRE_BIND_READS in scripts/check-settings-bind-window.mjs is shrink-only and errors on a stale entry, so deleting the com.objectstack.auth row is part of the repair rather than follow-up. No entry was added, re-added or weakened — the com.objectstack.mcp row (#11580, a different lane) is untouched.

The green is a measurement, not a suppression, because the red was measured first — the entry deleted with the defect still standing:

✗ settings bind-window guard (#11045)
packages/plugins/plugin-auth/src/auth-plugin.ts:1207 — AuthPlugin
A 'kernel:ready' handler registered from start() resolves getService('settings')
(directly or through a helper it calls), and NOTHING declares that this plugin
must start after 'com.objectstack.service.settings'. ...
1 settings read(s) in the pre-bind window with no declaration covering them.

With the declaration in place, at 87d39a7e9e:

✓ settings bind-window: 4 declared / 0 self / 1 structurally upstream / 1 ledgered
(68 plugin unit(s) scanned, provider 'com.objectstack.service.settings').

3 declared / 2 ledgered4 declared / 1 ledgered: auth moved from the ledger into the declared column. --self-test also passes (its case 2b is literally this fixed shape).

ADR-0049 — declared is enforced

packages/plugins/plugin-auth/src/auth-settings-ordering.pin.test.ts, 5 cases, all green:

  1. the edge is declared, and as optionalDependencies rather than dependencies;
  2. it moves resolution order — a hostile registry composing auth before settings resolves settings first;
  3. …and the declaration is what does it — stripped from a live instance, the order reverts (the case that keeps 2 from passing vacuously);
  4. the edge is SOFT — a registry with no settings plugin still resolves;
  5. no cycle — settings declares only com.objectstack.engine.objectql, so this direction is free.

The settings plugin is a name-only stub there (resolvePluginOrder reads only the OrderablePlugin surface); that com.objectstack.service.settings is the real provider id is check:settings-bind-window's job, which DERIVES the provider from providesServices: ['settings']. The header states that division of labour so neither claim is assumed to be the other's.

Verification

Union re-run after the final commit, at 87d39a7e9e:

checkverdict
check:settings-bind-window✓ 4 declared / 0 self / 1 structurally upstream / 1 ledgered
check:settings-bind-window --self-test✓ all cases pass
@objectstack/plugin-auth suiteTest Files 73 passed (73) · Tests 1480 passed (1480)
@objectstack/plugin-auth typecheckVERDICT command-exit 0 (after building the package's own dist/)
check:nul-bytesOK (scanned 6501 text file(s) ... no raw ASCII control bytes)
check:test-source-aliasOK — 72 packages with tests scanned (no registry change needed)
check:slot-lookup✓ ratchet holds: 107 unswept site(s), none new
check:query-options-erasure✓ ratchet holds: 67 unswept non-test site(s), none new
check:engine-double-contract379 (file, verb) row(s) held by the RETAINED ledger
check:where-matcher✓ 292 matcher(s) discovered, 292 conformant
check:type-check-coverageOK — 65/78 packages type-checked, 13 in DEBT
check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declared

Also green before the commit, on the same content: check:auth-mount-ledger, check:route-envelope, check:plugin-teardown-shape, check:published-files, check:entry-guard, check:parse-guard, check:pnpm-filter-targets, check:ci-filter-parity, check:type-source-resolution, the changeset family (check-empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:objectui-changeset, check:changeset-gate-self-tests, release-rehearsal-clone --self-test) and docs-audit/check-affected-docs. Gate list derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, not from a hand-written diff.

The zero-margin ratchet this change could have moved

TEST_DEBT['@objectstack/plugin-auth'] records 97 raw tsc errors with no margin ("the next new error here goes red immediately"), and this PR adds a test file. The package's own tsconfig.json excludes **/*.test.ts, so the green package typecheck does not cover the new file — measuring it needed the test exclusion lifted, which is what measureTestDebt does. Mirrored locally with a temp in-package project (removed afterwards, verified absent on disk) against the built dependency closure:

RAW_ERROR_LINES_TOTAL=97
LINES_MENTIONING_MY_NEW_TEST_FILE=0
SETUP_ERRORS(must be 0)=0

97 is the ledger's recorded number exactly, which is what makes the instrument credible rather than merely quiet — the new file contributes zero.

Lint: a declared narrowing, not a repo-wide run

Repo-wide pnpm lint was not run locally; the shared verify lock had a holder plus a waiter and queuing it risked the foreground cap. Narrowed instead, with the three pieces of evidence a narrowing needs:

  1. population read from eslint's own config — the run reports .changeset/…md as "File ignored because no matching configuration was supplied", i.e. eslint decided the population, not I;
  2. count from --format json — 4 results, 3 linted, errorCount=0, warningCount=0 on all three code files;
  3. invariance for untouched fileseslint.config.mjs states in its own docblock that this repo "runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file", and this diff changes no eslint config, so no untouched file's verdict can move.

CI runs the farm exactly once regardless.

Not measured

  • check:type-check-debt --re-measure was not run whole (it refuses without the entire ledgered closure built). The one entry this change could move was measured directly, above.
  • Repo-wide pnpm lint, per the declared narrowing.
  • No runtime/browser dogfood: the change is a declaration read by resolvePluginOrder, and the ordering claim is pinned by the new test rather than by a boot.

Scope

packages/mcp carries the same pre-bind class in a different lane and is untouched here; #11580 remains open for it. No packages/spec/**, no content/docs/releases/**, no governed surfaces. Not flipped ready, not enqueued, no auto-merge.


Generated by Claude Code

…ttings apply at boot
`AuthPlugin` reached `getService('settings')` from `kernel:ready` hooks
registered in its own `start()` — at depth 3 through `runBackfill` →
`ensureAuthSettingsBound` → `bindAuthSettings` — and called
`getNamespace('auth')` in the same tick. `SettingsServicePlugin` binds its
data engine from ITS `start()`-registered `kernel:ready` hook, handlers fire
in registration order, and `AuthPlugin` declared nothing about settings, so
nothing ordered it after the provider.
On the shipped composition that order was wrong, not merely unconstrained:
`os serve` uses `AuthPlugin` before the capability loop registers
`SettingsServicePlugin`, so at boot `getNamespace('auth')` took the empty
in-memory fallback and answered manifest DEFAULTS with `source: 'default'`
while the workspace's `sys_setting` rows went unread — the ADR-0093
membership policy the D6 backfill runs under, and the `google_*`
social-provider config, both computed from defaults. `subscribe('auth', …)`
only re-applies on a later change, so a workspace configured once in Setup
kept booting wrong.
Repair is one declaration, the #10250 shape the three other shipped readers
already carry: `optionalDependencies = ['com.objectstack.service.settings']`.
Soft, not hard — `bindAuthSettings` already returns early with no service.
The `com.objectstack.auth` entry is deleted from `KNOWN_PRE_BIND_READS` in
`scripts/check-settings-bind-window.mjs`; that ledger is shrink-only and
errors on a stale entry, so the deletion is part of the repair. Deleting it
while the defect stood reproduces the finding, so the green is a measurement
rather than a suppression.
`auth-settings-ordering.pin.test.ts` is the ADR-0049 half: it resolves a
hostile registry composing auth BEFORE settings, then strips the declaration
from a live instance and watches the order revert.
Scope note: `packages/mcp` carries the same pre-bind class and is a separate
lane — untouched here, and #11580 remains open for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via AuthPlugin (symbol))
  • content/docs/deployment/tenancy-modes.mdx(via AuthPlugin (symbol))
  • content/docs/kernel/services-checklist.mdx(via AuthPlugin (symbol))
  • content/docs/permissions/authentication.mdx(via AuthPlugin (symbol))
  • content/docs/permissions/record-view-auditing.mdx(via AuthPlugin (symbol))
  • content/docs/plugins/anatomy.mdx(via optionalDependencies (symbol))
  • content/docs/plugins/index.mdx(via AuthPlugin (symbol))
  • content/docs/plugins/packages.mdx(via AuthPlugin (symbol))
  • content/docs/protocol/kernel/lifecycle.mdx(via optionalDependencies (symbol))
  • content/docs/protocol/kernel/plugin-spec.mdx(via optionalDependencies (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via optionalDependencies (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 11 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 d15ddba02c698ec4116b2f032bc99c235095bad4packageMentionDocs.

Which tree this was computed on

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

⚠️ 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 d15ddba02c698ec4116b2f032bc99c235095bad4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
@os-sam
os-sam marked this pull request as ready for review August 24, 2026 09:56
@os-sam
os-sam added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 2efa1e1Aug 24, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-11579-auth-settings-prebind branch August 24, 2026 10:06
os-zhuang pushed a commit that referenced this pull request Aug 24, 2026
Conflict: KNOWN_PRE_BIND_READS in scripts/check-settings-bind-window.mjs.
Both sides DELETE a different adjacent entry — main removed
com.objectstack.auth (#11579's fix, PR #11619), this branch removes
com.objectstack.mcp. Both deletions are correct and both survive, so the
shrink-only ledger is now empty: the baseline is fully burned down.
Verified rather than assumed, because an empty ledger is a code path that
could have gone unexercised:
✓ settings bind-window guard self-test: all cases pass.
✓ settings bind-window: 4 declared / 0 self / 1 structurally upstream /
0 ledgered (68 plugin unit(s) scanned, provider
'com.objectstack.service.settings').
The ledger docblock is updated in the same stroke: it described 'the two
live readers below' in the present tense, which the empty array makes false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AuthPlugin reads the auth settings namespace in the pre-bind window — stored sys_setting rows are ignored at boot under the shipped os serve order

2 participants

@os-sam@claude