Repository navigation
fix(objectql,plugin-security): an insert's explicit organization_id the Layer 0 wall judges is stored as sent - #25048
Conversation
…he Layer 0 wall judges is stored as sent The wall's stored-image seam now declares that it judges the insert's organization (PostHookWriteImageCheck.judgesOrganization), set by step 3.7 only when the wall's own parts are composed in. engine.insert then leaves a non-system caller's explicit organization_id out of the static-readonly withhold and strip, so a value the wall admits is stored as sent instead of being replaced by the active organization. Every path the wall does not judge strips exactly as before. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…ted column, and every no-wall path as measured Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…member and step 3.7's setting of it; changeset Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…ion_id use in the injected-column reader census Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…e column Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…n the no-wall pins Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…plicit-org-honoured
…plicit-org-honoured
📓 Docs Drift Check5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a9d667e8920a924fb6cde59d4ce7b6c3e0c508ee && git checkout a9d667e8920a924fb6cde59d4ce7b6c3e0c508ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b2890909aa48b64d91b4b18748b91374f32b4b44 64a75d2bc00f44ca510217d5f8e16f5778f6781d && git checkout -B drift-repro b2890909aa48b64d91b4b18748b91374f32b4b44 && git merge --no-ff 64a75d2bc00f44ca510217d5f8e16f5778f6781d
node scripts/docs-audit/affected-docs.mjs --json b2890909aa48b64d91b4b18748b91374f32b4b44 |
Contract reviewServed-tier: Inputs: card #24822 (body and all 8 comments, including the seat call ① Derived judgments(a) Boundary. RIGHT: no pair outside the wall becomes storable.
(b) Setter. RIGHT.
(c) Engine. RIGHT.
(d) Pins. RIGHT for the ruled matrix.
(e) Public surface. The two named changes are right and declared, but the list is incomplete.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…plicit-org-honoured
…ember, the delegated crossing operator and the array empty value on walled inserts Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…organization_id spare Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…plicit-org-honoured
… authored read-only one keeps the strip Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…plicit-org-honoured
Contract reviewServed-tier: Inputs read. Card #24822: its body and all 11 comments, through ① Derived judgments(a) RIGHT: the storable
(b) RIGHT:
(c) RIGHT: the engine's spare.
(d) RIGHT: the pins cover the ruled matrix.
(e) As the brief words it, two items as the ONLY public-surface changes, the list is WRONG. The PR's own list is RIGHT.
② Semver level
③ Boundary flagsThe earlier review's escalations, judged again on this head:
The Acceptance-notes finding (the
The dev's flags:
Notes. None of them blocks.
What the UPDATE half (half 2) must not regress:
Check-runs on
Implemented-by: VERDICT: PASS |
席位核对ACCEPT · #24822 half 1 (insert) ·
|
Part of #24822
Clause-②: yes (widening) —
PostHookWriteImageCheckgains the optional published memberjudgesOrganization, and an in-processstrictReadonlyWrites: trueinsert naming an organization the wall admits is now stored instead of refusedERR_READONLY_FIELD_REJECTED.This is half 1 of the card: INSERT only. The UPDATE half is the card's half 2. It reuses the member and is not in this PR. The seat call that ruled the channel and the declaration is
6305891812on the card (Q1 → A, Q2 → A).What was wrong
Under
group, take a member whose active organization is A and whose membership set includes C. An insert namingorganization_id: Con an app object, which carries the platform-injected tenant column:readonlyinsert strip then took the value, becauseTENANT_SCOPE_FIELD_DEFdeclaresreadonly: true.injectTenantOnInsertthen filled the now-absent column with the active organization.droppedFieldsreadonlyentry and a WARN.ADR-0105 D5 and the organizations runtime's stamp comment ("a SUPPLIED value is never touched here … admitted where the caller's organization scope admits it, otherwise refused") both rule out that replacement.
What changed
packages/objectql/src/engine.ts.PostHookWriteImageCheckgains one optional member,judgesOrganization?: boolean, with its doc.insert(),wallJudgesOrganizationis read once: true only for a non-system context whose installed seam declares the member.insertStaticReadonlySubjectreturns the static-readonlyinsert subject withoutorganization_idwhen that holds. It is the one function both passes use: the pre-hook withhold (withholdInsertReadonlyFromHooks) and the post-hookstaticReadonlyCreateStripcall site.validate()is unchanged.packages/plugins/plugin-security/src/security-plugin.ts.judgesOrganization: trueon the stored-image seam it composes, only for an insert withtenantPartsnon-empty.WriteImageCheckSeammirror type gains the member.objectql:engine-insert-wall-judged-organization.test.ts, which is new, plus one census row infederated-injected-column-readers.test.tsfor the neworganization_iduse.plugin-security:insert-seam-judges-organization.test.ts, which is new.organizations:create-explicit-organization-wall.test.ts..changeset/24822-wall-judged-organization-stored.md:@objectstack/objectqlminor,@objectstack/plugin-securitypatch.Public surface
Every change below is a widening; none admits a pair the wall does not.
PostHookWriteImageCheck.judgesOrganization: a new optional published member.strictReadonlyWrites: trueinsert naming an admitted organization is stored, where it used to be refusedERR_READONLY_FIELD_REJECTED.droppedFields, not throughonFieldsDropped, not by the read-only WARN.beforeInserthooks see the caller'sorganization_idon a walled insert.group, a member with no active organization naming a member organization is stored there. It used to be refused under ADR-0123 D2.organization_idis no longer reported or refused under strict, and is still stored as the active organization.resolveInjectedColumnProvenance). An AUTHOREDorganization_iddeclaredreadonly: truekeeps today's strip: taken, reported, and refused under strict.Seam
@objectstack/plugin-security, at step 3.7. It sets it on the seam it composes for an INSERT, and only when the tenant wall's own parts (tenantParts: the caller's Layer 0, and the ADR-0090 D10 delegator's) are in it. That is exactly when the seam's loop holds every stored row'sorganization_idto the wall. The payload half of the same step has already refused every supplied value outside it, before the engine runs.checkinstalls alone at step 3.6, which is howsinglegets a seam with no wall behind it;checkseam and the operator seam are both seams with no organization judgement. Measured in the dev report6305849709, a strip exemption the engine decides alone stored the caller's value on five no-wall paths. Deriving the answer from the posture and the context would be a mirror of the wall, the re-derivation the The bulk-event producer re-derives the tenant wall and can only see one of its three clauses — a deploymentplatformGlobalObjectsexemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706 ruling deleted. It is wider than the wall wherever the wall is not mounted. So the wall states its verdict on the seam it owns, and the flag and the judgement are the same object: a seam that does not judge the organization does not carry the flag.tenantLayer0Verdictis untouched.Boundary statement
The set of
(caller, organization)pairs an insert can store into is the set the Layer 0 wall admits, before and after. Every row below was measured in process on a real stack. A, C and D are organizations: A is the active one, C is in the membership set, D is outside it.OrganizationsPlugin(Middleware A) + the realSecurityPlugin. Before is baseaaee0840b; after is the pins.false &&, throughablation-replace); after is the head.SecurityPlugin, a delegator rebuilt from storedsys_user,sys_memberandsys_user_permission_setrows.groupmemberdroppedFieldsreadonly + WARNgroupmemberPERMISSION_DENIEDPERMISSION_DENIEDgroupmembergroupmember,strictReadonlyWrites(in process)ERR_READONLY_FIELD_REJECTEDgroupmember with NO active organization (F1)PERMISSION_DENIED(ADR-0123 D2: the value was taken, so the row named none)groupmember with NO active organization (F1)PERMISSION_DENIED(the wall)PERMISSION_DENIED(the wall)groupmember with NO active organization (F1)PERMISSION_DENIED(D2)PERMISSION_DENIED(D2)groupcrossing operator acting for a delegator holding {A, C} (F2, ADR-0090 D10)droppedFieldsreadonlygroupcrossing operator acting for that delegator (F2)groupcrossing operator acting for that delegator (F2)isolatedmemberisolatedmemberisolatedcrossing operator acting for a delegator (F2)droppedFieldsreadonlyisolatedcrossing operator acting for a delegator (F2)isolatedmember, ARRAY insert (F3)organization_id: null/''droppedFieldsreadonly + WARN; under strictERR_READONLY_FIELD_REJECTEDgroupmember, object with an AUTHOREDreadonly: trueorganization_id(F4)droppedFieldsreadonly + WARN; under strictERR_READONLY_FIELD_REJECTEDdroppedFieldsreadonly + WARN; under strictERR_READONLY_FIELD_REJECTEDgroup{A, C},isolated{A}, and a delegated insert the delegator's own set. Ablations A3 and R3A show the refusals are the wall's own; R3B shows the no-organization refusal is the ADR-0123 D2 arm's.groupgoes from {A} to {A, C}, the wall's set;isolatedstays {A} to {A}.groupbefore this change: a declared-column object, and abeforeInserthook's value judged by the same seam.2e932a613, before the narrowing, the head read "2xx, stored C, nothing reported; strict stores". The narrowing (resolveInjectedColumnProvenanceis'author'⇒ keep the strip) restores the before column. The wall still judges an authored column and its seam declares it; the author's read-only declaration is what keeps the strip.Unchanged before and after. Each path is measured at base and pinned at the head, and on each the value is taken, reported and refused under strict as before:
single, no organizations runtimesinglewith a business insertchecksingle,strictReadonlyWritesERR_READONLY_FIELD_REJECTEDgroup/isolated, security plugin absentgroup/isolated, security plugin absentgroup/isolatedtenancy.enabled: falseobject, authored read-onlyorganization_idPins
organizations/src/create-explicit-organization-wall.test.ts. This is the real stack. An innermost probe middleware records each insert's seam: whether one is installed, and whether it carries the member.group/isolatedcells.isolated: "a caller that sends its own active organization has it stored as sent, and nothing is reported". Itssingletwin stays.plugin-security/src/insert-seam-judges-organization.test.ts. This is real ObjectQL + SqlDriver + SecurityPlugin.isolated,groupsingle and array, composed with a businesscheck).singlebusiness-checkseam, on the operator's no-active-organization seam, and on an update's seam.objectql/src/engine-insert-wall-judged-organization.test.ts. The engine's reading, with a stand-in installer.beforeInsert, handed to the seam, not reported, and not refused under strict. Another read-only key (created_by) is still taken.false, or no seam at all, the value is taken, withheld from the hook, reported, WARNed, and refused under strict.validate()preview is unchanged.Ablations
Every ablation went through
node scripts/ablation-replace.mjs(WRAP mode) inside a script with its owntraprestore. Each ran the three pin files above, and each predicted direction was stated before the run. objectql, plugin-security and driver-sqlite-wasm are vitest src aliases in these suites, so there is nodist/leg. Every restore left the blob equal to HEAD andgit diff HEADempty.Rounds 1–2 (pin files at 40 / 8 / 13 cases):
checkseam carriesjudgesOrganization: truesinglebusiness-checkpins- "organization_id": "org_alpha"/+ "organization_id": "org_south"tenantPartsconjunct dropped)Round 3 (pin files at 44 / 12 / 13 cases, head
a89f493fe):groupD /isolatedCfalse &&)expected 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' to be 'PERMISSION_DENIED'; sec 0, oql 0Round 4 (pin files at 45 / 12 / 14 cases, head
534d68315):false && resolveInjectedColumnProvenance(…) === 'author')- "organization_id": "org_alpha"/+ "organization_id": "org_south"), sec 0 / 12, oql 1 / 14Tests and gates (final head
64a75d2bc)objectqlfull suite (vitest run --project local): 454 files, 8547 passed,VERDICT command-exit 0.plugin-securityfull suite: 202 files, 4151 passed, 45 skipped.organizationsfull suite: 14 files, 190 passed.plugin-securityandorganizationssuites and all three typechecks ran in one&&-joined command, which printedVERDICT command-exit 0.typecheckexits 0 forobjectql,plugin-securityandorganizations, andcheck:test-typecheckis OK for each.objectql's ledger is unchanged at 39 files / 233 errors / 64 pinned signatures, and no signature was added.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derived 76 commands from 7 paths against merge baseb2890909a. All 76 exit 0, each exit captured before any pipe. The--ranreconciliation reads '76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN' and exits 0. That includescheck-engine-split-ratio(98.6%, on deepened history),check:dual-build-cjs-loads(69 packages, 728 cjs files) andcheck:i18n('all bundles in sync'), each after a fullturbo run build.64a75d2bc: 41 check-runs, 34 success and 7 skipped (the opt-in and main-only jobs), 0 failed, including Test Core 1–6, Build Core, TypeScript Type Check, Lint & Repo Gates and Governed Surface Queue Guard. The PR is mergeable (clean).origin/mainhas moved again (to72518d20f: fix(deps): re-lock @modelcontextprotocol/sdk, follow-redirects, launch-editor and vite onto their fixed releases #25047, fix(plugin-auth)!: the OAuth register door refuses an application type the provider does not judge with 400 INVALID_REQUEST, where it registered it as web #25038, fix(objectql): an unevaluable advisory validation rule logs one line saying the write proceeded, not two saying it was rejected #25051; fix(objectql): an unevaluable advisory validation rule logs one line saying the write proceeded, not two saying it was rejected #25051 touchesobjectql'srule-validator.ts, which this PR does not edit). It is not merged: the PR is open, mergeable and green, so §10's merge triggers (a conflict, amain-caused red, a queue removal) are absent.Acceptance notes
validate()preview (import dry run,validateData) is wall-blind. This is finding 2 of the dev report. It runs no middleware, so it holds no seam. After this change, agroupinsert preview naming C still reportsdroppedFields: organization_id readonly, while the write stores C. Before and after, a preview naming D answersvalid: truewith the same drop, while the write answers 403.ValidateDataResponsedescribesdroppedFieldsas what "the write would LEGALLY strip". Measured in process only (engine.validate, pinned in the objectql file above), not at the HTTP validate door. Dedupe words:validate preview organization_id droppedFields wall·import dry run tenant wall not run·validateData Layer 0 wall blind.judgesOrganization.Generated by Claude Code