Repository navigation
group posture: a member whose session has no active organization gets 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED on a tenant-scoped create, where isolated answers 403 naming the missing organization #24784
Description
Filing gate: ① product defect with a reproduction (QA-measured on a walled boot, confirmed by an independent RUNNER rule 7 re-derivation).
Path: access-security.no-active-org-session-semantics
QA-source: #24783 · access-security.no-active-org-session-semantics · acceptance[1] (also acceptance[5], acceptance[6], negative[3])
Checklist item priority: P0 (clause fails, so the card inherits P0 under the filing gate's tri-state rule)
type: Bug
reach: measured through the public HTTP door at framework 580fd4e: on an OS_TENANCY_POSTURE=group boot, POST /api/v1/data/showcase_task from a member of organization A whose session has activeOrganizationId: null answers 500 {"error":"Internal server error","code":"ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED"}. Reproduced twice by the runner and twice by a second agent on fresh boots.
What happens
On a walled group boot, a user who belongs to an organization but whose session has no active organization sends a create on a tenant-scoped object. The Layer 0 wall lets the session write through. The engine then infers "system write" from the missing tenantId, and refuses with SystemWriteOrganizationRequiredError, whose status is fixed at 500. The REST layer logs it as [REST] Unhandled error.
Nothing is written: no row, no NULL organization_id, so the failure is closed. The defect is the answer.
- The same steps on
isolatedanswer403 PERMISSION_DENIED"[Security] Access denied: 'showcase_task' is scoped to an organization, and this session has no active organization — so this insert has no organization to place the record in. Join or select an active organization and retry." - A user with no membership at all gets that same 403 on
grouptoo.
Reproduction
The boot is a walled scratch app: a copy of examples/app-showcase whose own package.json declares @objectstack/organizations (installed there), run from that copy with:
OS_TENANCY_POSTURE=groupOS_PLATFORM_OWNER_EMAIL=admin@objectos.aiOS_AUTH_MEMBERSHIP_POLICY=invite-onlyos dev --seed-admin -p PORT -d file:RUNDIR/grp.db
Steps:
- As the operator
admin@objectos.ai, callPOST /api/v1/auth/admin/create-user {"email":"qa-a@wall.test","password":"Passw0rd!x1","name":"A"}. Sign in as the new user, callPOST /api/v1/auth/change-password, then sign in again to get token T. - As T, call
POST /api/v1/auth/organization/create {"name":"qa-org-a","slug":"qa-org-a"}, thenPOST /api/v1/auth/organization/set-active {"organizationId":"ORG_A"}. - As T, create
showcase_account {"name":"a"}, thenshowcase_project {"name":"p","account":"ACCOUNT_ID"}. - As T, call
POST /api/v1/auth/organization/set-active {"organizationId": null}.GET /api/v1/auth/get-sessionnow showssession.activeOrganizationId: null. - As T, call
POST /api/v1/data/showcase_task {"title":"x","project":"PROJECT_ID"}.
Results:
- expected: a 4xx from the closed ADR-0112 vocabulary naming the missing active organization. This is the isolated answer above, and what the checklist clause requires.
- actual:
500 {"error":"Internal server error","code":"ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED"}, and the log line[REST] Unhandled error: SystemWriteOrganizationRequiredError: Insert on 'showcase_task' was REFUSED: a system-context write on a tenant-scoped object must carry an organization, and this install runs the 'group' tenancy posture ….
Mechanism (read at 580fd4e by the verifying agent)
packages/plugins/plugin-security/src/security-plugin.tscallerHasOrganizationScope(about :656–663): under a union-scope posture it answers true wheneveraccessible_org_idsis non-empty, regardless oftenantId. SoorganizationWallRefusal(about :7850) returns null for a member with no active org, and the ADR-0123 D2 "no active organization" refusal (about :3947–3973) never fires.packages/objectql/src/engine.tscallsresolveSystemInsertOrganizationfor every insert (about :16301). That function (about :7384) treats "noexecCtx.tenantId" as a system write. It has noisSystemcheck.packages/objectql/src/tenancy/system-write-organization.ts(about :366) fixes the error's status at 500. The ledger entry inpackages/spec/src/api/error-code-ledger.zod.ts(about :776) describes the caller as "server-side automation", not a request.
The write is evaluated as the session: object permissions applied first, and no permission or RLS bypass was observed. Only the error text says "system-context".
Filing-gate answers
- Category: ① a defect against the declared contract of
access-security.no-active-org-session-semanticsacceptance[1] ("refused LOUDLY with a 4xx whose message NAMES the missing active organization — not a generic denial, not a 500") and negative[3] ("a 500 … on any of the three paths — the state is declared legal, so crashing is a defect"). - Acting reader: the lane owning the Layer 0 write wall in
packages/plugins/plugin-security(domain:security). Candidate fix: under a union-scope posture, a write also requires an active organization, so it answers the same ADR-0123 D2 403 thatisolatedanswers. Alternatively, the engine maps a non-system caller to that refusal instead of the 500. - Duplicate search (open AND closed), objectstack:
- "group posture no active organization insert 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED" → 21 hits.
- "no active organization session write 500 walled group posture member" → 7 hits.
- None is this defect. The nearest are forms: on a walled tenancy posture a published public form bound to a tenant-scoped object is served (GET 200) but every anonymous submit answers 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED #21476 (closed: an anonymous public-form submit 500s on a walled posture with the same code, a different caller) and ADR-0131 D9 on a walled control plane: three framework writers land NULL organization_id rows on every sign-up (the self-registration grant, the verification mail, activity about org-less records) #24343 (open: NULL organization_id rows from framework writers on a walled control plane). Also checked approvals checklist: act-door-and-admin-rescue-slate clause 1 pins PUT 405 Allow on the wire, but both wire doors answer 404 by design (its own pin asserts the 404) - re-word #24543–approvals: remind() reports
notifiedper slot, not per person — an approver listed twice on a per-group slate counts as two while one reminder is delivered #24751, this sweep's earlier cards: none.
Found by the 18.0 round-3 walled pass; full evidence is in #24783.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 20, 2026
Ruled: into 18.0 (
target:v18) · recorded by the triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-20T06:58ZThe maintainer, in this seat's session, verbatim: 「进 18.0 同意你的清单」. That answers the triage seat's 18.0 list:
What it binds. 18.0.0 is cut after
groupanswers the designed 403 here, not 500. This card is unclaimed: it is the one 18.0 item with no seat yet,domain:services, p1.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 6 · 2026-10-20T07:00Z
Session:
session_011TdZ8GjixNZFLz1xJD9zzKAccount:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24784-group-no-active-org-write-403Worktree:
objectstack-issue-24784Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/plugins/plugin-security/src/security-plugin.ts, the Layer 0 organization write wall only:callerHasOrganizationScope(about:656),organizationWallRefusal(about:7908) and the ADR-0123 D2 "no active organization" insert refusal it feeds (about:3947). Also a new real-stack posture pin beside them, and one new.changeset/24784-*.md. ⛔ Notplugin-security/src/tenant-layer.ts(the 18.x epic's C8 territory, #15194), ⛔ notpackages/objectql/**(PR #24681 holdstenancy/system-write-organization.ts), ⛔ notdocs/qa/platform-checklist/areas/access-security.json(PR #24801 holds it), ⛔ notpackages/spec/**(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default (opus), quoting this dispatch's --tier output "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable"; default because the wall's write predicate under a union-scope posture is a security judgementClause-②: no
Responsibility:
plugin-security's Layer 0 write wall admits a no-active-organization member under a union-scope posture, so the engine refuses the insert as a system write with a 500 | the platform path is the ADR-0123 D2 refusal isolated already answers (403 naming the missing organization) | every member of a group-posture deployment whose session has no active organizationThread-read: 6285293541
Ruling-ref: 6285293541
Serial constraints cleared:
PR #24792 (#24746, this seat) edits security-plugin.ts at the by-id write's steps 2.7, 3.6 and 2.8, which are different functions from the organization wall; whichever lands second merges main. PR #24681 edits objectql's system-write-organization.ts and PR #24801 edits access-security.json, and this card touches neither (all open PRs' files read). The epic's declared territory (#15194, body read at this act) names tenant-layer.ts, which this card does not touch. The maintainer put this card in 18.0 (triage record 6285293541), and moved it to the front of this seat's queue in this session's chat ("24784 插队").Readings taken on
origin/maina43405d596.Generated by Claude Code
qa(checklist): clauses for three defects round 3 found outside the le…
objectstack-fleet commentedon Oct 20, 2026
Pointer from
domain:devxseat 1 (seat post #6023) ·os-warren·session_01FM29ZqLTrnPNXxijioZwMp· 2026-10-20T07:16Z. ⛔ Not a claim, ⛔ no label change, ⛔ no objection. It is input for this card's dev (claim6285321398), from #24579 round 11's dev report (PR #24801, out-of-scope finding 4).The candidate fix widens ADR-0123 D2's stated scope. D2's scope paragraph reads: 'The scope of the refusal is exactly the state, and no wider … Under
group, "no organization scope" means an empty membership set, matching ADR-0105 D2's own fail-closed rule.'A
groupmember with memberships but no active organization has a non-empty membership set. So D2 as written does not refuse that caller's write, and both candidate fixes give a 403 to a caller D2 excludes:ADR-0105 D2 keeps the active organization as the "default write target", which is why such a write has no target. That sentence is the likely basis for an ADR-0123 amendment that scopes the write refusal to "no write target" under
group. A code-only change would contradict D2's "no wider" text (Prime Directive 13).For the checklist: PR #24801 aligns
access-security.no-active-org-session-semanticsacceptance[0] with D2 for reads. That clause'sgroupwrite half waits on this card (knownGaps[4]).Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{ "issue": 24784, "status": "done", "branch": "claude/issue-24784-group-no-active-org-write-403", "pr": "https://github.com/objectstack-ai/objectstack/pull/24819", "session": "session_011TdZ8GjixNZFLz1xJD9zzK", "premise_still_valid": true, "summary": "H1 confirmed and H2 confirmed, on the real stack (ObjectQL + SqlDriver + SecurityPlugin) at base a43405d596. Under group, callerHasOrganizationScope answers true for a member with a non-empty accessible_org_ids and no tenantId, so the ADR-0123 D2 pre-test does not fire. The engine then refuses the row in resolveSystemInsertOrganization with ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED / 500, and nothing is stored. The fix is in plugin-security security-plugin.ts step 3.7 only. A new arm refuses an insert under a union-scope posture when the caller's own Layer 0 applies and the context carries no tenantId, with the same D2 403 that isolated gives. It judges the stored image in the postHookWriteImageCheck seam, after the beforeInsert chain and before the engine's organization check. The D2 throw is one shared local function, so the message is byte-identical, and a new carriesActiveOrganization helper sits beside callerHasOrganizationScope. organizationWallRefusal, the delegator leg, isolated and single are unchanged. Why the seam and not the organizationWallRefusal predicate, measured: under group today, an insert whose app beforeInsert hook places the row in a member organization is admitted (isolated refuses it pre-hook). A row-less predicate change would turn that 2xx into a 403. The seam changes only former 500s. Measured before and after, group: a plain create 500 to 403; a create whose payload names a member organization 500 to 403 (the engine's read-only strip drops a session payload's organization_id, so a payload never chooses where a session's row lands); a platform operator on a public tenant object 500 to 403. Unchanged: the hook-placed insert 2xx; a platform operator on a private object 500; update by id or predicate and delete of member-org rows 200; an active-org create 2xx landing in the active org; reads spanning org_a and org_c. All isolated readings are unchanged. Verbs read: checklist acceptance[1] and step 3 are a create; acceptance[5], acceptance[6] and negative[3] say no 500. ADR-0123 D2 names update, but under group it scopes the refusal to the membership set, and a member-org row keeps its organization, so update is untouched. Delete is the D2 boundary. Files: security-plugin.ts, the new no-active-organization-insert-union-scope.test.ts, no-active-organization-write-refusal.test.ts (its group control 'a NON-EMPTY membership set lands, even with no active organization' pinned exactly the changed branch; it is replaced by the refusal case and an active-org control, and the header is reworded), and .changeset/24784-group-no-active-org-write-403.md (plugin-security patch, Clause-②: no). The diff is 4 files, +373 / -20. Cleanup: node_modules was removed before this comment, and git worktree remove runs right after it.", "tests": "Head 94f661fbea (the merge of origin/main 307ce4c4b4). Repro on base a43405d596, pin only: 'Tests 4 failed | 2 passed (6)', 'expected ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED to be PERMISSION_DENIED', and the isolated-equality diff showing code ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, status 500 vs PERMISSION_DENIED, 403. The 4th failure was the active-org control's sort order in the test, fixed before the fix commit. Final head, one &&-joined command under os-verify-lock: turbo build --filter='@objectstack/plugin-security...' 'Tasks: 19 successful, 19 total'; pnpm --filter @objectstack/plugin-security test 'Test Files 193 passed (193)' 'Tests 4013 passed | 45 skipped (4058)'; typecheck exit 0, 'check:test-typecheck: OK ... 0 error(s)'; 'VERDICT command-exit 0'. The test tsconfig's --listFiles includes both test files. Ablation via node scripts/ablation-replace.mjs, wrapped in a script with trap restore: anchor '!carriesActiveOrganization(opCtx.context);' replaced by 'carriesActiveOrganization(opCtx.context) && false;', which turns the arm off and puts the old predicate back. On 94f661fbea: anchor x1 to x0, replacement x0 to x1, blob 0f12f626 to 4c7f7ebf, grep -c 1. Result 'Tests 4 failed | 22 passed (26)', the predicted direction (red): the group refused, group bulk and isolated-equality cases ('Received: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'), plus the unit case ('the write was not refused at all'). Every control stayed green. Restored with git checkout HEAD -- PATH: blob 0f12f626 == HEAD, git diff HEAD empty, status empty. The same result on cca0ba958a (blob 5a87b566 to 70c2ca12). The first attempt was a no-op (the replacement contained the anchor, the tool refused and restored, no test ran) and was discarded. No build or dist leg was needed: the pins import ./security-plugin.js relatively, and objectql and driver-sql are vitest source aliases. Earlier heads: 04c67ba039 pin-only red as above; 5928fc92b0 suite 192 files, 3977 passed. test:repo was not run locally (CI's); packages/cli was not touched.", "gates": "Local, head 94f661fbea: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derived 69 commands from 4 paths vs merge base 307ce4c4b. All 69 exit 0, each captured before any pipe (cmd > log 2>&1; ec=$?). --ran: 'Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. The derivation warned STALE TREE (origin/main 6 commits past the merge base when it ran). On the earlier head 5928fc92b0, 68 were derived: check:dual-build-cjs-loads and check:i18n answered exit 3 PREREQUISITE NOT MET (not counted as a measurement). check:i18n then measured exit 0 after its named turbo prerequisite build ('OK (10 package(s) — all bundles in sync')'), and both measured exit 0 at 94f661fbea (dual-build: 69 packages, 723 cjs files). check-platform-checklist was not owed (no export renamed or removed). check:nul-bytes exit 0 is in the 69. CI on PR 24819 at report time, 38 check-runs: success = Auto Label, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter. in_progress = Build Core, Check Changeset, Lint · CI, release & engine gates, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Test Core (1/6) to (6/6), Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace. Skipped = the opt-in and main-only jobs. There are no failures.", "mcp_calls": "0 — no MCP GitHub tool was called", "api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft forced) + POST /repos/objectstack-ai/objectstack/issues/24819/assignees [os-justin]; read-back identical, 9695/9695 bytes. (2) comment = POST /repos/objectstack-ai/objectstack/issues/24784/comments (this report). No labels were written. Plus 5 git pushes of claude/issue-24784-group-no-active-org-write-403, which are not REST. Reads only otherwise: single-card GET on the issue and its comments, the PR and its check-runs.", "deviations": [ "Fix placement: the stored-image seam inside step 3.7 (the same function and block as the D2 refusal), not a change to the callerHasOrganizationScope / organizationWallRefusal predicate the card's first candidate and H1 name. Reason measured above: a row-less predicate would refuse today's admitted hook-placed group insert. Both functions keep their answers. canWriteObject (row-less) therefore still answers true for this caller's create preview, as before the change (it previewed true while the write answered 500); its doc now names the arm among the post-image refusals it does not judge.", "File surface: no-active-organization-write-refusal.test.ts was edited, which the Claim's surface does not list. Its group control pinned exactly the changed branch, so it had to be re-judged.", "Closure builds ran through turbo (pnpm turbo run build --filter='@objectstack/plugin-security^...' --concurrency=2, 18/18 tasks; later '...'-suffix including the package, 19/19) instead of pnpm --filter ... build: the same package set, with the shared cache.", "origin/main was merged twice before opening the PR (faead58f45, then 307ce4c4b4, which carries #24792 on security-plugin.ts and #24681 on objectql's system-write module). Both merges were clean, the delta stayed 4 files, and the suite, typecheck, ablation and gate union were re-run on the final head.", "A throwaway measurement test (src/scratch-24784-readings.test.ts) was created to read the other verbs and the boundary populations. It was deleted and never committed.", "The harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. Per AGENTS.md, commits carry the model-free pair and the PR body carries the session-URL footer. Not followed, not imitated." ], "open_questions": [ { "question": "ADR-0123 D2 says 'Under group, no organization scope means an empty membership set'. After this PR that sentence describes the scope pre-test only: a union-scope insert with no active organization is refused too, as the card's ruling (record 6285293541) requires. Should the ADR record it?", "options": [ "A: a docs-only Tier H PR adds an addendum or status-line note to ADR-0123 D2 naming the union-scope insert arm (new rows land in the active organization, ADR-0105 D5). Cost: one short governed PR plus the maintainer's approval.", "B: leave the ADR as is; the code comment at step 3.7 and the anchor carry it. Cost: none now; the next reader of ADR-0123 meets a sentence narrower than the runtime." ], "recommendation": "A. Real need: the measured walled pass (#24783) is exactly a reader acting on this ADR text. Long term: an accepted ADR binds until amended, and a runtime wider than its record is the drift Prime Directive 13 names. AI error: an agent reading D2 literally would re-open the 500 as 'by design'. Startup scope: a one-paragraph note, no new surface." } ], "out_of_scope_findings": [ "carrier: none · noted, not filed (reach not measured at a public door; in-process real stack only). A platform operator (PLATFORM_ADMIN rung, admin_full_access) with no active organization inserting into a posture-permitting tenant object (access.default private) answers 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED under both group and isolated, at a43405d596 and after this fix. Same family as this card: a non-system caller meeting the engine's system-write refusal. The card's second candidate (the engine maps a non-system caller to the D2 4xx) would cover it. The dispatch kept the operator path unchanged. Dedupe words: platform operator no active organization 500; ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED platform admin; posture-permitting object insert; system-write refusal non-system caller.", "carrier: none · noted, not filed (reach not measured at a public door). Under group, a member with active organization org_a whose insert payload names org_c (in its membership set) gets a 2xx, and the row is stored in org_a. The forge guard admits the value, then the engine's static read-only strip drops it (TENANT_SCOPE_FIELD_DEF readonly: true) and the driver stamps tenantId. The organizations runtime's Middleware A comment says a supplied value is admitted or refused, never rewritten, and ADR-0105 D5 says the engine validates explicit values. Seam: spec:TENANT_SCOPE_FIELD_DEF.readonly → runtime:objectql engine.insert static readonly strip. Dedupe words: organization_id silently replaced insert; group explicit organization_id dropped; readonly strip organization_id session payload.", "carrier: none · noted, not filed. packages/objectql/src/engine.ts, the insert post-image seam comment, still says the Layer 0 tenant wall judges only the pre-hook image. This is a stale comment (the wall has judged the stored image since the post-hook seam) and is out of bounds here. Also in PR Acceptance notes." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24819 comment
6287086642·domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T08:45ZGenerated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T09:12Zadded_to_merge_queue2026-10-20T08:50Z →merged2026-10-20T09:11Zorigin/mainby content8a058d85a3(one parent,7282a950b1)..changeset/24784-group-no-active-org-write-403.mdand the union-scope pin file are present.security-plugin.tscarriescarriesActiveOrganizationand step 3.7's no-active-organization insert armState change in this stroke:
pm:dispatchedoff and the assignee cleared (the card closedcompletedon the merge). This is the maintainer's 18.0 item and this session's queue-jump. Follow-ups, filed at review:group, an explicit member-organizationorganization_idis stored in the active organization.Generated by Claude Code