Skip to content

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 into
mainfrom
claude/issue-21666-create-explicit-org-meets-wall
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21666-create-explicit-org-meets-wall

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21666
Clause-②: no (narrowing)

What changes

On a walled posture, the insert stamp in @objectstack/organizations (Middleware A) used to overwrite a supplied organization_id with the caller's active organization in every user context. The overwrite is packages/plugins/organizations/src/organizations-plugin.ts:334 at 72f3c74d60: data.organization_id = opCtx.context.tenantId; inside if (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-security code changes. The wall was already symmetric (see Zone 2.2 below). No packages/spec, packages/objectql or packages/metadata-protocol edit.

Measured on a real walled boot (before → after)

Harness: @objectstack/verify bootStack(app, { multiTenant: true, hostRoot }), run from a scratch host app that declares the real @objectstack/organizations. The stack is the real SecurityPlugin, the real REST routes and sqlite-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_set and sys_business_unit (platform objects that declare their own organization_id), qa_ledger (a public app object with the injected organization_id) and qa_vault (a private app object, where a platform admin is posture-exempt).

isolated

caller object create naming B PATCH to B createMany [B]
platform admin sys_user_permission_set 201, stored A → 403 PERMISSION_DENIED 403 PERMISSION_DENIED (both) 403 (both)
platform admin sys_business_unit 201, stored A → 403 PERMISSION_DENIED 403 (both) 403 (both)
platform admin qa_ledger 201, stored A → 403 PERMISSION_DENIED 403 (both) 403 (both)
platform admin qa_vault (exempt) 201, stored A + droppedFields readonly (unchanged) 200, stored A + droppedFields (both) 201, stored A + droppedFields (both)
member qa_ledger 201, stored A → 403 PERMISSION_DENIED 403 (both) 403 (both)
member qa_vault 201, stored A → 403 PERMISSION_DENIED 403 (both) 403 (both)

A create naming no organization, or naming A, answers 201 and is stored in A, before and after, in every row above.

group

caller object create naming B create naming C PATCH to C
platform admin sys_user_permission_set 201 A → 403 201 A → 201 C 200 C (both)
platform admin sys_business_unit 201 A → 403 201 A → 201 C 200 C (both)
platform admin qa_ledger 201 A → 403 201 A + dropped (unchanged) 200 A + dropped (both)
platform admin qa_vault 201 A + dropped (unchanged, exempt) 201 A + dropped (unchanged) 200 A + dropped (both)
member qa_ledger 201 A → 403 201 A + dropped (unchanged) 200 A + dropped (both)
member qa_vault 201 A → 403 201 A + dropped (unchanged) 200 A + dropped (both)

PATCH to B and createMany [B] were 403 in every row, before and after.

single (the control)

objectstack serve mounts the runtime only under a walled posture, so the production single shape has no Middleware A. Every cell there is byte-identical before and after. For example, sys_user_permission_set create B answers 201 stored B, PATCH B answers 200 stored B, and createMany [B] answers 201 stored B. As an extra non-production cell, I also mounted the runtime under single by hand. The create naming B moved from 201 stored A to 201 stored B, which now matches that boot's PATCH and createMany.

Other single-row doors (same middleware)

  • Import (POST /data/:object/import, isolated; rows [B, none]). The batch is refused by the wall and degrades to per-row createData. Before, both rows reported ok and were stored in A. After, the B row reports PERMISSION_DENIED and the none row reports ok in A. Measured as admin on sys_business_unit, as admin on qa_ledger, and as member on qa_ledger.
  • Clone (POST /data/:object/:id/clone, group, source row in C). For sys_business_unit it was 201 A and is now 201 C. For sys_user_permission_set it was 201 A and is now 409: the copy would duplicate its source's (user, set, organization) key in C. For qa_ledger, the clone strips the injected column, so it is 201 A both before and after.
  • The create operation of POST /batch writes 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 same PermissionDeniedError, code: PERMISSION_DENIED with status 403. The message names the verb: "the insert would place …" and "the update would place …". So security-plugin.ts is 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)

writer context sets organization_id itself? reliant?
per-org seed replay (seed-loader SEED_OPTIONS) system yes no: skips Middleware A
default-org bootstrap (ensureDefaultOrganization, claimOrgSeedOwnership) system yes no
orphan claim (claimOrphanOrgRows) system, update yes no: insert-only middleware
sharing, approvals, audit, auto-org-admin grant, invitation placement, email, settings audit, better-auth adapter system yes no
storage metadata-store (sys_file, sys_upload_session) caller = context.tenantId no: always equal
messaging, outboxes, sys-metadata-repository, database-loader no tenantId on the context yes no: the middleware no-ops
REST import runner (core/import-runner) caller from the user's file user input, not a platform writer; see Import above
REST clone (metadata-protocol cloneData) caller copied from the source when the object declares the column see Acceptance notes
flow create_record (runAs user) caller flow-authored fields user-authored input, same as REST

