fix(objectql): a published BulkDataEvent names the one organization the tenant wall named for the batch - #15687
Conversation
…he tenant wall named for the batch The bulk producer (`publishBulkDataEvent`, behind the predicate `update()` / `delete()` branches) never set `BulkDataEventSchema.organizationId`, so every `data.records.*` event read "not asserted" on the bulk path — the remaining half of the cross-tenant webhook fan-out leak. It now stamps the key from what it already holds (the execution context the Layer 0 wall was computed from, and the posture SecurityPlugin injected), with no second query: present under `isolated` (active organization) and singleton-membership `group`; omitted for `single`, `isSystem`, multi-membership `group`, non-walled objects, no injected posture, a PLATFORM_ADMIN rung or no rung. The value coercion is shared with the per-record helper (one ladder, two readers). Pins in engine-data-events.test.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…lk-event-organization-id
…nabled: false` object A registered object gets the kernel `organization_id` column injected, and the security plugin walls on that same injected field set, so `task` is not a "not tenant-scoped" fixture. The pin now uses the declared opt-out and measures that the column was withheld before asserting the key is omitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
… `isSystem` read site 107 The bulk producer's new `isSystem` read (no wall composed for a system write, so no batch organization is asserted) is an elevation behaviour the system-context page must anchor. Row 30 names it; rows 30-65 renumber to 31-66 with their prose cross-references; the six census-derived counts move 106 -> 107 (property reads 112 -> 113). `--fix` re-anchored the 15 engine.ts lines the insertion shifted and refused zero files once the row existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 2d4bb8d86ff2dcfc81cecfac7d609d52462dcd70 && git checkout 2d4bb8d86ff2dcfc81cecfac7d609d52462dcd70
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 16978315c4dfb388129f1c3955b056cc8aec0326 81b8329ee84beb27f07bc6aa4da09b006fa9dacb && git checkout -B drift-repro 16978315c4dfb388129f1c3955b056cc8aec0326 && git merge --no-ff 81b8329ee84beb27f07bc6aa4da09b006fa9dacb
node scripts/docs-audit/affected-docs.mjs --json 16978315c4dfb388129f1c3955b056cc8aec0326
|
Contract review at
|
…, not a re-spelling of one of its clauses Patch round R2 on the contract review of PR #15687 (items 1, 3, 4, 5, 7; item 6 re-judged). `bulkEventOrganizationId` answered its object exit with `resolveTenantFieldName(schema) !== DEFAULT_TENANT_FIELD`, which mirrors ONE of the clauses Layer 0 folds into `tenancyDisabled`; an object declaring `systemFields.tenant: false` beside its own `organization_id` composes NO wall in plugin-security and was still stamped with the caller's organization — a mislabel (the reviewer's P1). The exit now reads `carriesTenantScopeColumn`, the registry's binding of the wall's predicate (exported at module level only; `dist/index.d.ts`, `dist/core.d.ts` and both entries' runtime export lists are unchanged, measured with a firing control), beside the `external != null` superset of the phantom-anchor rule. A custom `tenancy.tenantField` is no longer an exit by itself: the key follows the wall, present iff the object carries `organization_id`. Pins: the P1 fixture (absent, matched 3); a federated object (absent); a custom `tenancy.tenantField` with the kernel column (present) and without it (absent); and the no-enforcement-layer pin now sets OS_TENANCY_POSTURE=isolated in the env and still expects omission, so the env-fallback exclusion is pinned by a test that goes red under the substitution the review measured green. JSDoc, changeset and the census page name which `tenancyDisabled` clauses the engine mirrors and which the seam carries; the census re-anchor rewrote 16 anchors and refused zero, population 107 unchanged. Level stays `patch`: no member reaches this package's published surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Contract re-review at
|
…lk-event-organization-id
Discharges the os-regen deferral recorded by the merge of origin/main (5315098): the driver kept this branch's side of the MIXED census page whole, dropping main's five rest-server.ts anchor moves on row 51 (main's row 50), and `pnpm gen:system-context-census` re-derives exactly those five numbers from the merged tree. Population unchanged at 107 in 20 packages across 45 files; a second `--fix` rewrites 0 and refuses 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Closes #15225
Blocked-by: #15706 — the merge only. Update (merge round, 2026-09-05): #15706 is ruled — shape (i), comment
5549579058on #15225 (「Unblocked」), #15706 closed as completed by ruling — the seam is a separate delivery with its own contract review and is ⛔ not in this PR; the bar this line named is discharged by that ruling, andorigin/mainis merged in the Merge round section below. Contract review5549091499(FAIL, patch round, pinned tod08186cc7) escalated its item 2 — the deployment-levelplatformGlobalObjectscarve-out is unclosable from the schema and needs a maintainer ruling on the seam's shape — and ruled 「this PR does not merge while the carve-out population can be mislabelled」. Items 1, 3, 4, 5, 7 and the item-6 re-judge are discharged in the Patch round R2 section below, at648e00433, appended on top of the pinned head.Which path of the leak this lands
This is the bulk producer half of the cross-tenant webhook fan-out leak:
data.records.updated/data.records.deletednow carryorganizationIdwhen the tenant wall named exactly one organization. The single-record half (data.record.*,DataEvent.organizationId) landed separately as PR #15220 (#14970). Parent #13566 remains open — its consumer half (the fan-out filter) isdomain:servicesand is not addressed here. A statement that "the leak is fixed" must name a path; with this PR both producer paths stamp the key, and the consumer half is still #13566's.What changed
publishBulkDataEvent(packages/objectql/src/engine.ts, the onlydata.records.*publisher — measured: one publisher, reached from the predicateupdate()/delete()branches) stampsBulkDataEventSchema.organizationIdfrom what the producer already holds, with no second query on the publish path:tenantId,accessible_org_ids,isSystem, the carried authz rungposture), andSecurityPlugininjects throughsetTenancyPostureProvider(private accessorenforcedTenancyPosture()— deliberately NOT the env-fallbackresolveEnginePosture(), now pinned by a test that goes red under that substitution, see R2 item 3).The derivation is the module-scope
bulkEventOrganizationId(objectSchema, execCtx, enforcedPosture), read againstcomputeTenantLayer0Filter's inputs (plugin-security/src/tenant-layer.ts): an exact mirror on the posture and context inputs, a partial one on the object input — the object rows below say which of the wall's clauses the engine mirrors and which the seam carries.isolated, ordinary resolved session (rung carried, notPLATFORM_ADMIN)group, membership set names exactly one organization (deduplicated)tenantIdgroup, several membershipssinglepostureisSystemcontextSecurityPlugin) — pinned withOS_TENANCY_POSTURE=isolatedset in the envdriver-memoryreads noDriverOptions.tenantId; the env fallback is not consultedtenancyDisabledclauses ①tenancy.enabled: falseand ②systemFields.tenant: false(getObjectSecurityMeta,security-plugin.ts), or noorganization_idcarried (the column clause) — read throughcarriesTenantScopeColumn, the registry's binding of the wall's own predicateexternalbinding) — the superset of the wall's phantom-anchor rule (#7835)organization_idkeeps its wall and is under-delivered here, never mislabelled)tenancy.tenantFieldorganization_id; the wall never reads the custom column, so the declaration is not an exit by itselfplatformGlobalObjectscarve-out — clause ③ oftenancyDisabled, deployment-declared (#12699)engine.ts— 2 JSDoc mentions at:2322/:2331that state the gap, and the reading-shaped predicate, comment lines stripped BEFORE numbering, returns 0 with a firing control of 1 onsecurity-plugin.ts;registry.ts0; bare-count control onengine.ts:tenancy26), so a deployment-exempted object under an armed wall is stamped with the caller's organization while Layer 0 composed no wall — the item-2 mislabel, escalated to #15706PLATFORM_ADMINrungabsenton this event means "the producer did not assert one organization for the batch" (producer knowledge), deliberately NOT theDataEventreading "belongs to no organization" (events.zod.tsrecords the divergence on the member). The value coercion ladder is factored out of the #14970 helper intoeventOrganizationValue, shared by both producers — one ladder, two readers.Context-readability finding (the measure-first question)
The publish site CAN know the wall for every context the authz resolver assembled, and cannot for three populations. What the engine holds: the posture (injected closure),
tenantId,accessible_org_ids,isSystem, and the carried rungposture—assemble-execution-context.tsstampsposture: authz.postureon every resolved session context. What it does NOT hold: (1) the Layer 0 exemption's second input, the superuser write-bypass bit over the caller's resolved permission sets; (2) the capability probe the plugin runs for contexts that carry no rung (computeLayeredRlsFilter: carried rung when present, probe otherwise); (3) the deployment'splatformGlobalObjectsdeclaration, clause ③ of the wall'stenancyDisabledinput. For (1) and (2) the producer answers conservatively — absent — and neither leg can mislabel. For (3) it cannot answer conservatively from the schema at all: that is the item-2 mislabel and the seam #15706 rules on (either plugin-security records its Layer 0 verdict on the operation, or a per-object "walled here?" provider is injected besidesetTenancyPostureProvider). A contract decision, not taken here.Why the
isSystemread is load-bearing and not redundant with the rung guard: an assembled context can carryisSystem: truetogether with a rung (execution-context.zod.tsnames that shape), and the middleware bypasses the wall onisSystem— without the read, such a write underisolatedwould be stamped with an organization no wall constrained.Clause-②: yes
The contract gains no key (
BulkDataEventSchema.organizationIdwas declared optional by PR #15218), but the emitted wire payload gains one it never carried, and tenant-scoped consumers change delivery behaviour on it — that is 「已发布载荷上的新键」, and 「拿不准 ⇒ yes」 settles the rest.Changeset level:
patch, re-judged after R2 item 1The act of item 1:
carriesTenantScopeColumn(registry.ts) gains theexportkeyword so the engine reads the wall's predicate instead of re-spelling it. Measured on the builtdistat648e00433, with a firing control:dist/index.d.ts0 /dist/core.d.ts0 mentions of the symbol (controlSchemaRegistry15 / 1); the ESM export blocks ofindex.mjs(147 names) andcore.mjs(51 names) do not contain it (controlSchemaRegistrydoes); CJSexports.carriesTenantScopeColumn0 in both bundles (controlexports.SchemaRegistry1).src/index.tsandsrc/core.tsre-export NAMED members ofregistry.tsand neverexport *, so the symbol is unreachable from either entry (.,./core). No public member of@objectstack/objectqlis added ⇒patchunder the bump-level ruleb337a1308(the act decides; afix(that changes no public surface stayspatch); the emitted member itself was declared, typed and paid for atminorby the spec half (PR #15218). The R1 appeal to the #14970 precedent is dropped — the review read it as pre-rule, and the level stands without it.check:api-surfaceis green but measures@objectstack/speconly; the objectql reading is thedistmeasurement above.Patch round R2 — contract review
5549091499ond08186cc7, discharged at648e00433bulkEventOrganizationId's object exit is nowcarriesTenantScopeColumn(schema)(exported at module level fromregistry.ts; the wall's clauses ① ② plus the column clause, per the 2026-08-14 triage ruling) beside theexternal != nullexit;resolveTenantFieldName(schema) !== DEFAULT_TENANT_FIELDis gone andDEFAULT_TENANT_FIELDis no longer imported byengine.ts. Pin: the P1 fixture (systemFields: { tenant: false }+ author-declaredorganization_id, rows across two organizations and one org-less,isolated, MEMBER predicate update) ⇒matched: 3,hasOwnProperty === false. Red today, measured: the new pins against the old code readTests 2 failed | 40 passed (42), both reds on theorganizationIdassertion itself (P1: present; the custom-tenantFieldpresent pin: absent); after the changeTests 42 passed (42).platformGlobalObjectsexemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706). No seam, no item-2 pin, noplugin-securitychange. Measurement, as asked: item 1 does not close item 2 —carriesTenantScopeColumnreads clauses ① ② and the column, never the deployment declaration;platformGlobalObjectshas 0 readings inengine.tsand 0 mentions inregistry.ts.engine.ts」, which was measured before item 4's own JSDoc landed and is false at this head: the file now carries 2 mentions (:2322,:2331), both comment lines that state clause ③ is NOT mirrored. The reading-shaped predicate —grep -v '^\s*\*'applied BEFOREgrep -n, since-nwould prefix the line number and defeat the comment filter — returns 0, with a firing control of 1 onsecurity-plugin.ts.process.env.OS_TENANCY_POSTURE = 'isolated'(restored by anafterEachthat reinstates the collection-time value) and still expects omission. Mutation proof on the committed tree:this.enforcedTenancyPosture()→this.resolveEnginePosture()at the publish site, proven on disk by anchored counts (this.enforcedTenancyPosture(),1 → 0,this.resolveEnginePosture(),0 → 1, one-line diff), suite ⇒Tests 1 failed | 41 passed (42), the one red being exactly that pin; restored bygit checkout HEAD -- ABSOLUTE-PATHinside a trap, proven:git hash-object= the HEAD blobf3ff938abeab729ad2a9aac6b0f8e0ff58acee0a,git diff HEADempty, status clean, counts back to 1 / 0.bulkEventOrganizationId's JSDoc (the object bullet names clauses ① ② ③, which are mirrored, which the seam carries, and the phantom-anchor input answered by theexternalsuperset; the "mirrored input-for-input" sentence now says exact on posture/context, partial on the object); this body's table (the four object rows above replace the single "object the wall does not key on" row); the changeset (its "which objects the wall does not key on" paragraph).externalbinding, injected phantom anchor) ⇒ absent (green today, now pinned; the author-declared-remote-column variant is deliberately left unpinned so an exact provenance predicate may later answer it present). Customtenancy.tenantFieldwith the kernelorganization_idstill injected ⇒ present = the active organization (red today → green: the wall keys onorganization_id); customtenancy.tenantFieldon asystemFields: falseobject carrying noorganization_id⇒ absent (the column clause alone decides; the custom column is never a substitute).patchstands, on thedistmeasurement in the section above; the changeset states the reason.site-without-a-rowerrors, allengine.tsline shifts);--fix: 16 anchors rewritten, refused zero files; post-fixcheck-system-context-census: OK — 107 elevation read sites in 20 packages across 45 files, all anchored; population 107 → 107 (item 1 moved none); row 30 re-anchored toengine.ts:2371, which readsif (!execCtx || execCtx.isSystem === true) return undefined;.Zone 2 mechanism assumptions, measured: (1) falsified in its reason, upheld in its conclusion —
registry.tsIS re-exported from both package entries, by named lists, so the symbol stays off the surface because neither list names it, not because the module is private; (2) upheld — no present row flips to absent (theisolatedand singleton-groupfixtures carryorganization_idunder both predicates); one absent row flips to present by design, the custom-tenantFieldrow, which is the direction item 5 asked for; (3) upheld by the mutation proof; (4) upheld — pure re-anchor, 107 → 107.Verification (all at
648e00433, every heavy run throughscripts/pm/os-verify-lock.sh, one acquisition each, exit captured before any pipe)pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-data-events.test.ts:Tests 42 passed (42)(VERDICT command-exit 0). Red-today reading against the old code:Tests 2 failed | 40 passed (42). Mutation reading:Tests 1 failed | 41 passed (42).pnpm --filter @objectstack/objectql typecheck:tsc --noEmit×2 clean,check:test-typecheck: OK(VERDICT command-exit 0).pnpm --filter @objectstack/objectql build: both.d.tsemitted (VERDICT command-exit 0).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsrun from this worktree with no paths (change set 5 files vs merge base791a0cbe6) derived 86 commands; all 86 ran through a runner that writes one log per command and a ledger of exit codes — 86/86 exit 0 in their final runs. Two needed a second run:pnpm --filter @objectstack/spec run check:skill-examples(first run exit 1 as a PREREQUISITE NOT MET —packages/client-react/distheld no.d.tsuntil the whole package closure was built under the lock,turbo run build71/71 — then257 prose examples type-check), andpnpm check:docs-audit-scope(first run exit 1 inside its ownaffected-docsself-test, caused by the residue that aborted skill-examples run left inpackages/spec/.examples-build*on a base predatinga3eba0759's cleanup fix; residue removed, re-run green; not a finding on this diff). ⚠ The deriver reports this tree 17 commits behindorigin/mainwith 8 derivation files changed there (lint.yml,package.json, the docs-audit scripts, among others): the 86 are this tree's families, declared as such; CI runs the current farm on the merge ref.eslint --no-inline-configonengine.ts,registry.tsand the test file: exit 0 (narrowed run, declared; the repo-wide sweep is CI's).pnpm --filter @objectstack/spec run check:api-surface:public API surface + factory signatures unchanged ✓(spec only, see the changeset section).Census page — and a declared overlap with PR #15395
The⚠️ PR #15395 (#14147, draft) rewrites the same page (it removes a row and renumbers) and touches
execCtx.isSystemread is elevation site 107, socontent/docs/permissions/system-context.mdxcarries row 30 for it and its six census-derived counts read 107; R2 re-anchored the 16engine.tslines the edit shifted (refused zero).engine.ts— none of itsengine.tshunks (the INSERT readonly-strip path) overlap this PR's regions (event helpers, the posture accessor, the publish site). Per the dispatch, this p0 lands first and #15395's next merge ofmainabsorbs the page: it will need to re-run--fixand re-take the counts (they become 107 → 106 on its diff). One note for that merge: its current hunk renumbers row 51 to 50 but leaves the prose "row 50's doors" pointing at itself; this PR's renumber keeps the reference moving with the row.Merge round —
origin/mainmerged at5315098df, head81b8329ee(contract review PASS5550145387, pinned to648e00433)Why this round:
node scripts/pm/ci-failure.mjs --pr 15687answered UNDETERMINED (no check-runs on648e00433) — GitHub runs nopull_requestworkflow on a conflicted PR, so the reviewed head had never been tested by CI. ⛔ Not a patch round: no code changed.What landed — two append-only commits on
648e00433, no rebase, no amend, no force-push:cb0f019d6—git merge origin/main(5315098df, 62 commits past the merge base791a0cbe6) viascripts/pm/os-regen-merge.sh. The both-sides set, re-measured before merging: exactlycontent/docs/permissions/system-context.mdx. Theos-regendriver deferred that MIXED page to this branch's side whole (lossless by itsline-anchorscomparator: main's three page commitse13ede817/4bc982111/a84e1ced6changed only row-50 anchor numbers) and marked it pending; the script's step-2 took main's side into the worktree and its step-3 commit was refused as stale — the designed collection point — after which the branch bytes were restored fromHEAD(index and tree) before regenerating. No MERGE state existed at any regeneration.81b8329ee—pnpm gen:system-context-censuson the merged tree, as its own commit:5 anchor(s) rewritten, all in row 51 (main's row 50):rest-server.ts:5016→5084,:6430→6510,:6678→6758,:7109→7189,:7302→7382; nothing else moved (1 line). A second--fixrewrote 0 and refused 0; pre-commit printedcurrent — marker cleared.The merge changed nothing the review read — blob identity at
81b8329eeagainst648e00433for every PR path except the census page:packages/objectql/src/engine.tsf3ff938abeab729ad2a9aac6b0f8e0ff58acee0apackages/objectql/src/registry.ts6c5bb03639f183101a5a52e7cc3f733cfb0c1b69packages/objectql/src/engine-data-events.test.ts2c922204fa11e7311795a3bbc50d6d248fc46022.changeset/bulk-event-batch-organization.mde82c5e234049923e9a0b18972499ce5a1c4be8acThe merged tree differs from
5315098dfin exactly these four paths plus the page (git diff --name-only HEAD^2 HEAD= 5).Census page, by content in both directions (⛔ 「no conflict markers」 was not the reading): the page with anchor numbers blanked is byte-equal to the
648e00433page (diff: 0 lines) — this PR's prose, including row 30 (execCtx.isSystemread atengine.ts:2371, theorganizationIdomission), survived whole; row 51 carries main's fiverest-server.tsanchors verbatim (5084,6510,6758,7189,7382, equal to main's row 50) — main's side survived, re-derived by the generator. Counts re-derived, not carried:107 elevation read sites in 20 packages across 45 files, 113 reads, 103 behaviour-bearing / 4 carry-only — unchanged, because main moved no read site (the expectation that they would move was falsified).Verification at
81b8329ee(every heavy run throughscripts/pm/os-verify-lock.sh, slotmerge-15225, exits captured per command before any pipe): closure buildturbo run build --filter='@objectstack/objectql...'15/15 (VERDICT 0);vitest run --maxWorkers=2 src/engine-data-events.test.ts→Tests 42 passed (42);pnpm --filter @objectstack/objectql typecheck→tsc×2 clean,check:test-typecheck: OK;check-system-context-census: OK — 107 … all anchored; 141 anchors resolve, 27 declared non-read; whole-repoturbo run build73/73 (VERDICT 0); gate union derived from this worktree with no paths (dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, tree81b8329ee, declared 1 commit behindorigin/mainwith 4 derivation files changed there): 87 commands, 87/87 exit 0 in their final runs,--ranreconciliation87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN. Two first runs werePREREQUISITE NOT MET(not measured, never read as red) —check:skill-examples(noclient-react.d.ts) andcheck:dual-build-cjs-loads(54 packages withoutdist/) — and passed after the whole-repo build (257 prose examples type-check across 3 surface(s);103 published require entry point(s) across 66 package(s) load; 619 emitted CommonJS file(s) parse). Changeset gates in the union (check-changeset-no-major,check-empty-changeset,check:changeset-gate-self-tests,check:objectui-changeset) exit 0. Controls:content/docs/releases/**0 vscontent/docs/**1 (the page); control bytes on the page: grep exit 1, NUL control fires. Not measured locally: the always-runs CI tail and the repo-wide eslint sweep (CI's; no source file changed in this round).CI now runs: after the push the PR reads
mergeable: true,mergeable_state: clean(wasdirty); 38 check-runs on81b8329ee, the six required contexts (Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL)) allcompleted/success. ⛔ Read this head's checks, never648e00433's (it has none).Untouched by this round: the seam #15706 (ruled (i), a separate delivery with its own contract review), every item the patch round discharged, the draft flag, and both
needs:contract-reviewcarriers — clearing the gate is the dispatching seat's act.🤖 Generated with Claude Code
Generated by Claude Code