fix(organizations)!: a create naming an organization_id meets the Layer 0 write wall, as the update does (#21666) - #21680
Merged
objectstack-fleet[bot] merged 2 commits intoOct 4, 2026
Conversation
…; a supplied one meets the Layer 0 write wall A user-context insert naming another organization was silently rewritten to the caller's active organization (201), while the equivalent PATCH and the array insert were refused by the Layer 0 write wall. The stamp now fills an absent or empty value only, for every non-system context, so an explicit value reaches the same wall the PATCH reaches (ADR-0105 D5, ADR-0095 D1). Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…l runtime; docs and changeset The pins boot this package's Middleware A beside the real SecurityPlugin on a real ObjectQL engine over SQLite: a create naming another tenant's organization is refused with the PATCH's code (member and platform admin, declared and injected organization_id), a create naming none is stamped before the hooks run, a create naming the caller's own organization is admitted, the member forged-organization insert stays refused, an array insert agrees with the single-row answer, and under group a sister organization is admitted on create as on the PATCH. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Contributor
📓 Docs Drift Check
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
This was referenced Oct 4, 2026
objectstack-fleet
Bot
deleted the
claude/issue-21666-create-explicit-org-meets-wall
branch
October 4, 2026 05:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #21666
Clause-②: no (narrowing)
What changes
On a walled posture, the insert stamp in
@objectstack/organizations(Middleware A) used to overwrite a suppliedorganization_idwith the caller's active organization in every user context. The overwrite ispackages/plugins/organizations/src/organizations-plugin.ts:334at72f3c74d60:data.organization_id = opCtx.context.tenantId;insideif (isUserContext).The stamp now fills only an absent or empty value, for every non-system context (ADR-0105 D5). A supplied value is left as sent and meets the Layer 0 write wall in
plugin-security(step 3.7, ADR-0095 D1). That is the wall the PATCH already meets, so the create now gets the PATCH's answer. This follows triage ruling 5975961814: "the governed, loud side wins" and "⛔ No silent replacement on any posture."No
plugin-securitycode changes. The wall was already symmetric (see Zone 2.2 below). Nopackages/spec,packages/objectqlorpackages/metadata-protocoledit.Measured on a real walled boot (before → after)
Harness:
@objectstack/verifybootStack(app, { multiTenant: true, hostRoot }), run from a scratch host app that declares the real@objectstack/organizations. The stack is the realSecurityPlugin, the real REST routes andsqlite-wasm. Requests go over HTTP to/api/v1/data/.... Orgs: A is the caller's active organization, B is another tenant ("Tenant North D2"), and C is a sister organization the caller also holds. Objects:sys_user_permission_setandsys_business_unit(platform objects that declare their ownorganization_id),qa_ledger(a public app object with the injectedorganization_id) andqa_vault(a private app object, where a platform admin is posture-exempt).isolatedcreateMany[B]sys_user_permission_setsys_business_unitqa_ledgerqa_vault(exempt)droppedFieldsreadonly (unchanged)droppedFields(both)droppedFields(both)qa_ledgerqa_vaultA create naming no organization, or naming A, answers 201 and is stored in A, before and after, in every row above.
groupsys_user_permission_setsys_business_unitqa_ledgerqa_vaultqa_ledgerqa_vaultPATCH to B and
createMany[B] were 403 in every row, before and after.single(the control)objectstack servemounts the runtime only under a walled posture, so the productionsingleshape has no Middleware A. Every cell there is byte-identical before and after. For example,sys_user_permission_setcreate B answers 201 stored B, PATCH B answers 200 stored B, andcreateMany[B] answers 201 stored B. As an extra non-production cell, I also mounted the runtime undersingleby hand. The create naming B moved from 201 stored A to 201 stored B, which now matches that boot's PATCH andcreateMany.Other single-row doors (same middleware)
POST /data/:object/import,isolated; rows [B, none]). The batch is refused by the wall and degrades to per-rowcreateData. Before, both rows reportedokand were stored in A. After, the B row reportsPERMISSION_DENIEDand the none row reportsokin A. Measured as admin onsys_business_unit, as admin onqa_ledger, and as member onqa_ledger.POST /data/:object/:id/clone,group, source row in C). Forsys_business_unitit was 201 A and is now 201 C. Forsys_user_permission_setit was 201 A and is now 409: the copy would duplicate its source's(user, set, organization)key in C. Forqa_ledger, the clone strips the injected column, so it is 201 A both before and after.createoperation ofPOST /batchwrites row by row through the same path. I read this in code; I did not measure it.Zone 2.2: insert vs update on the wall
There is no asymmetry. Both verbs reach
computeWriteTenantCheckFilter→computeLayeredRlsFilter, and they share the platform-admin exemption (only on posture-permitting objects:private, platform-global, better-auth-managed). They throw the samePermissionDeniedError,code: PERMISSION_DENIEDwith status 403. The message names the verb: "the insert would place …" and "the update would place …". Sosecurity-plugin.tsis not edited. Its step 3.7 comment already said the stamp "only fills a MISSING value, never overwrites a supplied one", and that sentence is now true.Census: who relied on the overwrite (triage's stop condition)
organization_iditself?seed-loaderSEED_OPTIONS)ensureDefaultOrganization,claimOrgSeedOwnership)claimOrphanOrgRows)metadata-store(sys_file,sys_upload_session)= context.tenantIdsys-metadata-repository,database-loadertenantIdon the contextcore/import-runner)metadata-protocolcloneData)create_record(runAs user)No non-system writer sets an
organization_idand relies on the overwrite to correct it, so this is not a stop.seed-loader.ts(claimed by #21665) was read only.Pins (real runtime: this package's Middleware A + real
SecurityPlugin+ObjectQL+SqliteWasmDriver)New file
packages/plugins/organizations/src/create-explicit-organization-wall.test.ts, 14 cases:beforeInsertpayload) and stored there.organization_idis refused, and no row lands in either tenant.group. A sister organization is admitted on create, as on the PATCH. An organization outside the membership set is refused with the PATCH's code.organizations-plugin.test.ts: the old "OVERWRITES a forged organization_id" unit is now "leaves a supplied organization_id untouched". I added an empty-string fill unit.Ablations (predicted direction stated before each run; both through
scripts/ablation-replace.mjs, restore proven by blob == HEAD andgit diff HEADempty)the beforeInsert chain sees the stamp: expected [ undefined ] to deeply equal [ 'org_alpha' ]. The stored-row half alone could not catch this ablation. The SQL driver fills the same value fromDriverOptions.tenantId, measured:createMany[none], which Middleware A never touches, lands in A. That is why the pin asserts the payload the hooks see.Verification (all at
904a8e25c4)pnpm --workspace-concurrency=2 --filter '@objectstack/organizations^...' build: exit 0, 29 projects.pnpm --filter @objectstack/organizations test: 9 files, 123 passed.typecheck: exit 0 (tsc and the test layer, 0 errors).pnpm --filter @objectstack/plugin-security test: 164 files, 3527 passed, 45 skipped.dispatch-gates --commands(no paths) derived 105 families. 104 exit 0. NOT MEASURED:check:dual-build-cjs-loads, which exits 3 (PREREQUISITE NOT MET: 32 packages have nodist/, and it needs a full build; CI owns it).check:skill-examplesfirst exited 3 for lack of a client build. I built@objectstack/clientandclient-reactand it then exited 0.--ranreconciliation: 105 derived, 104 run, 1 NOT MEASURED (derived from the recorded exit 3), 0 unrun.--no-inline-config --format json): 4 files, 0 errors, 0 warnings. All 4 are inside the population of thefilesglobs ineslint.config.mjs(--print-configresolves for each). The other touched files (.md,.json,.yaml) match no lint glob. The config enables no type-aware linting (noparserOptions.project), so this diff cannot move a verdict on an untouched file.rls-multitenantskips by design (@objectstack/dogfooddoes not declare the runtime), andenterprise-organizations.test.tspasses 13. NOT MEASURED:attachments-permission-matrix:@objectstack/service-storageis unbuilt, and its multi-org block isskipIfthere anyway. The walled HTTP measurement above is this card's dogfood.origin/mainby 3 commits (fix(service-analytics): the SQL echo prints a date bucket only in the driver's own expression, on every driver (#21647) #21664, fix(spec): the protocol 16 → 17 upgrade rationale states each cited decision in words instead of a tracker number (stage 8) #21674, docs(skills): date-bucket engine sentences name what each arm emits #21677). None touchesorganizations,plugin-security,objectqlor the files here. The only gate file among them isscripts/cross-package-test-inputs.mjs.Docs and skills
content/docs/permissions/system-context.mdxrow 61 said "a forgedorganization_idis overwritten on the non-elevated path". This PR made that false, and the row now says the wall refuses it, as it refuses the update.content/docs/deployment/tenancy-modes.mdx("Filling in an absentorganization_id… validating a supplied one") was false before and is true now, so it is untouched.skills/**: no sentence about the insert stamp or aboutorganization_idon create, so nothing is false there.Package and lockfile
@objectstack/organizationsgains three devDependencies:objectql,plugin-security,driver-sqlite-wasm. Each is aliased to source invitest.config.ts, ascheck:test-source-aliasrequires.pnpm-lock.yamlcarries only the organizations importer hunk.pnpm installalso flipped an unrelatedesbuildpeer suffix in two other importers, and that churn was dropped.pnpm install --frozen-lockfilepasses.Changeset
.changeset/21666-create-explicit-organization-meets-wall.md:@objectstack/organizationsminor,fix(organizations)!, a**BREAKING.**marker, andClause-②: no (narrowing). The accept set narrows: a create, or an import row, naming another tenant's organization answered 201 and is now refused. The ADR-0087 disposition isnot-required (no-migration-prescription), andcheck:adr-0087-registrationis green on it. The one-line fix: omitorganization_idon create or name your active organization, and a platform operator moves a row with a system-context write.@objectstack/plugin-securityis unchanged, so it has no entry.Acceptance notes
organization_idto an app object answers 201 withdroppedFields: [{ fields: ['organization_id'], reason: 'readonly' }], naming a field the caller never sent. Middleware A's fill lands in the payload beforeObjectQL.insertsnapshots "what the caller sent", so the static-readonly strip reports the platform's own stamp as a caller write. Controls:singlewithout the runtime reports nothing, andcreateMany[none] onisolatedreports nothing. The seam is inpackages/objectql, outside this card's surface, and this PR leaves it unchanged. It goes to the seat to file.group. The clone door copies anorganization_idthat the object declares itself. A clone of a sister-organization row therefore now lands beside its source (or answers 409 on a unique key) instead of being re-homed into the active organization. The wall admits it, and so does a PATCH. Whether the clone door should strip a declaredorganization_idis ametadata-protocolquestion for the seat. This card neither answers nor edits it.driver-sqlloggedDATABASE_ERROR … no such table: _objectstack_sequences. The import still completed, the log is unrelated to this diff, and I have not filed it.Generated by Claude Code