Skip to content

fix(plugin-security): a platform operator's insert with no active organization gets the ADR-0123 D2 403, not a 500 - #24868

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24821-operator-no-org-insert
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24821-operator-no-org-insert

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24821

Clause-②: no

What changed

A platform operator (the PLATFORM_ADMIN rung, admin_full_access) whose session has no active organization sent a create on a tenant object whose posture lets the operator cross the organization wall (access.default: 'private', ADR-0095 D3). Under group and isolated it got 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED. It now gets the answer a member gets in the same state: 403 PERMISSION_DENIED, with a message that names the missing active organization (ADR-0123 D2/D4).

The fix is in plugin-security's Layer 0 write wall, security-plugin.ts step 3.7, and nowhere else:

Why this answer (governing text)

Measured (real stack: ObjectQL + SqlDriver + SecurityPlugin)

Before, at 4be728131 (the pin alone, committed as 743cacb33 and unchanged since): Tests 4 failed | 12 passed (16). The four failures, under both group and isolated, were the single and bulk operator inserts: expected 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' to be 'PERMISSION_DENIED'. Every control was already green.

After: the operator's insert gets exactly the member's outcome on the same object (code, status and message compared whole), and nothing is stored. These are unchanged, each pinned per posture:

  • the operator WITH an active organization is admitted, and the row lands there;
  • a row a beforeInsert hook places in an organization is admitted (the stored image is judged, not the session);
  • the operator's update of an existing row is admitted, and the row keeps its organization;
  • the operator's insert into an object that opted out of tenancy is admitted;
  • the operator's read spans every organization;
  • a system context with no organization still gets the engine's own 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED.

Also measured at base (scratch file, not committed): a payload that names organization_id is stripped by the engine for a session on a business object, so the operator's org-less insert naming one was a 500 as well. It is now the same 403.

Tests

  • New no-active-organization-insert-platform-operator.test.ts: the real-stack pin above, one block per posture, with the member's D2 403 as the control.
  • no-active-organization-write-refusal.test.ts: its control "a true PLATFORM operator crosses the wall on a posture-permitting object" pinned exactly the changed branch, an org-less insert passing the middleware. In the real stack that insert answered 500. It is re-judged as follows:
    • the crossing is now pinned on what it really admits, the operator's UPDATE with no active organization;
    • the operator's INSERT with no active organization is refused with the conforming D2 refusal;
    • an insert of a row that names its organization lands, and so does an insert WITH an active organization;
    • the business-object sibling now refuses both the insert and the update, so the posture-scoping of the exemption stays measured.
  • Final head d366c1712 (the merge of origin/main abd5acdaf), one locked run: closure build OK; pnpm --filter @objectstack/plugin-security test: Test Files 196 passed (196), Tests 4066 passed | 45 skipped (4111); typecheck exit 0 (check:test-typecheck: OK … 0 error(s)). tsc -p tsconfig.test.json --listFiles compiles both edited test files (196 test files in all).

Ablations (each one committed first, mutated through scripts/ablation-replace.mjs in a script with a restore trap, and restored to the HEAD blob with git diff HEAD empty)

No build or dist/ leg was needed: the pins import ./security-plugin.js relatively, and objectql and driver-sql are source aliases in this package's vitest config.

  1. The arm off. Anchor callerCrossesTenantWall && was replaced by /* ABLATION-24821 */ false && (anchor x1 to x0, marker x0 to x1, blob 25d6a049 to 9786d9c1). Result: Tests 5 failed | 34 passed (39), in the predicted direction (red). The four real-stack refusals failed with Received: "ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED", and the unit refusal failed with the write was not refused at all. Every control stayed green.
  2. The crossing discriminator off (any none verdict for an operator counts as a crossing). The second-ladder comparison was replaced by /* ABLATION-24821-B */ true; (blob 25d6a049 to f9b75967). Result: Tests 2 failed | 37 passed (39), in the predicted direction: the opted-out-of-tenancy control under both postures (expected the write to be admitted).

