You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit e9913cb
Browse filesBrowse the repository at this point in the historyBrowse files
docs(adr): ADR-0123 D2 records the two inserts its refusal now answers — the union-scope insert and the crossing operator's
ADR-0123 D2's scope paragraph and its "What does not change" line were
narrower than the runtime after PR #24819 (8a058d8) and PR #24868
(98c3d00). A dated amendment in the status block names both arms and
their reasons (ADR-0105 D5; ADR-0095 D3 with ADR-0131 D9); the scope
paragraph keeps its text with a dated pointer, and the "Platform
operators" entry is struck and corrected in place.
Claude-Session: https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/adr/0123-no-active-organization-session-semantics.md
+9-2Lines changed: 9 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,13 @@
1
1
# ADR-0123: An authenticated session with no active organization is a legal state — reads resolve to nothing, tenant-scoped writes are refused loudly
2
2
3
3
**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
+
4
11
**Deciders**: ObjectStack Protocol Architects (maintainer ruling on [#8247](https://github.com/objectstack-ai/objectstack/issues/8247), 2026-08-13)
5
12
**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)
@@ -57,7 +64,7 @@ So both halves were individually defensible and jointly produced a write that su
57
64
-*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.
58
65
-*`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.
59
66
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.)*
61
68
62
69
**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.
63
70
@@ -75,7 +82,7 @@ The reconciler is idempotent, yields to any pre-existing membership, and never t
75
82
76
83
**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.
77
84
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.
79
86
80
87
**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.
0 commit comments