Skip to content

fix(plugin-security): under group, a no-active-organization insert gets the ADR-0123 D2 403, not a 500 - #24819

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24784-group-no-active-org-write-403
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24784-group-no-active-org-write-403

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24784

Clause-②: no

What changed

On the group tenancy posture (any union-scope posture), a member whose session has no active organization sent a create on a tenant-scoped object. The Layer 0 write wall in plugin-security let it through: its scope pre-test (callerHasOrganizationScope) reads the membership set under a union-scope posture, and a member's set is not empty. But a new row lands in the active organization (ADR-0105 D5), and there was none. So the engine met the row as an organization-less system write and refused it with 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED. Nothing was written.

The wall now refuses that insert itself, with the ADR-0123 D2 answer isolated already gave the same session: 403 PERMISSION_DENIED, "[Security] Access denied: 'OBJECT' 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."

packages/plugins/plugin-security/src/security-plugin.ts, the step 3.7 write wall:

  • carriesActiveOrganization(context) is new, beside callerHasOrganizationScope. The non-union branch of callerHasOrganizationScope now calls it, with the same answer as before.
  • The ADR-0123 D2 throw is one local function, refuseNoActiveOrganization. The scope pre-test throws it as before, with the same message, details and operator text. The new arm throws it too, with its own operator text, which says what the context does and does not carry under this posture.
  • The new arm applies to an insert, under a union-scope posture, when the caller's own Layer 0 applies to the object and the context carries no tenantId. It judges the stored image, in the post-hook seam the engine already runs (postHookWriteImageCheck). That is the row after the beforeInsert chain, the same rows the engine's own organization check (resolveSystemInsertOrganization) reads one step later. It runs first in that seam, so the session's state is the reason reported.
  • The canWriteObject doc names the new arm among the post-image refusals it does not judge.

no-active-organization-write-refusal.test.ts: the control "a NON-EMPTY membership set lands, even with no active organization" pinned exactly the branch this card changes. Its terminal stands in for the engine, so it never met the 500. It is replaced by the refusal case and its single-fact control: the same set with an active organization lands.

no-active-organization-insert-union-scope.test.ts is new. It runs on the real stack: ObjectQL, SqlDriver and SecurityPlugin, booted the way tenant-wall-post-hook-image.test.ts boots them.

Why the stored image, and not organizationWallRefusal

I measured the base before choosing. Under group today, an insert whose own app beforeInsert hook places the row in an organization the membership wall admits is admitted, while isolated refuses it ahead of the hooks. A row-less change to the predicate in organizationWallRefusal would refuse it too. That turns a 2xx into a 403, which is outside "the change turns a 500 into the designed 403". The stored image asks exactly the engine's question, so every answer this PR changes was a 500 before.

The cost: canWriteObject is asked about no row. It keeps answering true for this caller's create preview, as it did before. Its doc already lists post-image refusals as outside its answer, and now names this arm there.

Readings: real stack, in process

A member of org_a and org_c, no active organization, on a public tenant object. Base a43405d596, fix cca0ba958a:

case group before group after isolated before and after
create 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED 403 PERMISSION_DENIED 403 PERMISSION_DENIED
create, a payload naming a member organization 500 403 403
platform operator with no active org, public tenant object 500 403 403
platform operator with no active org, private object 500 500 (unchanged, see notes) 500
create whose app hook places the row in a member organization 2xx 2xx (unchanged) 403
same member with an active organization: create 2xx, row in org_a 2xx, row in org_a not run
update by id / by predicate of a member organization's row 200 200 404 (row not visible)
delete of a member organization's row 200 200 404
read member orgs' rows member orgs' rows not run

The engine's static read-only strip drops a session payload's organization_id on insert. So a payload value never decides where a session's new row lands, before or after this change.

Which verbs ADR-0123 D2 and the checklist item cover

  • access-security.no-active-org-session-semantics: acceptance[1] and step 3 test a create, and acceptance[5], acceptance[6] and negative[3] require no 500 anywhere. Covered for insert, single and bulk.
  • Update: ADR-0123 D2 names update. Under group it states the scope as the membership set. A no-active-org member's update of a row that set admits answers 200 before and after, and the row keeps its own organization. Nothing has nowhere to land, so it is not changed.
  • Delete: this is the ADR-0123 D2 boundary, and it is not changed.

Tests (head 94f661fbea, the merge of origin/main 307ce4c4b4)

  • The real-stack pin on the base a43405d596, before the fix: 4 failed, 2 passed. group answered ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED / 500 where isolated answered PERMISSION_DENIED / 403. The fourth failure was the active-org control's sort order in the test itself, fixed before the fix commit.
  • pnpm --filter @objectstack/plugin-security test: 193 files passed, 4013 passed, 45 skipped.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0, and check:test-typecheck is OK. tsc -p tsconfig.test.json --listFiles lists both test files among the package's test files.
  • Both ran in one &&-joined command under the verify lock, after the closure build (turbo, --filter='@objectstack/plugin-security...', 19/19 tasks). The lock printed VERDICT command-exit 0.

Ablation, on the committed fix, run on cca0ba958a and again on 94f661fbea. node scripts/ablation-replace.mjs replaced the arm's last conjunct with a constant false, which puts the old predicate back:

  • On disk: the anchor went from 1 to 0, the replacement from 0 to 1, and the blob changed (0f12f626 to 4c7f7ebf on the final head).
  • Result, both times: 4 failed, 22 passed. The ablated group case received ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, the equality-with-isolated case failed, and the unit case was not refused. Every control stayed green.
  • Restored with git checkout HEAD -- PATH: the blob equals HEAD's, and git diff HEAD and git status are empty.
  • The first attempt was a no-op. Its replacement contained the anchor, so the tool refused and restored before any test ran. It is discarded.

Gates (local, head 94f661fbea)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derived 69 commands for this diff. All 69 exited 0, and each exit was captured before any pipe.
  • --ran over that record answered "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN" and exited 0.
  • check:i18n and check:dual-build-cjs-loads first answered exit 3 (PREREQUISITE NOT MET, nothing measured) on an earlier head. Both measured green once the builds they name existed.
  • These run in CI, not locally: the repo-wide scans (pnpm lint first), test:repo, Test Core, Build Core and the Type Check lanes. The main-only battery (dogfood, Temporal Conformance) runs after merge. This PR's check-runs decide them.
  • The derivation warned STALE TREE: origin/main was 6 commits past this head's merge base when it last ran. The queue rebuilds on current main.

Acceptance notes


Generated by Claude Code

…rganization insert

Reads red on the base: group answers ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/500
where isolated answers PERMISSION_DENIED/403 for the same context.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…n gets the ADR-0123 D2 403

Under group, a member whose session has no active organization passed the
Layer 0 scope pre-test (it reads the membership set) and the engine then refused
the row as an organization-less system write with a 500. The wall now refuses
that insert on its stored image with the same answer isolated gives.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 3 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 15 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 c56115bf962cb301fb046c54680620d37e5ab2fb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3fd0e664c69869edfd7c4eecf9e54e4ca8b99ee0 — the merge of head 94f661fbeab27684553f418c182d46fb8e700dcd into base c56115bf962cb301fb046c54680620d37e5ab2fb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3fd0e664c69869edfd7c4eecf9e54e4ca8b99ee0 && git checkout 3fd0e664c69869edfd7c4eecf9e54e4ca8b99ee0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c56115bf962cb301fb046c54680620d37e5ab2fb 94f661fbeab27684553f418c182d46fb8e700dcd && git checkout -B drift-repro c56115bf962cb301fb046c54680620d37e5ab2fb && git merge --no-ff 94f661fbeab27684553f418c182d46fb8e700dcd

