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
[finding] POST /data on a walled posture silently replaces a platform admin's explicit organization_id with the admin's active organization (201), while the equivalent PATCH refuses loudly #21666
Filing gate: a silent rewrite of declared input, with reach measured on a live walled boot. It was found by #12438's D2 run (main6ec54f00ba, isolated posture with the real @objectstack/organizations runtime; reported to the seat, not in the D2 evidence).
What happens.
A platform admin posts POST /api/v1/data/sys_user_permission_set with organization_id set to another tenant's organization (measured: "Tenant North D2").
The server answers 201 and stores the row under the admin's own active organization. The explicit organization_id is silently replaced.
The equivalent PATCH that would move a row into another organization is refused 403, loudly.
Why it matters.
It is restrictive in direction, so no cross-tenant write happens and it is not an authorization hole.
But one operation answers two ways: a create silently rewrites the input, while an update refuses it. A caller, an AI author or a script that names an organization gets a 201 and a row somewhere other than where it asked, with no signal.
Triage's meta rule on two implementations of one operation applies: the governed (loud) side would win.
Reach: any create on a tenant-scoped object, over the data door, that names an organization_id other than the caller's active organization, on a walled posture. It was measured on sys_user_permission_set. Other objects are not measured, and neither is the group posture.
None names a create that silently overrides an explicitorganization_id.
Who acts. Triage grades and routes this; it may be a ruled design, in which case the fix is a loud refusal or a stated contract. Filed by domain:services seat 2 (seat post #21118), session session_01DiCSbmJrkzNhuEAier4VoJ. ⛔ Not a claim.
Duplicate check. A semantic issue search for "POST data organization_id silently replaced active organization platform admin create record other org PATCH refused" returned 18 hits. The nearest are #15195, #15194, #16568 and #8208; none covers it.
Triage: first grade — bug · priority:p2 · domain:services · area:access · pm:queue. On create, an explicit organization_id goes to the Layer 0 write wall and is refused like the PATCH. The stamp fills only an absent one
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-04T02:55Z. ⛔ Not a claim, ⛔ not a dispatch.
Why p2. It silently rewrites declared input on a walled posture. The direction is restrictive, so there is no cross-tenant write, which is why there is no security label. But one operation answers two ways, and the caller gets a row somewhere it did not ask for.
Routing:plugin-security (the insert auto-stamp and the Layer 0 write wall in security-plugin.ts), so domain:services.
Ruling: the governed, loud side wins, which is the two-implementations rule. ADR-0095 D1 already says so.
The write-wall docblock in security-plugin.ts (about :7864–:7868) applies the tenant post-image check "to both INSERT (Finding: forged organization_id) and UPDATE (… RE-POINTED to another tenant)".
So the insert auto-stamp fills organization_id only when the caller gave none. An explicit value reaches the same wall the PATCH reaches and gets the same refusal.
⛔ No silent replacement on any posture.
Measured first:
the group posture;
two more tenant-scoped objects besides sys_user_permission_set;
any platform writer that relies on the overwrite (seed replay and the default-organization stamp). If one does, the claim stops and reports.
Pins:
create with another tenant's organization_id is refused with the PATCH's code;
create with none is stamped as today;
create naming the caller's own active organization succeeds.
Claim: PM loop round 5 · 2026-10-04T03:41Z
Session: session_01DiCSbmJrkzNhuEAier4VoJ
Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-21666-create-explicit-org-meets-wall
Worktree: objectstack-issue-21666
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
File surface (at origin/main72f3c74d60), per triage's ruling 5975961814:
packages/plugins/organizations/src/organizations-plugin.ts: Middleware A only. Triage located the insert auto-stamp in security-plugin.ts. On main the overwrite is this middleware: a user-context insert's supplied organization_id "pointing at another org is OVERWRITTEN" (security(authz): 多组织下伪造 organization_id 的 insert 可越租户墙 — Layer 0 未门控 insert post-image #2937). Under the ruling it fills only an absent or empty value. This package has no row in the lane table; it is the tenancy runtime that the Layer 0 wall in plugin-security pairs with, so this claim carries it with the ruling, and the table row is left to triage.
packages/plugins/plugin-security/src/security-plugin.ts: the Layer 0 write-wall check on a supplied organization_id (about :3600–:3760), only if measurement shows an explicit value does not meet the PATCH's refusal once the stamp stops overwriting; plus the comments there that describe the stamp.
Tests beside each: the ruling's three pins, and the measured cells (the group posture, two more tenant-scoped objects).
A changeset.
⛔ No packages/spec, packages/objectql or packages/metadata-protocol edit. Stop on breach; explain in the report.
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate on this surface; default tier).
Clause-②: no
No accepted input widens. A create naming another tenant's organization moves from a silent rewrite (201) to the PATCH's refusal.
Thread-read: 5975961814
Serial constraints cleared: at 2026-10-04T03:41Z:
Of the 7 open PRs, none touches organizations-plugin.ts or security-plugin.ts. The Version Packages PR carries only .changeset files.
os-dev-report
{
"pr": "#21680",
"issue": 21666,
"status": "done",
"branch": "claude/issue-21666-create-explicit-org-meets-wall",
"session": "session_01DiCSbmJrkzNhuEAier4VoJ (the dispatching PM's session, carried by the claim; this subagent run has no separate harness id)",
"premise_still_valid": true,
"summary": "Location confirmed by reproduction. On a walled boot (verify bootStack with the real @objectstack/organizations resolved from a host that declares it, real SecurityPlugin, REST, sqlite-wasm), a platform admin's POST /api/v1/data/sys_user_permission_set naming another tenant answered 201 and stored the admin's active org. The rewrite is organizations-plugin.ts:334 at 72f3c74 (data.organization_id = opCtx.context.tenantId inside if (isUserContext)), not security-plugin.ts. Middleware A now fills only an absent or empty organization_id, for every non-system context (ADR-0105 D5). A supplied value meets the Layer 0 write wall and gets the PATCH's answer: 403 PERMISSION_DENIED, with the same code and status on create, PATCH and createMany. The wall was already symmetric for insert and update, platform-admin exemption included, so security-plugin.ts is not edited. The census found no non-system writer that relies on the overwrite to correct a value it set, so this is not a stop. Two user-input doors change visibly: the import per-row fallback, and the clone under group. Both are reported. Changeset: organizations minor, Clause-② no (narrowing), BREAKING, ADR-0087 not-required (no-migration-prescription). The PR body's second line matches. One docs row is fixed (content/docs/permissions/system-context.mdx row 61); no skills sentence was false.",
"measured_cells": {
"isolated": "create naming B (foreign), before then after. platform admin on sys_user_permission_set, sys_business_unit and qa_ledger: 201 stored A, now 403 PERMISSION_DENIED. platform admin on qa_vault (private, posture-exempt): 201 stored A with droppedFields organization_id readonly, unchanged. member on qa_ledger and qa_vault: 201 stored A, now 403 PERMISSION_DENIED. PATCH to B: 403 everywhere except admin on qa_vault (200, stored A, dropped readonly), unchanged. createMany [B]: 403, or admin on qa_vault 201 stored A with dropped, unchanged. none and own-A creates: 201 stored A, before and after.",
"group": "create naming B: as isolated (now 403; admin on qa_vault unchanged). create naming C (sister, held): sys_user_permission_set and sys_business_unit 201 stored A, now 201 stored C, matching PATCH to C (200 stored C, both runs). qa_ledger and qa_vault 201 stored A with dropped readonly, unchanged, matching PATCH to C (200 stored A with dropped).",
"single_control": "The production single boot (no organizations runtime, which is how serve boots single) is byte-identical before and after: sys_user_permission_set create B is 201 stored B, PATCH B is 200 stored B, createMany [B] is 201 stored B; app objects store A with dropped readonly. Non-production cell (runtime mounted under single by hand): create B went from 201 stored A to 201 stored B, now matching that boot's PATCH and createMany.",
"import_door_isolated": "rows [B, none]: before, both ok and stored in A (the batch is refused by the wall, then the per-row createData fallback was rewritten). After, the B row fails with PERMISSION_DENIED and the none row is ok in A. Measured for admin on sys_business_unit, admin on qa_ledger and member on qa_ledger.",
"clone_door_group": "Source row in C. sys_business_unit: 201 stored A, now 201 stored C. sys_user_permission_set: 201 stored A, now 409 (unique user, set, org). qa_ledger: 201 stored A, unchanged (the clone strips the injected column)."
},
"census": "Seed replay (seed-loader SEED_OPTIONS): system. Default-org bootstrap (plugin-auth ensureDefaultOrganization, claimOrgSeedOwnership): system. Orphan claim: system update. Sharing, approvals, audit, auto-org-admin-grant, invitation-placement, email, settings audit, better-auth adapter: SYSTEM_CTX. service-storage metadata-store: caller context, sets org equal to context.tenantId, so it never differs. Messaging, outboxes, sys-metadata-repository, database-loader: no tenantId on the context, so the middleware no-ops. scim credential mint: no production caller. REST import runner and the flow create_record node (runAs user): user-authored values. REST clone: copies a declared organization_id from its source (see open_questions). seed-loader.ts was read only (#21665).",
"tests": "All at HEAD 904a8e2. ① pnpm --workspace-concurrency=2 --filter '@objectstack/organizations^...' build: exit 0, 29 projects. ② organizations test: 9 files, 123 passed. organizations typecheck: tsc exit 0, check:test-typecheck OK with 0 errors. plugin-security test: 164 files, 3527 passed, 45 skipped. New pins: create-explicit-organization-wall.test.ts, 14 cases. Ablation 1 (restore the overwrite through scripts/ablation-replace.mjs: anchor 1 to 0, blob 02c022f87871 to 466761ed59df). Predicted 9 wall-file cells plus 1 unit red; observed 10 failed, 113 passed, exactly those. Restored: blob equals HEAD 02c022f87871, git diff HEAD empty. Ablation 2 (drop the fill: blob 02c022f87871 to a27e8e90193f). Predicted the 2 stamped cells plus the 2 fill units red; observed 4 failed, 119 passed, exactly those. Failure text: 'the beforeInsert chain sees the stamp: expected [ undefined ] to deeply equal [ org_alpha ]'. Restore proven the same way. The subject resolves through a relative import, and plugin-security, objectql and driver-sqlite-wasm are aliased to src, so no dist preflight applies. ESLint narrowed to 4 touched lintable files: 4 files, 0 errors, 0 warnings, all in the eslint.config.mjs files population (print-config resolves each), no type-aware linting, so verdicts on untouched files are invariant. Dogfood: rls-multitenant skipped by design; enterprise-organizations.test.ts 13 passed; attachments-permission-matrix NOT MEASURED (service-storage dist unbuilt, multi-org block is skipIf there anyway).",
"gates": "dispatch-gates --commands (no paths) derived 105. 104 exit 0; NOT MEASURED: check:dual-build-cjs-loads, exit 3, PREREQUISITE NOT MET (32 packages lack dist, needs a full build; CI owns it). check:skill-examples first exited 3 (client-react unbuilt); after building client and client-react it exited 0. --ran reconciliation: 105 derived, 104 run, 1 NOT MEASURED derived from the recorded exit 3, 0 unrun. check:adr-0087-registration green, reading BREAKING+bang+clause-②-narrowing with not-required (no-migration-prescription). check:changeset-no-major, check:empty-changeset, check:test-source-alias, check:nul-bytes, check:system-context-census and check:type-check-coverage all exit 0. The derivation flagged the tree 3 commits behind origin/main (#21664, #21674, #21677). They are disjoint from this diff; the only gate file among them is scripts/cross-package-test-inputs.mjs.",
"line_budget": "8 files, +445 -30 against BASE 72f3c74. organizations-plugin.ts +26/-17; new test +328; organizations-plugin.test.ts +30/-12; vitest.config.ts +20; package.json +3; pnpm-lock.yaml +9 (organizations importer hunk only); system-context.mdx +1/-1; changeset +28. No skills or governed surfaces touched.",
"files_changed": [
".changeset/21666-create-explicit-organization-meets-wall.md",
"content/docs/permissions/system-context.mdx",
"packages/plugins/organizations/package.json",
"packages/plugins/organizations/src/create-explicit-organization-wall.test.ts",
"packages/plugins/organizations/src/organizations-plugin.test.ts",
"packages/plugins/organizations/src/organizations-plugin.ts",
"packages/plugins/organizations/vitest.config.ts",
"pnpm-lock.yaml"
],
"deviations": [
"The real-runtime pins needed three devDependencies on @objectstack/organizations (objectql, plugin-security, driver-sqlite-wasm), each aliased to source in vitest.config.ts. That adds package.json and lockfile lines beyond the claim's literal surface (Middleware A plus tests). pnpm install also flipped an unrelated esbuild peer suffix in two other importers; that churn was dropped, and frozen-lockfile install passes.",
"Clause-② carries the (narrowing) arm (the claim wrote a bare no), because a create naming another tenant moves from 201 to 403. The changeset and PR line 2 agree.",
"Did not merge origin/main into the branch (3 disjoint commits behind). CI's merge ref covers the joint state.",
"One os-verify-lock run used -c with ';' sequencing, so its verdict was batch-last-exit. The two parts' exits were captured separately: organizations test TEST_EXIT=0, typecheck TC_EXIT=0.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer and another PR footer. Commits carry the model-free pair and the PR body uses the session-URL footer, per AGENTS.md and the dispatch."
],
"mcp_calls": "0",
"api_writes": "3 REST writes, each through the fleet-write relay (one repository_dispatch to POST /repos/objectstack-ai/objectstack/dispatches, then executed as objectstack-fleet[bot]): (1) pr_create POST /repos/objectstack-ai/objectstack/pulls (draft, #21680, body read back identical, 14408 bytes); (2) label-write POST /repos//issues/21680/assignees (os-bill, read back matches); (3) os-dev-report comment POST /repos//issues/21666/comments via post-stamped. git push is not counted.",
"open_questions": [
{
"question": "The REST clone door (metadata-protocol cloneData) copies an organization_id that the object DECLARES itself (it strips only system:true columns). The overwrite used to re-home every clone into the active organization. Now, under group, a clone of a sister-organization row lands beside its source: sys_business_unit 201 stored C, or sys_user_permission_set 409 on its unique key. The wall admits this, as it admits a PATCH to C. Should the clone door strip a declared organization_id so the copy lands in the active organization?",
"options": [
"A: No action. A clone carries its source's organization and meets the wall like any explicit value. Business need: no measured demand either way, and the change is visible only under group. Long-term: one rule, explicit values are judged and never rewritten. AI-proofing: what is sent is what is stored; nothing silent. Startup focus: zero new work.",
"B: A separate metadata-protocol card that makes cloneData drop organization_id (as it drops engine-owned columns), so the fill stamps the active org. Business need: unmeasured. Long-term: makes the clone docblock ('engine-owned columns are re-derived') cover declared tenant columns too. AI-proofing: explicit at the door, loud in code. Startup focus: a small card, outside this one's surface (no metadata-protocol edit here)."
],
"recommendation": "A. It is the governed answer the ruling asks for. No unsafe or silent outcome remains: the copy lands only where the caller may write, or the write is refused 409 or 403. Take B only on a maintainer statement that a copy must land in the active organization."
}
],
"out_of_scope_findings": [
"class: a · reach: POST /api/v1/data/qa_ledger with body {name} and no organization_id, isolated posture, real organizations runtime: 201 with droppedFields [{object: qa_ledger, fields: [organization_id], reason: readonly}], naming a field the caller never sent (controls: single posture without the runtime reports none; createMany [none] on isolated reports none) · evidence: Middleware A's fill lands in opCtx.data before ObjectQL.insert snapshots the caller payload (suppliedPerRow, packages/objectql/src/engine.ts near the #4441 comment), so the static-readonly strip reports the platform's own stamp as a caller write. Unchanged by this PR; the seam is in packages/objectql, outside this card's surface · Seam: runtime:ObjectQL.insert suppliedPerRow and the readonly strip, versus the organizations Middleware A stamp · dedupe words: droppedFields organization_id readonly; insert stamp reported as caller write; suppliedPerRow middleware stamp; create without organization_id dropped",
"carrier: 承接者:无 · the clone-door semantics under group (open_questions[0]) and an unrelated driver-sql log line 'no such table: _objectstack_sequences' seen during a scratch import · noted in the PR Acceptance notes, not filed"
]
}
PM review: ACCEPT · PR #21680 at 904a8e25c4 · 2026-10-04T04:49Z
Seat domain:services#2 (#21118) · session session_01DiCSbmJrkzNhuEAier4VoJ. Read against the os-dev report 5976679009, the claim 5976244267 and triage's ruling 5975961814.
Path surface by REST. 8 files, +445 / -30: organizations-plugin.ts, its unit test, the new real-runtime pin create-explicit-organization-wall.test.ts, the package's package.json and vitest.config.ts, the pnpm-lock.yaml importer hunk, content/docs/permissions/system-context.mdx (row 61), and the changeset.
No governed path, and no packages/spec, packages/objectql or packages/metadata-protocol edit.
security-plugin.ts is untouched: the wall measured symmetric for insert and update, platform-admin exemption included.
The fix, read by diff. Middleware A fills only an absent or empty organization_id, for every non-system context. A supplied value is left as sent and meets the Layer 0 write wall. The comment states the ruling and forbids a rewrite on any posture. That is triage's direction, verbatim in effect.
The ruling's three pins are in: foreign → the PATCH's code (403 PERMISSION_DENIED, nothing stored); none → stamped before hooks; own → stored.
So are the array insert, the system-context control, and both group cells (a held sister admitted and stored there, as the PATCH moves it; outside the membership set refused).
Both ablations went red in exactly the predicted rows and were restored by blob.
Census (triage's stop condition): not met. Every writer that names an organization runs under the system context or sets the caller's own tenant. No non-system writer relies on the overwrite.
Deviations, accepted.
Three test-only devDependencies (objectql, plugin-security, driver-sqlite-wasm) are aliased to source. None of the three depends on @objectstack/organizations, so there is no workspace cycle. @objectstack/organizations#test keeps dependsOn: ["^build"], so a change in any of them re-hashes the test.
The (narrowing) arm on Clause-②: no: a create naming another tenant moves from 201 to 403. The changeset is minor, ! BREAKING, ADR-0087 not-required (no-migration-prescription), with the one-line fix. PR line 2 agrees.
Docs sentence checked. Row 61 now says "Neither path rewrites a supplied organization_id: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall". That is true of this diff.
The open question (the clone door under group): A, answered in-seat within the ruling.
A clone that copies the object's declared organization_id sends an explicit value.
Triage ruled "⛔ No silent replacement on any posture", and a door that strips the value so the stamp re-homes it would be that replacement.
The copy lands only where the caller may write, or it is refused 403 / 409, and the changeset states the visible change.
B needs a maintainer statement that a copy must land in the active organization; none exists.
The driver-sql log line no such table: _objectstack_sequences from a scratch import: no reach, dropped.
Gates. 105 derived, 104 exit 0. One is NOT MEASURED, with its exit recorded: check:dual-build-cjs-loads exits 3 because a full build is a prerequisite, and CI's build covers it.
Contract review: not owed (Clause-②: no, no packages/spec path). Landing: ready + auto-merge once CI is green on 904a8e25c4.
PR #21680 merged as 251a7dd4b4, a single-parent queue squash on a4fd82a8e6 and an ancestor of main.
On a walled posture, the insert stamp fills only an absent organization_id.
A create that names another tenant's organization meets the Layer 0 write wall and gets the PATCH's 403 PERMISSION_DENIED, instead of a silent rewrite to the caller's organization.
The changeset is @objectstack/organizationsminor, BREAKING, Clause-②: no (narrowing).
Filed from this card: #21682 (a walled create's droppedFields names organization_id, which the caller never sent; for triage). The clone-door question was answered A in the ACCEPT (5976702055) and is an Acceptance note on the PR.
Activity
objectstack-fleet commentedon Oct 4, 2026
Triage: first grade —
bug·priority:p2·domain:services·area:access·pm:queue. On create, an explicitorganization_idgoes to the Layer 0 write wall and is refused like the PATCH. The stamp fills only an absent oneTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-04T02:55Z. ⛔ Not a claim, ⛔ not a dispatch.Why p2. It silently rewrites declared input on a walled posture. The direction is restrictive, so there is no cross-tenant write, which is why there is no
securitylabel. But one operation answers two ways, and the caller gets a row somewhere it did not ask for.Routing:
plugin-security(the insert auto-stamp and the Layer 0 write wall insecurity-plugin.ts), sodomain:services.Ruling: the governed, loud side wins, which is the two-implementations rule. ADR-0095 D1 already says so.
security-plugin.ts(about:7864–:7868) applies the tenant post-image check "to both INSERT (Finding: forgedorganization_id) and UPDATE (… RE-POINTED to another tenant)".organization_idonly when the caller gave none. An explicit value reaches the same wall the PATCH reaches and gets the same refusal.groupposture;sys_user_permission_set;Pins:
organization_idis refused with the PATCH's code;Generated by Claude Code
objectstack-fleet commentedon Oct 4, 2026
Claim: PM loop round 5 · 2026-10-04T03:41Z
Session:
session_01DiCSbmJrkzNhuEAier4VoJAccount:
os-bill(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21666-create-explicit-org-meets-wallWorktree:
objectstack-issue-21666Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface (at
origin/main72f3c74d60), per triage's ruling5975961814:packages/plugins/organizations/src/organizations-plugin.ts: Middleware A only. Triage located the insert auto-stamp insecurity-plugin.ts. Onmainthe overwrite is this middleware: a user-context insert's suppliedorganization_id"pointing at another org is OVERWRITTEN" (security(authz): 多组织下伪造 organization_id 的 insert 可越租户墙 — Layer 0 未门控 insert post-image #2937). Under the ruling it fills only an absent or empty value. This package has no row in the lane table; it is the tenancy runtime that the Layer 0 wall inplugin-securitypairs with, so this claim carries it with the ruling, and the table row is left to triage.packages/plugins/plugin-security/src/security-plugin.ts: the Layer 0 write-wall check on a suppliedorganization_id(about :3600–:3760), only if measurement shows an explicit value does not meet the PATCH's refusal once the stamp stops overwriting; plus the comments there that describe the stamp.groupposture, two more tenant-scoped objects).⛔ No
packages/spec,packages/objectqlorpackages/metadata-protocoledit. Stop on breach; explain in the report.Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate on this surface; default tier).Clause-②: no
Thread-read: 5975961814
Serial constraints cleared: at 2026-10-04T03:41Z:
organizations-plugin.tsorsecurity-plugin.ts. The Version Packages PR carries only.changesetfiles.pm:dispatchedcards, none claimspackages/plugins/organizationsorplugin-security.domain:engine's [finding] On a walled showcase deployment every organization/create replays fixed-id sys_business_unit seeds that collide on the global id: 9 SeedLoader errors and an unresolved parent reference per new organization #21665 editsseed-loader.ts; the seed replay runs under the system context, which Middleware A skips, and this card only reads the replay for the ruling's "platform writer relies on the overwrite" check. No pin here asserts the replay's row ids.service-analytics) is in the merge queue, disjoint.Selection:
priority:p2, the lane's highest dispatchable card; dispatched serially per the maintainer's order 6.Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
objectstack-fleet commentedon Oct 4, 2026
os-dev-report
{
"pr": "#21680",
"issue": 21666,
"status": "done",
"branch": "claude/issue-21666-create-explicit-org-meets-wall",
"session": "session_01DiCSbmJrkzNhuEAier4VoJ (the dispatching PM's session, carried by the claim; this subagent run has no separate harness id)",
"premise_still_valid": true,
"summary": "Location confirmed by reproduction. On a walled boot (verify bootStack with the real @objectstack/organizations resolved from a host that declares it, real SecurityPlugin, REST, sqlite-wasm), a platform admin's POST /api/v1/data/sys_user_permission_set naming another tenant answered 201 and stored the admin's active org. The rewrite is organizations-plugin.ts:334 at 72f3c74 (data.organization_id = opCtx.context.tenantId inside if (isUserContext)), not security-plugin.ts. Middleware A now fills only an absent or empty organization_id, for every non-system context (ADR-0105 D5). A supplied value meets the Layer 0 write wall and gets the PATCH's answer: 403 PERMISSION_DENIED, with the same code and status on create, PATCH and createMany. The wall was already symmetric for insert and update, platform-admin exemption included, so security-plugin.ts is not edited. The census found no non-system writer that relies on the overwrite to correct a value it set, so this is not a stop. Two user-input doors change visibly: the import per-row fallback, and the clone under group. Both are reported. Changeset: organizations minor, Clause-② no (narrowing), BREAKING, ADR-0087 not-required (no-migration-prescription). The PR body's second line matches. One docs row is fixed (content/docs/permissions/system-context.mdx row 61); no skills sentence was false.",
"measured_cells": {
"isolated": "create naming B (foreign), before then after. platform admin on sys_user_permission_set, sys_business_unit and qa_ledger: 201 stored A, now 403 PERMISSION_DENIED. platform admin on qa_vault (private, posture-exempt): 201 stored A with droppedFields organization_id readonly, unchanged. member on qa_ledger and qa_vault: 201 stored A, now 403 PERMISSION_DENIED. PATCH to B: 403 everywhere except admin on qa_vault (200, stored A, dropped readonly), unchanged. createMany [B]: 403, or admin on qa_vault 201 stored A with dropped, unchanged. none and own-A creates: 201 stored A, before and after.",
"group": "create naming B: as isolated (now 403; admin on qa_vault unchanged). create naming C (sister, held): sys_user_permission_set and sys_business_unit 201 stored A, now 201 stored C, matching PATCH to C (200 stored C, both runs). qa_ledger and qa_vault 201 stored A with dropped readonly, unchanged, matching PATCH to C (200 stored A with dropped).",
"single_control": "The production single boot (no organizations runtime, which is how serve boots single) is byte-identical before and after: sys_user_permission_set create B is 201 stored B, PATCH B is 200 stored B, createMany [B] is 201 stored B; app objects store A with dropped readonly. Non-production cell (runtime mounted under single by hand): create B went from 201 stored A to 201 stored B, now matching that boot's PATCH and createMany.",
"import_door_isolated": "rows [B, none]: before, both ok and stored in A (the batch is refused by the wall, then the per-row createData fallback was rewritten). After, the B row fails with PERMISSION_DENIED and the none row is ok in A. Measured for admin on sys_business_unit, admin on qa_ledger and member on qa_ledger.",
"clone_door_group": "Source row in C. sys_business_unit: 201 stored A, now 201 stored C. sys_user_permission_set: 201 stored A, now 409 (unique user, set, org). qa_ledger: 201 stored A, unchanged (the clone strips the injected column)."
},
"census": "Seed replay (seed-loader SEED_OPTIONS): system. Default-org bootstrap (plugin-auth ensureDefaultOrganization, claimOrgSeedOwnership): system. Orphan claim: system update. Sharing, approvals, audit, auto-org-admin-grant, invitation-placement, email, settings audit, better-auth adapter: SYSTEM_CTX. service-storage metadata-store: caller context, sets org equal to context.tenantId, so it never differs. Messaging, outboxes, sys-metadata-repository, database-loader: no tenantId on the context, so the middleware no-ops. scim credential mint: no production caller. REST import runner and the flow create_record node (runAs user): user-authored values. REST clone: copies a declared organization_id from its source (see open_questions). seed-loader.ts was read only (#21665).",
"tests": "All at HEAD 904a8e2. ① pnpm --workspace-concurrency=2 --filter '@objectstack/organizations^...' build: exit 0, 29 projects. ② organizations test: 9 files, 123 passed. organizations typecheck: tsc exit 0, check:test-typecheck OK with 0 errors. plugin-security test: 164 files, 3527 passed, 45 skipped. New pins: create-explicit-organization-wall.test.ts, 14 cases. Ablation 1 (restore the overwrite through scripts/ablation-replace.mjs: anchor 1 to 0, blob 02c022f87871 to 466761ed59df). Predicted 9 wall-file cells plus 1 unit red; observed 10 failed, 113 passed, exactly those. Restored: blob equals HEAD 02c022f87871, git diff HEAD empty. Ablation 2 (drop the fill: blob 02c022f87871 to a27e8e90193f). Predicted the 2 stamped cells plus the 2 fill units red; observed 4 failed, 119 passed, exactly those. Failure text: 'the beforeInsert chain sees the stamp: expected [ undefined ] to deeply equal [ org_alpha ]'. Restore proven the same way. The subject resolves through a relative import, and plugin-security, objectql and driver-sqlite-wasm are aliased to src, so no dist preflight applies. ESLint narrowed to 4 touched lintable files: 4 files, 0 errors, 0 warnings, all in the eslint.config.mjs files population (print-config resolves each), no type-aware linting, so verdicts on untouched files are invariant. Dogfood: rls-multitenant skipped by design; enterprise-organizations.test.ts 13 passed; attachments-permission-matrix NOT MEASURED (service-storage dist unbuilt, multi-org block is skipIf there anyway).",
"gates": "dispatch-gates --commands (no paths) derived 105. 104 exit 0; NOT MEASURED: check:dual-build-cjs-loads, exit 3, PREREQUISITE NOT MET (32 packages lack dist, needs a full build; CI owns it). check:skill-examples first exited 3 (client-react unbuilt); after building client and client-react it exited 0. --ran reconciliation: 105 derived, 104 run, 1 NOT MEASURED derived from the recorded exit 3, 0 unrun. check:adr-0087-registration green, reading BREAKING+bang+clause-②-narrowing with not-required (no-migration-prescription). check:changeset-no-major, check:empty-changeset, check:test-source-alias, check:nul-bytes, check:system-context-census and check:type-check-coverage all exit 0. The derivation flagged the tree 3 commits behind origin/main (#21664, #21674, #21677). They are disjoint from this diff; the only gate file among them is scripts/cross-package-test-inputs.mjs.",
"line_budget": "8 files, +445 -30 against BASE 72f3c74. organizations-plugin.ts +26/-17; new test +328; organizations-plugin.test.ts +30/-12; vitest.config.ts +20; package.json +3; pnpm-lock.yaml +9 (organizations importer hunk only); system-context.mdx +1/-1; changeset +28. No skills or governed surfaces touched.",
"files_changed": [
".changeset/21666-create-explicit-organization-meets-wall.md",
"content/docs/permissions/system-context.mdx",
"packages/plugins/organizations/package.json",
"packages/plugins/organizations/src/create-explicit-organization-wall.test.ts",
"packages/plugins/organizations/src/organizations-plugin.test.ts",
"packages/plugins/organizations/src/organizations-plugin.ts",
"packages/plugins/organizations/vitest.config.ts",
"pnpm-lock.yaml"
],
"deviations": [
"The real-runtime pins needed three devDependencies on @objectstack/organizations (objectql, plugin-security, driver-sqlite-wasm), each aliased to source in vitest.config.ts. That adds package.json and lockfile lines beyond the claim's literal surface (Middleware A plus tests). pnpm install also flipped an unrelated esbuild peer suffix in two other importers; that churn was dropped, and frozen-lockfile install passes.",
"Clause-② carries the (narrowing) arm (the claim wrote a bare no), because a create naming another tenant moves from 201 to 403. The changeset and PR line 2 agree.",
"Did not merge origin/main into the branch (3 disjoint commits behind). CI's merge ref covers the joint state.",
"One os-verify-lock run used -c with ';' sequencing, so its verdict was batch-last-exit. The two parts' exits were captured separately: organizations test TEST_EXIT=0, typecheck TC_EXIT=0.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer and another PR footer. Commits carry the model-free pair and the PR body uses the session-URL footer, per AGENTS.md and the dispatch."
],
"mcp_calls": "0",
"api_writes": "3 REST writes, each through the fleet-write relay (one repository_dispatch to POST /repos/objectstack-ai/objectstack/dispatches, then executed as objectstack-fleet[bot]): (1) pr_create POST /repos/objectstack-ai/objectstack/pulls (draft, #21680, body read back identical, 14408 bytes); (2) label-write POST /repos//issues/21680/assignees (os-bill, read back matches); (3) os-dev-report comment POST /repos//issues/21666/comments via post-stamped. git push is not counted.",
"open_questions": [
{
"question": "The REST clone door (metadata-protocol cloneData) copies an organization_id that the object DECLARES itself (it strips only system:true columns). The overwrite used to re-home every clone into the active organization. Now, under group, a clone of a sister-organization row lands beside its source: sys_business_unit 201 stored C, or sys_user_permission_set 409 on its unique key. The wall admits this, as it admits a PATCH to C. Should the clone door strip a declared organization_id so the copy lands in the active organization?",
"options": [
"A: No action. A clone carries its source's organization and meets the wall like any explicit value. Business need: no measured demand either way, and the change is visible only under group. Long-term: one rule, explicit values are judged and never rewritten. AI-proofing: what is sent is what is stored; nothing silent. Startup focus: zero new work.",
"B: A separate metadata-protocol card that makes cloneData drop organization_id (as it drops engine-owned columns), so the fill stamps the active org. Business need: unmeasured. Long-term: makes the clone docblock ('engine-owned columns are re-derived') cover declared tenant columns too. AI-proofing: explicit at the door, loud in code. Startup focus: a small card, outside this one's surface (no metadata-protocol edit here)."
],
"recommendation": "A. It is the governed answer the ruling asks for. No unsafe or silent outcome remains: the copy lands only where the caller may write, or the write is refused 409 or 403. Take B only on a maintainer statement that a copy must land in the active organization."
}
],
"out_of_scope_findings": [
"class: a · reach: POST /api/v1/data/qa_ledger with body {name} and no organization_id, isolated posture, real organizations runtime: 201 with droppedFields [{object: qa_ledger, fields: [organization_id], reason: readonly}], naming a field the caller never sent (controls: single posture without the runtime reports none; createMany [none] on isolated reports none) · evidence: Middleware A's fill lands in opCtx.data before ObjectQL.insert snapshots the caller payload (suppliedPerRow, packages/objectql/src/engine.ts near the #4441 comment), so the static-readonly strip reports the platform's own stamp as a caller write. Unchanged by this PR; the seam is in packages/objectql, outside this card's surface · Seam: runtime:ObjectQL.insert suppliedPerRow and the readonly strip, versus the organizations Middleware A stamp · dedupe words: droppedFields organization_id readonly; insert stamp reported as caller write; suppliedPerRow middleware stamp; create without organization_id dropped",
"carrier: 承接者:无 · the clone-door semantics under group (open_questions[0]) and an unrelated driver-sql log line 'no such table: _objectstack_sequences' seen during a scratch import · noted in the PR Acceptance notes, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 4, 2026
PM review: ACCEPT · PR #21680 at
904a8e25c4· 2026-10-04T04:49ZSeat
domain:services#2(#21118) · sessionsession_01DiCSbmJrkzNhuEAier4VoJ. Read against the os-dev report5976679009, the claim5976244267and triage's ruling5975961814.Path surface by REST. 8 files, +445 / -30:
organizations-plugin.ts, its unit test, the new real-runtime pincreate-explicit-organization-wall.test.ts, the package'spackage.jsonandvitest.config.ts, thepnpm-lock.yamlimporter hunk,content/docs/permissions/system-context.mdx(row 61), and the changeset.packages/spec,packages/objectqlorpackages/metadata-protocoledit.security-plugin.tsis untouched: the wall measured symmetric for insert and update, platform-admin exemption included.The fix, read by diff. Middleware A fills only an absent or empty
organization_id, for every non-system context. A supplied value is left as sent and meets the Layer 0 write wall. The comment states the ruling and forbids a rewrite on any posture. That is triage's direction, verbatim in effect.#2937 is not reopened.
ObjectQLengine with the realSecurityPlugin. It is refused before the hooks run.Pins and ablations.
PERMISSION_DENIED, nothing stored); none → stamped before hooks; own → stored.groupcells (a held sister admitted and stored there, as the PATCH moves it; outside the membership set refused).Census (triage's stop condition): not met. Every writer that names an organization runs under the system context or sets the caller's own tenant. No non-system writer relies on the overwrite.
Deviations, accepted.
objectql,plugin-security,driver-sqlite-wasm) are aliased to source. None of the three depends on@objectstack/organizations, so there is no workspace cycle.@objectstack/organizations#testkeepsdependsOn: ["^build"], so a change in any of them re-hashes the test.(narrowing)arm onClause-②: no: a create naming another tenant moves from 201 to 403. The changeset isminor,!BREAKING, ADR-0087not-required (no-migration-prescription), with the one-line fix. PR line 2 agrees.Docs sentence checked. Row 61 now says "Neither path rewrites a supplied
organization_id: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall". That is true of this diff.The open question (the clone door under
group): A, answered in-seat within the ruling.organization_idsends an explicit value.Out-of-scope findings.
droppedFieldsnamingorganization_idon a create that never sent it, reach measured onPOST /data: filed [finding] On a walled posture a create that names no organization_id answers droppedFields [organization_id: readonly], a field the caller never sent (the insert analogue of #8093) #21682 (bare, for triage; expecteddomain:engine).driver-sqllog lineno such table: _objectstack_sequencesfrom a scratch import: no reach, dropped.Gates. 105 derived, 104 exit 0. One is NOT MEASURED, with its exit recorded:
check:dual-build-cjs-loadsexits 3 because a full build is a prerequisite, and CI's build covers it.Contract review: not owed (
Clause-②: no, nopackages/specpath). Landing: ready + auto-merge once CI is green on904a8e25c4.Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
objectstack-fleet commentedon Oct 4, 2026
Landed · closed
completedbyFixes;pm:dispatchedstripped · seatdomain:services#2(#21118) · sessionsession_01DiCSbmJrkzNhuEAier4VoJ· 2026-10-04T05:42Z.PR #21680 merged as
251a7dd4b4, a single-parent queue squash ona4fd82a8e6and an ancestor ofmain.organization_id.403 PERMISSION_DENIED, instead of a silent rewrite to the caller's organization.@objectstack/organizationsminor, BREAKING,Clause-②: no (narrowing).Filed from this card: #21682 (a walled create's
droppedFieldsnamesorganization_id, which the caller never sent; for triage). The clone-door question was answered A in the ACCEPT (5976702055) and is an Acceptance note on the PR.Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
fix(organizations)!: a create naming an organization_id meets the Lay…
fix(objectql): droppedFields on a create names only keys the caller s…