Skip to content

fix(core)!: the localization sys_setting fallback reads the caller organization only (ADR-0131 D8) - #25301

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-25248-localization-fallback-org-scope
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-25248-localization-fallback-org-scope

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #25248

Part of #15212. Filed from the contract review of #25226 (record 6343868210, ③ 3a). Claim: 6350248910. Owed under ADR-0131 D8.

Measurement (step 1, on main at 1eb1afae07)

resolveLocalizationContextUncached reads sys_setting directly when the settings service does not answer. On main that read is scope: 'tenant' with no organization filter, and it takes the first row per key.

how the fallback is reached reached on a served composition? leaks on main?
settings service absent only sys_setting's own registrar is @objectstack/service-settings (manifest.ts), so without it the read fails ("object not found"), memoized as a backend fault, and UTC / en-US answer no rows to leak, unless a host registers sys_setting itself
getMany throws: localization namespace not registered yet (bind window), or a partial manifest yes yes
getMany throws: the organization source's AuthzStoreUnavailableError (#25226, a caller that names no organization under single), or the posture source throws yes yes
a healthy service never: the manifest's UTC / en-US defaults always answer, so the fallback is skipped no
lean embedding (no sys_organization) when the service throws, as above NULL rows are the legitimate population there
each posture the fallback ignored the posture entirely yes, under single, group and isolated

Two organizations, each with a tenant localization.timezone row: can a user of one read the other's? Yes. A scratch probe (not committed) used #25226's rig: a real ObjectKernel + ObjectQL + SettingsServicePlugin, with only the driver a Map.

  • isolated, org_a and org_b, the settings service throwing (namespace not bound), caller org_a: main answers Asia/Tokyo (org_b's row). This branch answers Europe/Paris (org_a's).
  • single, two organizations, the organization read failing, caller names none: main answers org_b's Asia/Tokyo. This branch answers UTC.
  • Control, a healthy service: both answer UTC, and the fallback is not reached.

The success cache can then hold a leaked answer for up to OS_LOCALIZATION_CACHE_TTL_MS.

What changes (step 2): scoped, not retired

The fallback now reads with the reach SettingsService reads with since #25226 (localizationFallbackReach):

caller rows read
names organization X (any posture) organization_id = X
names none, single, D9 derives X organization_id = X
names none, single, zero or several organizations none (built-ins)
names none, walled (group / isolated) none, and no query is issued
names none, the organization read fails none, never a guess
names none, no sys_organization registered (lean embedding, #25133 letter A) organization_id NULL only

Why not retire it. Two things still use it. The #10221 failure memo is keyed on this direct read and keeps the "no such table" log line from repeating. And the bind-window answer is still the caller's own stored value, which the #11580 re-resolve then replaces. Scoping keeps both, with no other organization's row and no NULL row outside lean.

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

A localization read stops admitting another organization's row, or a NULL row outside lean embedding. The changeset ships @objectstack/core as patch with the BREAKING banner, the FROM/TO and the remedy. ADR-0087 disposition: not-required (no-migration-prescription).

Pins, and the ablation

resolve-localization-fallback-organization.pin.test.ts (new, 14 cases):

  • own organization, with the settings service absent and throwing AuthzStoreUnavailableError;
  • no fallback to another organization, or to a NULL row;
  • the in-memory compare, behind an engine that drops the filter;
  • single with one, several or no organizations;
  • isolated and group, for a caller naming none and a caller naming its own organization;
  • a failed organization read;
  • lean.

Existing fixtures in resolve-authz-context.test.ts, resolve-localization-cache.test.ts and plugin-hono-server's current-user-endpoints-localization.test.ts gave their rows no organization while their caller named o1 / org_lang. Their rows now carry the caller's organization. Nothing else in them changed.

Ablation, from the committed state, each restored with git checkout HEAD -- (git status clean afterwards):

ablation pins red
A1: main's fallback (no organization at all) 12 / 14
A2: no in-memory compare 1 / 14 (the filter-dropping engine)
A3: the wall ignored for a caller naming none 2 / 14 (isolated, group)
A4: the lean NULL arm removed 1 / 14 (lean)

Proof (head 0139af1fe3)

  • @objectstack/core:
    • pnpm typecheck: exit 0.
    • vitest: 117 files / 2734 tests pass.
  • Downstream consumers:
    • plugin-hono-server: 448 / 448 after the fixture change, and pnpm typecheck exit 0.
    • mcp: 532 / 532.
    • plugin-audit: 985 / 985.
    • rest: 6607 pass, 344 skipped.
  • node scripts/pm/dispatch-gates.mjs --commands: 70 derived. Each exit code was captured before any pipe, and all 70 now exit 0.
    • First pass: 66 exit 0.
    • check:objectql-double-limit and check:where-matcher were red on the new pin's engine double. They are fixed in 8c59890e8f and 0139af1fe3, and both now exit 0.
    • check-plugin-teardown-shape --self-test exited 3 (shallow clone). After git fetch --unshallow it exits 0.
    • check:dual-build-cjs-loads exited 3 (no full build). After pnpm build it exits 0.
  • check-governed-merges --branch: NOT governed (0 of 6 paths). 336 changed lines (+305 / −31). Not Tier H/S.
  • Upgrade harness (upgrade-18x-harness.dogfood.test.ts, both fixture legs and the fresh store): 16 / 16 pass.
  • R1, fresh boot: a production os serve of the showcase on an empty store under single:
    • ready in 10 s, health 200;
    • sign-up 200, sign-in 200;
    • GET /api/v1/auth/me/localization 200 (UTC / en-US).
  • R4, a walled production os serve (NODE_ENV=production, OS_PLATFORM_OWNER_EMAIL, OS_AUTH_MEMBERSHIP_POLICY=invite-only, a 32-byte OS_SECRET_KEY), on an empty store:
    • isolated and group: each ready in 9 s, health 200, sign-up 200, sign-in 200, me/localization 200.
    • isolated: PUT /api/settings/localization with timezone: Asia/Tokyo answered 200, and the next me/localization read answered Asia/Tokyo.
    • ⚠ The showcase declares no @objectstack/organizations, and a walled os serve refuses without it. For R4 only, the dependency was added locally. package.json and the lockfile were restored afterwards. None of it is in the diff.

Acceptance notes

  • main moved past 1eb1afae07 (to f47f7f222f). None of its commits touch these files, so no merge was taken (§10).
  • Seat amendment · 2026-10-22T23:10Z (epic PM session_01Y5583bs2RDd53MH6kM1ca3). The body was written at head 0139af1fe3, and three facts above have aged:

🤖 Generated with Claude Code

https://claude.ai/code/session_01DydJpxUXfktYEyRLvwbAEF

…s organization (ADR-0131 D8)

Claude-Session: https://claude.ai/code/session_01DydJpxUXfktYEyRLvwbAEF
Co-authored-by: Claude <noreply@anthropic.com>
… fixture rows with the caller's organization

Claude-Session: https://claude.ai/code/session_01DydJpxUXfktYEyRLvwbAEF
Co-authored-by: Claude <noreply@anthropic.com>
…he caller's organization

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

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

What this run could not see
  • 1 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 — 28 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 09e07c259a633eb02fdb29bedfe911e808529f88 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 09e07c259a633eb02fdb29bedfe911e808529f88

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 83ff895f4b65351f139a482ef598626b27e2df72
Local-runs: none
Scope: packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/resolve-authz-context.test.ts, packages/core/src/security/resolve-localization-cache.test.ts, packages/core/src/security/resolve-localization-fallback-organization.pin.test.ts, packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts, packages/runtime/src/security/resolve-execution-context.test.ts + .changeset/25248-localization-fallback-organization-scope.md (judged under ②), packages/core/src/security/system-write-organization.ts on origin/main (resolveSystemWriteOrganization :109-125, the D9 predicate), packages/services/service-settings/src/settings-service.ts on origin/main (callerOrganizationIdOf :141-143, organizationReachOf :1867-1877, organizationWallStands :1890-1895, withinReach :2976-2981, loadScopedRows :3006-3044 — the #25226 reach this fallback mirrors), packages/services/service-settings/src/settings-service-plugin.ts on origin/main (:334-345 the tenancyPosture and organization wiring; :499-506 "not the requested posture"), packages/plugins/plugin-auth/src/tenancy-service.ts (:109-137, the posture in force against the requested one), packages/types/src/env.ts (:147-164 resolveTenancyPosture), the four production callers — packages/core/src/security/request-execution-context.ts :274, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts :730-749, packages/mcp/src/plugin.ts :662-675, packages/plugins/plugin-audit/src/audit-plugin.ts :376-392 — docs/adr/0131-total-organization-ownership-no-null-organization-id.md D8 :663-696 and D9 :698-734, scripts/pm/clause2-line.mjs (the question the Clause-②: value answers), scripts/pm/record-recognisers.mjs :185-191 (how yes (narrowing) is faced), scripts/check-changeset-no-major.mjs (judgeLevel :1715 ff.), scripts/check-adr-0087-registration.mjs (:746-752 and the operand grammar :1196), AGENTS.md :1084-1093.

Inputs read: card #25248 body, its claim (6350248910) and the os-dev-report (6353098105); the #25226 record 6343868210 (③ 3a, this card's origin); the PR body, file list and its one comment (the docs-drift bot); net.diff (7 files, +313/−36 against 09e07c25); the 43 check-runs staged on the head; the head copies and ADR-0131 in the brief's folder. Every cited file was read by ref (83ff895f or origin/main), never from a working tree. The head is a merge of origin/main (09e07c25) into the dev's last commit 417fe64988; the seven files are byte-identical between the two, and main's 12 commits since the dev's measurement base 1eb1afae07 touch none of the cited files.

① Derived judgments

1. Which sys_setting rows the fallback now reads, per posture and caller shape. localizationFallbackReach (head :2489-2508) is asked before the direct read (:2583) and its answer is applied twice: in the query (organization_id in the where, :2597-2602) and in memory (settingRowOrganizationOf(r) === organization_id, :2609), the two-sided rule preferredRow / withinReach gives the settings service. Per shape:

  • a caller naming organization X, under every posture: own(X) (:2497) — organization_id = X, nothing else. Same as organizationReachOf :1868-1869, which also takes '' as none (callerOrganizationIdOf :141-143).
  • naming none, requested posture single, D9 derives X: own(X) (:2499-2500) through resolveGuestOrganization (:1276-1296), the same predicate the settings plugin wires as its organization source (plugin :344-345) and the engine asks on a system insert. The organization-less America/New_York row beside it is not read (pin :127-134).
  • naming none, single, zero or several organizations: refuse (no-organization / ambiguous-organization, predicate :121, :125) → unowned (:2507) → no query at all (:2593; pins :136-148 assert settingQueries empty). The service does the same (organizationReachOf :1876, loadScopedRows :3012).
  • naming none behind a requested wall (group / isolated): the predicate refuses walled-posture before any probe (:115-116) → unowned → no query, even at one organization (pins :150-156). A walled caller naming its own organization still reads it (pins :158-162).
  • naming none, the organization read failing: tryFind throws AuthzStoreUnavailableError for anything but an unprovisioned table (:440-441); the catch at :2502-2506 turns it into unowned — nothing read on a guess, no memo written (pin :165-175, settingQueries empty). An unprovisioned sys_organization table is zero organizations (:440) → refuse → unowned: the same answer by a different route.
  • no sys_organization registered (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): no-organization-object (:2501) → organization_id: null in the where and === null in memory, so the rows written with no organization and only those (pin :178-187, [null]).
    Right in every row of the table, and it is the reach fix(service-settings)!: no tenant- or user-rung settings read admits an organization-less row (ADR-0131 D8) #25226 gave the service (withinReach :2976-2981), so the fallback can never answer a row the service it stands in for would refuse — see 3 for the one direction in which it answers less.

2. No cross-organization read remains. Before (merge-base :2537-2545 as #25226 read it): scope: 'tenant', no organization, first row per key, limit: 10. After: the organization is in the query and compared again in memory; the $in over the three keys bounds own at three rows; the success cache (:2447-2456) can only hold a scoped answer. The two-organization fixture lists the OTHER organization's row first (pin :76-79) so a positional pick would be caught; the filter-dropping engine (pin :110-118) catches a reach trusted to the filter alone; the organization with no row of its own gets the built-in, not a neighbour's (pin :105-108); a NULL row is not an organization's fallback (pin :120-123). Right. The dev's real-engine probe (isolated, org_a reads org_b's Asia/Tokyo on main) is the leak the card named; it is not committed, and nothing here depends on it.

3. The posture input. The fallback reads the REQUESTED posture (resolveTenancyPosture(), env :147-164), as this file's platform-standing anchor does (:1617-1624); the settings service reads the posture IN FORCE off the tenancy service (plugin :337, :499-506). The two differ only when a wall was requested and the deployment runs degraded (OS_ALLOW_DEGRADED_TENANCY=1): the posture in force is the requested one or single and nothing else (tenancy-service.ts :109-137). In that one case the fallback reads nothing for a caller naming none where the service would derive — the fail-stricter direction, the direction the anchor already takes, and it costs only an organization-less caller's localization on a degraded wall while the settings service is also failing. Right, and the fallback is never wider than the service (3a in ③ records it as the residual it is).

4. No legitimate read is lost.

5. Callers. All four production callers hand a vetted organization: request-execution-context.ts :274 (authz.tenantId); current-user-endpoints.ts :733-742 (the request's execution context, me/localization); mcp/plugin.ts :669-674 (initial.tenantId of the resolved entry); audit-plugin.ts :376-388 (the row's organization, undefined for an organization-less record — which now derives under single and reads nothing under a wall, the plugin's own D9 comment at :390-392). Nothing client-supplied reaches tenantId. Right.

6. The failure memo. A failed derivation does not populate the memo (:2502-2506): the memo exists for the DIRECT sys_setting read throwing (:2025-2042, #11877), and the derivation is not that read. backendFailed is still set only inside the direct read's catch (:2610-2613). Right. Observation, no action: core's resolveGuestOrganization carries no memo (#25226 ③ 4 said so), so on the #10221 environment a caller naming none now repeats a failing sys_organization probe where it used to repeat a memoized sys_setting one; on a real engine the success cache (seams + TTL, :2447-2456) bounds it to one per TTL per key, since unowned returns a cacheable success. The success key is tenantId|userId (:2413) and LOCALIZATION_CACHE_WATCHED_OBJECTS is sys_setting / sys_platform_setting (:2155), so a derived answer for a caller naming none survives a second organization's creation for at most the TTL, on the failing-service path only. Bounded; noted in ③.

7. Scope, not retire — sound. Retiring would drop what #10221's memo is keyed on and the bind-window answer (#11580's re-resolve replaces a value, not a built-in), and would leave plugin-audit's and mcp's organization-less readers on built-ins whenever the service is unbound. Scoping makes the fallback a strict subset of the service's reach (1-3). The cost is a second copy of the reach rule (core :2489-2508 beside service :1867-1877 / :2976-2981); @objectstack/service-settings depends on @objectstack/core, so the one predicate D8 asks for would have to live in core — a follow-up, not this card's (③ 3b).

8. Tests edited beside the pins. The fixture rows gain the caller's organization and nothing else: makeCountingQl (:178-199) reads an absent column as NULL and refuses combinators; the cache test's t1 / t2 case asserts read counts only (:166-176), so t1 reading no o1 row leaves it exact; plugin-hono-server's rows carry ORG = 'org_lang' (:54) and the session's activeOrganizationId is the same ORG (:166); the runtime fixture's key carries active_organization_id: ORG and that file's own pin (:139-152) is what makes it the tenantId. The new pin's double applies the where, refuses every combinator, implements $in alone, honours limit and toggles registry.getObject for the lean case (:26-65). Right. The ablation (A1 12/14, A2 1/14, A3 2/14, A4 1/14) is the dev's measurement, not re-run here; each red set names the pin that owns the arm, which is the shape the card's acceptance asks for.

9. Gate verdicts on the head (43 runs): 13 success — Governed Surface Queue Guard, Dogfood Regression Gate, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Check PR Size, Auto Label, filter, Flag docs affected by code changes, Check Documentation Links, the claim check, the part-of check, the single-writer-path check and the same-issue check; 10 skipped (event- or path-filtered, the dogfood shards and Dogfood Verify CLI among them, as on #25226); 20 in_progress — Test Core 1-6, Build Core, Check Changeset (twice), the four Type Check lanes, seven Lint lanes, the 18.x upgrade harness; 0 failures. The dev reports all 41 runs green on 417fe64988, whose seven files this head carries unchanged; the seat confirms the 20 on this head before posting. check-governed-merges --branch: 0 of 7 paths governed, not Tier H/S.

② Semver level

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

  • Level — right. Under the house rule (AGENTS.md :1091-1093; judgeLevel) the level follows the metadata-protocol kind alone: none takes patch, and a non-protocol breaking change ships patch with its banner and ADR-0087 disposition. The diff changes which stored row a degraded read takes; no authorable key, value, type, default or meaning changes, so none is the right kind and patch the right level. Both carriers are present.
  • ADR-0087 marker — right. no-migration-prescription is honest: nothing exported is removed or renamed (resolveLocalizationContext keeps its signature), the remedy is an operator data action (the ownership ceremony, re-saving each value inside its organization). Mechanically safe: the FROM: / TO: bullets carry no arrow, and Setup → Localization has no code-ish operand on either side (operand grammar :1196: a backticked span or a dotted/slashed path), the same shape fix(service-settings)!: no tenant- or user-rung settings read admits an organization-less row (ADR-0131 D8) #25226's Setup → the settings page passed on.
  • Clause-②: yes (narrowing) — the value is wrong; no (narrowing) is right. The value answers one question (clause2-line.mjs): does this card widen an accept set or expand the public surface. This diff widens nothing: ResolveLocalizationInput and the module's export set are unchanged; localizationFallbackReach and settingRowOrganizationOf are module-private; no new env var, no new accepted shape, no new error, no new route. The one new demand on ql — a null comparand honoured as IS NULL — tightens what a ql must do, it does not widen. The dev's reason ("follows fix(service-settings)!: no tenant- or user-rung settings read admits an organization-less row (ADR-0131 D8) #25226's declaration for the same narrowing") misreads that record: fix(service-settings)!: no tenant- or user-rung settings read admits an organization-less row (ADR-0131 D8) #25226's yes was earned by bindEngine's new optional organization input (its ② paragraph says so in those words), and this diff carries no such widening. The arm (narrowing) is right: an accepted read input stops answering. Face and level are unaffected either way — yes (narrowing) already routes to the narrowing face (record-recognisers :187-188) and judgeLevel reads the kind — so the correction is gate-neutral: the value on the changeset's line 7 and on the PR body becomes no, and nothing else in either carrier changes.
  • Who is affected / remedy match the measurement: a healthy service never reaches the read; only a store holding organization-less tenant localization rows, or an organization with no row of its own, sees a different answer while the service fails.

③ Boundary flags

  1. Dev flag — check-plugin-teardown-shape --self-test (shallow) and check:dual-build-cjs-loads (no build) exited 3 locally, 0 after unshallow / build. Answered: the Lint and Build lanes that run them are in progress on this head with 0 failures so far; the dev reports them green on 417fe64988, identical in these files. The seat confirms.
  2. Dev flag — @objectstack/organizations added to the showcase locally for R4, then restored. Answered: the file list is the seven files and no manifest; nothing of it is in the diff. That a walled os serve of the showcase refuses without the package is pre-existing and not this card's.
  3. open_questions: [] — nothing to answer. Three observations the dev did not list, none blocking:
  4. out_of_scope_findings (a) — the PR body predates 417fe64988 and this merge head. Answered: the body names a 6-file size, proof head 0139af1fe3, and says no merge was taken; the head is a merge of origin/main into 417fe64988 (made 22:54Z, after the os-dev-report), 7 files, +313/−36. Judged the code, as the brief says; the seat may amend the body.
  5. out_of_scope_findings (b) — the Clause-② re-declaration to yes (narrowing). Not accepted — ② above. The claim said the dev re-declares from the diff, and the diff carries no widening to earn yes. This is the one item behind the verdict; the remedy is the value on both carriers.
  6. Deviation 1 — the first edit was made in the primary checkout through a python write the guard hook does not see; work then moved to a linked worktree, the primary left detached at origin/main and clean. Escalated to the seat as a process matter: the committed tree is what this record judges and it is right; whether a guard bypass in the dev container needs a note of its own is the seat's call, not a code question.
  7. Deviation 2 — builds and package tests ran outside os-verify-lock.sh; only the dogfood battery ran under it, in a container with no other session. Answered: the check-runs on the head are the gate; local runs are advisory, and the battery the lock exists for ran under it.
  8. CI still running when staged. The verdict rests on the code and on what has completed (0 failures); the seat confirms Test Core, Build Core, Check Changeset, the Type Check and Lint lanes and the upgrade harness on 83ff895f before posting, and this record does not stand if any of them fails.

Implemented-by: claude/issue-25248-localization-fallback-org-scope
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8e9e7d48fc79bf28989970391209dd0299045d6a
Local-runs: none
Scope: packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/resolve-authz-context.test.ts, packages/core/src/security/resolve-localization-cache.test.ts, packages/core/src/security/resolve-localization-fallback-organization.pin.test.ts, packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts, packages/runtime/src/security/resolve-execution-context.test.ts + .changeset/25248-localization-fallback-organization-scope.md (judged under ②), packages/core/src/security/system-write-organization.ts on origin/main (resolveSystemWriteOrganization :109-125, the D9 predicate), packages/services/service-settings/src/settings-service.ts on origin/main (callerOrganizationIdOf :141-143, organizationReachOf :1867-1877, organizationWallStands :1890-1895, withinReach :2976-2981, loadScopedRows :3006-3044 — the #25226 reach this fallback mirrors), packages/services/service-settings/src/settings-service-plugin.ts on origin/main (:334-345, :499-506), packages/plugins/plugin-auth/src/tenancy-service.ts (:109-137, the posture in force against the requested one), packages/types/src/env.ts (:147-164 resolveTenancyPosture), the four production callers — packages/core/src/security/request-execution-context.ts :274, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts :730-749, packages/mcp/src/plugin.ts :662-675, packages/plugins/plugin-audit/src/audit-plugin.ts :376-392 — docs/adr/0131-total-organization-ownership-no-null-organization-id.md D8 :663-696 and D9 :698-734, scripts/pm/clause2-line.mjs on origin/main (:90-118 the question the value answers and the four spellings; :144 CLAUSE2_KEY_LINE; :152, :200-211 the near-miss reporters; :443-490 readClause2Line), scripts/check-changeset-no-major.mjs on origin/main (ruling A :899-943; judgeLevel :1916-2069, the lane table :1860-1906, the none lane :2050-2056), scripts/check-adr-0087-registration.mjs on origin/main (:699-700 the arm read; :746-752 and :1196 the rewrite and operand grammar), scripts/pm/record-recognisers.mjs :185-191 (identical to origin/main), AGENTS.md on origin/main :1084-1093.

Inputs read: everything the first record on this PR read (6353329341, on 83ff895f4b), plus the delta: git diff 83ff895f4b 8e9e7d48fc (one file, one line); the restaged PR (gh-r2/pr.json: head 8e9e7d48fc, base 09e07c259a, 7 files, +313/−36, 10 commits, draft, the seat amendment of 23:10Z at the foot of the body), its two comments (the docs-drift bot and the first record), the card's two comments (byte-identical to the first round: the claim 6350248910 and the os-dev-report 6353098105), the file list and the 24 check-runs on the new head. The dev's follow-up measurements on 83ff895f4b reached this record as the seat's relay, not as a staged comment; their transfer to this head is judged on the diff below, which was verified here and not taken on anyone's word. Every cited file was read by ref (8e9e7d48fc, 83ff895f4b or origin/main), never from a working tree.

The delta. 8e9e7d48fc is one commit on 83ff895f4b (23:11Z, "chore(changeset): Clause-② is no (narrowing) — the diff widens no accept set"). Its whole diff is line 7 of .changeset/25248-localization-fallback-organization-scope.md: Clause-②: yes (narrowing) became Clause-②: no (narrowing). No other byte of any file moved; the six code and test paths are byte-identical to 83ff895f4b and, through it, to the dev's green head 417fe64988. The merge-base is still origin/main (09e07c259a), so the net diff against main is the same seven files, +313/−36.

A correction to the first record's citations, not its judgments. Record 6353329341 cited scripts/check-changeset-no-major.mjs (judgeLevel :1715 ff.) and scripts/pm/clause2-line.mjs by line from the container's working checkout, which is 266 commits behind origin/main and differs in both files. This record re-cites both from origin/main. The substance is the same at main — the value's question is unchanged (:90-91), the four spellings are written out (:111-118), and ruling A's "the level follows the kind alone" is the gate's own text (:899-907, :1903-1906) — so no judgment moves; the line numbers do.

① Derived judgments

Carried forward from 6353329341 unchanged: the delta touches none of the six code or test paths, so every judgment below was made on code that is byte-identical at this head. Each is restated so this record stands on its own; item 9 is new to this head.

1. Which sys_setting rows the fallback reads, per posture and caller shape — right. localizationFallbackReach (:2489-2508) is asked before the direct read (:2583) and applied in the query (organization_id in the where, :2597-2602) and again in memory (:2609), the two-sided rule the settings service's preferredRow / withinReach gives. A caller naming X: own(X) under every posture (:2497; organizationReachOf :1868-1869 and callerOrganizationIdOf :141-143 agree, '' is none). Naming none under requested single with D9 deriving X: own(X) through resolveGuestOrganization (:1276-1296), the predicate the settings plugin wires (:344-345); the organization-less row beside it is not read (pin :127-134). Naming none under single at zero or several organizations: refuse (:121, :125) → unowned → no query (:2593; pins :136-148). Naming none behind a requested wall: walled-posture refused before any probe (:115-116) → unowned, even at one organization (pins :150-156); a walled caller naming its own organization still reads it (pins :158-162). The organization read failing: tryFind throws for anything but an unprovisioned table (:440-441), the catch at :2502-2506 answers unowned, no memo (pin :165-175); an unprovisioned sys_organization is zero organizations → refuse → unowned. No sys_organization registered (lean, #25133 letter A): no-organization-object → organization_id: null in the where and === null in memory (pin :178-187). This is the reach #25226 gave the service (withinReach :2976-2981, loadScopedRows :3012, :3030-3033).

2. No cross-organization read remains — right. The merge-base read had no organization and took the first row per key under limit: 10; now the organization is in the query and compared again, the $in bounds own at three rows, and the success cache (:2447-2456) can hold only a scoped answer. The two-organization fixture lists the other organization first (pin :76-79); the filter-dropping engine (pin :110-118), the organization with no row (pin :105-108) and the NULL row (pin :120-123) each pin an arm.

3. The posture input — right, with a recorded residual. The fallback reads the REQUESTED posture (resolveTenancyPosture(), :147-164) as the anchor does (:1617-1624); the service reads the posture IN FORCE (plugin :337, :499-506). In force is the requested posture or single under degradation and nothing else (tenancy-service.ts :109-137), so the fallback is never wider than the service; on a requested wall running degraded it reads nothing for a caller naming none where the service would derive — the fail-stricter direction (③ 3a).

4. No legitimate read is lost — right. Lean kept (1); a lean composition that nonetheless requests a wall reads nothing, and the engine's write side refuses in the same order (:115-122), so no such row exists to lose. A fresh single boot: zero organizations → built-ins (R1); a signed-in user names its active organization → own; one holding none → the derived organization. An 18.0-born store: owned rows → own; a v17 store under single is attributed by the D10 ceremony; under a wall its NULL rows answer nobody, as #25226 ruled, and the changeset's remedy names the ceremony. group: own(X) alone, as the service.

5. Callers — right. request-execution-context.ts :274 (authz.tenantId), current-user-endpoints.ts :733-742, mcp/plugin.ts :669-674 (initial.tenantId), audit-plugin.ts :376-388 (the row's organization; undefined for an organization-less record, which now derives under single and reads nothing under a wall, the plugin's own D9 comment :390-392). Nothing client-supplied reaches tenantId.

6. The failure memo — right. A failed derivation does not populate it (:2502-2506); backendFailed is set only in the direct read's catch (:2610-2613), the fault the memo exists for (:2025-2042). Observation: core's probe is unmemoized and the success key (:2413) omits the derived organization, with LOCALIZATION_CACHE_WATCHED_OBJECTS at sys_setting / sys_platform_setting (:2155); on a real engine the success cache bounds both to one TTL, on the failing-service path only (③ 3c).

7. Scope, not retire — sound. Retiring would drop #10221's memo subject, the bind-window answer #11580's re-resolve replaces, and the organization-less readers in plugin-audit and mcp; scoping makes the fallback a strict subset of the service's reach. The cost is a second copy of the reach rule in core, which the dependency direction forces (③ 3b).

8. Tests — right. Fixture rows gain the caller's organization and nothing else; makeCountingQl (:178-199) reads an absent column as NULL and refuses combinators; the cache test's t1 / t2 case asserts counts only (:166-176); plugin-hono-server's ORG = 'org_lang' (:54) is the session's activeOrganizationId (:166); the runtime key's active_organization_id: ORG is what that file's own pin (:139-152) makes the tenantId. The new pin's double applies the where, refuses combinators, implements $in alone, honours limit and toggles registry.getObject (:26-65). The ablation (A1 12/14, A2 1/14, A3 2/14, A4 1/14) is the dev's measurement, not re-run.

9. Gate verdicts on this head (24 runs staged at 23:12Z, two minutes after the push): 9 success — Auto Label, the claim check, the part-of check, the single-writer-path check, the same-issue check, Lint · scoped · tooling self-tests, Check Documentation Links, Flag docs affected by code changes, Check PR Size; 1 skipped (Packed-tarball smoke, opt-in); 14 in_progress — seven Lint lanes (PM dispatch-gates among them), the four Type Check lanes, Governed Surface Queue Guard, Check Changeset, 18.x upgrade harness; 0 failures. Test Core, Build Core and the Dogfood Regression Gate had not yet reported at staging. The dev reports all 41 runs green on 417fe64988 and the seat's relay reports pnpm build VERDICT 0, core security 48 files / 1042 tests, the harness 16/16, R1 under single and R4 under group and isolated (the tenant PUT of Asia/Tokyo reading back) on 83ff895f4b; both heads carry these six files byte-for-byte, and nothing those runs execute reads a changeset, so the transfer holds for every lane but the two that read it — Check Changeset and the queue guard — which ② judges and the seat confirms on this head before posting. check-governed-merges --branch: 0 of 7 paths governed, not Tier H/S.

② Semver level

Judged afresh on both carriers as they now stand. Changeset .changeset/25248-localization-fallback-organization-scope.md at 8e9e7d48fc: '@objectstack/core': patch; summary fix(core)!: …; Clause-②: no (narrowing) (line 7); Metadata-protocol: none (line 8); the **BREAKING** banner; FROM / TO and the remedy; the adr-0087 marker not-required (no-migration-prescription) with its why — all but line 7 byte-identical to the first round. PR body as amended by the seat at 23:10Z: Clause-②: no (narrowing) (line 45) and Metadata-protocol: none (line 46), bare and line-initial; the amendment's own bullet (line 102) names the key in backticks inside a sentence with no colon after it, so it is neither a declaration nor malformed under CLAUSE2_KEY_LINE (:144) nor an inline key (:200-211); it qualifies only as a spelling near-miss candidate (:152), which readClause2Line never returns once a declaration has been read (:480). Both carriers therefore read no (narrowing) + none, and they agree.

  • Clause-②: no (narrowing) — right. The value answers one question (clause2-line.mjs :90-91, 本卡放宽接受集或扩大公开面吗): does this card widen an accept set or expand the public surface. It does not: ResolveLocalizationInput and the module's export set are unchanged, localizationFallbackReach and settingRowOrganizationOf are module-private, no new env var, shape, error or route; the one new demand on ql — a null comparand honoured as IS NULL — tightens. no (narrowing) is the spelling the reader defines for exactly this (:115: "NOT a widening, but breaking"), where yes (narrowing) is "a diff that widens one surface and narrows another" (:113) — fix(service-settings)!: no tenant- or user-rung settings read admits an organization-less row (ADR-0131 D8) #25226's case, earned there by bindEngine's new organization input, and not this diff's. The arm is right: an accepted read input (another organization's row, a NULL row outside lean) stops answering, a breaking narrowing.
  • Level — right. Under ruling A (AGENTS.md :1091-1093; the gate's own text :899-907) the level follows the metadata-protocol kind alone and the Clause-② value never stands the axis down (:1903-1906). none is the right kind — no authorable key, value, type, default or meaning changes — and the none lane (:2050-2056) is none-clean here: the one introduced entry is patch, nothing is graded minor or above, and no authorable-surface/ or authorable-defaults/ shard moves in the seven files. Neither contradiction lane applies (:2043-2048: the kind is not added under no, and it is not narrowed). So the one-line delta is gate-neutral for the level — the same lane answers yes (narrowing) and no (narrowing) alike — and the gate that read yes (narrowing) green on 417fe64988 has the same inputs here but for a value it does not route on.
  • ADR-0087 marker — right, and unmoved by the delta. The registration gate reads the ARM, not the value (:699-700: decl.arm === 'narrowing' pushes the breaking signal), so the banner and marker it demands are the same as before and both are present. no-migration-prescription is honest: nothing exported is removed or renamed, the remedy is an operator data action. Mechanically safe: the FROM: / TO: bullets carry no arrow, and Setup → Localization has no code-ish operand on either side (:746-752, :1196).
  • Face — unmoved. The narrowing arm on either value routes to the narrowing face at this tier (record-recognisers :187-188), so the review owed did not change with the value, and this record is that review.
  • Who is affected / remedy match the measurement: a healthy service never reaches the read; only a store holding organization-less tenant localization rows, or an organization with no row of its own, sees a different answer while the service fails.

③ Boundary flags

Items 1-3 and 6-7 are carried forward from 6353329341 unchanged — the delta touches none of what they answer; items 4, 5 and 8 are re-answered on this head.

  1. Dev flag — check-plugin-teardown-shape --self-test (shallow) and check:dual-build-cjs-loads (no build) exited 3 locally, 0 after unshallow / build. Answered: the Lint and Build lanes that run them are in progress here with 0 failures so far and were green on 417fe64988, byte-identical in every file they read. The seat confirms.
  2. Dev flag — @objectstack/organizations added to the showcase locally for R4, then restored. Answered: the file list is the seven files and no manifest. The showcase's walled refusal without the package is pre-existing and not this card's.
  3. open_questions: [] — nothing to answer. Three observations, none blocking: a. the posture divergence (① 3) is bounded to a degraded wall, where the fallback is stricter; b. two copies of the reach rule (① 7) — a follow-up 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 ③ could hoist OrganizationReach and withinReach into core beside resolveSystemWriteOrganization; c. the unmemoized probe and the cache key without the derived organization (① 6), bounded by the success cache on a real engine, reachable only while the service fails.
  4. out_of_scope_findings (a) — the PR body predates 417fe64988. Answered on this head: the seat's dated amendment at the foot of the body restates the aged facts (7 files, +313/−36; the runtime fixture; the merge of origin/main at 83ff895f4b; the proof's home in the os-dev-report) and corrects the Clause-② line at :45. The stale sentences above it are left standing and superseded in the amendment's own words. Nothing is owed.
  5. out_of_scope_findings (b) — the Clause-② re-declaration. Closed: the first record refused yes as unearned; the dev's 8e9e7d48fc and the seat's body amendment both now read no (narrowing), which ② judges right on both carriers.
  6. Deviation 1 — the first edit was made in the primary checkout through a python write the guard hook does not see; work then moved to a linked worktree, the primary left detached at origin/main and clean. Escalated to the seat as a process matter, as before: the committed tree is what this record judges and it is right.
  7. Deviation 2 — builds and package tests ran outside os-verify-lock.sh; only the dogfood battery ran under it. Answered: the check-runs on the head are the gate; local runs are advisory, and the battery the lock exists for ran under it.
  8. The dev's follow-up transfer argument, and CI still running. The argument that pnpm build, the core security suite, the harness, R1 and R4 measured on 83ff895f4b hold for 8e9e7d48fc because only the changeset line changed is accepted — on the diff this record verified, not on the claim: none of those runs reads a changeset. The two things that do read it, Check Changeset and the queue guard, are in progress here and judged gate-neutral in ②. The verdict rests on the code and on what has completed (0 failures); the seat confirms Test Core, Build Core, Check Changeset, the Governed Surface Queue Guard, the Type Check and Lint lanes and the upgrade harness on 8e9e7d48fc before posting, and this record does not stand if any of them fails.

Implemented-by: claude/issue-25248-localization-fallback-org-scope
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-22T23:47Z. PR #25301 at 8e9e7d48fc79bf28989970391209dd0299045d6a (#25248; claim 6350248910; report 6353098105). ACCEPT.

Seat check

  • Shape: 7 files, +313 / −36: core's resolve-authz-context.ts, its pins and fixtures, and one changeset. check-governed-merges --pr 25301: not governed, so this is an ordinary queue landing.
  • Review tier: Clause-②: no (narrowing), so CONTRACT_REVIEW_TIER on the narrowing face.
    • Round 1 at 83ff895f4b: FAIL on ② alone (6353329341). The value said yes (narrowing), and the diff widens nothing.
    • Fixed: the dev corrected the changeset line in 8e9e7d48fc, and the seat amended the PR body, the gate's carrier.
    • Round 2 on this head: PASS (6353849043).
  • What it does (ADR-0131 D8): when the settings service cannot answer, core's direct sys_setting localization fallback now reads only:
    • the caller's organization's rows;
    • under single, for a caller naming none, the D9-derived organization;
    • nothing behind a requested wall, at zero or several organizations, or when the organization read fails;
    • NULL rows only where no sys_organization is registered (lean embedding).
      On main, an isolated caller of org_a read org_b's timezone through it.
  • Scoped, not retired: accepted. The 新环境日志被 sys_setting 'no such table' ERROR 刷屏:本地化读取先于建表,真错误被噪音淹没 #10221 failure memo and the bind-window answer depend on this read.
  • Follow-ups, not blocking (on the 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 ledger):
  • CI on the head: 41 check-runs, every one success or skipped, including Test Core, Build Core, TypeScript Type Check, Governed Surface Queue Guard, Check Changeset and the labelled 18.x upgrade harness (opt-in) lane. Mergeable clean. main is merged in at 83ff895f4b (09e07c259a).
  • R1–R4, measured by the dev on 83ff895f4b. 8e9e7d48fc changes only the changeset line, so they hold for this head.
    • Build under the verify lock: exit 0. Core security: 48 files / 1042 tests.
    • R2 and R3: the harness passed 16/16, both fixture legs.
    • R1: a production os serve under single on a fresh store answered health, auth and me/localization 200.
    • R4: under group and isolated, health, auth and localization answered 200. Under isolated, a tenant localization PUT (Asia/Tokyo) reads back.
  • Changeset: @objectstack/core patch, fix(core)!, **BREAKING**, Clause-②: no (narrowing), Metadata-protocol: none, ADR-0087 not-required (no-migration-prescription). Right under the house rule.
  • Landing: the seat flips the PR ready and enables auto-merge. Fixes #25248 closes the card.

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

Labels

Projects

None yet

2 participants