Gates (local, head d366c1712)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 69 commands from the 4 changed paths against merge base abd5acdaf. All 69 exit 0, each code captured before any pipe. check:dual-build-cjs-loads and check:i18n first answered exit 3 PREREQUISITE NOT MET, which is not a measurement. After a workspace build (turbo run build --filter=!@objectstack/docs, 76/76) both measured exit 0. --ran: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. The repo-wide runs (pnpm lint, test:repo) are CI's.

Acceptance notes (observed, not fixed here)

  1. ADR-0123 D2's scope sentence now under-describes the runtime. It still says a platform operator crossing the wall is outside the refusal. Carrier: 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 (its amendment note can name this arm next to the union-scope one). See the open question in the report.

  2. A platform operator's cross-organization by-id update on a posture-permitting object answers 404, while the same caller reads the row. Measured in-process at 4be728131 on qa_vault (private), row in org_b:

    • group with no active organization: 404 RECORD_NOT_FOUND;
    • group with active org_a: 404;
    • isolated with active org_a: 404;
    • isolated with no active organization: 200.

    findOne returns the row in all four. This looks like the engine's by-id write scope being computed over its own facts (no platform-admin fact), against ADR-0131 D8's one predicate. Reach was not measured at a public door. Carrier: none.

  3. The engine's organization check has no isSystem test. Any non-system caller that reaches it without an organization still gets the system-write 500. After 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 and this PR the wall covers members and crossing operators on every object the wall itself recognises as a tenant object. Populations it may not cover, all unmeasured:

    • an object whose declared tenancy.tenantField is not organization_id;
    • systemFields.tenant: false together with an explicit organization_id column;
    • a federated object that declares a real remote organization_id;
    • a composition with no security plugin.

    This is the card's second candidate, which is domain:engine's. Carrier: none.

  4. canWriteObject's create preview. It is row-less, so for this operator it still answers true (as it did while the write answered 500). The same boundary 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 recorded.


Generated by Claude Code

…on insert on a posture-permitting object, per walled posture

Real stack (ObjectQL + SqlDriver + SecurityPlugin), group and isolated.
On this commit the operator's insert answers 500
ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED (4 cases red); the member's D2 403
on the same object is the control, and the unchanged neighbours are green.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
…anization gets the ADR-0123 D2 403, not the engine's 500

A true PLATFORM_ADMIN crosses the Layer 0 wall on a posture-permitting
object (ADR-0095 D3), so neither the D2 pre-test nor the union-scope
insert arm reached it, and its organization-less insert met the engine's
system-write refusal (ADR-0131 D9) as a 500. Step 3.7 now judges that
insert's stored image and refuses a row with nowhere to land with the
member's answer. The crossing fact is read from the one Layer 0 ladder,
asked again with only the exemption withheld.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
…organization insert refusal

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 5 documentable anchor(s).

6 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/permissions/permissions-matrix.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/sharing-rules.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • 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 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4bb57ba2dd4129ddafc00eae9accd75910c42f8a — the merge of head d366c1712d1d9f3d979bbea5293e5870c7ebe5d1 into base 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91, 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 4bb57ba2dd4129ddafc00eae9accd75910c42f8a && git checkout 4bb57ba2dd4129ddafc00eae9accd75910c42f8a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91 d366c1712d1d9f3d979bbea5293e5870c7ebe5d1 && git checkout -B drift-repro 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91 && git merge --no-ff d366c1712d1d9f3d979bbea5293e5870c7ebe5d1

node scripts/docs-audit/affected-docs.mjs --json 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91

