Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22261-settings-tenant-isolation
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22261-settings-tenant-isolation

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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_setting under 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.

  • Row identity. rowIdentity keys a tenant or user row by the identity sys_setting declares, organization included. setMany writes every such row with the caller's organization (SettingsContext.tenantId). A global row is unchanged: it lives in sys_platform_setting and has no organization column.
  • Reads. loadScopedRows filters 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.
  • Cascade. preferredRow makes the tenant and user rungs take the caller organization's own row ahead of an organization-less one. The lock pre-flight in setMany reads the same row, so a lock applies only where the cascade reads.
  • Refusal. Under a walled posture (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 with code: invalid_value and constraint: { scope: 'tenant' }. A reset is refused the same way.
  • Posture. The posture comes from the tenancy service. The plugin passes it through bindEngine (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 by SettingsServicePlugin)

  • An engine middleware registered by object name on sys_setting, sys_setting_audit and sys_platform_setting. It ANDs a namespace predicate 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.
  • The predicate is SettingsService.namespaceReadScope. It uses the same requiredCapability table the settings door enforces. A namespace with no registered manifest reads at the default capability, setup.access.
  • Seam (H4). The seam sits inside service-settings. No file in objectql, runtime or plugin-security is edited.

Census page

content/docs/permissions/system-context.mdx gains row 18b for the middleware's isSystem read. check:system-context-census requires a row for every elevation read site. The counts were regenerated with pnpm gen:system-context-census.

Posture single

The default organization keeps the answers it had. These are pinned in settings-organization-isolation.pin.test.ts:

  • a value stored before rows carried an organization is still read by the default organization;
  • the default organization's new write is read by itself and by a process-wide reader that names no organization;
  • a reset reads back the default for both;
  • an organization-less tenant-scope write is not refused under 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, after service-settings was rebuilt and its dist/ was proven to carry the HEAD source.

Suite Result
pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2 41 files, 752 passed
settings-organization-isolation.pin.test.ts (part of the suite above) 18 passed
settings-read-door.pin.test.ts (part of the suite above) 27 passed
test/settings-organization-isolation.dogfood.test.ts, real stack over HTTP, two organizations under a non-degraded isolated posture 6 passed
single-posture HTTP regression: the existing dogfood files that write and read settings (settings-config-change-audit, analytics-timezone, audit-log-parent-read-gate) 3 files, 14 passed
pnpm --filter @objectstack/service-settings typecheck exit 0
pnpm --filter @objectstack/dogfood typecheck exit 0

Over HTTP, with a positive control per refusal

  • Each organization sets and reads its own tenant-scope value. One organization's write and its reset leave the other's value unchanged. A global value is read by both.
  • An organization-less tenant-scope write under the walled posture answers 400 with error.code SETTINGS_VALIDATION and a field entry invalid_value, and writes nothing. Control: the identical write from inside an organization answers 200.
  • The data-API read of sys_setting hides a namespace's row from a principal lacking that namespace's readPermission: 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 HEAD under a trap, and was proven restored by blob hash and an empty git diff HEAD. All ablations ran at e1407dc55f, except the two dogfood rows, which ran at 672e54e08e.

Mutation Result
settings-service.ts set to the base commit isolation pin 11 red, 5 green. The 5 that stay green are the regression guards: the global row, and the three single cases plus its no-refusal control.
namespaceReadScope answers no predicate read-door pin 19 red, 8 green. The 8 green: system context, registration by name, writes untouched, refusal of a read with no query.
preferredRow made positional isolation pin 2 red (both preference cases)
Dogfood, service and plugin set to the base commit, package rebuilt, ablation-dist-preflight --absent passed 4 of 6 red. Green: the guard, and the global row read by both. Restored, rebuilt, marker present again: 6 of 6 green.
Dogfood, only the door predicate disabled, plant proven in dist/ only the read-door case red (1 of 6). Restored, rebuilt, --absent passed, tree clean.

Gates

  • Derived set. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 94 commands at HEAD 61911cf17a. All 94 were run and exit 0, plus check:settings-bind-window as dispatched.
  • Reconciliation. --ran reports 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN.
  • Fixed on the way. check:system-context-census went red on the first pass for the new read site. It is green after the row-18b edit.
  • Lint, narrowed. The 9 changed .ts files lint with 0 errors and 0 warnings at 61911cf17a (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.
  • Base. The branch sits on c8bb3c8d. origin/main gained 4 commits since, and none of them touches service-settings. CI on the merge ref is the arbiter.

Acceptance notes

Seat's append (domain:services seat 1, #6021), carried verbatim from the dev's round-2 report 6060893787; the dev never edits a PR body.

Round 2

  • Merge. origin/main d1dbe70ebd is merged with a merge commit (dcbf66b41a), with no rebase and no force-push. The only conflict was the derived counts on content/docs/permissions/system-context.mdx. Both rows are kept, 18b from this branch and 23d from main, and the counts were regenerated with pnpm gen:system-context-census. check:system-context-census is 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.

    Check Result
    service-settings suite 41 files, 752 passed
    settings-organization-isolation.dogfood.test.ts 6 passed
    single-posture settings dogfood files 3 files, 14 passed
    typecheck of service-settings and dogfood exit 0
  • Gates. dispatch-gates --commands, run with no paths, derives 94 commands at dcbf66b41a with no stale-tree warning. All 94 were run, plus check:settings-bind-window. --ran reports 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_setting or sys_setting_audit by 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

claude added 6 commits October 8, 2026 11:46
…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
…door, over HTTP; changeset

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
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings, touching 41 documentable anchor(s).

32 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 4578c56e65c1f50f04da9259579091272d319558.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (symbol, 33 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 9 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 4578c56e65c1f50f04da9259579091272d319558 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4acb11d8eabd523eb9e835d64c0bfc2a19319a8c — the merge of head dcbf66b41a74c5ad989df4f214eed409b624ad93 into base 4578c56e65c1f50f04da9259579091272d319558, 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 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

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 13:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 79c35d4 Oct 8, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22261-settings-tenant-isolation branch October 8, 2026 14:05
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] settings: tenant-scope settings are not isolated per organization on multi-organization deployments — detail withheld pending maintainer

2 participants