Skip to content

ADR-0123 + enforcement: an authenticated session with no active organization — reads resolve to nothing, tenant-scoped writes are refused loudly - #8471

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-8247-no-active-org-semantics
Aug 13, 2026
Merged

ADR-0123 + enforcement: an authenticated session with no active organization — reads resolve to nothing, tenant-scoped writes are refused loudly#8471
hotlong merged 5 commits into
mainfrom
claude/issue-8247-no-active-org-semantics

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes#8208
Part of #8247 — the declared-semantics half (ruling rules 1 and 3). The signup-ordering half (rule 2) is #8473, which declares #8245.

This PR touches docs/adr/** — Prime Directive #14 reserves its landing to the maintainer. Do not merge it, do not add it to the merge queue, do not arm auto-merge. It is deliberately left awaiting a human merge.

⚠️ Cross-lane touch, absorbed with PM authorisation — declared here, veto open

Two files in packages/qa/dogfood are domain:cli's surface, not domain:identity's. They are absorbed into this PR rather than handed to that seat, with the PM's explicit authorisation, because the only reason either file changes is this diff and a cross-seat round-trip buys lane purity on a PR that is maintainer-gated anyway:

  • test/federated-phantom-share-grant.dogfood.test.tssetup repair (beforeAll only). Its control-note write is a caller-path tenant-scoped write under an active wall, i.e. exactly the state D2 now refuses.
  • test/no-active-organization-write-refusal.dogfood.test.tsnew, and the reason the repair is safe to make (below).

If domain:cli would rather own either file, say so and I will split them out — nothing else in this PR depends on them.

The ruling this implements

Maintainer ruling on #8247, 2026-08-13 — Option C: the state is legal, with declared fail-closed semantics, plus a targeted signup-ordering fix. This PR carries the ADR that records it and the enforcement of its write half.

What was open

security-plugin.ts step 3.7 — the Layer 0 write-side wall — validated suppliedorganization_id values only. Its own comment says so:

This validates SUPPLIED values only; it never fills an absent one.

That is the right guard for what it guards (a payload naming another tenant, ADR-0095 / ADR-0105 D5), and it leaves the opposite case wide open: a payload naming no tenant, written by a caller who has no tenant. Nothing filled it downstream either — auto-stamping lives in the enterprise @objectstack/organizations runtime and has nothing to stamp when the caller carries no active organization.

So the row landed with organization_id NULL, and the read wall then hid it from every reader including the author who had just created it. Both halves were individually defensible, which is exactly why it sat.

The rule (ADR-0123)

Under an authenticated session with no active organization:

  • tenant-scoped reads resolve to nothing — Layer 0's deny sentinel, unchanged, silent, HTTP 200;
  • tenant-scoped insert / update are refused loudlyPERMISSION_DENIED / 403, with a message that names the missing active organization;
  • no silent NULL stamping, anywhere.

delete is deliberately absent and the ADR says so rather than leaving it as an omission: it places no row and decides no tenant, so its target is selected through the Layer 0 row wall, which already resolves to nothing.

The code and status are #8237's, on purpose.#8158's landed fix already chose refuse, naming the missing organization over answer empty. Reusing PERMISSION_DENIED/403 rather than registering a synonym is what makes ruling rule 3 — "#8158's landed fix conforms and stands" — a measured statement rather than an assertion, and it follows ADR-0112 D3 (a generic permission-class condition uses the standard catalog).

The verdict is derived, never re-derived

The refusal asks computeWriteTenantCheckFilter — the same Layer 0 the read side computes — so every exemption stays in one place and cannot drift: a tenancy.enabled:false platform-global object, an object with no organization_id column, a federated phantom anchor (#7835), a true PLATFORM_ADMIN on a posture-permitting object (ADR-0095 D3), and the whole single posture all yield null and are untouched. System / boot writes never reach the line (isSystem short-circuits the middleware). Under group, "no organization scope" means an empty membership set — ADR-0105 D2's own fail-closed rule, not a missing tenantId.

A cheap posture/context pre-test runs first so the common deployment (single) and the common caller (has an organization) never pay for the layered compile.

Ordered ahead of the forge guard, deliberately. A caller with no organization scope who also supplies a foreign organization_id satisfies both guards, and both answer 403 PERMISSION_DENIED. "You have no active organization" is the actionable half — the supplied value is not why the write cannot land, since no value could have satisfied the wall.

#8208 is closed END TO END, over real HTTP — and how that got measured

When this PR first ran, the HTTP-level proof arrived as a sibling dogfood fixture going red on its own setup step:

FAIL |isolated| test/federated-phantom-share-grant.dogfood.test.ts
AssertionError: admin creates the control note: expected 403 to be less than 300

That beforeAll is #8208's scenario verbatim — posture-only boot (wall ACTIVE), the seeded harness admin whose resolved context carries no tenantId, POST /data/showcase_private_note through the real HTTP path. showcase_private_note declares sharingModel: 'private', the row-ownership OWD and notaccess.default: 'private', so it is an ordinary business tenant object and the ADR-0095 D3 platform-operator exemption correctly does not apply.

Attribution was verified, not assumed. A 403 alone cannot separate this refusal from the object's own CRUD gate, which answers 403 PERMISSION_DENIED on the same route — and grepping the CI log for the refusal's own marker returned 0 hits. The discriminator: #8473 shares this PR's base and carries no plugin-security change, and its Dogfood Regression Gate (3/3) is success. So the red was the refusal.

The measurement is now a permanent assertion, and it was committed BEFORE the fixture was repaired — a repaired fixture is green again and carries no record of what it was red for. no-active-organization-write-refusal.dogfood.test.ts, 6 cases, all green:

  • PRECONDITION — tenancy reports posture: 'isolated', isolationActive: true, degraded: false (a degraded boot resolves to single, where Layer 0 contributes nothing and the file would be vacuous while green);
  • PRECONDITION — the admin's resolved tenantId is null, A record an admin creates over HTTP is immediately invisible to its own creator when the caller has no active organization and the Layer 0 wall is active (organization_id stamped NULL) #8208's own fact re-measured;
  • the refusal — 403, code: 'PERMISSION_DENIED', message naming the missing active organization;
  • nothing landed — a system-context read, which no wall narrows, finds zero rows (the defect was a write that succeeded and then hid; a refusal that still landed the row would reproduce it while looking fixed);
  • the control — the same caller, object and route SUCCEED once a sys_member row exists and a fresh session is minted. One fact differs between refusal and success: the organization.

That upgrades Fixes #8208 from enforcement-layer evidence to end-to-end evidence. Recorded on #8208 itself too.

Why the fixture repair is a bind, not an assertion change. The alternative — expecting 403 there — would have deleted #8119's own control. Binding the admin restores the setup's intent (a record created through the caller's own HTTP path). It is done by hand because bootStack's orgContext option refuses to compose with multiTenant: under a walled posture it would be a no-op that reads like a feature, the #7762 vacuity class. And #8473's ordering fix cannot do it either — tenancy.defaultOrgId() returns null under any walled posture by design, so nothing auto-binds under posture-only.

One adjacent property, noted and NOT closed here: under posture-only an org-bound caller's write still lands organization_id: null, because auto-stamping lives in the enterprise runtime. Pre-existing, deliberate, documented at the stamping site ("a broken deployment rather than a working unlicensed one"), and out of scope — recorded because it is visible in the control case above.

Tests — no-active-organization-write-refusal.test.ts, 19 unit cases

Built around the two vacuity traps this card carries.

Trap 1 — "reads resolve to nothing" and the bug look identical from outside. No read-side case asserts "empty". They take one concrete row and assert the same row is admitted under a system context and excluded under the org-less caller's own composed filter, and that the excluding filter is the deny sentinel specifically — not an absent policy (undefined, unrestricted) and not an ordinary wall (organization_id = ...). A row that was never written would be admitted by neither, so the system-context leg is what makes empty-by-rule and empty-by-accident different verdicts. A third leg pins that one membership turns the sentinel into a real wall that admits the row, ruling out "denied to everyone but system".

Trap 2 — a status-only 4xx assertion cannot tell a conforming refusal from any other 4xx. Every write case pins code, statusCode, and message content (naming the missing active organization, and not the forge guard's sentence), never a bare toThrow(). The sharpest case is the ordering one, where both guards fire and only the message tells them apart.

Every exemption is paired with a sibling differing in one fact where the refusal does fire — including the one that matters most: the same platform operator, same superuser bits, same platform capabilities, is refused on an ordinary business tenant object, because ADR-0095 D3's exemption is posture-scoped and never a property of the person (the W2 hole).

Ablation (predictions written before the mutation, then measured)

Restoring security-plugin.ts to origin/main and re-running: 6 red, 13 green, exactly the write-refusal pins and nothing else. The first failure reads the write was not refused at all: expected undefined to be defined — the pre-fix build letting the org-less insert through. Every read-side pin, every exemption control, and the existing forge-guard pins stayed green.

Suites: plugin-security52 files / 1052 tests green. Dogfood: the new pin 6/6, the repaired fixture 18/18, and every other walled-posture dogfood file (attachments-permission-matrix, federated-anchor-provenance, federated-rls-injectors, org-create-default-team, rls-multitenant, showcase-external-autoconnect) green — a scoped sweep by the change's real consumption radius, confirming federated-phantom-share-grant was the only casualty.

Why two PRs, and why this one waits

Ruling rule 2 (signup ordering) is #8473, which can land through the normal flow. The split is not stylistic — it is forced, and measurably so:

The landing order is also the favourable one: #8473 landing first reduces how often this state occurs at signup, before this PR starts refusing writes in it.

skip-changeset is not applicable — this PR changes runtime behaviour and carries its own changeset, declared breaking with an ADR-0087 not-required (no-migration-prescription) disposition (gate green).

Files

  • docs/adr/0123-no-active-organization-session-semantics.md — the record (D1 legal state, D2 read/write asymmetry, D3 no NULL stamping + the ledger's ordering carve-out, D4 the refusal names what is missing), with the rejected options and their reasons.
  • packages/plugins/plugin-security/src/security-plugin.ts — the refusal, plus two helpers (callerHasOrganizationScope, posture-shaped by ADR-0105 D2; isTenantWallDenial, which compares the sentinel by value because computeTenantLayer0Filter spreads it and a reference check would answer false for every real denial).
  • packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts — new, 19 unit cases.
  • packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts — new, 6 HTTP cases (cross-lane, declared above).
  • packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.tsbeforeAll setup repair (cross-lane, declared above).
  • scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json — ADR-0123 added, with an invariant that teaches why the refusal cannot be folded into the forge guard.
  • .changeset/no-active-organization-write-refusal.md.

Dispositions


Generated by Claude Code

Claudeand others added 2 commits August 13, 2026 14:29
…active organization (ADR-0123 D2)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 3:24pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Claudeand others added 2 commits August 13, 2026 15:14
… refused, names the missing organization, and lands nothing
The first evidence that the ADR-0123 D2 refusal fires over real HTTP arrived as
a SIBLING fixture going red on its own setup step. That is a real measurement in
a fragile place: repairing that fixture would erase it, and a fixture that is
green again carries no record of what it was red for. This moves the
measurement into an assertion that exists to hold it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
…te (ADR-0123 D2)
The fixture's setup step created a tenant-scoped row as an org-less caller under
an ACTIVE Layer 0 wall — the state ADR-0123 D2 now refuses. Bind the admin to an
organization before the first sign-in so the setup can reach the behaviour the
file actually tests. Cross-lane touch (packages/qa is domain:cli), absorbed with
PM authorisation and declared on the PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
… universal stamping
D3's opening sentence dropped the ruling's own antecedent ('under an
authenticated session with activeOrganizationId == null') and so read as a
promise about every authenticated write, including callers who DO hold an
organization. Measured: under 'posture-only' an org-bound caller's row still
lands organization_id null, because auto-stamping lives in the enterprise
runtime that activates the walled posture in the first place.
Restores fidelity to the ruling rather than narrowing it: 'anywhere' scopes
which layers the rule binds, and that force is kept verbatim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

ADR-0123 D3 scope clarification — 5c36d7591

Added ahead of maintainer review so the text under review states its own scope accurately. This narrows the ADR's wording, not the ruling — reasoning below, because that distinction is the whole question.

What was wrong with my draft

The #8247 ruling reads:

under an authenticated session with activeOrganizationId == null, tenant-scoped reads resolve to nothing and tenant-scoped writes are refused loudly (4xx naming the missing active organization). ⛔ No silent NULL stamping anywhere.

Its "anywhere" never leaves that antecedent — it scopes which layers the rule binds (no subsystem gets an exemption), which is exactly the point of a card filed because three subsystems each improvised.

My D3 opening sentence dropped the antecedent:

No path may write a tenant-scoped row with a NULL tenant on behalf of an authenticated caller and report success.

That reads as covering any authenticated caller, org-bound ones included — a promise the ruling never made and the open packages cannot keep. So this is a correction of my own over-reach, not a trim of the maintainer's decision.

The measurement behind it

Under multiTenant: 'posture-only' — walled posture, no enterprise organizations runtime — an org-bound caller's write still lands organization_id: null. Auto-stamping belongs to that runtime, which is also what activates every walled posture, so a real walled deployment has the stamper by construction. Visible in this PR's own dogfood control case, where the control row lands null-org even though its creator holds an organization.

Pre-existing and deliberate; the stamping site in security-plugin.ts already gives the reason — keeping the stamp there means a forged org-scoping registration "yields NULL-org rows that the wall hides, i.e. a broken deployment rather than a working unlicensed one".

What changed

docs/adr/0123-*.md only — two places, no code:

  • D3's opening now carries the ruling's antecedent explicitly, and keeps its force verbatim: "…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."
  • A scope paragraph states that the guarantee is about the caller's missing organization, not a promise that every tenant-scoped row is stamped; that D2 does not reach the org-bound case and is not meant to (the caller has an organization, so nothing is missing for a refusal to name); and that the record "neither changes that property nor endorses it — it declines to restate a packaging decision as a security guarantee the open packages cannot keep alone."
  • The TL;DR bullet was tightened the same way, so the summary and the decision cannot drift apart.

No decision is added, removed or reversed; D1, D2 and D4 are untouched; no code changed, so every test and gate result already reported stands.

If you read this as changing the ruling's meaning rather than stating its scope, it should be reverted and asked as a question instead — that call is yours, not mine, and git revert 5c36d7591 restores the broader wording exactly.

check:adr-anchors OK (48 anchored files, 23637 citations resolve) · check:nul-bytes OK (7632 files) · no new links added.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review August 13, 2026 15:48
@hotlong
hotlong self-requested a review as a code ownerAugust 13, 2026 15:48
@hotlong
hotlong added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit 3208222Aug 13, 2026
27 of 29 checks passed
@hotlong
hotlong deleted the claude/issue-8247-no-active-org-semantics branch August 13, 2026 16:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@hotlong