node scripts/docs-audit/affected-docs.mjs --json c56115bf962cb301fb046c54680620d37e5ab2fb

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c56115bf962cb301fb046c54680620d37e5ab2fb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T08:44Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 87c09ebb7a, not against the report (os-dev-report on #24784). This is the maintainer's 18.0 item (triage record 6285293541) and the queue-jump of this session ("24784 插队"). Disclosure read first: the PR and the changeset state the wall's rule and its refusal; neither gives a recipe for writing into an organization not selected. Head under review:

94f661fbeab27684553f418c182d46fb8e700dcd
  1. Shape. Draft, base main, first line Fixes #24784, line-start Clause-②: no in the body and the changeset. The changeset's line carries a parenthetical, and readClause2Line reads it as declared no with no arm. 4 files, +373/−20, none on a governed surface. The trial merge onto origin/main is clean; the branch already carries fix(plugin-security): an on-behalf-of by-id write never admits a row its delegator's own by-id write refuses (ADR-0090 D10) #24792's security-plugin.ts change and feat(cli,runtime)!: the upgrade ceremony opens — v17 and 18.0 databases take it, and a server serves only a database it created or one the ceremony certified (18.x steps 5 and 6) #24681's objectql change.
  2. Placement, read in the code.
    • The new arm lives in step 3.7's stored-image seam (writeImageCheckSeam), the same block as the ADR-0123 D2 refusal.
    • It fires only on an insert under a union-scope posture, when the caller's own Layer 0 applies (callerTenantCheck != null), the context carries no tenantId, and a row about to be stored names no organization.
    • The D2 throw is one shared local function, so the message is byte-identical to isolated's.
    • callerHasOrganizationScope and organizationWallRefusal keep their answers. carriesActiveOrganization is extracted beside them.
    • The dev measured why the seam and not the predicate: a hook-placed group insert that is admitted today stays admitted. Only the former 500s change.
  3. Measured, before and after, on a real stack.
    • group: a plain create, a create naming a member organization in its payload, and an operator on a public tenant object each move 500 → 403.
    • Unchanged: the hook-placed insert (2xx), updates and deletes of member-organization rows (200), an active-organization create (2xx), and reads across member organizations.
    • Every isolated reading is unchanged.
    • The ablation (the arm off) turns 4 red, with every control green; the restore is proven by blob equality.
    • The replaced unit control ("a NON-EMPTY membership set lands, even with no active organization") pinned exactly the branch the maintainer's 18.0 ruling changes. It is now the refusal case, beside an active-organization control.
  4. Changeset, @objectstack/plugin-security patch, read sentence by sentence: "Before" (the 500), "After" (403, isolated's message), and "Unchanged" (nothing written, the stored image judged, the listed paths). Each matches 2 and 3.
  5. Gates. At ACCEPT, every check-run on this head is success or an expected skip (including 18.x upgrade harness (opt-in)), among them TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core and Governed Surface Queue Guard. Locally, 69 of 69 derived families exited 0.
  6. Accepted deviations.
    • The seam placement (2), the unit test re-judged (3), and turbo closure builds.
    • Two clean merges of main, with everything re-run on the final head.
    • A throwaway measurement test, never committed.
    • Model-free trailers.
  7. Open question (ADR-0123 D2's sentence now under-describes the runtime): A. Filed ADR-0123 D2 says "under group, no organization scope means an empty membership set"; since PR #24819 a union-scope insert with no active organization is refused too, so the ADR under-describes the runtime #24823 for a dated amendment note (docs/adr, which may be Tier H).
  8. Out-of-scope dispositions.
  9. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 08:46
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 08:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 8a058d8 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24784-group-no-active-org-write-403 branch October 20, 2026 09:11
huangyiirene pushed a commit that referenced this pull request Oct 20, 2026
…s — the union-scope insert and the crossing operator's

ADR-0123 D2's scope paragraph and its "What does not change" line were
narrower than the runtime after PR #24819 (8a058d8) and PR #24868
(98c3d00). A dated amendment in the status block names both arms and
their reasons (ADR-0105 D5; ADR-0095 D3 with ADR-0131 D9); the scope
paragraph keeps its text with a dated pointer, and the "Platform
operators" entry is struck and corrected in place.

Claude-Session: https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants