Repository navigation
fix(service-settings)!: settings rows carry the caller's organization, and the data API read of the settings stores applies each namespace's readPermission - #22295
Conversation
…y, reads and the generic read door Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
…nd the generic read door Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
…er returns it Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
…door, over HTTP; changeset Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
…e census page Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
…; pin where the posture is read Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Conflict only in the census page's derived counts (both rows kept: 18b from this branch, 23d from main); counts regenerated with pnpm gen:system-context-census. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
📓 Docs Drift CheckThis PR changes 1 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4acb11d8eabd523eb9e835d64c0bfc2a19319a8c && git checkout 4acb11d8eabd523eb9e835d64c0bfc2a19319a8c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4578c56e65c1f50f04da9259579091272d319558 dcbf66b41a74c5ad989df4f214eed409b624ad93 && git checkout -B drift-repro 4578c56e65c1f50f04da9259579091272d319558 && git merge --no-ff dcbf66b41a74c5ad989df4f214eed409b624ad93
node scripts/docs-audit/affected-docs.mjs --json 4578c56e65c1f50f04da9259579091272d319558
|
…ore the settings engine binds (objectstack-ai#22257) (objectstack-ai#22312) Fixes objectstack-ai#22257 Clause-②: no ## What was wrong On `examples/app-showcase`, `os dev --seed-admin --fresh` logged one `[SettingsService] Pre-bind READ of namespace 'auth'` on every boot. Re-measured on `origin/main` `79c35d45` (after PR objectstack-ai#22295): 1 line, in the boot-diagnostics block. **The reader.** A temporary stack capture in the built `reportPreBindRead` (applied to `service-settings/dist/index.js` and reverted; the sha256 matched before and after, and the marker count after the revert was 0) named it: `SettingsService.getNamespace` from `AuthPlugin.bindAuthSettings`, then `applySettings` (`auth-plugin.ts:1535` at `79c35d45`), from `AuthPlugin.ensureAuthSettingsBound`, from `runBackfill` (`:1235` / `:1244`), from the kernel's `trigger`. The handler is the `app:seeded` hook registered in `AuthPlugin.start()` (`:1298`), which calls `runBackfill('app:seeded')`. It is the ADR-0093 D6 one-time membership backfill. **The phase.** A debug-level boot puts the read in Phase 2 (start). It comes after `[Seeder] Seed loading complete` (132 rows, `plugin.app.com.example.showcase`) and before `Triggering kernel:ready hook`. `AppPlugin.start()` emits `app:seeded` when its inline seed lands. On `os dev`, the auth plugin started first, so its handler ran during Phase 2. `SettingsServicePlugin` binds the engine in its `kernel:ready` hook (`settings-service-plugin.ts:221`), which is later. The `optionalDependencies: ['com.objectstack.service.settings']` edge on `AuthPlugin` orders only `kernel:ready` HOOKS. An event fired during Phase 2 is outside it. **The consequence.** `ensureAuthSettingsBound` is memoized, so the auth binding was computed from the manifest defaults. In the debug log, `Auth: bound to settings namespace=auth` appears at 14:18:11.661, before `kernel:ready` at 11.672. The persisted rows were re-applied later only through a fire-and-forget subscription callback. The one-time pass's first run read the default policy `auto`. **Why `check:settings-bind-window` missed it.** The gate walks `init()` / `start()` bodies and `kernel:ready` handlers only (`READY_HOOK`). It reached this same read through the `kernel:ready` registration of `runBackfill`, and classified it `declared`. The `app:seeded` registration fires during Phase 2, from another plugin's `start()`, and is not in the gate's population. This is reported as a blind spot below; this PR adds no gate. ## The fix (`packages/plugins/plugin-auth/src/auth-plugin.ts`) The one-time pass is now armed by its own `kernel:ready` hook. Any trigger before that does nothing: `app:seeded` during Phase 2, or `default-org-created` from the bootstrap middleware. The `kernel:ready` pass still runs afterwards, after the settings bind, and scans every row such a trigger was about. Triggers after `kernel:ready` behave exactly as before. That includes an over-budget seed that settles in the background (the objectstack-ai#2996 path). - No change to which settings are read, only to when. - No change to the reporter's level or text. - No `packages/spec` change. - The comment above `ensureAuthSettingsBound` in `runBackfill` was stale: it said "registered in `init()`". It is corrected. ## Boot, before and after | | `Pre-bind READ` lines | `Auth: bound to settings namespace=auth` | |---|---|---| | `79c35d45` (base) | 1 (`auth`) | Phase 2, before the `kernel:ready` trigger | | `9b7ac6cf` (fix; plugin-auth dist rebuilt, `backfillArmed` grep 3 in `dist/index.mjs`) | 0 | after the `kernel:ready` trigger (16.538 vs 16.381) | In both boots the D6 pass still records `adr-0093-membership-backfill` once. The other boot-diagnostic lines are unchanged: `sys_migration` UNIQUE and `[sharing-rule]`. See the acceptance notes. ## Pins `packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts` composes: - a real `ObjectKernel` - ObjectQL on in-memory SQLite - the REAL `SettingsServicePlugin` - the real `AuthPlugin`, used before the settings plugin (the `os serve` order) - an app plugin in `AppPlugin`'s place: it writes the persisted `auth.membership_policy = invite-only` row, then emits `app:seeded` from its `start()`. The dogfood harness cannot compose this. `bootStack` registers `AppPlugin` before `AuthPlugin`, so `app:seeded` fires before auth registers its handler. The main case asserts four things: 1. The window is real: at `app:seeded`, the settings engine is unbound. 2. No `Pre-bind READ` is reported. 3. The auth binding's first `getNamespace('auth')` answers `{ value: 'invite-only', source: 'global' }`. 4. Every run of the one-time pass gets `policy: 'invite-only'`. A control case forces a pre-bind read of `auth` in the same composition and expects exactly one report. `@objectstack/service-settings` is added as a devDependency of plugin-auth. It is aliased to `src/` in `vitest.config.ts` (anchored, like the three existing entries), so `KNOWN_UNALIASED_TEST_IMPORTS` is unchanged. The lockfile gains only that importer entry. **Control.** `settings-prebind-read-warning.test.ts` passes 9/9 unchanged. It still fires on a forced pre-bind read. **Ablation**, via `scripts/ablation-replace.mjs` from committed `d907051b`, with an EXIT/INT/TERM restore and the restore proven by blob == HEAD and an empty `git diff HEAD`. `AuthPlugin` is imported relatively, so `src/` is what runs and no dist rebuild was involved. - Leg 1: delete `if (!backfillArmed) return backfillChain;` (anchor 1 to 0, blob `d559d607` to `83e4649d`). The main case goes RED at assertion 2: one `Pre-bind READ of namespace 'auth'`. The control stays green. Restored to `d559d607`. - Leg 2: the same deletion held, plus assertion 2 removed from the test. The main case goes RED at assertion 3: the first `auth` read answered `{ value: 'auto', source: 'default' }`. Both files restored (blob == HEAD). ## Verification (the gate union was run on `b67e95f1`) - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 129 files, 2639 passed, 10 skipped, exit 0. This ran on `d907051b`. The merge after it brought only `docs/adr/0096`. - `pnpm --filter @objectstack/plugin-auth run typecheck`: exit 0. `check:test-typecheck` is OK, and the new file is in the `tsconfig.test.json` program (`--listFiles`: 1). - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `b67e95f1` derived 79 commands, and all 79 exited 0. `--ran`: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm check:settings-bind-window`: `4 declared / 0 self / 1 structurally upstream / 0 ledgered (73 plugin unit(s) scanned)`. - Selected verdict lines: - `check-test-source-alias OK — 73 packages with tests scanned; 60 registered` - `check:workspace-manifest-cycles OK: 80 workspace package(s), 515 workspace: edge(s)` - `check-nul-bytes: OK` - `check:undeclared-dep-imports` passed - `check-adr-0087-registration: 1 non-breaking changeset(s) seen` - eslint, narrowed to the 3 changed TS files: 0 errors and 0 warnings in `--format json`. Each file has a non-empty rule set under `--print-config`. `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. - NOT MEASURED: CI's Test Core, Dogfood, Build Core and the type-check lanes. These are CI-owned. ## Acceptance notes - **Gate blind spot (not fixed; no gate added).** `check:settings-bind-window` cannot see two things: - a settings read reached through a hook other than `kernel:ready` that fires during Phase 2 (`app:seeded`, emitted from `AppPlugin.start()`); - a read reached through a data-middleware or callback closure fired by a Phase-2 write. Its header says "Everything that runs before that bind hook is the window." Its population is narrower than that window. - **Landing order with PR objectstack-ai#22186.** That PR makes `AppPlugin` declare `com.objectstack.auth` as an optional dependency, and creates the Default Organization in `AuthPlugin.start()`. Without this fix, the Phase-2 `app:seeded` pass would then have a target and would DECIDE the one-time backfill under the manifest-default policy. This fix arms the pass at `kernel:ready`, so it holds under either landing order. The two diffs touch different regions of `auth-plugin.ts`: theirs is around `:725` and `:1168`, this one is `:1232` to `:1316`. - **Unchanged boot line.** `WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}` appears on the fresh showcase boot both before and after this change. It comes right after `adr-0093-default-org-owner-bind` is recorded. Root cause is NOT MEASURED; it is reported to the seat. - **Merge state.** `origin/main` was merged at `799eb000`. `main` has since moved 4 commits. None touches plugin-auth or service-settings; plugin-security's strict mode is the nearest, and this composition mounts no `SecurityPlugin`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22261
Clause-②: no (narrowing)
Executes option A as ruled on the card. This body stays at the level of classes, positions and functions, as the card asks; the detailed measurement went to the dispatching seat privately.
What changes
SettingsService(packages/services/service-settings/src/settings-service.ts)The service reads and writes
sys_settingunder its own system context, and that context names no organization. So no driver tenant scope and no organization wall reaches those calls. The organization now travels in the service's own query and row identity.rowIdentitykeys atenantoruserrow by the identitysys_settingdeclares, organization included.setManywrites every such row with the caller's organization (SettingsContext.tenantId). Aglobalrow is unchanged: it lives insys_platform_settingand has no organization column.loadScopedRowsfilters explicitly, in the query itself, by the caller's organization plus rows stored with no organization. The in-memory store applies the same reach (OrganizationReach). The global rung (loadGlobalRows) is unchanged.preferredRowmakes the tenant and user rungs take the caller organization's own row ahead of an organization-less one. The lock pre-flight insetManyreads the same row, so a lock applies only where the cascade reads.group/isolated), a tenant-scope write that names no organization is refused whole, before anything is written. It reuses the vocabulary the ownerless user-key refusal already has:SettingsValidationError,code: SETTINGS_VALIDATION, HTTP 400 at the settings routes, one field entry per key withcode: invalid_valueandconstraint: { scope: 'tenant' }. A reset is refused the same way.tenancyservice. The plugin passes it throughbindEngine(tenancyPosture), read the same way its HTTP door reads it. The service only asks when the caller names no organization.The generic read door (
settings-read-door.ts, registered bySettingsServicePlugin)sys_setting,sys_setting_auditandsys_platform_setting. It ANDs anamespacepredicate into every non-system read (find,findOne,count,aggregate). It is a filter, not a pass over the result, so counts, aggregates and pages see exactly the rows a list returns.SettingsService.namespaceReadScope. It uses the samerequiredCapabilitytable the settings door enforces. A namespace with no registered manifest reads at the default capability,setup.access.service-settings. No file inobjectql,runtimeorplugin-securityis edited.Census page
content/docs/permissions/system-context.mdxgains row 18b for the middleware'sisSystemread.check:system-context-censusrequires a row for every elevation read site. The counts were regenerated withpnpm gen:system-context-census.Posture
singleThe default organization keeps the answers it had. These are pinned in
settings-organization-isolation.pin.test.ts:single, nor where no posture is reported.Existing rows (H3)
No stored row is rewritten, as the dispatch fences. What stored rows carry today, and the decision that follows from it, went to the seat privately.
Tests
Every run in this table is on HEAD
61911cf17a, afterservice-settingswas rebuilt and itsdist/was proven to carry the HEAD source.pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2settings-organization-isolation.pin.test.ts(part of the suite above)settings-read-door.pin.test.ts(part of the suite above)test/settings-organization-isolation.dogfood.test.ts, real stack over HTTP, two organizations under a non-degradedisolatedposturesingle-posture HTTP regression: the existing dogfood files that write and read settings (settings-config-change-audit,analytics-timezone,audit-log-parent-read-gate)pnpm --filter @objectstack/service-settings typecheckpnpm --filter @objectstack/dogfood typecheckOver HTTP, with a positive control per refusal
globalvalue is read by both.400witherror.codeSETTINGS_VALIDATIONand a field entryinvalid_value, and writes nothing. Control: the identical write from inside an organization answers 200.sys_settinghides a namespace's row from a principal lacking that namespace'sreadPermission: the list returns 0 rows and the by-id read answers 404. Control 1: the same principal reads its own row of a namespace whose capability it holds. Control 2: a holder of the withheld capability reads the withheld row (200).Ablations
Each ablation started from a committed fix, restored from
HEADunder a trap, and was proven restored by blob hash and an emptygit diff HEAD. All ablations ran ate1407dc55f, except the two dogfood rows, which ran at672e54e08e.settings-service.tsset to the base commitsinglecases plus its no-refusal control.namespaceReadScopeanswers no predicatepreferredRowmade positionalablation-dist-preflight --absentpasseddist/--absentpassed, tree clean.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 94 commands at HEAD61911cf17a. All 94 were run and exit 0, pluscheck:settings-bind-windowas dispatched.--ranreports 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN.check:system-context-censuswent red on the first pass for the new read site. It is green after the row-18b edit..tsfiles lint with 0 errors and 0 warnings at61911cf17a(eslint --no-inline-config --format json). The repository runs no type-aware lint, so this change cannot move an untouched file's lint verdict. The full lint run is CI's.c8bb3c8d.origin/maingained 4 commits since, and none of them touchesservice-settings. CI on the merge ref is the arbiter.Acceptance notes
@objectstack/service-settingsminorchangeset, declared breaking, with ADR-0087 dispositionnot-required (no-migration-prescription).check-adr-0087-registrationandcheck-changeset-no-majorare green.settings-service.types.tschanges onlySettingsRow(theorganization_idrow-shape field). PR feat(plugin-audit,plugin-security)!: sys_audit_log loses its injected organization column; tenant_id carries the organization a row is about and scopes organization readers (ADR-0131 D7) #22266 edits a different region of it.settings-service-plugin.tsis touched for wiring only: the posture source and the read-door registration.settings-getmany.test.tsrows now spellorganization_id: nullthe way a real driver returns it.settings-routes.test.tspasses the reach its privateloadRowscall now takes.sys-setting.object.ts(platform-objects) quotes aloadRowscomment sentence this change retires. The quote is prose only; no gate reads it.Round 2
Merge.
origin/maind1dbe70ebdis merged with a merge commit (dcbf66b41a), with no rebase and no force-push. The only conflict was the derived counts oncontent/docs/permissions/system-context.mdx. Both rows are kept, 18b from this branch and 23d from main, and the counts were regenerated withpnpm gen:system-context-census.check:system-context-censusis green: 118 elevation read sites in 20 packages across 55 files.Re-verified at
dcbf66b41a. All results below follow a rebuild of what the merge touched.service-settingssuitesettings-organization-isolation.dogfood.test.tsservice-settingsanddogfoodGates.
dispatch-gates --commands, run with no paths, derives 94 commands atdcbf66b41awith no stale-tree warning. All 94 were run, pluscheck:settings-bind-window.--ranreports 94 derived, 94 run, 0 NOT-MEASURED and 0 UNRUN. Two gates first refused with exit 3 on unbuilt packages; they were re-run after those packages were built and exit 0.Measurement. Under the isolated posture, with the real tenant wall in the composition, a non-system read of
sys_settingorsys_setting_auditby one organization's administrator does not return another organization's organization-stamped row. The harness is a temporary test, removed after the run.Generated by Claude Code