Skip to content

fix(service-settings)!: no tenant- or user-rung settings read admits an organization-less row (ADR-0131 D8) - #25226

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-25202-settings-no-organization-less-rung
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-25202-settings-no-organization-less-rung

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25202

What

ADR-0131 D8 says no layer has a NULL arm, and D1 (as amended by record 6328195425) says nothing reads a NULL tenant value as an owner. SettingsService's organization reach still had a NULL arm:

  • own: a caller that named an organization read that organization's sys_setting rows and the rows written with no organization, as a fallback for every organization.
  • organization-less (no organization, walled): it read only the organization-less rows.
  • unwalled (no organization, single): it read every row.

This PR retires the NULL arm. Each reach now owns exactly one organization:

caller reach tenant/user rows read tenant/user rows written
names organization X own(X) organization_id = X X
names none, walled (group/isolated) unowned none (global rung / default) tenant write refused
names none, single, D9 derives X own(X) organization_id = X X
names none, single, D9 derives none (zero or several organizations) unowned none tenant write refused
names none, composition registers no organization object (lean embedding; ruled kept, ADR-0131 D9 note) no-organization-object organization_id NULL NULL

D9's answer comes from the predicate the engine already applies to a system write: resolveGuestOrganization / resolveSystemWriteOrganization (@objectstack/core). SettingsServicePlugin binds it next to tenancyPosture. The process-wide callers keep passing {}, and the service names the organization for them: plugin-auth's email brand name and auth locale, the anonymous runtime/config branding read, and resolveLocalizationContext for a caller with no active organization.

A live defect fixed on the way (same cause). Under single, a caller with no organization wrote its row with organization_id NULL. The engine stamped the Default Organization on insert, but upsertRow's identity probe still asked for NULL. The second save of a key therefore missed the first row and inserted a second one. Readers kept answering the first value. On a SQL store the identity index refuses that insert, so the save fails. Probe: [[org_default,'one'],[org_default,'two']], with every read answering 'one'. The write now carries the organization the reach owns.

Measurement (step 1)

This was read on main 9c431bad37 before building. The full report was prepared as an os-dev-report for #25202.

  • Kept by ruling? No. D8 note 6325596117 names only storage's organizationOutOfWriteReach. D13 lists the settings global rung, which step 2 already moved. The one organization-less path a ruling keeps is the lean embedding ([Decision] A production os serve that composes no organization object (no auth secret, or no auth) still writes ownerless rows under --no-server: refuse that boot too, or refuse such writes in the engine everywhere #25133 letter A), and this PR keeps it.
  • Writable organization-less tenant/user row, probed through the real engine:
    • fresh single: no. The engine derives the Default Organization.
    • group/isolated: no. A tenant key is refused at the service. A user key is refused by the engine (ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED).
    • 18.0.0-born store: no on a served composition.
    • lean embedding: yes, by ruling.
    • --dev: same as served, unless the auth tier is off, which takes the lean path.
  • fixture-18.0.0: 2 sys_setting rows, both owned by org_mvgqf77lj6oc60fk; 0 organization-less. fixture-18.0: 2.
  • Ceremony inventory: unchanged. sys_setting already has the attribution fate (organization-ownership-inventory.ts:660). Under single the ceremony gives such rows to the Default Organization. Walled stores keep them NULL and reported, and those reported rows are the population this narrows.

Clause-② and Metadata-protocol

Clause-②: yes (narrowing)
Metadata-protocol: none

A reported organization-less tenant/user row stops answering every organization's read.

  • The changeset ships as patch with the BREAKING banner, the FROM/TO and the remedy. ADR-0087 disposition: not-required (no-migration-prescription).