No non-system writer sets an organization_id and 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:

  • Another tenant's organization. A create naming it is refused with the PATCH's code and status. Covered for a member and for a platform admin, on a declared-column object and on an injected-column object.
  • Array insert. An array insert naming it gets the single-row answer.
  • No organization. A create naming none is stamped with the active organization before the hooks run (asserted at the beforeInsert payload) and stored there.
  • Own active organization. A create naming it is admitted.
  • security(authz): 多组织下伪造 organization_id 的 insert 可越租户墙 — Layer 0 未门控 insert post-image #2937. A member's forged organization_id is refused, and no row lands in either tenant.
  • System context. An explicit cross-organization value is kept (the seed-replay path).
  • 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 and git diff HEAD empty)

  1. Restore the overwrite. I predicted red on exactly the 9 wall-file cells where a create names an organization and expects a refusal or a non-active placement (6 refusals, 2 array/single parity cells, the group sister cell), plus the one unit "leaves … untouched". Observed: 10 failed, 113 passed (123), exactly those cells. The stamped, own-organization and system cells stayed green.
  2. Drop the fill for an absent value. I predicted red on exactly the 2 "stamped before the hooks run" cells and the 2 fill units (absent, empty). Observed: 4 failed, 119 passed (123), exactly those. The failure reads 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 from DriverOptions.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 no dist/, and it needs a full build; CI owns it). check:skill-examples first exited 3 for lack of a client build. I built @objectstack/client and client-react and it then exited 0. --ran reconciliation: 105 derived, 104 run, 1 NOT MEASURED (derived from the recorded exit 3), 0 unrun.
  • ESLint, narrowed to the 4 touched lintable files (--no-inline-config --format json): 4 files, 0 errors, 0 warnings. All 4 are inside the population of the files globs in eslint.config.mjs (--print-config resolves for each). The other touched files (.md, .json, .yaml) match no lint glob. The config enables no type-aware linting (no parserOptions.project), so this diff cannot move a verdict on an untouched file.
  • Dogfood walled-posture files: rls-multitenant skips by design (@objectstack/dogfood does not declare the runtime), and enterprise-organizations.test.ts passes 13. NOT MEASURED: attachments-permission-matrix: @objectstack/service-storage is unbuilt, and its multi-org block is skipIf there anyway. The walled HTTP measurement above is this card's dogfood.
  • The derivation flagged the tree as behind origin/main by 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 touches organizations, plugin-security, objectql or the files here. The only gate file among them is scripts/cross-package-test-inputs.mjs.

Docs and skills

  • content/docs/permissions/system-context.mdx row 61 said "a forged organization_id is 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 absent organization_id … validating a supplied one") was false before and is true now, so it is untouched.
  • skills/**: no sentence about the insert stamp or about organization_id on create, so nothing is false there.

Package and lockfile

  • @objectstack/organizations gains three devDependencies: objectql, plugin-security, driver-sqlite-wasm. Each is aliased to source in vitest.config.ts, as check:test-source-alias requires.
  • pnpm-lock.yaml carries only the organizations importer hunk. pnpm install also flipped an unrelated esbuild peer suffix in two other importers, and that churn was dropped. pnpm install --frozen-lockfile passes.

Changeset

.changeset/21666-create-explicit-organization-meets-wall.md: @objectstack/organizations minor, fix(organizations)!, a **BREAKING.** marker, and Clause-②: 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 is not-required (no-migration-prescription), and check:adr-0087-registration is green on it. The one-line fix: omit organization_id on create or name your active organization, and a platform operator moves a row with a system-context write. @objectstack/plugin-security is unchanged, so it has no entry.

Acceptance notes

  • Out-of-scope finding (class a), not fixed here. On a walled posture, a create that sends no organization_id to an app object answers 201 with droppedFields: [{ fields: ['organization_id'], reason: 'readonly' }], naming a field the caller never sent. Middleware A's fill lands in the payload before ObjectQL.insert snapshots "what the caller sent", so the static-readonly strip reports the platform's own stamp as a caller write. Controls: single without the runtime reports nothing, and createMany [none] on isolated reports nothing. The seam is in packages/objectql, outside this card's surface, and this PR leaves it unchanged. It goes to the seat to file.
  • Clone under group. The clone door copies an organization_id that 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 declared organization_id is a metadata-protocol question for the seat. This card neither answers nor edits it.
  • Observation. During the scratch import, driver-sql logged DATABASE_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

claude added 2 commits October 4, 2026 04:02
…; 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>
@github-actions github-actions Bot added the size/m label Oct 4, 2026
@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/plugins/organizations/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/organizations/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7d0781482dbb6502aa33c29bec96ca03636f7df9 → packageMentionDocs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants