Skip to content

fix(objectql,plugin-security): an insert's explicit organization_id the Layer 0 wall judges is stored as sent - #25048

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-24822-explicit-org-honoured
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-24822-explicit-org-honoured

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Part of #24822

Clause-②: yes (widening) — PostHookWriteImageCheck gains the optional published member judgesOrganization, and an in-process strictReadonlyWrites: true insert naming an organization the wall admits is now stored instead of refused ERR_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 6305891812 on 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 naming organization_id: C on an app object, which carries the platform-injected tenant column:

  • Answer: 2xx, and the row was stored in A, single and array alike.
  • Mechanism:
    • The Layer 0 write wall (plugin-security step 3.7) judged C and admitted it.
    • The engine's static-readonly insert strip then took the value, because TENANT_SCOPE_FIELD_DEF declares readonly: true.
    • driver-sql's injectTenantOnInsert then filled the now-absent column with the active organization.
  • Visibility: the only signals were a droppedFields readonly entry 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.
    • PostHookWriteImageCheck gains one optional member, judgesOrganization?: boolean, with its doc.
    • In insert(), wallJudgesOrganization is read once: true only for a non-system context whose installed seam declares the member.
    • The new insertStaticReadonlySubject returns the static-readonly insert subject without organization_id when that holds. It is the one function both passes use: the pre-hook withhold (withholdInsertReadonlyFromHooks) and the post-hook staticReadonlyCreateStrip call site.
    • validate() is unchanged.
  • packages/plugins/plugin-security/src/security-plugin.ts.
    • Step 3.7 sets judgesOrganization: true on the stored-image seam it composes, only for an insert with tenantParts non-empty.
    • The private WriteImageCheckSeam mirror type gains the member.
  • Tests:
    • objectql: engine-insert-wall-judged-organization.test.ts, which is new, plus one census row in federated-injected-column-readers.test.ts for the new organization_id use.
    • plugin-security: insert-seam-judges-organization.test.ts, which is new.
    • organizations: create-explicit-organization-wall.test.ts.
  • Changeset: .changeset/24822-wall-judged-organization-stored.md: @objectstack/objectql minor, @objectstack/plugin-security patch.

Public surface

Every change below is a widening; none admits a pair the wall does not.

  • PostHookWriteImageCheck.judgesOrganization: a new optional published member.
  • An in-process strictReadonlyWrites: true insert naming an admitted organization is stored, where it used to be refused ERR_READONLY_FIELD_REJECTED.
  • An admitted organization is no longer reported as dropped: not in droppedFields, not through onFieldsDropped, not by the read-only WARN.
  • beforeInsert hooks see the caller's organization_id on a walled insert.
  • F1: under group, a member with no active organization naming a member organization is stored there. It used to be refused under ADR-0123 D2.
  • F2: an on-behalf-of insert by a crossing operator is held to the delegator's wall. An admitted value is stored as sent; a refused one is refused.
  • F3: an array row's explicit empty organization_id is no longer reported or refused under strict, and is still stored as the active organization.
  • F4: only the platform's injected tenant column is spared, told apart by the registry's provenance reader (resolveInjectedColumnProvenance). An AUTHORED organization_id declared readonly: true keeps today's strip: taken, reported, and refused under strict.

Seam

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.

  • Rounds 1–2: ObjectQL + SqliteWasmDriver + the real OrganizationsPlugin (Middleware A) + the real SecurityPlugin. Before is base aaee0840b; after is the pins.
  • F1, F3 (round 3): the same stack. Before is the head with the engine's member read disabled (false &&, through ablation-replace); after is the head.
  • F2 (round 3): ObjectQL + SqlDriver + SecurityPlugin, a delegator rebuilt from stored sys_user, sys_member and sys_user_permission_set rows.