Proof (head 576b8283)

  • Pins:
    • settings-organization-isolation.pin.test.ts (rewritten): own / walled / single / lean, tenant and user rungs, and the lock pre-flight.
    • settings-organization-reach.pin.test.ts (new): the real SettingsServicePlugin on a real ObjectKernel + ObjectQL, with single and one, zero or several organizations, group/isolated, and lean. The source is the one the plugin wires.
    • Upgrade harness, 18.0.0 leg: new test. The fixture's settings still answer through GET /api/settings/branding and for a reader that names no organization (branding.workspace_name, localization.timezone).
  • Ablation, from the committed state, each restored with git checkout HEAD --:
    • A1, own admits NULL again: 8 pins red.
    • A2, the plugin wires no organization source: 4 plugin pins red. The harness's new 18.0.0 test is also red (expected { value: 'ObjectStack', … }).
    • A3, the write stores the caller's organization instead of the reach's: 3 pins red.
  • Package: pnpm --filter @objectstack/service-settings typecheck exit 0. vitest 58 files / 982 tests pass.
  • Downstream spot-check: plugin-auth (4 settings files, 124 pass), core (localization cache + authored-translation sync, 33 pass), organizations membership-policy gate (32 pass).
  • Upgrade harness (upgrade-18x-harness.dogfood.test.ts, both fixture legs and the fresh store): 16/16 pass.
    • R1: the fresh store boots and serves.
    • R2: the 18.0 and 18.0.0 stores upgrade and keep their data.
    • R3: the v17 trait is refused, then takes the ceremony.
  • R4 (walled): the walled pins above. NOT MEASURED on a walled dogfood boot locally; CI's dogfood lane covers it.
  • node scripts/pm/dispatch-gates.mjs --commands: 72 derived; 70 exit 0. Two exit 3 (NOT MEASURED, prerequisites in this container):
    • check-plugin-teardown-shape --self-test needs an unshallow clone.
    • check:dual-build-cjs-loads needs the full build.
    • --ran verdict: ✓ 72 accounted.
  • check-governed-merges --branch: 0 of 9 paths governed. 645 changed lines (+527/−118). Not Tier H/S.

Acceptance notes

  • audit.record's tenantId and the crypto context keep ctx.tenantId. Only the row's organization and auditWriter's organizationId follow the derived organization.
  • A user-scope write by a caller with no organization behind a wall is still refused by the engine, not the service (unchanged).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP

…ation-less row (ADR-0131 D8)

Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP
Co-authored-by: Claude <noreply@anthropic.com>
…gle, lean (ADR-0131 D8/D9)

Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP
Co-authored-by: Claude <noreply@anthropic.com>
…swer, organization-less readers included

Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/drivers.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/kernel/runtime-services/settings-service.mdx (via setMany (symbol, a method of class SettingsService))
  • content/docs/protocol/kernel/config-resolution.mdx (via SettingsService (symbol, a top-level class), setMany (symbol, a method of class SettingsService))
  • content/docs/protocol/kernel/index.mdx (via SettingsService (symbol, a top-level class))

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

  • content/docs/releases/v14.mdx (via setMany (symbol, a method of class SettingsService))

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
  • 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 123ff6f4e2faa4beffcf1875618c358bc1692c95 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 123ff6f4e2faa4beffcf1875618c358bc1692c95

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 576b82834cd74ce0fc09692d4a58476d5ca822c5
Local-runs: none
Scope: packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts, packages/services/service-settings/src/settings-getmany.test.ts, packages/services/service-settings/src/settings-organization-isolation.pin.test.ts, packages/services/service-settings/src/settings-organization-reach.pin.test.ts, packages/services/service-settings/src/settings-routes.test.ts, packages/services/service-settings/src/settings-service-plugin.ts, packages/services/service-settings/src/settings-service.ts, packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts + .changeset/25202-settings-no-organization-less-rung.md (judged under ②), packages/core/src/security/resolve-authz-context.ts (resolveGuestOrganization :1274-1294 at the merge-base; resolveLocalizationContext :2407-2560), packages/core/src/security/system-write-organization.ts (resolveSystemWriteOrganization, the D9 predicate and its decision type), packages/objectql/src/engine.ts (resolveSystemInsertOrganization and probeInstallOrganizations, the write-side asker of the same predicate), packages/plugins/plugin-auth/src/auth-plugin.ts (:1102 branding.workspace_name, :1155 localization.locale, :1800 auth global key, all read with an empty context), packages/cloud-connection/src/runtime-config-plugin.ts (:1172 anonymous branding getMany with an empty context), packages/objectql/src/lifecycle/lifecycle-service.ts (:882-903 lifecycle.retention_overrides, once with no organization and once per organization), packages/cli/src/utils/organization-ownership-inventory.ts (:661-664 sys_setting fate attribution), packages/qa/dogfood/test/upgrade-harness/fixture-18.0.0/database.sql (:665-666 the two sys_setting rows, :895 the identity index), docs/adr/0131-total-organization-ownership-no-null-organization-id.md on origin/main (D1 :234-270 with the 6328195425 amendment, D8 :663-696 with the 6325596117 note, D9 :698-733 with the 6328987873 / #25133 letter-A note, D13 :870-889).

Inputs read: card #25202 body, its claim (6339806560) and the os-dev-report (6342115583); the parent's reconciliation note 6339761793 on #15212; the PR body and file list; git diff 9c431bad371f62bf5edac369d2405745ee1155e7 576b82834cd74ce0fc09692d4a58476d5ca822c5 (9 files, +527/−118); the 47 check-runs on the head. The working checkout in this container is behind origin/main, so every cited file was read by ref (9c431bad, the head, or origin/main), never from the working tree.

① Derived judgments

1. The reach, before and after — which sys_setting rows a tenant- or user-rung read admits. Before (merge-base settings-service.ts :150-170, :1822-1833, :2935-2940, :2984-2994): own(X) queried each rung crossed with organization_id IN {X, NULL} and preferredRow fell back to the NULL row; organization-less (no organization behind a wall) read the NULL rows alone; unwalled (no organization under single or no posture source) read every row and preferred the first organization-stamped one. After (head :150-184, :1853-1855, :1867-1878, :2976-2980, :3006-3041): own(X) reads organization_id = X and nothing else; no-organization-object reads organization_id = NULL alone; unowned loads no sys_setting row at all (loadScopedRows :3012 returns empty). The one predicate withinReach (:2976) now drives the engine $or, the in-memory filter (:2965-2971) and the rung's pick (:1853), so the three cannot disagree. Right: D1 as amended ("nothing reads a NULL tenant value as an owner") and D8 ("no layer has a NULL arm; no shared-owner set") are met for every reach that names an organization. A NULL-organization row is still admitted only through no-organization-object, reached (a) when the engine's predicate answers no-organization-object — the composition registers no sys_organization, exactly the #25133 letter-A case the D9 note keeps — or (b) when no organization source is bound at all (organizationReachOf :1872: if (!source) return no-organization-object). The in-repo production binder (the plugin, :344-345) always binds one; (b) is a host-binding residual, flagged in ③, not a reading any served composition reaches.

2. A caller with no organization, by posture. organizationReachOf (:1867) asks the wall first, then the source. Under group / isolated: unowned — no tenant or user rung; the global rung (sys_platform_setting, loadGlobalRows :2990, loaded by loadRowSets :2944 for every reach) and the default answer; a tenant-scope write is refused at the service (:2227, SETTINGS_VALIDATION, constraint: { scope: 'tenant' }). Under single with exactly one organization: own(X) where X is resolveGuestOrganization(engine, posture)'s derived answer — the same resolveSystemWriteOrganization the engine's insert path asks, over the same probe (sys_organization, limit: 2, system context: core :1286 and engine :7765), so the read side derives the organization the write side stamps. Under single with zero or several organizations: refuse → unowned (reads from the global rung or default; tenant write refused) — the engine already refused such writes (no-organization, ambiguous-organization), so the service now agrees with it rather than reading "whichever row came first". Lean embedding (no sys_organization registered): no-organization-object → NULL rows read and written, as ruled. No posture source, or one reporting none, reads as single (plugin :345 passes undefined, core :1283 defaults it) — the same "no posture is no wall" reading the authorization face gives. Right, and the derived organization is D9's.

3. Legitimate callers. plugin-auth's email brand name (:1102) reads branding, a namespace the plugin offers only where no wall stands (UNWALLED_ONLY_NAMESPACES, plugin :80), so it now reads the one organization's own row — the row it already preferred under unwalled. plugin-auth's auth locale (:1155) is a tenant key read under every posture: single → the derived organization's row; walled → global rung or default, where before it read NULL rows only — on a served walled store those exist only as ceremony-reported rows, so this is the declared narrowing and nothing else. The anonymous runtime/config branding read (:1172) is single-only by the manifest gate → derived. Lifecycle's organization-less retention_overrides read (:882-893) → derived under single, global/default under a wall; the per-organization loop (:894-903) names each organization and is unchanged. resolveLocalizationContext for a signed-in user with no active organization (:2471-2498, { tenantId: undefined, userId }) → same pattern. The settings HTTP door (verifiedContextFromRequest, plugin :392-462) hands the vetted tenantId from resolveRequestExecutionContext; a caller with no active organization is the same case. auth and the other process-wide namespaces are global keys, read with UNOWNED (:1625, :1732) — the global rung is loaded regardless of reach and scope === 'global' is excluded from loadScopedRows in the query, so no process-wide reader loses the global rung. The 18.0.0 harness test (new, :406-420) pins both routes: GET /api/settings/branding as the admin and settings.get(branding.workspace_name, {}) / settings.get(localization.timezone, {}) answer source: 'tenant' from the fixture's two owned rows.

4. The write side. Head :2289: organizationId = scope !== 'global' && reach.kind === 'own' ? reach.organizationId : null, so the row's identity (rowIdentity :3129-3152) and upsertRow's existence probe name the organization the reach owns. The reported defect is real on the merge-base: the service wrote organization_id: null under a system context, the engine's resolveSystemInsertOrganization stamped the derived organization on insert (rows lacking the tenant column are the ones it decides, engine :7839-7858), and the next probe for (…, organization_id NULL) missed that row and inserted beside it — on SQL the COALESCE(organization_id,'__global__') identity index (fixture :895) refuses the second insert instead. The fix is right and lands no write in a different organization than before: under single with one organization the engine was already stamping the derived organization; the service now names it up front. Walled, caller-named organizations are unchanged (own(X) always was X). A user-scope write by an unowned caller still passes organization_id: null and is refused by the engine (ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED), as before — the PR body says so. reset (resetNamespace :2850) resolves through getNamespace and setMany with the same context, so it clears the derived organization's rows, consistent with what it read. audit.record keeps tenantId: ctx.tenantId (undefined for a process-wide writer) — the ledger insert is itself a system write the engine derives under single, so the ledger row lands in the same organization; auditWriter.organizationId follows the reach (:2412). The lock pre-flight (:2232-2243) picks with the same preferredRow, so the row that answers a read is the row whose lock refuses the write; a lock on a NULL row locks no organization (pinned).

5. Cross-organization leakage under a wall. None: own(X) admits X alone; a caller naming none admits nothing; setMany under own(X) writes X's row and never finds another organization's (pinned: the organization's write lands beside the NULL row, [[null,'Legacy'],[ORG_A,'Alpha']]). The reach pins (settings-organization-reach.pin.test.ts) run the real SettingsServicePlugin on a real ObjectKernel with a real ObjectQL and its tenancy service, so the source under test is the one the plugin wires.

6. R1–R4. R1 (fresh store boots and serves), R2 (18.0 and 18.0.0 fixtures upgrade and keep their data, the 18.0.0 leg now including the settings-still-answer test) and R3 (the v17 trait is refused, takes the ceremony, boots) are the 18.x upgrade harness (opt-in) lane, success on this head (run started 14:34:44Z, completed 14:40:37Z; the earlier run was skipped before the seat's label). R4 (walled boot): the service's walled behaviour is pinned under group and isolated with the real engine and the real plugin; the dogfood shards (Dogfood Regression Gate (shard/3), Dogfood Verify CLI) are skipped on this head because ci.yml runs them only off pull_request events (:2037), so no walled app boot was measured pre-merge, locally or in CI — see ③.

7. Tests edited beside the pins. settings-getmany.test.ts and settings-user-rung-owner.pin.test.ts drop the contexts that named an organization against fixture rows carrying none — right under D8 (such a read no longer falls back), and the user-rung pin keeps its {} and { userId } controls. settings-routes.test.ts renames the private-reach literal unwalled → no-organization-object for a service bound with no source — right. No reference to the retired kinds or ANY_ORGANIZATION remains anywhere at the head.

8. main moved. Nine commits since 9c431bad; git log 9c431bad..origin/main -- PATHS over the PR's nine files is empty. One later commit, 3a440b01f6 (#25210), touches packages/objectql/src/engine.ts and tenancy/system-write-organization.ts: the tenant-audit mute moves from the per-table ledger to the schema, and an explicit bypassTenantAudit: true (which loadScopedRows :3039 passes) stays honoured; resolveSystemInsertOrganization's decision body is untouched. No effect on this diff.

9. Gate verdicts on the head (/commits/576b8283…/check-runs, 47 runs, latest run per name): 34 success, 8 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, the dogfood shards, Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance — event- or path-filtered), 0 failures. Check Changeset success twice (13:35:58Z and the label-triggered re-run 14:36:14Z); Governed Surface Queue Guard success; check-governed-merges --branch: 0 of 9 paths governed.

② Semver level

Changeset .changeset/25202-settings-no-organization-less-rung.md: '@objectstack/service-settings': patch; summary fix(service-settings)!: …; Clause-②: yes (narrowing); Metadata-protocol: none; the **BREAKING** banner; FROM / TO and the remedy; the adr-0087 comment marker not-required (no-migration-prescription) with its why. The PR body carries the same two declaration lines, bare and line-initial.

  • Level. Under ruling A (2026-10-21, changeset levels: minor only when the metadata protocol changes (added / narrowed / semantic), patch otherwise; release notes grouped by what the reader must do (maintainer ruling A, 2026-10-21) #25105 — AGENTS.md on origin/main :1082-1093; the WHICH LEVEL paragraph of the Check Changeset step, pr-automation.yml :754 ff.) the level follows the metadata-protocol kind alone: none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". This diff changes which stored sys_setting row answers a read and gives bindEngine one optional input; no authorable key, value, type, default or meaning changes, so none is the right kind and patch is the right level. The level axis in check-changeset-no-major.mjs (judgeLevel) requires a declared none to grade every introduced entry patch — satisfied — and the Check Changeset lane that runs it (and the check-adr-0087-registration.mjs step at :999 of the same job) is green twice on this head.
  • Clause-②: yes (narrowing). The claim expected no (narrowing); the dev re-declared from the diff. yes is earned: SettingsService.bindEngine's extras accept a new optional organization input (a widening of a published class's accept set; export type SettingsOrganizationSource is module-level and not re-exported from index.ts). (narrowing) is the face: a tenant- or user-scope sys_setting row with a NULL organization_id stops answering any organization's read, and a single caller naming none now reads nothing where D9 derives no organization. Under ruling A the yes no longer forces minor, so the declaration and the level agree.
  • ADR-0087 marker. no-migration-prescription is honest: the body prescribes no consumer code rewrite — the remedy is an operator data action (os migrate organization-ownership --out PLAN, re-save each reported value inside its organization, or declare the key global), nothing for objectstack migrate meta to rewrite, no tombstone, no export removed. Mechanically, the detector finds no OPERAND → OPERAND rewrite and no FROM … → … TO label line in it (the Setup → the settings page fragment has no operand on either side), and the gate passed. The marker's "other categories closed on facts" paragraph is accurate.
  • Who is affected / remedy are stated and match the measurement: fresh installs and os serve-written 18.0.0 stores hold no such row (fixture :665-666: both rows owned by org_mvgqf77lj6oc60fk); a v17 store under single has them attributed by the ceremony (inventory :661-664, fate attribution, D10 fate 3); walled stores keep them NULL and reported, and from this release those rows answer nobody.

③ Boundary flags

  1. Dev flag — check-plugin-teardown-shape --self-test and check:dual-build-cjs-loads NOT MEASURED locally (shallow clone, partial build). Answered: every lint, type-check and build lane on this head is success, and those two checks run there; nothing is owed.
  2. Dev flag — walled dogfood boot NOT MEASURED locally; "CI's dogfood lane covers it". Not on a PR: ci.yml :2037 runs the dogfood shards only when github.event_name != 'pull_request', so they are skipped on this head and R4 rests pre-merge on the walled pins (service + real engine + real plugin on a real kernel). Escalated to the seat as a residual, not a block: the first post-merge main battery is the compensating measurement of a walled boot, and the plugin's wiring (:337-345) is posture-independent, so the pins exercise the same seam the boot would.
  3. open_questions: [], out_of_scope_findings: [] — two findings the dev did not list, both outside this PR's surface, for cards under feat(spec,drivers,objectql,plugin-security): organization_id NOT NULL per cleared table; one predicate for Layer 0 and every driver; both orWhereNull arms, the __global__ sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212's gate ③:
    • a. resolveLocalizationContextUncached's direct fallback (packages/core/src/security/resolve-authz-context.ts :2537-2545 at the merge-base) reads sys_setting with scope: 'tenant' and no organization filter, taking the first row per key — an any-organization reading outside the settings service, with NULL rows admitted. It is reached when the settings service is absent or throws; after this PR an organization source that throws (AuthzStoreUnavailableError from tryFind, not caught by organizationReachOf by design) is one more way to reach it. Pre-existing; not this diff's.
    • b. SettingsService.bindEngine without an organization source reads as the lean composition (NULL rows) on any composition (head :1872). The in-repo production binder always wires it, but index.ts documents hosts that bind their own engine ("control-plane mocks, embedded runtimes"); such a host on a composition that registers sys_organization keeps a NULL arm and the very duplicate-insert shape this PR fixes. Suggest the service derive from the bound engine when the source is unset, or refuse. Not blocking: no served composition in this repo reaches it.
  4. Observation, no action owed: resolveGuestOrganization is asked once per no-organization read of a non-global key (and once per setMany batch); core's copy carries no memo where the engine's probe does (engine :7754). A sys_organization probe per such read is a cost, not a correctness question; resolveLocalizationContext keeps its own cache in front of it.
  5. needs:upgrade-harness: the dev judged the ceremony inventory unchanged and the label not owed; the seat applied it anyway and the lane ran green on this head. No contradiction, and the lane's result is what certifies R1–R3 here.
  6. Clause-② re-declaration from the claim's no (narrowing) to yes (narrowing) is accepted (② above); the claim said the dev measures first and re-declares from the diff, and the diff carries the widening it names.

Implemented-by: claude/issue-25202-settings-no-organization-less-rung
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-22T15:26Z. PR #25226 at 576b82834cd74ce0fc09692d4a58476d5ca822c5 (#25202; claim 6339806560; os-dev-report 6342115583). ACCEPT.

Seat check

  • Shape: 9 files, +527 / −118. check-governed-merges --pr 25226: 0 of 9 paths governed, so this is an ordinary queue landing.
  • What it does:
  • Review: CONTRACT_REVIEW_TIER, narrowing face, from an isolated reviewer: PASS 6343868210 at this head.
    • The reach is right for every kind.
    • The derived organization is the one the engine's insert path stamps.
    • No legitimate caller loses the global rung, and nothing leaks across a wall.
    • The changeset is right: patch, Clause-②: yes (narrowing) (a widening bindEngine input beside the narrowing), Metadata-protocol: none, BREAKING, and ADR-0087 not-required (no-migration-prescription).
  • CI: the latest run of every check is success or skipped. The opt-in 18.x upgrade harness lane, which the seat labelled, passed on this head; it covers R1–R3, including the PR's new 18.0.0 "settings still answer" test.
  • R4, measured before landing. ci.yml skips the dogfood shards on pull_request and merge_group, so the seat asked the dev for a walled boot. On this head, a production os serve of the showcase was run under isolated and under group, with a secret, an owner email, invite-only membership and @objectstack/organizations composed through a temporary local rig that was restored afterwards.
    • Both reached "Server is ready", and health answered 200.
    • A signed-in user with no active organization read settings 200, all defaults.
    • After creating and activating an organization, a settings write and re-read answered from the tenant rung.
    • The boot log has 0 ERROR lines, and every sys_setting row carries an organization.
    • Source: the dev session's own measurement on 576b8283. Its message to the seat is pending the session owner's approval, and the seat read the results from the session record.
  • Carried from the review's ③, filed as cards by the seat:
    • resolveLocalizationContextUncached's direct sys_setting fallback in core reads with no organization filter;
    • a host that binds SettingsService without an organization source reads as lean (NULL rows).
  • Landing: the seat flips the PR ready and enables auto-merge. Fixes #25202 closes the card.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 15:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 22, 2026 15:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 279fdcf Oct 22, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25202-settings-no-organization-less-rung branch October 22, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants