Skip to content

Commit 4b3955e

Browse files
fix(objectql): a published BulkDataEvent names the one organization the tenant wall named for the batch (#15687)
* fix(objectql): a published BulkDataEvent names the one organization the 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 * test(objectql): pin the tenancy opt-out case on a declared `tenancy.enabled: 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 * docs(permissions): census the bulk-event `organizationId` omission as `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 * fix(objectql): the bulk-event object exit is the wall's own predicate, 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 * docs(permissions): re-anchor census row 51 on the merged tree 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54bb2f1 commit 4b3955e

5 files changed

Lines changed: 803 additions & 66 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a published `BulkDataEvent` now names the ONE organization the tenant wall named for the batch
6+
7+
`BulkDataEventSchema.organizationId` (`@objectstack/spec/api`, declared by the
8+
contract half) is one organization for a whole predicate write, or absent. The
9+
only bulk producer — `publishBulkDataEvent`, behind the `multi: true` branches
10+
of `update()` / `delete()` — never set it, so every `data.records.updated` /
11+
`data.records.deleted` event read "not asserted" and a tenant-scoped consumer
12+
could deliver nothing per organization on the bulk path. This is the bulk half
13+
of the cross-tenant webhook fan-out leak; the single-record half (`DataEvent`)
14+
landed separately.
15+
16+
The producer now stamps the key from what it already holds — no second query
17+
on the publish path: under `isolated` the caller's active organization (the
18+
Layer 0 wall's equality term), under `group` the caller's membership set when
19+
it names exactly one organization. It is OMITTED — never the caller's active
20+
organization standing in — on a `single`-posture deployment, on an `isSystem`
21+
context (no wall composed), on a multi-membership `group` sweep, when no
22+
enforcement layer injected a posture (the `OS_TENANCY_POSTURE` env fallback is
23+
deliberately not consulted), when the caller may have crossed the wall as a
24+
`PLATFORM_ADMIN` or carries no resolved posture rung, and on an object the wall
25+
does not key on. `absent` here means "the producer did not assert one
26+
organization for the batch", deliberately NOT the `DataEvent` reading
27+
"belongs to no organization".
28+
29+
Which objects "the wall does not key on", stated exactly rather than claimed as
30+
a mirror: plugin-security's Layer 0 composes no wall when its `tenancyDisabled`
31+
input is true or the object carries no `organization_id`, and it folds THREE
32+
clauses into `tenancyDisabled``tenancy.enabled === false`,
33+
`systemFields.tenant === false`, and the deployment's `platformGlobalObjects`
34+
carve-out. The producer reads the registry's binding of that predicate
35+
(`carriesTenantScopeColumn`: the first two clauses plus the column clause) and
36+
answers absent on a federated (`external`) object; a custom
37+
`tenancy.tenantField` is therefore not an exit by itself — the object is walled
38+
iff it carries `organization_id`, and the key follows the wall. The third
39+
clause is deployment-declared and not readable by the engine: a
40+
deployment-exempted object under an armed wall is still stamped with the
41+
caller's organization by this producer alone, and that population's exact
42+
answer is decided by the seam ruled on in #15706.
43+
44+
`patch`, not `minor`: the act adds no member to this package's published
45+
surface. `carriesTenantScopeColumn` is exported at module level inside
46+
`registry.ts` only — `@objectstack/objectql`'s entries (`.`, `./core`) re-export
47+
named members and never `export *`, so `dist/index.d.ts`, `dist/core.d.ts` and
48+
both entries' runtime export lists are unchanged (measured on the built `dist`,
49+
with a firing control) — and the emitted event's member was declared, typed
50+
and paid for at `minor` by the spec half. Producer conformance to an existing
51+
optional member under `fix(` changes no public surface of this package.

0 commit comments

Comments
 (0)