⚠️ 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 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91 → 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 · #24821 · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T12:36Z · head d366c1712d, first round, the review of record for claim 6289134908.

  1. Form:
    • The first line is Fixes #24821; the seat's grep of the body finds no other closing keyword beside an issue number.
    • Clause-②: no at line start in the body, in the changeset and in the claim. The seat confirmed it: the insert was refused before and is refused now, and both new members (crossedByPlatformAdmin on computeLayeredRlsFilter's return, and computeWriteTenantWall) are private on SecurityPlugin. The package's exports map reaches only ., so nothing published moves.
    • One patch changeset for @objectstack/plugin-security.
    • 4 files, +478/−24. No packages/objectql/**, no tenant-layer.ts, no packages/spec/**, no new error code. One file sits outside the claim's surface (item 4).
    • Path face read with the files endpoint on this head: check-governed-merges --pr 24868 reports 0 of 4 paths governed, 502 changed lines. Ordinary queue landing.
  2. Premise and fix:
    • Premise measured. At base 4be728131 the pin alone was 4 failed | 12 passed, under group and isolated, with ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED where PERMISSION_DENIED belongs. Every control was already green.
    • The fix is one arm in step 3.7. An insert from a caller whose null Layer 0 is a platform operator's crossing, from a session with no active organization, is judged on its stored image in the same postHookWriteImageCheck seam. It throws the same D2 refusal (refuseNoActiveOrganization) as the 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 arm. It runs after the beforeInsert chain and before the engine's organization check, so a hook that places the row is still admitted.
    • The crossing fact asks the one ladder, it doesn't copy it. crossedByPlatformAdmin re-asks computeTenantLayer0Verdict with only isPlatformAdmin withheld. The seat read the ladder in packages/core/src/security/tenant-layer0-verdict.ts. single, tenancy disabled and a missing organization_id column all answer none before the exemption rung, so they answer none both ways and can never count as a crossing. The changeset's "every single answer is unchanged" therefore follows from the ladder's order, not from a pin. The cost is a second call of a pure function on the write path.
    • Pins and ablation. A new real-stack file (ObjectQL + SqlDriver + SecurityPlugin), one block per walled posture: the refusal, single and bulk, compared whole with the member's answer on the same object; six unchanged neighbours. Two ablation legs were each red as predicted and restored to the HEAD blob:
      • A1, the arm off: 5 failed, the four refusals plus the unit refusal.
      • A2, the crossing discriminator off: 2 failed, the opted-out-of-tenancy control under both postures.
  3. Gates:
    • CI on d366c1712d, read by the seat after the last shard completed: 41 check-runs, 34 success, 7 skipped, 0 failed. Every skip is on the roster in scripts/pm/check-expected-skips.mjs, read from the file because the shared checkout has no yaml package to run it: Build Docs, Dogfood Verify CLI, Temporal Conformance, Console Pin Gate, the Dogfood Regression Gate matrix, Packed-tarball smoke, and the 18.x upgrade harness.
    • Local: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. Two prerequisite exits (3) were re-measured after a workspace build.
    • Package suite: plugin-security 196 files, 4066 passed and 45 skipped; typecheck exit 0.
    • Docs drift (6290453151): the six named pages reach this change through the class name or the private computeLayeredRlsFilter. The seat's grep of content/docs/permissions finds no page that says an operator's organization-less insert is admitted, so no page is now false. content/docs/releases/ is untouched.
  4. Accepted deviations:
    • no-active-organization-write-refusal.test.ts (+55/−11) is outside the claim's surface. Its control "a true PLATFORM operator crosses the wall on a posture-permitting object" pinned exactly the branch this card changes: an organization-less insert passing the middleware, which on the real stack answered 500. Leaving it would have pinned the defect. The re-judged pins keep the crossing measured on what it really admits (the operator's update with no active organization) and keep the business-object sibling's posture scoping. Same package; declared in the PR body.
    • The answer follows ADR-0131 D9 where ADR-0123 D2's scope paragraph says otherwise. D2 exempts "a platform operator crossing the wall by ADR-0095 D3", and its Consequences list platform operators under "What does not change". D9 refuses this write anyway, so the outcome (refused, nothing stored) is unchanged and only the answer moves from the 500 to the D2 403. The PR changes no ADR text (docs/adr/** is Tier H). The amendment belongs to 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 (item 5).
  5. Out of scope (the PR's ## Acceptance notes, one line each):
  6. Landing: ordinary queue landing. Every check on this head is green or an expected skip, so the seat flips it (pr_ready + automerge_enable) on this record.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 12:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 12:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 98c3d00 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24821-operator-no-org-insert branch October 20, 2026 13:07
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/l tests tooling

Projects

None yet

2 participants