Uh oh!
There was an error while loading. Please reload this page.
ADR-0123 + enforcement: an authenticated session with no active organization — reads resolve to nothing, tenant-scoped writes are refused loudly - #8471
Conversation
…active organization (ADR-0123 D2) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
… 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_01PEVB6w7D7uCszR9Mw1BL73os-zhuang
commented
Aug 13, 2026
ADR-0123 D3 scope clarification — |
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.Two files in
packages/qa/dogfoodaredomain:cli's surface, notdomain: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.ts— setup repair (beforeAllonly). 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.ts— new, and the reason the repair is safe to make (below).If
domain:cliwould 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.tsstep 3.7 — the Layer 0 write-side wall — validated suppliedorganization_idvalues only. Its own comment says so: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/organizationsruntime and has nothing to stamp when the caller carries no active organization.So the row landed with
organization_idNULL, 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:
insert/updateare refused loudly —PERMISSION_DENIED/ 403, with a message that names the missing active organization;deleteis 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: atenancy.enabled:falseplatform-global object, an object with noorganization_idcolumn, a federated phantom anchor (#7835), a truePLATFORM_ADMINon a posture-permitting object (ADR-0095 D3), and the wholesingleposture all yieldnulland are untouched. System / boot writes never reach the line (isSystemshort-circuits the middleware). Undergroup, "no organization scope" means an empty membership set — ADR-0105 D2's own fail-closed rule, not a missingtenantId.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_idsatisfies both guards, and both answer 403PERMISSION_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:
That
beforeAllis #8208's scenario verbatim —posture-onlyboot (wall ACTIVE), the seeded harness admin whose resolved context carries notenantId,POST /data/showcase_private_notethrough the real HTTP path.showcase_private_notedeclaressharingModel: '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_DENIEDon 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 noplugin-securitychange, and itsDogfood 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:tenancyreportsposture: 'isolated',isolationActive: true,degraded: false(a degraded boot resolves tosingle, where Layer 0 contributes nothing and the file would be vacuous while green);tenantIdisnull, 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_idstamped NULL) #8208's own fact re-measured;code: 'PERMISSION_DENIED', message naming the missing active organization;sys_memberrow exists and a fresh session is minted. One fact differs between refusal and success: the organization.That upgrades
Fixes #8208from 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'sorgContextoption refuses to compose withmultiTenant: 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()returnsnullunder any walled posture by design, so nothing auto-binds underposture-only.One adjacent property, noted and NOT closed here: under
posture-onlyan org-bound caller's write still landsorganization_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 casesBuilt 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 baretoThrow(). 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.tstoorigin/mainand re-running: 6 red, 13 green, exactly the write-refusal pins and nothing else. The first failure readsthe 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, confirmingfederated-phantom-share-grantwas 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:
check:adr-anchorsrequires everyADR-NNNNcited in a tracked file to name a real record underdocs/adr/. Prime Directive [WIP] Add Chinese version of the documentation #13 requires code realizing an ADR's decision to carry its id. So rule 1's enforcement, which cites ADR-0123, cannot be separated from the ADR that defines it — CI would reject it.ADR maintainer approvalpasses on fix(auth): settle the membership before the first session resolves its active organization, so its audit rows carry a tenant #8473, so ascripts/adr-anchors/**registry file is not a governed ADR document — onlydocs/adr/**is (ADR_PATH_PREFIX).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-changesetis not applicable — this PR changes runtime behaviour and carries its own changeset, declared breaking with an ADR-0087not-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 becausecomputeTenantLayer0Filterspreads it and a reference check would answerfalsefor 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.ts—beforeAllsetup 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
organization_idstamped NULL) #8208 — declared above; closed end to end, evidence recorded on the card.manage_sharingholder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #8158 / PR fix(plugin-sharing): a manage_sharing holder with no active organization no longer reads every tenant's sharing rules (#8158) #8237 — conforms and stands, untouched.Part ofonly. Neither PR closes the decision card alone; it closes when both have landed.Generated by Claude Code