Skip to content

Commit 05b28c1

Browse files
docs(adr): ADR-0123 D2 records the two inserts its refusal now answers — the union-scope insert and the crossing operator's (#24918)
Fixes #24823 Clause-②: no ADR-0123 D2's text was narrower than the runtime after two merged changes. PR #24819 (`8a058d85a`) made the D2 refusal answer a union-scope insert from a session with no active organization. PR #24868 (`98c3d00447`) made it answer a crossing platform operator's insert from such a session. This PR records both in a dated amendment on the ADR. It is docs only: one file, no package touched, no code, no test. ## What changed: `docs/adr/0123-no-active-organization-session-semantics.md` only (+9 / −2) 1. **Status block.** A dated `Amendment (2026-10-20)` blockquote under the `**Status**:` line, in the shape PR #24756 gave ADR-0047 (`795ff708d`): a bold lead with its provenance in parentheses, then the body. It records: - both arms are D2's own refusal (`403 PERMISSION_DENIED`, naming the missing active organization, D4), thrown from one place, anchored as `packages/plugins/plugin-security/src/security-plugin.ts#refuseNoActiveOrganization`; - neither arm changes an outcome. Each insert was already refused by the engine's organization check (ADR-0131 D9) with `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Only the answer moved; - arm 1, the union-scope insert (PR #24819), and its reason: a new row lands in the active organization (ADR-0105 D5); - arm 2, the crossing platform operator (PR #24868), and its reason: crossing (ADR-0095 D3) lets the operator read every organization's rows and update them where they are, but it gives a new row no organization. ADR-0131 D9 refused that write already; - the reach of both arms: they judge the stored image after the `beforeInsert` chain. Reads, updates of existing rows, deletes, inserts from a session with an active organization, and system contexts are unchanged. 2. **D2's scope paragraph** keeps its text as written. A dated italic pointer at its end says that the operator clause and the "empty membership set" sentence bound the scope pre-test, not every write, and it names the two inserts refused past them. 3. **Consequences, What does not change.** "Platform operators." is struck in place, not deleted, as PR #24756 did on ADR-0047. It is followed by the corrected entry ("Platform operators' reads, their updates of existing rows, and their inserts from a session with an active organization") and a dated italic note naming the changed insert. No other ADR text moves. The `**Status**:` line, the TL;DR, D2's bullets, D3, D4 and the "What changes" paragraph are untouched. ### Before / after Scope paragraph, last sentence. Before (`origin/main` `a8b44d407`, line 60): > Under `group`, "no organization scope" means an **empty membership set**, matching ADR-0105 D2's own fail-closed rule. After (line 67): the same sentence, then: > *(Amended 2026-10-20 — the operator clause and the "empty membership set" sentence bound the scope pre-test, not every write. Two inserts from a session with no active organization, of a row that names no organization, are refused past them: a `group` member's with a non-empty membership set, and a platform operator's crossing the wall. See the amendment in the status block above.)* What does not change. Before (line 78): `Reads. System contexts. Platform operators. Single-posture deployments …` After (line 85): `Reads. System contexts. ~~Platform operators.~~ Platform operators' reads, their updates of existing rows, and their inserts from a session with an active organization. *(Corrected by the 2026-10-20 amendment — …; see the status block above.)* Single-posture deployments …` Rendered locally with `marked` 16.4.2 (GFM): - the amendment renders as one blockquote: a paragraph, a two-item ordered list, a paragraph; - `Platform operators.` renders inside a `del` element; - the scope paragraph ends in the italic pointer. A blank line follows the blockquote, so the `**Deciders**` / `**Builds on**` / `**Consumers**` / `**Surfaced by**` lines stay one paragraph of their own and are not folded into the quote by lazy continuation. One visible side effect: the `**Status**:` line now renders as its own paragraph, where before it ran into the Deciders paragraph. ## Premise readings (the dispatch's mechanism hypotheses H1–H3) Base: the worktree was created at `origin/main` `a8b44d407`. The dispatch named `fcb40ad67`. `git log fcb40ad..origin/main -- docs/adr/0123-no-active-organization-session-semantics.md` is empty, so the ADR did not move between the two. - **H1 holds, read from the merged code** (`security-plugin.ts` step 3.7 at `a8b44d407`), not from either PR's body: - `git merge-base --is-ancestor 98c3d00 HEAD` exits 0. `8a058d85a` is in `git log` of `security-plugin.ts` on the same tree. - Union-scope arm, `insertLacksActiveOrganization`. It needs all four: the operation is `insert`; `postureUsesUnionScope` holds; the caller's own Layer 0 is non-null; `carriesActiveOrganization(context)` is false. - Operator arm, `operatorInsertLacksActiveOrganization`. It needs all three: the operation is `insert`; `callerCrossesTenantWall` holds (from `computeWriteTenantWall`'s `crossedByPlatformAdmin`); there is no active organization. - Both arms throw `refuseNoActiveOrganization('caller', …)` inside the `postHookWriteImageCheck` seam, and only when some stored row names no organization. - The enclosing guard requires `opCtx.context.userId`, and `isSystem` short-circuits the middleware earlier, so system contexts never reach either arm. - Neither arm fires on `update` or `delete`. - **H2 holds, with one reading added.** The ADR-0047 amendment (`795ff708d`) puts a status-block blockquote under the `**Status**:` line and strikes the affected text in place, followed by an italic pointer. That is the form matched here. The repo has other forms too: a `## Amendment (date, #n): …` section is the most common, and ADR-0099 carries an inline `> **Amendment note (date):**` beside the corrected row. ADR-0123 had no amendment chain of its own to join. - `check-adr-symbol-anchors` reads backticked `path#symbol` tokens. The one anchor added (`security-plugin.ts#refuseNoActiveOrganization`) is extracted from the new file and not from the base file. It resolves as `declaration` (a `const` binding). A probe with an unknown symbol on the same source resolves `null`. The gate's total went to 2370 anchors, all resolving. - Every other symbol named in the note is a bare identifier, which the gate does not read. Each exists on `a8b44d407`. - **H3 holds.** The scope paragraph was line 60 and the **What does not change** line was line 78, on `fcb40ad67` and on `a8b44d407`. ## Gates: all at `e9913cb22`, the final commit `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` was re-derived on the actual diff, with no paths. It gives the same 20 commands as the dispatch. Each ran with its output redirected first and its exit captured before any read. `--ran` reads `20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero. | command | exit | |---|---| | `node scripts/check-adr-links.mjs` | 0 — 770 relative link destinations under docs/adr/ resolve | | `node scripts/check-adr-links.mjs --self-test` | 0 | | `node scripts/check-adr-symbol-anchors.mjs` | 0 — 2370 anchors across 142 records resolve, 0 line anchors | | `node scripts/check-adr-symbol-anchors.mjs --self-test` | 0 | | `node scripts/check-ci-filter-parity.mjs` | 0 | | `node scripts/check-closing-keyword-parity.mjs` | 0 | | `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | | `node scripts/check-comment-mask-corpus.mjs` | 0 — 9691 files, 0 disagree | | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` | 0 | | `pnpm check:adr-anchors` | 0 | | `pnpm check:closed-ceremony-text` | 0 | | `pnpm check:cross-package-test-inputs` | 0 | | `pnpm check:doc-authoring` | 0 | | `pnpm check:driver-memory-census` | 0 | | `pnpm check:gitlink-declared` | 0 | | `pnpm check:nul-bytes` | 0 — no raw ASCII control bytes | | `pnpm check:pm-governed-merges` | 0 | | `pnpm check:pm-prior-rulings` | 0 | | `pnpm check:refd-timer-probe` | 0 | | `pnpm check:watch-hint-literal` | 0 | - `check:doc-formula-expressions` reads the built `@objectstack/formula` / `@objectstack/lint`. Those were built first, under the verify lock (`turbo run build --concurrency=2`, 4/4 tasks, `VERDICT command-exit 0`). - No package is touched, so there is no dependency-closure build, no package test or typecheck, and no ablation: the change is documentation, and no tested behaviour moves. - There is no changeset: `docs/adr/**` publishes nothing, so this PR carries `skip-changeset`. - Not run locally, and owned by CI: the repo-wide scans and the families outside the derived total. ## Acceptance notes - **Two developer messages in step 3.7 still end "System contexts and platform operators are unaffected."** One is on the scope pre-test's refusal, the other on the union-scope arm's. Each is true of its own arm, because a crossing operator never meets either of them; it meets the operator arm, whose text says only "System contexts are unaffected." Read as a claim about D2 as a whole, it no longer holds after this amendment. This is an observation, not filed: a read-only inference, with no public door measured. Carrier: none. This PR changes no code. - **`canWriteObject`'s row-less create preview still answers `true`** for both arms' callers. PR #24819 and PR #24868 each recorded this as a known boundary in their own acceptance notes. The amendment does not restate it. Carrier: none. - **The `**Status**:` line is left alone.** It still reads "Accepted (2026-08-13)". The dated amendment block carries the 2026-10-20 change, as ADR-0047's amendment did. ## 维护者速读(草稿) **改了什么**:只动了一个文件 ADR-0123(“已登录但没有当前组织”的会话语义),三处改动,都标了日期 2026-10-20: - 文件顶部状态区下面加了一段修订说明,写明两种新被拒的“新建记录”情形以及各自的理由。 - D2 的适用范围那一段原文不动,只在段尾加一句指向修订说明的注。 - “不变的部分”里原来写着“平台运维人员”,现在用删除线划掉,改成更准确的说法,并加了注。 **为什么改**:两次已合并的修复让运行时的行为比 ADR 原文写的更宽: - PR #24819:在 `group` 多组织模式下,没有当前组织的成员新建记录,现在返回 403 并说明“没有当前组织”。新记录要落在当前组织里,没有当前组织就无处可落。 - PR #24868:平台运维人员(可以跨组织墙的人)在没有当前组织时新建记录,现在也返回同一个 403。 这两种写入以前本来就被拒绝,只是返回的是看不出原因的 500。现在换成能看懂原因的 403。原文还写着“平台运维人员不受影响”,已经不准确。按第一指令 13,ADR 要如实反映已落地的行为;否则后来读 ADR 的人(和 AI)会误以为运维人员没有当前组织也能新建记录。 **风险与代价(含回滚)**:纯文档改动,没有运行时影响,不发包,没有 changeset。回滚就是 revert 这一个提交。 **席位意见**: **你要做的**:审阅这处 ADR 修订;同意的话,在 PR 上点 Approve(席位随后落地)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent be1fd31 commit 05b28c1

1 file changed

Lines changed: 9 additions & 2 deletions

File tree

‎docs/adr/0123-no-active-organization-session-semantics.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# ADR-0123: An authenticated session with no active organization is a legal state — reads resolve to nothing, tenant-scoped writes are refused loudly
22

33
**Status**: Accepted (2026-08-13)
4+
> **Amendment (2026-10-20) — D2's refusal also answers two inserts its scope paragraph did not name, and the "What does not change" line no longer lists platform operators whole (#24823; PR #24819, `8a058d85a`, and PR #24868, `98c3d00447`).** Both arms are D2's own refusal — `403 PERMISSION_DENIED`, naming the missing active organization (D4) — thrown from the one place it is spelled (`packages/plugins/plugin-security/src/security-plugin.ts#refuseNoActiveOrganization`, step 3.7). Neither changes an outcome: each insert was already refused by the engine's organization check (ADR-0131 D9) as an organization-less system write, answered `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` with its prose withheld from the wire. Only the answer moved, to the one this record gives the state.
5+
>
6+
> 1. *A union-scope insert with no active organization (PR #24819).* Under `group`, the scope pre-test reads the membership set, so a member whose session has no active organization passes it — rightly, for reads, and for updates and deletes of rows the membership wall selected, which keep the organization they already have. An insert needs somewhere new to land, and a new row lands in the active organization: the engine stamps it from `tenantId` (ADR-0105 D5). With none, the insert is refused, as `isolated` already refused the same session.
7+
> 2. *A platform operator crossing the wall (PR #24868).* A platform operator crossing the wall on a posture-permitting object (ADR-0095 D3) has no Layer 0 there, so neither the pre-test nor arm 1 reaches it, under `isolated` or `group`. Crossing lets it read every organization's rows and update them where they are; it gives a new row no organization. From a session with no active organization, its insert is refused. ADR-0131 D9 refused that write already: "Nothing defaults to any owner other than the derivable one."
8+
>
9+
> Both arms judge the insert's stored image — the rows after the `beforeInsert` chain, the ones the engine's check reads next — so they refuse exactly the rows that met the 500. A stored row that names an organization (a hook placed it) is not refused by either arm and is judged as before; one row that names none refuses the whole write. Reads, updates of existing rows, deletes, inserts from a session with an active organization, and system contexts are unchanged. D2's scope paragraph is kept as written, with a pointer here: its operator clause and its "empty membership set" sentence still bound the scope pre-test. The **What does not change** line is corrected in place.
10+
411
**Deciders**: ObjectStack Protocol Architects (maintainer ruling on [#8247](https://github.com/objectstack-ai/objectstack/issues/8247), 2026-08-13)
512
**Builds on**: [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) (D1 — Layer 0 is an independent, always-first, AND-composed tenant filter; W1/W2), [ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) (D1/D2 — the posture spectrum and the `group` union wall; "empty/absent scope → deny"), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (D1/D2 — the membership reconciler as the single owner of the "every user gets a membership" invariant, and the `session.create.before` hook that resolves `activeOrganizationId` from `sys_member`), [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (D3 — the closed error-code vocabulary this refusal draws from rather than extending), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a declared wall the write path does not meet is the defect this record closes)
613
**Consumers**: `@objectstack/plugin-security` (`security-plugin.ts` step 3.7, `tenant-layer.ts`), `@objectstack/plugin-auth` (`auth-manager.ts` `composeDatabaseHooks`, `reconcile-membership.ts`), `@objectstack/plugin-sharing` (`sharing-rule.ts` — already conforming), `@objectstack/plugin-audit` (`auth-session-audit.ts` login/logout rows)
@@ -57,7 +64,7 @@ So both halves were individually defensible and jointly produced a write that su
5764
- *Writes that place or move a row* — `insert` and `update` — are **refused**, with `PERMISSION_DENIED` / HTTP 403 and a message that **names the missing active organization**. The code comes from ADR-0112's standard catalog rather than a new registration: the condition is a permission-class refusal, and ADR-0112 D3 directs a generic condition to the catalog instead of a synonym. This is the same code and status PR #8237 put on the wire for the same state, which is what makes "#8158 conforms" a measured statement rather than an assertion.
5865
- *`delete`* places nothing and decides no tenant. Its target is selected through the Layer 0 row wall, which already resolves to nothing — so a delete under this state matches no row and is governed by the read rule, deliberately. This boundary is stated so the next author does not read its absence as an oversight.
5966

60-
**The scope of the refusal is exactly the state, and no wider.** It fires only for an authenticated, non-system caller, on an object the posture actually walls (a tenant object under `isolated`/`group`; never a `tenancy.enabled:false` platform-global object, never an object with no `organization_id` column, never a federated phantom anchor), who is not a platform operator crossing the wall by ADR-0095 D3. System contexts — boot seeding, reconcile hooks, backfills, imports — short-circuit the security middleware entirely and are untouched. Under `group`, "no organization scope" means an **empty membership set**, matching ADR-0105 D2's own fail-closed rule.
67+
**The scope of the refusal is exactly the state, and no wider.** It fires only for an authenticated, non-system caller, on an object the posture actually walls (a tenant object under `isolated`/`group`; never a `tenancy.enabled:false` platform-global object, never an object with no `organization_id` column, never a federated phantom anchor), who is not a platform operator crossing the wall by ADR-0095 D3. System contexts — boot seeding, reconcile hooks, backfills, imports — short-circuit the security middleware entirely and are untouched. Under `group`, "no organization scope" means an **empty membership set**, matching ADR-0105 D2's own fail-closed rule. *(Amended 2026-10-20 — the operator clause and the "empty membership set" sentence bound the scope pre-test, not every write. Two inserts from a session with no active organization, of a row that names no organization, are refused past them: a `group` member's with a non-empty membership set, and a platform operator's crossing the wall. See the amendment in the status block above.)*
6168

6269
**D3 — ⛔ No silent NULL stamping, and the ledger carve-out is paid for by ordering.** Under the state this record governs — an authenticated caller with **no active organization** — no path may write a tenant-scoped row with a NULL tenant and report success, in any subsystem; D2's refusal is what makes that hold, and no layer gets an exemption from it.
6370

@@ -75,7 +82,7 @@ The reconciler is idempotent, yields to any pre-existing membership, and never t
7582

7683
**What changes.** An authenticated caller with no active organization, writing to a walled tenant object, now gets `403 PERMISSION_DENIED` naming the missing organization where they previously got `2xx` and an unreadable row. This is a **wire-visible behaviour change** on a security boundary; it is breaking in the direction of refusing something that used to silently corrupt.
7784

78-
**What does not change.** Reads. System contexts. Platform operators. Single-posture deployments (no wall, Layer 0 inert). Objects that opted out of tenancy. The supplied-`organization_id` forge guard, which keeps its own semantics and its own message. #8158's landed fix.
85+
**What does not change.** Reads. System contexts. ~~Platform operators.~~ Platform operators' reads, their updates of existing rows, and their inserts from a session with an active organization. *(Corrected by the 2026-10-20 amendment — a crossing operator's insert from a session with no active organization, of a row that names no organization, now gets D2's 403 where the engine answered 500; see the status block above.)* Single-posture deployments (no wall, Layer 0 inert). Objects that opted out of tenancy. The supplied-`organization_id` forge guard, which keeps its own semantics and its own message. #8158's landed fix.
7986

8087
**Dispositions this record carries.** #8208 becomes the D2 refusal — the silent NULL stamp is unreachable. #8245 becomes the D3 ordering fix — the first session mints with its organization, so its ledger rows carry a tenant.
8188

0 commit comments

Comments
 (0)