posture · caller insert on the injected column before after
group member names C, single and array 2xx, stored A, droppedFields readonly + WARN 2xx, stored C, nothing reported
group member names D, single and array 403 PERMISSION_DENIED 403 PERMISSION_DENIED
group member names none stored A, not reported stored A, not reported
group member, strictReadonlyWrites (in process) names C ERR_READONLY_FIELD_REJECTED stored C
group member with NO active organization (F1) names C, single and array 403 PERMISSION_DENIED (ADR-0123 D2: the value was taken, so the row named none) 2xx, stored C, nothing reported
group member with NO active organization (F1) names D 403 PERMISSION_DENIED (the wall) 403 PERMISSION_DENIED (the wall)
group member with NO active organization (F1) names none 403 PERMISSION_DENIED (D2) 403 PERMISSION_DENIED (D2)
group crossing operator acting for a delegator holding {A, C} (F2, ADR-0090 D10) names C 2xx, stored A, droppedFields readonly 2xx, stored C, nothing reported
group crossing operator acting for that delegator (F2) names D 403 (the delegator's wall) 403 (the delegator's wall)
group crossing operator acting for that delegator (F2) names none stored A stored A
isolated member names A stored A, reported readonly + WARN stored A, nothing reported
isolated member names C or D, single and array 403 403
isolated crossing operator acting for a delegator (F2) names A stored A, droppedFields readonly stored A, nothing reported
isolated crossing operator acting for a delegator (F2) names C 403 (the delegator's wall) 403 (the delegator's wall)
isolated member, ARRAY insert (F3) organization_id: null / '' stored A, droppedFields readonly + WARN; under strict ERR_READONLY_FIELD_REJECTED stored A, nothing reported, not refused under strict
group member, object with an AUTHORED readonly: true organization_id (F4) names C 2xx, stored A, droppedFields readonly + WARN; under strict ERR_READONLY_FIELD_REJECTED unchanged: 2xx, stored A, droppedFields readonly + WARN; under strict ERR_READONLY_FIELD_REJECTED
  • What the wall admits is unchanged: group {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.
  • Through an explicit payload on the injected column: group goes from {A} to {A, C}, the wall's set; isolated stays {A} to {A}.
  • F1 turns a refusal into a store only for a row that names an organization the wall admits. That matches ADR-0123's amended scope: the insert arms refuse a row that names no organization.
  • Through every other route, already {A, C} under group before this change: a declared-column object, and a beforeInsert hook's value judged by the same seam.
  • No pair becomes writable that the wall does not admit.
  • F4 measurement. At 2e932a613, before the narrowing, the head read "2xx, stored C, nothing reported; strict stores". The narrowing (resolveInjectedColumnProvenance is '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:

path explicit organization stored seam
single, no organizations runtime C A (the context's), reported none
single with a business insert check C A, reported installed, member unset
single, strictReadonlyWrites C refused ERR_READONLY_FIELD_REJECTED none
group / isolated, security plugin absent C A, reported none
group / isolated, security plugin absent none A, not reported (#21682) none
platform operator crossing on a private object, group / isolated D A, reported none
platform operator with no active organization, private object D refused 403 (no active organization) installed, member unset
tenancy.enabled: false object, authored read-only organization_id C NULL, reported none
system context C, D as sent none

Pins

  • 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.
    • The rows of both tables above, as group / isolated cells.
    • The old cell "a caller that sends the readonly key itself still sees it reported" is rewritten to isolated: "a caller that sends its own active organization has it stored as sent, and nothing is reported". Its single twin stays.
  • plugin-security/src/insert-seam-judges-organization.test.ts. This is real ObjectQL + SqlDriver + SecurityPlugin.
    • The member is SET on a walled insert (isolated, group single and array, composed with a business check).
    • It is UNSET on the single business-check seam, on the operator's no-active-organization seam, and on an update's seam.
    • There is NO seam for an operator with an active organization, or for a system context.
  • objectql/src/engine-insert-wall-judged-organization.test.ts. The engine's reading, with a stand-in installer.
    • With the member SET, the value is stored as sent, shown to beforeInsert, handed to the seam, not reported, and not refused under strict. Another read-only key (created_by) is still taken.
    • With the member absent, false, or no seam at all, the value is taken, withheld from the hook, reported, WARNed, and refused under strict.
    • A system context is unchanged. The validate() preview is unchanged.

Ablations

Every ablation went through node scripts/ablation-replace.mjs (WRAP mode) inside a script with its own trap restore. 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 no dist/ leg. Every restore left the blob equal to HEAD and git diff HEAD empty.

Rounds 1–2 (pin files at 40 / 8 / 13 cases):

id mutation predicted red observed
A1 (ruled) the step 3.6 business-check seam carries judgesOrganization: true the two single business-check pins org 1 failed / 40, sec 1 failed / 8, oql 0 failed / 13. The organizations pin: - "organization_id": "org_alpha" / + "organization_id": "org_south"
A2 step 3.7 sets the member on every insert seam it composes (the tenantParts conjunct dropped) the two operator no-active-organization pins org 1 / 40 ("expected the no-active-organization refusal: expected true to be false", meaning the write was admitted), sec 1 / 8, oql 0 / 13
A3 the wall's predicate admits every row every wall refusal org 10 / 40, sec 0 / 8, oql 0 / 13
A4 the engine spares the column without the member every no-wall cell org 10 / 40, sec 3 / 8, oql 6 / 13
A5 the #21682 caller-key record switched off the two security-absent "stamp not reported" cells only org 2 / 40, sec 0, oql 0. The walled #21682 cells stay green: on a walled insert the member now spares the column too
A6 the engine ignores the member (positive control) every stored-as-sent cell org 5 / 40, sec 1 / 8, oql 4 / 13

Round 3 (pin files at 44 / 12 / 13 cases, head a89f493fe):

id mutation predicted red observed
R3A the wall's predicate admits every row every wall refusal, now including F1's D and F2's group D / isolated C org 11 / 44 (the round-2 ten plus "a create naming an organization outside the membership set is still refused by the wall"), sec 2 / 12 (both delegated refusals), oql 0 / 13
R3B the ADR-0123 D2 insert arm switched off (false &&) F1's no-organization cell only org 1 / 44: expected 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' to be 'PERMISSION_DENIED'; sec 0, oql 0
R3C the engine ignores the member (positive control) every stored-as-sent cell, now including F1, F2 and F3 org 7 / 44, sec 3 / 12, oql 4 / 13

Round 4 (pin files at 45 / 12 / 14 cases, head 534d68315):

id mutation predicted red observed
R4 the provenance narrowing switched off (false && resolveInjectedColumnProvenance(…) === 'author') the two F4 pins only org 1 / 45 (- "organization_id": "org_alpha" / + "organization_id": "org_south"), sec 0 / 12, oql 1 / 14

Tests and gates (final head 64a75d2bc)

Acceptance notes

  • The 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, a group insert preview naming C still reports droppedFields: organization_id readonly, while the write stores C. Before and after, a preview naming D answers valid: true with the same drop, while the write answers 403. ValidateDataResponse describes droppedFields as 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.
  • The UPDATE half is not here. It is the card's half 2: a re-point the wall admits that the update-side strip then takes. It reuses judgesOrganization.

Generated by Claude Code

…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>
…n the no-wall pins

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

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 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
  • 2 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 — 28 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 b2890909aa48b64d91b4b18748b91374f32b4b44 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a9d667e8920a924fb6cde59d4ce7b6c3e0c508ee — the merge of head 64a75d2bc00f44ca510217d5f8e16f5778f6781d into base b2890909aa48b64d91b4b18748b91374f32b4b44, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: e14af57d4d315853c3ba70c26db01810822645a5
Local-runs: none

Inputs: card #24822 (body and all 8 comments, including the seat call 6305891812 and the dev reports 6305849709 and 6308104846), PR #25048 (body and 7 files), and the net diff against main from merge base dfe60e3a0. Every finding below was read off the code at this head. Nothing was built or run.

① Derived judgments

(a) Boundary. RIGHT: no pair outside the wall becomes storable.

  • What the wall is. The Layer 0 predicate takes four shapes (tenant-layer.ts tenantLayer0FilterOf): {organization_id: id}, {organization_id: {$in: [...]}}, the frozen deny sentinel, or null. clearsWall matches it with matchesFilterCondition. There, $in and equality use looseEq, which is strict === with a Date-only exception. So a row clears only when its value is exactly an admitted id string. An array, object or number is refused.
  • Where the value is judged. The spare happens only where step 3.7 composed the seam with tenantParts non-empty. That same block has already run the payload half (suppliedRows.every(clearsWall)) before next(). The engine then calls the seam's per-row loop (namesOrganization(row) and not clearsWall(row) refuses) after the static-readonly strip. That is before resolveSystemInsertOrganization and before the driver.
  • Who can set the seam. insert() builds opCtx fresh from {object, operation, data, options, context}. So no caller, and no REST door, can supply the seam. Only a registered middleware can.
  • By posture.
    • single: no Layer 0 and no member. Unchanged.
    • isolated: {A} before and after. The value is now stored as sent, not stripped and stamped.
    • group: the explicit payload on the injected column goes from {A} to {A, C}, which equals the wall's set.
  • Unwall-judged paths. I found no path where a caller-supplied organization_id reaches storage without a wall verdict.
  • Accept-set changes the diff implies that the PR body's two tables do not list:
    • F1 (right, unpinned, not in the tables). Case: under group, a member with a non-empty membership set and NO active organization inserts a row naming C, which is in the set.
      • Before: the strip took C, the stored image named no organization, and the insertLacksActiveOrganization arm refused 403 (ADR-0123 D2).
      • After: the member is set (an insert, tenantParts non-empty), so C stays on the row. someRowHasNowhereToLand() is false and the loop admits C. resolveSystemInsertOrganization returns undefined because every row carries an organization. driver-sql's injectTenantOnInsert leaves C in place. Result: refused before, stored as C after. This is read off the code, not measured.
      • Why it is right: ADR-0123's amendment scopes the D2 insert arms to "a row that names no organization". The arm's own comment says a row naming its organization "where the engine keeps a session's value, carries one and lands as before". Naming D is still refused by the payload half.
    • F2 (right, unpinned). Case: an on-behalf-of insert (ADR-0090 D10) where the caller crosses the wall (its Layer 0 is null) and the delegator is walled. tenantParts is the delegator's part alone, so the member is set. The explicit value is then held to the delegator's wall and stored as sent where that wall admits it. Before, it was taken and stamped, or refused under D2. The PR body's operator rows cover only the shape with no delegator.
    • F3 (right, reporting only). Case: an ARRAY insert by a walled member with an explicit empty organization_id (null or empty string). Middleware A fills only non-array payloads, so the empty value reaches the engine.
      • Before: taken, reported, and refused under strict.
      • After: spared, not reported, and not refused under strict.
      • What is stored does not change: driver-sql's injectTenantOnInsert fills an empty value from tenantId, so the active organization is stored as before.

(b) Setter. RIGHT.

  • The one assignment. security-plugin.ts:4340 is the only assignment in source. It is a spread conditioned on operation === 'insert' and tenantParts non-empty, on the new object step 3.7 composes.
  • Where it is not set:
    • Step 3.6's newWriteImageCheck() (:3818; installed at :3831 for an insert and :3901 for an update) carries no member, and the composition never mutates it.
    • An update seam fails the operation conjunct.
    • The crossing operator's no-active-organization seam has empty tenantParts, so the member stays unset.
    • isSystem short-circuits the middleware, and the engine also gates on !isSystem (engine.ts:15845).
  • The private mirror. WriteImageCheckSeam gains the member (:271).
  • The engine only reads it. The engine never sets the member.
  • The delegated case. F2 sets the member with non-empty tenantParts. That is right under the ruled condition, because the loop does judge the row there.

(c) Engine. RIGHT.

  • The read. wallJudgesOrganization is !isSystem AND judgesOrganization === true. It is read once, inside the middleware chain's final handler, so the seam is already in place.
  • Both passes. The value feeds the pre-hook withhold and the post-hook strip through insertStaticReadonlySubject. That helper deletes only SystemFieldName.ORGANIZATION_ID from staticReadonlyInsertSubject's {name, fields}. It returns null when nothing remains, which is the same as a strip with nothing to take.
  • Unchanged: rule-validator.ts; validate(), which still calls staticReadonlyInsertSubject directly (engine.ts:15353); stripRuntimeOwnedFields; the update strips; and tenantLayer0Verdict.
  • F4 (observation, not named). The spare is keyed on the column NAME, not on the injected definition.
    • The registry keeps an authored organization_id, and "the object stays walled on it". So on a tenancy-enabled object that is not sys_, an author-declared readonly: true organization_id is also spared on walled inserts.
    • This is the same silent-replacement class, and the wall still judges the value.
    • The PR body and changeset describe only the platform-injected column.
    • No in-tree object reaches this: sys_ objects are outside staticReadonlyInsertSubject.

(d) Pins. RIGHT for the ruled matrix.

  • organizations, real stack:
    • group: a sister organization stored, single and array; a non-member refused, single and array; an absent value stamped and not reported; strict stores.
    • isolated: the equal organization stored and not reported; a different one refused, single and array.
    • The no-wall paths: single, single with a check, single under strict, security plugin absent (both postures), the operator crossing with and without an active organization, and a tenancy-disabled authored column.
    • isSystem.
  • plugin-security: the member is SET for isolated, for group single and array, and when composed with a check. It is UNSET on the single check seam, the operator's no-organization seam and an update seam. There is no seam for an operator with an active organization, or for a system context.
  • objectql: set, absent, false and no seam; created_by is still taken; strict; isSystem; validate().
  • Ablations. A1 through A6 are in the PR body, each with a predicted direction and the red counts observed. The operation === 'insert' conjunct was not ablated. The update pin asserts the member directly, and no engine path reads it on an update today.
  • Gaps: F1 and F2 have no pin.
  • Nit: the PR body's A1 row reads org 1 failed / 39. The dev report reads / 40.

(e) Public surface. The two named changes are right and declared, but the list is incomplete.

  • Named and declared: the optional PostHookWriteImageCheck.judgesOrganization, and strict changing from refusing to storing for an admitted organization.
  • Also implied by the diff:
    • an admitted organization is no longer reported through droppedFields, the readonly WARN or onFieldsDropped (named in the changeset);
    • F1's change from refused to stored;
    • beforeInsert hooks now see the caller's organization_id on walled inserts (named in the PR body's pins, not in the changeset);
    • F4;
    • F3.
  • Effect on the declaration: every one of these widens, none moves a pair outside the wall, and none changes the level.

② Semver level

  • The changeset (.changeset/24822-wall-judged-organization-stored.md) declares @objectstack/objectql minor and @objectstack/plugin-security patch. It MATCHES what the diff publishes:
    • objectql is minor: an additive optional member on an exported published interface, plus strict changing from refusing to storing. The precedent is the tenantLayer0Verdict CHANGELOG entry.
    • plugin-security is patch: it changes a private type and its own seam composition.
  • Packaging: neither package is private. Both are in the fixed group, so the line moves minor regardless.
  • Version skew: either package upgraded alone is inert. An older engine ignores the member. A newer engine without the setter strips as before.
  • Clause-②: yes (widening) sits at line start in both the PR body and the changeset. RIGHT: I found no narrowing, meaning nothing accepted today is refused after this change.
  • Card link: the PR is Part of #24822.

③ Boundary flags

  • Dev questions. Round 2 had no open_questions. Round 1's Q1 and Q2 were answered A in 6305891812, and the diff implements A:

    • the member, and step 3.7 as its only setter;
    • both passes;
    • validate() untouched;
    • the isolated-equal pin rewritten, and its single twin kept.
  • Ruled premise. The premise holds by reading.

  • Deviations.

    • A1 was applied at step 3.6's seam creation (:3831). That is right: the check-only seam is step 3.6's object.
    • The census row sits inside the claim.
    • The trailer deviation is outside this record.
  • The Acceptance-notes finding (the wall-blind validate preview).

    • Introduced in part. The preview was wall-blind before this PR: for a row naming D, it answered valid: true with a drop while the write refused 403, before and after.
    • What this PR adds is one false report. For a walled insert naming an admitted organization, the preview still lists organization_id as dropped while the write stores it. ValidateDataResponse says "fields the write would LEGALLY strip", so that is false for this cell, where before the preview and the write agreed.
    • It does not block. The preview runs no middleware by design, and the engine doc already calls it "not a promise the write would succeed". Agreement would need the wall's verdict in the preview: either a mirror, which is the re-derivation the The bulk-event producer re-derives the tenant wall and can only see one of its three clauses — a deployment platformGlobalObjects exemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706 ruling removed, or a preview that runs the middleware. Both are outside this claim.
    • It is pinned (objectql file) and carried in the Acceptance notes with dedupe words.
    • ESCALATED to the seat at ACCEPT: file it to fix the preview, or to state the limit in ValidateDataResponse.
  • F1 and F2. ESCALATED to the seat at ACCEPT. They need pins:

    • group, no active organization: naming C stores C; naming D is refused 403; naming none gets the D2 refusal;
    • the delegated crossing operator.

    They also need a row in the boundary table, in this PR or as half 2's first pins. They do not block: each is admitted by the wall and matches ADR-0123's amended scope.

  • F4. ESCALATED to the seat. Either name the spare of an authored read-only column in the changeset, or narrow the spare to the injected definition. Decide before half 2, where an authored readonly organization_id on UPDATE reads as an intent to make it immutable.

  • What half 2 (UPDATE) must not regress:

    1. The setter stays conditional on non-empty tenantParts. It must never be set on the step 3.6 check seam or the crossing operator's seam. Replace the operation === 'insert' conjunct deliberately; do not just delete it.
    2. Step 3.7 composes no wall seam for an ARRAY update payload, so the update strip must not spare the column there.
    3. Keep security(plugin-security): an organization-scoped grant row can be re-scoped by a non-system update so that it applies outside its organization — detail held by the filing seat #22278's refusal of an emptied organization on both halves, and the payload-half refusal of D.
    4. Spare on the by-id seam (engine.ts near :18223) and the predicate seam (near :18526) from ONE read, as this PR does, so the hook view and the stored row agree. Keep isSystem and update-mode validate() as they are. Add the census row.
    5. Keep the honoured fail-closed guard.
    6. Rewrite step 3.7's comment "For UPDATE this makes organization_id effectively immutable" once re-points under group are stored.
  • Check-runs on this head, read 2026-10-21T04:45Z.

    • 40 runs: 29 success, 7 skipped, 0 failed.
    • Skipped: 18.x upgrade harness (opt-in), Build Docs, Console Pin Gate, the Dogfood Regression Gate matrix, Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance.
    • Green: Check Changeset, Governed Surface Queue Guard, Lint · PM dispatch-gates, every Lint and Type Check lane, Build Core, Test Core (1/6) and Test Core (5/6).
    • pending: Test Core (2/6), Test Core (3/6), Test Core (4/6), Test Core (6/6).
    • This PASS rests on the gates read so far. A red Test Core voids it.

Implemented-by: claude/issue-24822-explicit-org-honoured
Reviewed-by: session_01UmqzD97e14iws6hmVhgZ5y

VERDICT: PASS

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 64a75d2bc00f44ca510217d5f8e16f5778f6781d
Local-runs: probe — one git merge-tree of this head against origin/main 72518d20f, to answer the main-moved question; it wrote only unreferenced objects (no ref, checkout, build, test or gate run), and the disjoint file lists answer the same question without it

Inputs read. Card #24822: its body and all 11 comments, through 6309258045. PR #25048: its body, its 7-file list, and the net diff against merge base b2890909a, which is the PR's base. The code the hunks cite, at the head. The head's check-runs, read last. Record rendered 2026-10-21T06:19Z.

① Derived judgments

(a) RIGHT: the storable (caller, organization) pairs equal the Layer 0 wall's admitted set, before and after, on every posture.

  • When the engine spares the column. Only when opCtx.postHookWriteImageCheck?.judgesOrganization === true and the context is not system (engine.ts :15908).
  • Who sets the member. Only step 3.7's composed seam (security-plugin.ts :4346).
  • What that seam judges. Its evaluate holds every live row that names an organization to clearsWall (:4325), which is every tenantParts filter (:4139).
  • When it runs. After the hooks, and before resolveSystemInsertOrganization, encryption, autonumbers and the driver (engine.ts :16445).
  • The payload half. suppliedRows.every(clearsWall) (:4230) has already refused every supplied value outside the wall, before the engine runs.
  • So with the member set, every stored organization is one clearsWall admitted.
    • clearsWall is matchesFilterCondition over { organization_id: A } or { organization_id: { $in: set } }.
    • Its looseEq is strict for anything but a Date. So a number, an array or an object in organization_id counts as naming an organization, and it is refused, never admitted.
  • What reaches storage unjudged: only null, '' or an absent key (namesOrganization is false for these).
    • driver-sql's injectTenantOnInsert (:15839, shared by driver-turso) fills all three from tenantId, so the active organization is stored. That is F3.
    • With no active organization, the ADR-0123 D2 arm refuses the row.
    • The memory and mongodb drivers refuse tenant-scoped calls outright.
    • No path found where a caller-chosen organization reaches storage without a wall verdict.
  • Per posture:
    • single composes no Layer 0, so tenantParts is empty, the member is never set, and the strip runs as before.
    • isolated: {A} before and {A} after.
    • group: an explicit payload on the injected column goes from {A} to {A, C}, which is exactly the Layer 0 $in set. Declared-column objects and hook-placed values were already {A, C}.
  • Paths with no member, unchanged:
    • step 3.6's business-check seam (:3837, :3907);
    • the crossing operator's seam with no wall parts;
    • every update seam;
    • isSystem: the middleware returns before step 3.6 (:2451), and the engine strips nothing for it;
    • a stack without plugin-security;
    • a context with no userId (step 3.7's own gate, :3995).

(b) RIGHT: judgesOrganization is set only on step 3.7's insert seam, and only when tenantParts is non-empty.

  • One assignment in the tree outside tests, inside the composition that runs when tenantParts is non-empty or the operator has no active organization: ...(opCtx.operation === 'insert' && tenantParts.length ... ? { judgesOrganization: true } : {}) (:4346).
  • The engine only reads it (:15909).
  • The private WriteImageCheckSeam type gains the optional member. It is not exported.
  • Never set on:
    • step 3.6's newWriteImageCheck() seams, for insert (:3837) and update (:3907);
    • the operator's no-active-organization seam with no parts, where the spread is {};
    • any update (the operation conjunct);
    • isSystem, which never reaches steps 3.6 and 3.7.
  • tenantParts is not reassigned after the composition.
  • The delegator-only case sets it, rightly. Take a crossing operator (its own Layer 0 is null) acting for a walled delegator. The parts are then the delegator's wall alone, and that is the composed admitted set: intersectTenantLayer0Verdicts turns none on one side into the other side. This is F2.

(c) RIGHT: the engine's spare.

  • One read, one helper. wallJudgesOrganization is read once (:15908) and passed to ONE helper, insertStaticReadonlySubject (:2414).
  • Both passes use the helper: the pre-hook withhold (:2509) and the post-hook strip (:16346). Both read this._registry.getObject(object), so the hook view and the stored row cannot disagree.
  • What it removes: organization_id only, and only when the member is set AND resolveInjectedColumnProvenance(schema, organization_id) !== 'author'.
    • Otherwise it returns the subject unmodified.
    • When it does remove the column, it rebuilds the whole { name, fields } shape that staticReadonlyInsertSubject returns.
    • Other read-only keys stay judged; created_by is pinned.
  • validate() is unchanged. It still calls staticReadonlyInsertSubject directly (:15417), and rule-validator.ts is untouched.
  • The !isSystem conjunct is an equivalent mutant. The strips never run for isSystem, so no ablation can turn it red. It is defence in depth, not a gap in the pins.

(d) RIGHT: the pins cover the ruled matrix.

  • organizations, real stack:
  • plugin-security:
    • the member SET or UNSET on each seam;
    • no seam for an operator with an active organization, or for the system context;
    • F2, on a delegator rebuilt from stored rows.
  • objectql:
    • the member set, absent, false, and no seam at all;
    • strict;
    • another read-only key;
    • F4;
    • the system context;
    • validate().
  • The negative pins are ablation-verified, per the PR body:
    • A1, the business-check seam;
    • A2, the operator seam;
    • A4, every no-wall cell. Its org count of 10 / 40 is consistent with the ten no-wall cells that carry a value;
    • R3B, the D2 arm;
    • R4, F4 alone.
  • The refusals and the positive controls are ablated too: refusals by A3 and R3A; positive controls by A6 and R3C.

(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.

  • The diff implies more than the member and strict:
    • the headline change: an admitted organization is stored as sent;
    • an admitted value is no longer reported (no droppedFields, no onFieldsDropped, no read-only WARN);
    • beforeInsert sees the caller's value;
    • F1: a 403 becomes a stored row;
    • F2;
    • F3;
    • F4's boundary.
  • All of them are in the PR body's ## Public surface and in the changeset.
  • How the member is published: through the exported OperationContext type (index.ts :159), which is the reach the changeset names.
  • Nothing is missing. Two precision notes are in ③ (N1, N2).

② Semver level

  • @objectstack/objectql minor: RIGHT. An optional member is added to a published type, and the strict flag widens. Clause-②: yes requires at least minor.
  • @objectstack/plugin-security patch: RIGHT. The type it changes is private, and the change is a fix; nothing it publishes moves.
    • Each package degrades safely without the other's new version: an older engine ignores the member, and an older plugin never sets it.
  • @objectstack/organizations: RIGHT. Its diff is tests only, so no changeset is owed.
  • The Clause-② line: RIGHT. Clause-②: yes (widening) stands at line start in both the PR body and the changeset, as the same sentence.
    • There is no narrowing arm: nothing the engine's own gates accepted is refused now. N2 covers outcomes that depend on stored state.
    • Check Changeset is green.
  • Tier: RIGHT. None of the 7 paths is a published schema or a governed path, so the declaration face at TIER_DEFAULT is the right tier.

③ Boundary flags

The earlier review's escalations, judged again on this head:

  • F1, group with no active organization: inside the admitted set.
    • C is stored only where C is in accessible_org_ids, the context's Layer 0 $in.
    • D is still the wall's 403 (R3A). Naming none still meets the D2 arm (R3B).
    • This matches ADR-0123's amended scope: its insert arms refuse a row "that names no organization".
  • F2, a crossing operator acting for a walled delegator: inside the admitted set.
    • C is stored only where the delegator's wall admits it.
    • D, and C under isolated, are refused (R3A, sec 2 / 12).
  • F3, an array row's null / '': inside the admitted set. The value is not judged, the active organization is stamped, and strict no longer refuses it.
  • F4, an authored readonly: true organization_id:
    • It gets the subject unmodified, so it is taken, reported and refused under strict exactly as before. This is pinned in organizations and in objectql, and R4 turns only those two red.
    • The reader is resolveInjectedColumnProvenance from @objectstack/spec/data: the export that registry.ts re-exports and that federated-object.ts and lifecycle-service.ts already call. The diff adds one import specifier (line 32) and one call. There is no second copy.
    • No injected-column cell changes: R4 left sec 0 / 12 red, and qa_ledger and qa_vault read injected-provisioned.
    • Two edges, both following the reader's own rule:
      • an authored byte-identical copy of the platform's definition reads as injected, so it is spared;
      • an injected-unprovisioned column (a federated phantom anchor) would be spared, but Layer 0 is null there, so the member is never set.
  • The changeset and ## Public surface: complete, as ①(e) shows.

The Acceptance-notes finding (the validate preview is wall-blind). It is partly introduced here, and it does not block.

  • The part this PR introduces. Before, the preview and the write both reported the drop of an admitted C. Now the preview still reports it, while the write stores C. So ValidateDataResponse's "the write would LEGALLY strip" is false for a walled insert naming an admitted organization.
  • The part that predates it. For D, the preview answers valid while the write answers 403, before and after.
  • Why it does not block:
    • validate stores nothing.
    • Its error runs toward over-reporting a drop; it never predicts a store that the write refuses.
    • The preview is documented as no promise that the write succeeds.
    • The mismatch is pinned.
    • The fix, running the wall in a preview that runs no middleware, is a design question of its own.
  • What the follow-up card needs. The card the seat files at ACCEPT should quote that ValidateDataResponse sentence.

The dev's flags:

Notes. None of them blocks.

  • N1. Stale prose.
    • In security-plugin.ts, the ADR-0123 D2 arm comment (:4181) still says the column "is read-only to a session payload".
    • In engine.ts, the post-image comment (:16421) says an explicit value after the strips is "the caller's own organization".
    • ADR-0123 amendment item 1 ("a new row lands in the active organization … With none, the insert is refused") now reads wider than the scope sentence F1 relies on.
    • Worth a pointer at ACCEPT or in half 2. An ADR edit does not belong in this PR.
  • N2. A sentence in the changeset is broader than the code. "Nothing accepted before is refused now" holds for the wall, the strict flag and the strip. But outcomes that depend on the stored organization now follow C, not A:
    • an organization-scoped unique key;
    • a rule or hook that reads organization_id;
    • a beforeInsert hook that fills the column only when it is missing, which now keeps the caller's admitted value.
    • Declared-column objects already behave this way, so this is not a narrowing. The changeset could say so.
  • N3. Pre-existing, identical before and after, not this diff. Read at source only, not measured.
    • A crossing operator's on-behalf-of insert that names NO organization is stamped from the operator's tenantId after the seam has run.
    • So under group, that organization is never held to the delegator's set.
    • For the seat to weigh.

What the UPDATE half (half 2) must not regress:

  1. Where the member is set. Set it on an update seam only where step 3.7 composes the wall's parts into the by-id or predicate stored-row seam. Never set it:
    • on step 3.6's update seam;
    • on an array update payload, where step 3.7 installs no seam;
    • for isSystem.
  2. security(plugin-security): an organization-scoped grant row can be re-scoped by a non-system update so that it applies outside its organization — detail held by the filing seat #22278. On UPDATE, an emptied organization_id (null or '') is refused, and no stamp refills it. F3's insert behaviour must not carry across.
  3. F4's provenance rule. An authored readonly: true organization_id keeps the update strip.
  4. sys_ objects. Unlike the insert subject, the update strip does not exclude sys_ objects. A spare there would let a walled caller re-point sys_user_position and sys_user_permission_set rows between its own organizations. Measure and rule on that explicitly.
  5. The existing gates. Keep the by-id falsy-id refusal (security: a by-id UPDATE's row-level check is judged on the pre-hook change set, so a beforeUpdate stamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989), the step 2.7 pre-image selection, and the predicate seam's judgement of every matched row.
  6. preserveAudit. Its update-only exemption must not widen to organization_id.
  7. One helper, and the preview. Spare both passes through one helper, and state what the wall-blind validate update preview reports.

Check-runs on 64a75d2bc, read last, at 2026-10-21T06:14Z: 48 runs, 39 success, 9 skipped, 0 failed, 0 pending.

  • Success:
    • Test Core 1/6 to 6/6 and the rollup;
    • Build Core;
    • TypeScript Type Check and the four Type Check lanes;
    • Lint & Repo Gates and the eight Lint lanes;
    • Check Changeset, twice;
    • Governed Surface Queue Guard;
    • Dogfood Regression Gate;
    • the four claim and closing-keyword guards, twice each;
    • Check Documentation Links, Flag docs affected by code changes, Auto Label, Check PR Size, and filter.
  • Skipped (opt-in or main-only):
    • 18.x upgrade harness and Packed-tarball smoke;
    • Build Docs and Console Pin Gate;
    • the Dogfood Regression Gate matrix and Dogfood Verify CLI;
    • Temporal Conformance;
    • the body-edit event's second Auto Label and Check PR Size runs.
  • The legacy combined status reads pending with zero contexts. It is not a gate.

Implemented-by: claude/issue-24822-explicit-org-honoured
Reviewed-by: session_01UmqzD97e14iws6hmVhgZ5y

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24822 half 1 (insert) · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-21T06:22Z · head 64a75d2bc0.

  • This is the review of record for the claim 6305515833, widened in place by the seat call 6305891812 (Q1 → A, Q2 → A) and for round 3's F4.
  • Contract review: owed on the declaration face (Clause-②: yes). PASS at TIER_DEFAULT on this same head (6309495055, an isolated reviewer; Local-runs: probe, one git merge-tree with no ref written, declared in the record).
  • The first review, on e14af57d4 (6308267963), escalated F1, F2, F4 and the public-surface list. Rounds 3 and 4 closed them (REWORK 6308287004).
  1. Form:
  2. Premise and fix:
    • An explicit organization_id that the Layer 0 write wall admits is stored as sent. One it refuses stays refused. ⛔ Never silently replaced (ADR-0105 D5, the Middleware A comment, [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).
    • Mechanism:
      • PostHookWriteImageCheck gains one optional member, judgesOrganization, and step 3.7 alone sets it, on an insert seam whose tenantParts is non-empty.
      • The engine spares the column in both passes through one helper, only for a non-system caller.
      • F4: only the platform-injected column is spared. An authored readonly: true organization_id keeps the strip.
    • The boundary statement (PR body, measured per posture): the storable (caller, organization) set equals the wall's admitted set, before and after.
      • Under group, an explicit payload goes from {A} to {A, C}, which is the wall's set.
      • F1, F2 and F3 are rows of the table, inside the wall.
      • Every no-wall path is pinned unchanged.
    • Ablations: A1–A6, R3A–R3C and R4 each went red as predicted and were restored to blob == HEAD.
  3. Gates:
    • CI on 64a75d2bc0, latest run per check name: 41 checks, 32 success, 9 skipped, 0 failed.
    • Every skip is on the roster in scripts/pm/check-expected-skips.mjs: Auto Label, Check PR Size, the Dogfood Regression Gate matrix, Dogfood Verify CLI, Temporal Conformance, Console Pin Gate, Build Docs, Packed-tarball smoke and the 18.x upgrade harness.
    • Lint & Repo Gates, TypeScript Type Check, Build Core, all six Test Core shards, Dogfood Regression Gate and Check Changeset are success. mergeable_state is clean.
    • Local: 76 derived, 76 run, 0 NOT-MEASURED.
      • objectql 454 files / 8547 passed;
      • plugin-security 202 / 4151;
      • organizations 14 / 190;
      • three typechecks with check:test-typecheck OK, and no debt signature.
  4. Accepted deviations:
  5. Out of scope:
  6. Landing: ordinary queue landing. Every check on this head is green or an expected skip, so the seat flips it on this record. The card stays open for half 2.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 06:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 06:24
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants