feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218
Conversation
…ole batch Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
…face entry Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 128 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 5d70725b061fd9088744b801b07e7ab61bc61ab6 && git checkout 5d70725b061fd9088744b801b07e7ab61bc61ab6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c1bcda382067e75e2d69f11086d6c986ccb987a 9ffb659b38c07ae5f83c41f45fdd596729d38b6e && git checkout -B drift-repro 9c1bcda382067e75e2d69f11086d6c986ccb987a && git merge --no-ff 9ffb659b38c07ae5f83c41f45fdd596729d38b6e
node scripts/docs-audit/affected-docs.mjs --json 9c1bcda382067e75e2d69f11086d6c986ccb987a |
Fixes #14971
Contract half of the bulk path of the webhook cross-organization delivery defect:
BulkDataEventSchema(data.records.updated/data.records.deleted,packages/spec/src/api/events.zod.ts) gains exactly one optional member,organizationId: z.string().min(1).optional(), the same spelling, position (beside the match termobject) and empty-string refusal asDataEventSchema.organizationId, no.default().DataEventSchemaandMetadataEventSchemaare untouched. The producer threading (packages/objectql, thepublishBulkDataEventsite) and the fan-out filter (packages/plugins/plugin-webhooks) are separate cards owned by other lanes and are not touched here.Rulings applied (dispatch comment 5536212015 on #14971, quoted where operative)
organizationId: z.string().min(1).optional()onBulkDataEventSchema, the same spelling, position and refusal of the empty string asDataEventSchema.organizationId, no.default(). ⛔ No per-row array, noorganizationIds, no second envelope, no change toDataEventSchema."DataEventSchemaand said so in the JSDoc: "present = every record the predicate write affected belongs to exactly this organization, never fabricated, never the caller's organization as a substitute for the records'; absent = the producer did not assert one organization for the batch … A bulk event names no rows, so absence is a statement about the producer's knowledge, not about the rows — ⛔ it is NOT the single-record reading 'belongs to no organization, not behind any wall'." The consumer sentence is stated in the JSDoc and the.describe(): a tenant-scoped consumer must treat an absent key as not attributable to its organization and must not deliver the event inside an organization wall; a deployment-wide consumer may use it. The fan-out filter therefore stays one comparison, never a partition.Premise check at the producer (verified before writing;
premise_still_valid: true, with one qualification)Premise as ruled: at
publishBulkDataEventthe producer holds the execution context and the middleware-composed predicate and no rows; under a walled posture a tenant-scoped predicate write is row-scoped by the security layer to the caller's organization, so the affected rows belong to exactly one organization, while a system / unscoped predicate write cannot assert one.Evidence face, all on
origin/mainat919beca4(BASE):packages/objectql/src/engine.ts:11545— the predicate branch callsdriver.updateMany(object, ast, …)with the middleware-composedast;:11608-11612/:13062-13066publishdata.records.updated|deletedwith{ matched, context: opCtx.context };publishBulkDataEvent(:5709) validates withBulkDataEventSchema.parseand carriesuserIdfrom the context — the context (tenantId,accessible_org_ids,isSystem,packages/spec/src/kernel/execution-context.zod.ts:86,247,269) is in hand, no rows are.packages/plugins/plugin-security/src/security-plugin.tsstep 3 (:3040-3055):computeRlsFilter(permissionSets, object, operation, context)is AND-composed intoopCtx.astfor every dispatch that carries anast— its own comment measures the dispatch set as "insert/update/deleteby-id (no ast) and bulkupdate/delete(with ast)".computeRlsFilter(:5404-5422) isandComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, fromcontext.tenantIdandcontext.accessible_org_ids).packages/plugins/plugin-security/src/tenant-layer.ts:116-150(computeTenantLayer0Filter):isolated→{ organization_id: input.organizationId }(strict equality on the caller's active organization; a missing active org → the deny sentinel);group→{ organization_id: { $in: accessibleOrgIds } }(the membership set; empty → deny);single→null; a truePLATFORM_ADMINon a posture-permitting object →null. Layer 1 (business RLS, sharing'sbuildWriteFilteratpackages/plugins/plugin-sharing/src/sharing-service.ts:505-538, an owner/depth/share narrowing) is AND-ed under Layer 0 and cannot widen it (ADR-0095 W1/W2, ADR-0105 D2/D4).isSystemcontexts short-circuit the middleware (security-plugin.ts:2873-2874). The driver's nativetenantIdscope (engine.ts:3683,organization_id = :tenant OR organization_id IS NULL) is a second AND under the composed AST, so the strict equality above is what bounds anisolatedwrite — NULL-organization rows are not reachable through it.isolatedthe premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. Ongroup(ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach ingroupposture — membership does"; the same Layer 0 governs writes here) the write is pinned to the caller's membership set, so the batch is attributable to one organization only when that set names exactly one; a multi-membership sweep is exactly ruling 3's third absence case ("any write whose affected rows are not known to belong to one organization"). The JSDoc states this qualification explicitly rather than leaving it to the producer.organizationIdsarray and (C) the caller's active organization "which mislabels a group sweep". This change is that shape decision, taken by the owning lane, and rejects B and C on the same grounds — no conflict with A.What changed
packages/spec/src/api/events.zod.ts—BulkDataEventSchemablock only: one header paragraph (one organization for the whole batch, or nothing; cross-reference toDataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, thegroupqualification) and.describe().git grep -c organizationIdon the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); controlgit grep -c objecton the same file unchanged in kind (non-zero both sides).packages/spec/src/api/events.test.ts— newdescribe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property andundefined; present → round-trips;42→invalid_typeat['organizationId'];null→ same;''→too_small,minimum: 1; a per-row array →invalid_type(the ruled-out shape); member set = exactly the six prior keys plusorganizationId, andorganizationIdson neither schema; structural + behavioural identity of the twoorganizationIddeclarations (optionaloverstringwith equal checks; the same probes yield the same issue codes on both). The stale "deliberately untouched" comment on the single-record block now points at the bulk block.check:generated --fix(it proved exactly one artifact stale,gen:docs) and thecheck:authorable-surfacetree:content/docs/references/api/events.mdx(+1 row),packages/spec/authorable-surface/api.json(+api/BulkDataEvent:organizationId). The json-schema manifest and the strictness-ledger counts were NOT stale (the manifest lists types, not members; the ledger counts strip sites) — the dispatch's assumption that they would be is falsified, harmlessly.packages/spec/liveness/api.jsonhas noBulkDataEventrow and none is invented.authorable-surface.base.jsonuntouched (manual-only anchor;baseRevlag is information)..changeset/bulk-data-event-organization-id.md—@objectstack/spec: minor..changeset/README.mdcarries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additiveDataEvent.organizationId, shippedminor) — an additive published key on a public payload.MetadataEventSchema(read-and-report only, no edit): membersid, type, metadataType, name, packageId?, definition?, userId?, timestamp—definitionis the full item body. Producerpackages/metadata/src/metadata-manager.ts:696passes no organization. Consumers:packages/client/src/realtime-api.ts:107(subscribeMetadata, deployment-wide SDK) and the client-react hooks; the webhook fan-out (packages/plugins/plugin-webhooks/src/auto-enqueuer.ts:822-826,1010) returns early for every type that is notdata.record./data.records., so no per-organization consumer receives it today. No card filed (triage: not a claim).Measurements (head
9ffb659b; exits captured before any pipe, verdict lines quoted; shared box, so ratios not wall-clock)pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts(locked)Tests 29 passed (29)(21 prior + 8 new)pnpm --filter @objectstack/spec build(locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.pnpm --filter @objectstack/spec check:generated --fixthen a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**→✓ gen:docs; second run✓ All 15 generated artifacts are up to date.src/apisuite… exec vitest run --maxWorkers=2 src/api(locked)Test Files 41 passed (41) · Tests 1349 passed (1349)@objectstack/clientrealtime… exec vitest run --maxWorkers=2 src/realtime-api(locked)Test Files 2 passed (2) · Tests 22 passed (22)@objectstack/client-reacttypecheck(exit 0, 0error TS) +… exec vitest run --maxWorkers=2 src/realtime-hooks(locked)Tests 18 passed (18)@objectstack/objectqltypecheck(tsc --noEmit && tsc -p tsconfig.scripts.json && check:test-typecheck, exit 0, 0 errors;engine-data-events.bench.tsis insidesrc/**/*, not excluded) +… exec vitest run --maxWorkers=2 src/engine-data-events.test.ts(locked)Tests 17 passed (17)@objectstack/plugin-webhookstypecheck(exit 0, 0 errors) +… exec vitest run --maxWorkers=2 src/auto-enqueuer.test.ts(locked)Tests 26 passed (26)tsc --noEmit(src program)tsconfig.test.json --listFileslistssrc/api/events.test.ts(1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debtcheck:test-typecheckholds, green above)npx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsbuildof@objectstack/objectql^...+objectql(15 tasks, 0 cached), then@objectstack/plugin-webhooks^...+client+plugin-webhooks(34 tasks, 15 cached), thenclient-react— all lockedpkg^...= dependencies), built so the DOWNSTREAM consumers above read freshdist/*.d.tsnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat9ffb659b(5 paths vs merge base919beca4): 82 commandsnode scripts/check-*exit 0; 20pnpm --filterspec/lint gates exit 0 (check:skill-examplesfirst exit 1 for a missingclient-react/distprerequisite, exit 0 after that build:✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 rootpnpm check:*exit 0 —check:nul-bytesOK (scanned 8262 text file(s) … no raw ASCII control bytes),check:spec-parsed-aliasOK,check:doc-authoring✓,check:system-context-censusOK — 106 elevation read sites … all anchoredpnpm check:dual-build-cjs-loads("Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured." — 27 packages withoutdist) andpnpm check:type-check-debt(needs every package's built declarations)pnpm lint(repo-wide eslint, CI-owned);check:react-declaration-parity(needs objectui's manifest, by design)Reverse verification (ablation of the pins; source path — the spec suite imports
./events.zodfromsrc, nodiston its resolution path, so no rebuild is involved)From the committed state (
7dfe65b8, the member in HEAD): removed the bulkorganizationIddeclaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blobea9532c0…→57b23d7a…); ransrc/api/events.test.ts:Tests 7 failed | 22 passed (29)— exactly the seven new pins that name the key went red (present round-trip, the four refusals, the member set, the cross-schema identity) and the absent-key pin stayed green, the predicted direction. Restore insidetrap … EXIT INT TERMbygit checkout HEAD -- ABSOLUTE_PATH, proven:git hash-objectback to the HEAD blobea9532c0…,git diff HEADempty, tree clean.Contract review
Clause ② card (
needs:contract-reviewon this PR and on #14971): a new key on a published event payload, plus an absence reading that deliberately diverges from its single-record sibling — both stated in the JSDoc for the reviewer's veto window.🤖 Generated with Claude Code
Generated by Claude Code