feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization
Sep 4, 2026
Merged

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted#15218
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 term object) and empty-string refusal as DataEventSchema.organizationId, no .default(). DataEventSchema and MetadataEventSchema are untouched. The producer threading (packages/objectql, the publishBulkDataEvent site) 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)

  • Shape, seat ruling: "the bulk event carries ONE organization for the whole batch — a new optional key organizationId: z.string().min(1).optional() on BulkDataEventSchema, the same spelling, position and refusal of the empty string as DataEventSchema.organizationId, no .default(). ⛔ No per-row array, no organizationIds, no second envelope, no change to DataEventSchema."
  • Absence semantics, deliberately divergent from DataEventSchema and 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.
  • Triage (5535736661): the JSDoc states whether a batch's organization is one for the whole batch or per-row — it is one for the whole batch, never per-row, never a list.

Premise check at the producer (verified before writing; premise_still_valid: true, with one qualification)

Premise as ruled: at publishBulkDataEvent the 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/main at 919beca4 (BASE):

  • packages/objectql/src/engine.ts:11545 — the predicate branch calls driver.updateMany(object, ast, …) with the middleware-composed ast; :11608-11612 / :13062-13066 publish data.records.updated|deleted with { matched, context: opCtx.context }; publishBulkDataEvent (:5709) validates with BulkDataEventSchema.parse and carries userId from 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.ts step 3 (:3040-3055): computeRlsFilter(permissionSets, object, operation, context) is AND-composed into opCtx.ast for every dispatch that carries an ast — its own comment measures the dispatch set as "insert/update/delete by-id (no ast) and bulk update/delete (with ast)". computeRlsFilter (:5404-5422) is andComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, from context.tenantId and context.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); singlenull; a true PLATFORM_ADMIN on a posture-permitting object → null. Layer 1 (business RLS, sharing's buildWriteFilter at packages/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). isSystem contexts short-circuit the middleware (security-plugin.ts :2873-2874). The driver's native tenantId scope (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 an isolated write — NULL-organization rows are not reachable through it.
  • Reading: on isolated the premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. On group (ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach in group posture — 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.
  • PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's recorded open question 1 / recommendation A (read from its body): A leaves the bulk contract as is and defers "whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all" to the services half, calling the tenant question "a shape decision of its own, and nothing here pins it either way"; it rejects (B) a per-row organizationIds array 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.tsBulkDataEventSchema block only: one header paragraph (one organization for the whole batch, or nothing; cross-reference to DataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, the group qualification) and .describe(). git grep -c organizationId on the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); control git grep -c object on the same file unchanged in kind (non-zero both sides).
  • packages/spec/src/api/events.test.ts — new describe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property and undefined; present → round-trips; 42invalid_type at ['organizationId']; null → same; ''too_small, minimum: 1; a per-row array → invalid_type (the ruled-out shape); member set = exactly the six prior keys plus organizationId, and organizationIds on neither schema; structural + behavioural identity of the two organizationId declarations (optional over string with 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.
  • Generated followers, regenerated by check:generated --fix (it proved exactly one artifact stale, gen:docs) and the check:authorable-surface tree: 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.json has no BulkDataEvent row and none is invented. authorable-surface.base.json untouched (manual-only anchor; baseRev lag is information).
  • .changeset/bulk-data-event-organization-id.md@objectstack/spec: minor. .changeset/README.md carries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additive DataEvent.organizationId, shipped minor) — an additive published key on a public payload.

MetadataEventSchema (read-and-report only, no edit): members id, type, metadataType, name, packageId?, definition?, userId?, timestampdefinition is the full item body. Producer packages/metadata/src/metadata-manager.ts:696 passes 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 not data.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)

ReadingCommandVerdict
spec events pinspnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts (locked)Tests 29 passed (29) (21 prior + 8 new)
spec buildpnpm --filter @objectstack/spec build (locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.
generated artifactspnpm --filter @objectstack/spec check:generated --fix then a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; second run ✓ All 15 generated artifacts are up to date.
spec src/api suite… exec vitest run --maxWorkers=2 src/api (locked)Test Files 41 passed (41) · Tests 1349 passed (1349)
@objectstack/client realtime… exec vitest run --maxWorkers=2 src/realtime-api (locked)Test Files 2 passed (2) · Tests 22 passed (22)
@objectstack/client-reacttypecheck (exit 0, 0 error 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.ts is inside src/**/*, 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)
spec tsc --noEmit (src program)exit 0, 0 errors; tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debt check:test-typecheck holds, green above)measured
eslint on the two edited TS filesnpx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsexit 0
dependency closureturbo build of @objectstack/objectql^... + objectql (15 tasks, 0 cached), then @objectstack/plugin-webhooks^... + client + plugin-webhooks (34 tasks, 15 cached), then client-react — all lockedfilter direction UPSTREAM (pkg^... = dependencies), built so the DOWNSTREAM consumers above read fresh dist/*.d.ts
derived gate farmnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9ffb659b (5 paths vs merge base 919beca4): 82 commands29 node scripts/check-* exit 0; 20 pnpm --filter spec/lint gates exit 0 (check:skill-examples first exit 1 for a missing client-react/dist prerequisite, exit 0 after that build: ✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 root pnpm 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 anchored
NOT MEASURED (prerequisite exit 3, not red)pnpm check:dual-build-cjs-loads ("Run pnpm build first. ⛔ This is NOT a pass: nothing was measured." — 27 packages without dist) and pnpm check:type-check-debt (needs every package's built declarations)CI owns the farm
not run locally, declaredpnpm lint (repo-wide eslint, CI-owned); check:react-declaration-parity (needs objectui's manifest, by design)CI

Reverse verification (ablation of the pins; source path — the spec suite imports ./events.zod from src, no dist on its resolution path, so no rebuild is involved)

From the committed state (7dfe65b8, the member in HEAD): removed the bulk organizationId declaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blob ea9532c0…57b23d7a…); ran src/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 inside trap … EXIT INT TERM by git checkout HEAD -- ABSOLUTE_PATH, proven: git hash-object back to the HEAD blob ea9532c0…, git diff HEAD empty, tree clean.

Contract review

Clause ② card (needs:contract-review on 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

…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
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 9c1bcda382067e75e2d69f11086d6c986ccb987apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d70725b061fd9088744b801b07e7ab61bc61ab6 — the merge of head 9ffb659b38c07ae5f83c41f45fdd596729d38b6e into base 9c1bcda382067e75e2d69f11086d6c986ccb987a, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justinos-justin added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026 — with Claude
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justin
os-justin marked this pull request as ready for review September 4, 2026 06:56
@os-justin
os-justin added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit 97bcd99Sep 4, 2026
52 checks passed
@os-justin
os-justin deleted the claude/issue-14971-bulk-data-event-organization branch September 4, 2026 07:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization
Sep 4, 2026
Merged

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted#15218
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 term object) and empty-string refusal as DataEventSchema.organizationId, no .default(). DataEventSchema and MetadataEventSchema are untouched. The producer threading (packages/objectql, the publishBulkDataEvent site) 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)

  • Shape, seat ruling: "the bulk event carries ONE organization for the whole batch — a new optional key organizationId: z.string().min(1).optional() on BulkDataEventSchema, the same spelling, position and refusal of the empty string as DataEventSchema.organizationId, no .default(). ⛔ No per-row array, no organizationIds, no second envelope, no change to DataEventSchema."
  • Absence semantics, deliberately divergent from DataEventSchema and 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.
  • Triage (5535736661): the JSDoc states whether a batch's organization is one for the whole batch or per-row — it is one for the whole batch, never per-row, never a list.

Premise check at the producer (verified before writing; premise_still_valid: true, with one qualification)

Premise as ruled: at publishBulkDataEvent the 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/main at 919beca4 (BASE):

  • packages/objectql/src/engine.ts:11545 — the predicate branch calls driver.updateMany(object, ast, …) with the middleware-composed ast; :11608-11612 / :13062-13066 publish data.records.updated|deleted with { matched, context: opCtx.context }; publishBulkDataEvent (:5709) validates with BulkDataEventSchema.parse and carries userId from 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.ts step 3 (:3040-3055): computeRlsFilter(permissionSets, object, operation, context) is AND-composed into opCtx.ast for every dispatch that carries an ast — its own comment measures the dispatch set as "insert/update/delete by-id (no ast) and bulk update/delete (with ast)". computeRlsFilter (:5404-5422) is andComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, from context.tenantId and context.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); singlenull; a true PLATFORM_ADMIN on a posture-permitting object → null. Layer 1 (business RLS, sharing's buildWriteFilter at packages/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). isSystem contexts short-circuit the middleware (security-plugin.ts :2873-2874). The driver's native tenantId scope (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 an isolated write — NULL-organization rows are not reachable through it.
  • Reading: on isolated the premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. On group (ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach in group posture — 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.
  • PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's recorded open question 1 / recommendation A (read from its body): A leaves the bulk contract as is and defers "whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all" to the services half, calling the tenant question "a shape decision of its own, and nothing here pins it either way"; it rejects (B) a per-row organizationIds array 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.tsBulkDataEventSchema block only: one header paragraph (one organization for the whole batch, or nothing; cross-reference to DataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, the group qualification) and .describe(). git grep -c organizationId on the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); control git grep -c object on the same file unchanged in kind (non-zero both sides).
  • packages/spec/src/api/events.test.ts — new describe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property and undefined; present → round-trips; 42invalid_type at ['organizationId']; null → same; ''too_small, minimum: 1; a per-row array → invalid_type (the ruled-out shape); member set = exactly the six prior keys plus organizationId, and organizationIds on neither schema; structural + behavioural identity of the two organizationId declarations (optional over string with 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.
  • Generated followers, regenerated by check:generated --fix (it proved exactly one artifact stale, gen:docs) and the check:authorable-surface tree: 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.json has no BulkDataEvent row and none is invented. authorable-surface.base.json untouched (manual-only anchor; baseRev lag is information).
  • .changeset/bulk-data-event-organization-id.md@objectstack/spec: minor. .changeset/README.md carries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additive DataEvent.organizationId, shipped minor) — an additive published key on a public payload.

MetadataEventSchema (read-and-report only, no edit): members id, type, metadataType, name, packageId?, definition?, userId?, timestampdefinition is the full item body. Producer packages/metadata/src/metadata-manager.ts:696 passes 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 not data.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)

ReadingCommandVerdict
spec events pinspnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts (locked)Tests 29 passed (29) (21 prior + 8 new)
spec buildpnpm --filter @objectstack/spec build (locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.
generated artifactspnpm --filter @objectstack/spec check:generated --fix then a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; second run ✓ All 15 generated artifacts are up to date.
spec src/api suite… exec vitest run --maxWorkers=2 src/api (locked)Test Files 41 passed (41) · Tests 1349 passed (1349)
@objectstack/client realtime… exec vitest run --maxWorkers=2 src/realtime-api (locked)Test Files 2 passed (2) · Tests 22 passed (22)
@objectstack/client-reacttypecheck (exit 0, 0 error 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.ts is inside src/**/*, 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)
spec tsc --noEmit (src program)exit 0, 0 errors; tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debt check:test-typecheck holds, green above)measured
eslint on the two edited TS filesnpx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsexit 0
dependency closureturbo build of @objectstack/objectql^... + objectql (15 tasks, 0 cached), then @objectstack/plugin-webhooks^... + client + plugin-webhooks (34 tasks, 15 cached), then client-react — all lockedfilter direction UPSTREAM (pkg^... = dependencies), built so the DOWNSTREAM consumers above read fresh dist/*.d.ts
derived gate farmnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9ffb659b (5 paths vs merge base 919beca4): 82 commands29 node scripts/check-* exit 0; 20 pnpm --filter spec/lint gates exit 0 (check:skill-examples first exit 1 for a missing client-react/dist prerequisite, exit 0 after that build: ✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 root pnpm 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 anchored
NOT MEASURED (prerequisite exit 3, not red)pnpm check:dual-build-cjs-loads ("Run pnpm build first. ⛔ This is NOT a pass: nothing was measured." — 27 packages without dist) and pnpm check:type-check-debt (needs every package's built declarations)CI owns the farm
not run locally, declaredpnpm lint (repo-wide eslint, CI-owned); check:react-declaration-parity (needs objectui's manifest, by design)CI

Reverse verification (ablation of the pins; source path — the spec suite imports ./events.zod from src, no dist on its resolution path, so no rebuild is involved)

From the committed state (7dfe65b8, the member in HEAD): removed the bulk organizationId declaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blob ea9532c0…57b23d7a…); ran src/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 inside trap … EXIT INT TERM by git checkout HEAD -- ABSOLUTE_PATH, proven: git hash-object back to the HEAD blob ea9532c0…, git diff HEAD empty, tree clean.

Contract review

Clause ② card (needs:contract-review on 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

…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
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 9c1bcda382067e75e2d69f11086d6c986ccb987apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d70725b061fd9088744b801b07e7ab61bc61ab6 — the merge of head 9ffb659b38c07ae5f83c41f45fdd596729d38b6e into base 9c1bcda382067e75e2d69f11086d6c986ccb987a, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justinos-justin added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026 — with Claude
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justin
os-justin marked this pull request as ready for review September 4, 2026 06:56
@os-justin
os-justin added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit 97bcd99Sep 4, 2026
52 checks passed
@os-justin
os-justin deleted the claude/issue-14971-bulk-data-event-organization branch September 4, 2026 07:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization
Sep 4, 2026
Merged

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted#15218
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 term object) and empty-string refusal as DataEventSchema.organizationId, no .default(). DataEventSchema and MetadataEventSchema are untouched. The producer threading (packages/objectql, the publishBulkDataEvent site) 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)

  • Shape, seat ruling: "the bulk event carries ONE organization for the whole batch — a new optional key organizationId: z.string().min(1).optional() on BulkDataEventSchema, the same spelling, position and refusal of the empty string as DataEventSchema.organizationId, no .default(). ⛔ No per-row array, no organizationIds, no second envelope, no change to DataEventSchema."
  • Absence semantics, deliberately divergent from DataEventSchema and 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.
  • Triage (5535736661): the JSDoc states whether a batch's organization is one for the whole batch or per-row — it is one for the whole batch, never per-row, never a list.

Premise check at the producer (verified before writing; premise_still_valid: true, with one qualification)

Premise as ruled: at publishBulkDataEvent the 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/main at 919beca4 (BASE):

  • packages/objectql/src/engine.ts:11545 — the predicate branch calls driver.updateMany(object, ast, …) with the middleware-composed ast; :11608-11612 / :13062-13066 publish data.records.updated|deleted with { matched, context: opCtx.context }; publishBulkDataEvent (:5709) validates with BulkDataEventSchema.parse and carries userId from 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.ts step 3 (:3040-3055): computeRlsFilter(permissionSets, object, operation, context) is AND-composed into opCtx.ast for every dispatch that carries an ast — its own comment measures the dispatch set as "insert/update/delete by-id (no ast) and bulk update/delete (with ast)". computeRlsFilter (:5404-5422) is andComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, from context.tenantId and context.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); singlenull; a true PLATFORM_ADMIN on a posture-permitting object → null. Layer 1 (business RLS, sharing's buildWriteFilter at packages/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). isSystem contexts short-circuit the middleware (security-plugin.ts :2873-2874). The driver's native tenantId scope (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 an isolated write — NULL-organization rows are not reachable through it.
  • Reading: on isolated the premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. On group (ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach in group posture — 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.
  • PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's recorded open question 1 / recommendation A (read from its body): A leaves the bulk contract as is and defers "whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all" to the services half, calling the tenant question "a shape decision of its own, and nothing here pins it either way"; it rejects (B) a per-row organizationIds array 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.tsBulkDataEventSchema block only: one header paragraph (one organization for the whole batch, or nothing; cross-reference to DataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, the group qualification) and .describe(). git grep -c organizationId on the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); control git grep -c object on the same file unchanged in kind (non-zero both sides).
  • packages/spec/src/api/events.test.ts — new describe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property and undefined; present → round-trips; 42invalid_type at ['organizationId']; null → same; ''too_small, minimum: 1; a per-row array → invalid_type (the ruled-out shape); member set = exactly the six prior keys plus organizationId, and organizationIds on neither schema; structural + behavioural identity of the two organizationId declarations (optional over string with 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.
  • Generated followers, regenerated by check:generated --fix (it proved exactly one artifact stale, gen:docs) and the check:authorable-surface tree: 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.json has no BulkDataEvent row and none is invented. authorable-surface.base.json untouched (manual-only anchor; baseRev lag is information).
  • .changeset/bulk-data-event-organization-id.md@objectstack/spec: minor. .changeset/README.md carries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additive DataEvent.organizationId, shipped minor) — an additive published key on a public payload.

MetadataEventSchema (read-and-report only, no edit): members id, type, metadataType, name, packageId?, definition?, userId?, timestampdefinition is the full item body. Producer packages/metadata/src/metadata-manager.ts:696 passes 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 not data.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)

ReadingCommandVerdict
spec events pinspnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts (locked)Tests 29 passed (29) (21 prior + 8 new)
spec buildpnpm --filter @objectstack/spec build (locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.
generated artifactspnpm --filter @objectstack/spec check:generated --fix then a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; second run ✓ All 15 generated artifacts are up to date.
spec src/api suite… exec vitest run --maxWorkers=2 src/api (locked)Test Files 41 passed (41) · Tests 1349 passed (1349)
@objectstack/client realtime… exec vitest run --maxWorkers=2 src/realtime-api (locked)Test Files 2 passed (2) · Tests 22 passed (22)
@objectstack/client-reacttypecheck (exit 0, 0 error 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.ts is inside src/**/*, 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)
spec tsc --noEmit (src program)exit 0, 0 errors; tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debt check:test-typecheck holds, green above)measured
eslint on the two edited TS filesnpx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsexit 0
dependency closureturbo build of @objectstack/objectql^... + objectql (15 tasks, 0 cached), then @objectstack/plugin-webhooks^... + client + plugin-webhooks (34 tasks, 15 cached), then client-react — all lockedfilter direction UPSTREAM (pkg^... = dependencies), built so the DOWNSTREAM consumers above read fresh dist/*.d.ts
derived gate farmnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9ffb659b (5 paths vs merge base 919beca4): 82 commands29 node scripts/check-* exit 0; 20 pnpm --filter spec/lint gates exit 0 (check:skill-examples first exit 1 for a missing client-react/dist prerequisite, exit 0 after that build: ✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 root pnpm 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 anchored
NOT MEASURED (prerequisite exit 3, not red)pnpm check:dual-build-cjs-loads ("Run pnpm build first. ⛔ This is NOT a pass: nothing was measured." — 27 packages without dist) and pnpm check:type-check-debt (needs every package's built declarations)CI owns the farm
not run locally, declaredpnpm lint (repo-wide eslint, CI-owned); check:react-declaration-parity (needs objectui's manifest, by design)CI

Reverse verification (ablation of the pins; source path — the spec suite imports ./events.zod from src, no dist on its resolution path, so no rebuild is involved)

From the committed state (7dfe65b8, the member in HEAD): removed the bulk organizationId declaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blob ea9532c0…57b23d7a…); ran src/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 inside trap … EXIT INT TERM by git checkout HEAD -- ABSOLUTE_PATH, proven: git hash-object back to the HEAD blob ea9532c0…, git diff HEAD empty, tree clean.

Contract review

Clause ② card (needs:contract-review on 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

…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
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 9c1bcda382067e75e2d69f11086d6c986ccb987apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d70725b061fd9088744b801b07e7ab61bc61ab6 — the merge of head 9ffb659b38c07ae5f83c41f45fdd596729d38b6e into base 9c1bcda382067e75e2d69f11086d6c986ccb987a, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justinos-justin added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026 — with Claude
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justin
os-justin marked this pull request as ready for review September 4, 2026 06:56
@os-justin
os-justin added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit 97bcd99Sep 4, 2026
52 checks passed
@os-justin
os-justin deleted the claude/issue-14971-bulk-data-event-organization branch September 4, 2026 07:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization
Sep 4, 2026
Merged

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted#15218
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 term object) and empty-string refusal as DataEventSchema.organizationId, no .default(). DataEventSchema and MetadataEventSchema are untouched. The producer threading (packages/objectql, the publishBulkDataEvent site) 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)

  • Shape, seat ruling: "the bulk event carries ONE organization for the whole batch — a new optional key organizationId: z.string().min(1).optional() on BulkDataEventSchema, the same spelling, position and refusal of the empty string as DataEventSchema.organizationId, no .default(). ⛔ No per-row array, no organizationIds, no second envelope, no change to DataEventSchema."
  • Absence semantics, deliberately divergent from DataEventSchema and 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.
  • Triage (5535736661): the JSDoc states whether a batch's organization is one for the whole batch or per-row — it is one for the whole batch, never per-row, never a list.

Premise check at the producer (verified before writing; premise_still_valid: true, with one qualification)

Premise as ruled: at publishBulkDataEvent the 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/main at 919beca4 (BASE):

  • packages/objectql/src/engine.ts:11545 — the predicate branch calls driver.updateMany(object, ast, …) with the middleware-composed ast; :11608-11612 / :13062-13066 publish data.records.updated|deleted with { matched, context: opCtx.context }; publishBulkDataEvent (:5709) validates with BulkDataEventSchema.parse and carries userId from 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.ts step 3 (:3040-3055): computeRlsFilter(permissionSets, object, operation, context) is AND-composed into opCtx.ast for every dispatch that carries an ast — its own comment measures the dispatch set as "insert/update/delete by-id (no ast) and bulk update/delete (with ast)". computeRlsFilter (:5404-5422) is andComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, from context.tenantId and context.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); singlenull; a true PLATFORM_ADMIN on a posture-permitting object → null. Layer 1 (business RLS, sharing's buildWriteFilter at packages/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). isSystem contexts short-circuit the middleware (security-plugin.ts :2873-2874). The driver's native tenantId scope (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 an isolated write — NULL-organization rows are not reachable through it.
  • Reading: on isolated the premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. On group (ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach in group posture — 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.
  • PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's recorded open question 1 / recommendation A (read from its body): A leaves the bulk contract as is and defers "whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all" to the services half, calling the tenant question "a shape decision of its own, and nothing here pins it either way"; it rejects (B) a per-row organizationIds array 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.tsBulkDataEventSchema block only: one header paragraph (one organization for the whole batch, or nothing; cross-reference to DataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, the group qualification) and .describe(). git grep -c organizationId on the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); control git grep -c object on the same file unchanged in kind (non-zero both sides).
  • packages/spec/src/api/events.test.ts — new describe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property and undefined; present → round-trips; 42invalid_type at ['organizationId']; null → same; ''too_small, minimum: 1; a per-row array → invalid_type (the ruled-out shape); member set = exactly the six prior keys plus organizationId, and organizationIds on neither schema; structural + behavioural identity of the two organizationId declarations (optional over string with 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.
  • Generated followers, regenerated by check:generated --fix (it proved exactly one artifact stale, gen:docs) and the check:authorable-surface tree: 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.json has no BulkDataEvent row and none is invented. authorable-surface.base.json untouched (manual-only anchor; baseRev lag is information).
  • .changeset/bulk-data-event-organization-id.md@objectstack/spec: minor. .changeset/README.md carries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additive DataEvent.organizationId, shipped minor) — an additive published key on a public payload.

MetadataEventSchema (read-and-report only, no edit): members id, type, metadataType, name, packageId?, definition?, userId?, timestampdefinition is the full item body. Producer packages/metadata/src/metadata-manager.ts:696 passes 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 not data.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)

ReadingCommandVerdict
spec events pinspnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts (locked)Tests 29 passed (29) (21 prior + 8 new)
spec buildpnpm --filter @objectstack/spec build (locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.
generated artifactspnpm --filter @objectstack/spec check:generated --fix then a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; second run ✓ All 15 generated artifacts are up to date.
spec src/api suite… exec vitest run --maxWorkers=2 src/api (locked)Test Files 41 passed (41) · Tests 1349 passed (1349)
@objectstack/client realtime… exec vitest run --maxWorkers=2 src/realtime-api (locked)Test Files 2 passed (2) · Tests 22 passed (22)
@objectstack/client-reacttypecheck (exit 0, 0 error 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.ts is inside src/**/*, 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)
spec tsc --noEmit (src program)exit 0, 0 errors; tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debt check:test-typecheck holds, green above)measured
eslint on the two edited TS filesnpx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsexit 0
dependency closureturbo build of @objectstack/objectql^... + objectql (15 tasks, 0 cached), then @objectstack/plugin-webhooks^... + client + plugin-webhooks (34 tasks, 15 cached), then client-react — all lockedfilter direction UPSTREAM (pkg^... = dependencies), built so the DOWNSTREAM consumers above read fresh dist/*.d.ts
derived gate farmnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9ffb659b (5 paths vs merge base 919beca4): 82 commands29 node scripts/check-* exit 0; 20 pnpm --filter spec/lint gates exit 0 (check:skill-examples first exit 1 for a missing client-react/dist prerequisite, exit 0 after that build: ✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 root pnpm 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 anchored
NOT MEASURED (prerequisite exit 3, not red)pnpm check:dual-build-cjs-loads ("Run pnpm build first. ⛔ This is NOT a pass: nothing was measured." — 27 packages without dist) and pnpm check:type-check-debt (needs every package's built declarations)CI owns the farm
not run locally, declaredpnpm lint (repo-wide eslint, CI-owned); check:react-declaration-parity (needs objectui's manifest, by design)CI

Reverse verification (ablation of the pins; source path — the spec suite imports ./events.zod from src, no dist on its resolution path, so no rebuild is involved)

From the committed state (7dfe65b8, the member in HEAD): removed the bulk organizationId declaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blob ea9532c0…57b23d7a…); ran src/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 inside trap … EXIT INT TERM by git checkout HEAD -- ABSOLUTE_PATH, proven: git hash-object back to the HEAD blob ea9532c0…, git diff HEAD empty, tree clean.

Contract review

Clause ② card (needs:contract-review on 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

…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
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 9c1bcda382067e75e2d69f11086d6c986ccb987apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d70725b061fd9088744b801b07e7ab61bc61ab6 — the merge of head 9ffb659b38c07ae5f83c41f45fdd596729d38b6e into base 9c1bcda382067e75e2d69f11086d6c986ccb987a, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justinos-justin added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026 — with Claude
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justin
os-justin marked this pull request as ready for review September 4, 2026 06:56
@os-justin
os-justin added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit 97bcd99Sep 4, 2026
52 checks passed
@os-justin
os-justin deleted the claude/issue-14971-bulk-data-event-organization branch September 4, 2026 07:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization
Sep 4, 2026
Merged

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted#15218
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 term object) and empty-string refusal as DataEventSchema.organizationId, no .default(). DataEventSchema and MetadataEventSchema are untouched. The producer threading (packages/objectql, the publishBulkDataEvent site) 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)

  • Shape, seat ruling: "the bulk event carries ONE organization for the whole batch — a new optional key organizationId: z.string().min(1).optional() on BulkDataEventSchema, the same spelling, position and refusal of the empty string as DataEventSchema.organizationId, no .default(). ⛔ No per-row array, no organizationIds, no second envelope, no change to DataEventSchema."
  • Absence semantics, deliberately divergent from DataEventSchema and 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.
  • Triage (5535736661): the JSDoc states whether a batch's organization is one for the whole batch or per-row — it is one for the whole batch, never per-row, never a list.

Premise check at the producer (verified before writing; premise_still_valid: true, with one qualification)

Premise as ruled: at publishBulkDataEvent the 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/main at 919beca4 (BASE):

  • packages/objectql/src/engine.ts:11545 — the predicate branch calls driver.updateMany(object, ast, …) with the middleware-composed ast; :11608-11612 / :13062-13066 publish data.records.updated|deleted with { matched, context: opCtx.context }; publishBulkDataEvent (:5709) validates with BulkDataEventSchema.parse and carries userId from 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.ts step 3 (:3040-3055): computeRlsFilter(permissionSets, object, operation, context) is AND-composed into opCtx.ast for every dispatch that carries an ast — its own comment measures the dispatch set as "insert/update/delete by-id (no ast) and bulk update/delete (with ast)". computeRlsFilter (:5404-5422) is andComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, from context.tenantId and context.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); singlenull; a true PLATFORM_ADMIN on a posture-permitting object → null. Layer 1 (business RLS, sharing's buildWriteFilter at packages/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). isSystem contexts short-circuit the middleware (security-plugin.ts :2873-2874). The driver's native tenantId scope (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 an isolated write — NULL-organization rows are not reachable through it.
  • Reading: on isolated the premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. On group (ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach in group posture — 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.
  • PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's recorded open question 1 / recommendation A (read from its body): A leaves the bulk contract as is and defers "whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all" to the services half, calling the tenant question "a shape decision of its own, and nothing here pins it either way"; it rejects (B) a per-row organizationIds array 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.tsBulkDataEventSchema block only: one header paragraph (one organization for the whole batch, or nothing; cross-reference to DataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, the group qualification) and .describe(). git grep -c organizationId on the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); control git grep -c object on the same file unchanged in kind (non-zero both sides).
  • packages/spec/src/api/events.test.ts — new describe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property and undefined; present → round-trips; 42invalid_type at ['organizationId']; null → same; ''too_small, minimum: 1; a per-row array → invalid_type (the ruled-out shape); member set = exactly the six prior keys plus organizationId, and organizationIds on neither schema; structural + behavioural identity of the two organizationId declarations (optional over string with 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.
  • Generated followers, regenerated by check:generated --fix (it proved exactly one artifact stale, gen:docs) and the check:authorable-surface tree: 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.json has no BulkDataEvent row and none is invented. authorable-surface.base.json untouched (manual-only anchor; baseRev lag is information).
  • .changeset/bulk-data-event-organization-id.md@objectstack/spec: minor. .changeset/README.md carries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additive DataEvent.organizationId, shipped minor) — an additive published key on a public payload.

MetadataEventSchema (read-and-report only, no edit): members id, type, metadataType, name, packageId?, definition?, userId?, timestampdefinition is the full item body. Producer packages/metadata/src/metadata-manager.ts:696 passes 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 not data.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)

ReadingCommandVerdict
spec events pinspnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts (locked)Tests 29 passed (29) (21 prior + 8 new)
spec buildpnpm --filter @objectstack/spec build (locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.
generated artifactspnpm --filter @objectstack/spec check:generated --fix then a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; second run ✓ All 15 generated artifacts are up to date.
spec src/api suite… exec vitest run --maxWorkers=2 src/api (locked)Test Files 41 passed (41) · Tests 1349 passed (1349)
@objectstack/client realtime… exec vitest run --maxWorkers=2 src/realtime-api (locked)Test Files 2 passed (2) · Tests 22 passed (22)
@objectstack/client-reacttypecheck (exit 0, 0 error 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.ts is inside src/**/*, 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)
spec tsc --noEmit (src program)exit 0, 0 errors; tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debt check:test-typecheck holds, green above)measured
eslint on the two edited TS filesnpx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsexit 0
dependency closureturbo build of @objectstack/objectql^... + objectql (15 tasks, 0 cached), then @objectstack/plugin-webhooks^... + client + plugin-webhooks (34 tasks, 15 cached), then client-react — all lockedfilter direction UPSTREAM (pkg^... = dependencies), built so the DOWNSTREAM consumers above read fresh dist/*.d.ts
derived gate farmnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9ffb659b (5 paths vs merge base 919beca4): 82 commands29 node scripts/check-* exit 0; 20 pnpm --filter spec/lint gates exit 0 (check:skill-examples first exit 1 for a missing client-react/dist prerequisite, exit 0 after that build: ✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 root pnpm 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 anchored
NOT MEASURED (prerequisite exit 3, not red)pnpm check:dual-build-cjs-loads ("Run pnpm build first. ⛔ This is NOT a pass: nothing was measured." — 27 packages without dist) and pnpm check:type-check-debt (needs every package's built declarations)CI owns the farm
not run locally, declaredpnpm lint (repo-wide eslint, CI-owned); check:react-declaration-parity (needs objectui's manifest, by design)CI

Reverse verification (ablation of the pins; source path — the spec suite imports ./events.zod from src, no dist on its resolution path, so no rebuild is involved)

From the committed state (7dfe65b8, the member in HEAD): removed the bulk organizationId declaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blob ea9532c0…57b23d7a…); ran src/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 inside trap … EXIT INT TERM by git checkout HEAD -- ABSOLUTE_PATH, proven: git hash-object back to the HEAD blob ea9532c0…, git diff HEAD empty, tree clean.

Contract review

Clause ② card (needs:contract-review on 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

…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
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 9c1bcda382067e75e2d69f11086d6c986ccb987apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d70725b061fd9088744b801b07e7ab61bc61ab6 — the merge of head 9ffb659b38c07ae5f83c41f45fdd596729d38b6e into base 9c1bcda382067e75e2d69f11086d6c986ccb987a, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justinos-justin added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026 — with Claude
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justin
os-justin marked this pull request as ready for review September 4, 2026 06:56
@os-justin
os-justin added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit 97bcd99Sep 4, 2026
52 checks passed
@os-justin
os-justin deleted the claude/issue-14971-bulk-data-event-organization branch September 4, 2026 07:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization
Sep 4, 2026
Merged

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted#15218
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 term object) and empty-string refusal as DataEventSchema.organizationId, no .default(). DataEventSchema and MetadataEventSchema are untouched. The producer threading (packages/objectql, the publishBulkDataEvent site) 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)

  • Shape, seat ruling: "the bulk event carries ONE organization for the whole batch — a new optional key organizationId: z.string().min(1).optional() on BulkDataEventSchema, the same spelling, position and refusal of the empty string as DataEventSchema.organizationId, no .default(). ⛔ No per-row array, no organizationIds, no second envelope, no change to DataEventSchema."
  • Absence semantics, deliberately divergent from DataEventSchema and 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.
  • Triage (5535736661): the JSDoc states whether a batch's organization is one for the whole batch or per-row — it is one for the whole batch, never per-row, never a list.

Premise check at the producer (verified before writing; premise_still_valid: true, with one qualification)

Premise as ruled: at publishBulkDataEvent the 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/main at 919beca4 (BASE):

  • packages/objectql/src/engine.ts:11545 — the predicate branch calls driver.updateMany(object, ast, …) with the middleware-composed ast; :11608-11612 / :13062-13066 publish data.records.updated|deleted with { matched, context: opCtx.context }; publishBulkDataEvent (:5709) validates with BulkDataEventSchema.parse and carries userId from 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.ts step 3 (:3040-3055): computeRlsFilter(permissionSets, object, operation, context) is AND-composed into opCtx.ast for every dispatch that carries an ast — its own comment measures the dispatch set as "insert/update/delete by-id (no ast) and bulk update/delete (with ast)". computeRlsFilter (:5404-5422) is andComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, from context.tenantId and context.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); singlenull; a true PLATFORM_ADMIN on a posture-permitting object → null. Layer 1 (business RLS, sharing's buildWriteFilter at packages/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). isSystem contexts short-circuit the middleware (security-plugin.ts :2873-2874). The driver's native tenantId scope (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 an isolated write — NULL-organization rows are not reachable through it.
  • Reading: on isolated the premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. On group (ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach in group posture — 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.
  • PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's recorded open question 1 / recommendation A (read from its body): A leaves the bulk contract as is and defers "whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all" to the services half, calling the tenant question "a shape decision of its own, and nothing here pins it either way"; it rejects (B) a per-row organizationIds array 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.tsBulkDataEventSchema block only: one header paragraph (one organization for the whole batch, or nothing; cross-reference to DataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, the group qualification) and .describe(). git grep -c organizationId on the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); control git grep -c object on the same file unchanged in kind (non-zero both sides).
  • packages/spec/src/api/events.test.ts — new describe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property and undefined; present → round-trips; 42invalid_type at ['organizationId']; null → same; ''too_small, minimum: 1; a per-row array → invalid_type (the ruled-out shape); member set = exactly the six prior keys plus organizationId, and organizationIds on neither schema; structural + behavioural identity of the two organizationId declarations (optional over string with 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.
  • Generated followers, regenerated by check:generated --fix (it proved exactly one artifact stale, gen:docs) and the check:authorable-surface tree: 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.json has no BulkDataEvent row and none is invented. authorable-surface.base.json untouched (manual-only anchor; baseRev lag is information).
  • .changeset/bulk-data-event-organization-id.md@objectstack/spec: minor. .changeset/README.md carries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additive DataEvent.organizationId, shipped minor) — an additive published key on a public payload.

MetadataEventSchema (read-and-report only, no edit): members id, type, metadataType, name, packageId?, definition?, userId?, timestampdefinition is the full item body. Producer packages/metadata/src/metadata-manager.ts:696 passes 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 not data.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)

ReadingCommandVerdict
spec events pinspnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts (locked)Tests 29 passed (29) (21 prior + 8 new)
spec buildpnpm --filter @objectstack/spec build (locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.
generated artifactspnpm --filter @objectstack/spec check:generated --fix then a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; second run ✓ All 15 generated artifacts are up to date.
spec src/api suite… exec vitest run --maxWorkers=2 src/api (locked)Test Files 41 passed (41) · Tests 1349 passed (1349)
@objectstack/client realtime… exec vitest run --maxWorkers=2 src/realtime-api (locked)Test Files 2 passed (2) · Tests 22 passed (22)
@objectstack/client-reacttypecheck (exit 0, 0 error 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.ts is inside src/**/*, 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)
spec tsc --noEmit (src program)exit 0, 0 errors; tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debt check:test-typecheck holds, green above)measured
eslint on the two edited TS filesnpx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsexit 0
dependency closureturbo build of @objectstack/objectql^... + objectql (15 tasks, 0 cached), then @objectstack/plugin-webhooks^... + client + plugin-webhooks (34 tasks, 15 cached), then client-react — all lockedfilter direction UPSTREAM (pkg^... = dependencies), built so the DOWNSTREAM consumers above read fresh dist/*.d.ts
derived gate farmnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9ffb659b (5 paths vs merge base 919beca4): 82 commands29 node scripts/check-* exit 0; 20 pnpm --filter spec/lint gates exit 0 (check:skill-examples first exit 1 for a missing client-react/dist prerequisite, exit 0 after that build: ✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 root pnpm 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 anchored
NOT MEASURED (prerequisite exit 3, not red)pnpm check:dual-build-cjs-loads ("Run pnpm build first. ⛔ This is NOT a pass: nothing was measured." — 27 packages without dist) and pnpm check:type-check-debt (needs every package's built declarations)CI owns the farm
not run locally, declaredpnpm lint (repo-wide eslint, CI-owned); check:react-declaration-parity (needs objectui's manifest, by design)CI

Reverse verification (ablation of the pins; source path — the spec suite imports ./events.zod from src, no dist on its resolution path, so no rebuild is involved)

From the committed state (7dfe65b8, the member in HEAD): removed the bulk organizationId declaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blob ea9532c0…57b23d7a…); ran src/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 inside trap … EXIT INT TERM by git checkout HEAD -- ABSOLUTE_PATH, proven: git hash-object back to the HEAD blob ea9532c0…, git diff HEAD empty, tree clean.

Contract review

Clause ② card (needs:contract-review on 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

…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
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 9c1bcda382067e75e2d69f11086d6c986ccb987apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d70725b061fd9088744b801b07e7ab61bc61ab6 — the merge of head 9ffb659b38c07ae5f83c41f45fdd596729d38b6e into base 9c1bcda382067e75e2d69f11086d6c986ccb987a, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justinos-justin added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026 — with Claude
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justin
os-justin marked this pull request as ready for review September 4, 2026 06:56
@os-justin
os-justin added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit 97bcd99Sep 4, 2026
52 checks passed
@os-justin
os-justin deleted the claude/issue-14971-bulk-data-event-organization branch September 4, 2026 07:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization
Sep 4, 2026
Merged

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted#15218
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 term object) and empty-string refusal as DataEventSchema.organizationId, no .default(). DataEventSchema and MetadataEventSchema are untouched. The producer threading (packages/objectql, the publishBulkDataEvent site) 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)

  • Shape, seat ruling: "the bulk event carries ONE organization for the whole batch — a new optional key organizationId: z.string().min(1).optional() on BulkDataEventSchema, the same spelling, position and refusal of the empty string as DataEventSchema.organizationId, no .default(). ⛔ No per-row array, no organizationIds, no second envelope, no change to DataEventSchema."
  • Absence semantics, deliberately divergent from DataEventSchema and 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.
  • Triage (5535736661): the JSDoc states whether a batch's organization is one for the whole batch or per-row — it is one for the whole batch, never per-row, never a list.

Premise check at the producer (verified before writing; premise_still_valid: true, with one qualification)

Premise as ruled: at publishBulkDataEvent the 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/main at 919beca4 (BASE):

  • packages/objectql/src/engine.ts:11545 — the predicate branch calls driver.updateMany(object, ast, …) with the middleware-composed ast; :11608-11612 / :13062-13066 publish data.records.updated|deleted with { matched, context: opCtx.context }; publishBulkDataEvent (:5709) validates with BulkDataEventSchema.parse and carries userId from 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.ts step 3 (:3040-3055): computeRlsFilter(permissionSets, object, operation, context) is AND-composed into opCtx.ast for every dispatch that carries an ast — its own comment measures the dispatch set as "insert/update/delete by-id (no ast) and bulk update/delete (with ast)". computeRlsFilter (:5404-5422) is andComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, from context.tenantId and context.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); singlenull; a true PLATFORM_ADMIN on a posture-permitting object → null. Layer 1 (business RLS, sharing's buildWriteFilter at packages/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). isSystem contexts short-circuit the middleware (security-plugin.ts :2873-2874). The driver's native tenantId scope (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 an isolated write — NULL-organization rows are not reachable through it.
  • Reading: on isolated the premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. On group (ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach in group posture — 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.
  • PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's recorded open question 1 / recommendation A (read from its body): A leaves the bulk contract as is and defers "whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all" to the services half, calling the tenant question "a shape decision of its own, and nothing here pins it either way"; it rejects (B) a per-row organizationIds array 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.tsBulkDataEventSchema block only: one header paragraph (one organization for the whole batch, or nothing; cross-reference to DataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, the group qualification) and .describe(). git grep -c organizationId on the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); control git grep -c object on the same file unchanged in kind (non-zero both sides).
  • packages/spec/src/api/events.test.ts — new describe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property and undefined; present → round-trips; 42invalid_type at ['organizationId']; null → same; ''too_small, minimum: 1; a per-row array → invalid_type (the ruled-out shape); member set = exactly the six prior keys plus organizationId, and organizationIds on neither schema; structural + behavioural identity of the two organizationId declarations (optional over string with 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.
  • Generated followers, regenerated by check:generated --fix (it proved exactly one artifact stale, gen:docs) and the check:authorable-surface tree: 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.json has no BulkDataEvent row and none is invented. authorable-surface.base.json untouched (manual-only anchor; baseRev lag is information).
  • .changeset/bulk-data-event-organization-id.md@objectstack/spec: minor. .changeset/README.md carries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additive DataEvent.organizationId, shipped minor) — an additive published key on a public payload.

MetadataEventSchema (read-and-report only, no edit): members id, type, metadataType, name, packageId?, definition?, userId?, timestampdefinition is the full item body. Producer packages/metadata/src/metadata-manager.ts:696 passes 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 not data.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)

ReadingCommandVerdict
spec events pinspnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts (locked)Tests 29 passed (29) (21 prior + 8 new)
spec buildpnpm --filter @objectstack/spec build (locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.
generated artifactspnpm --filter @objectstack/spec check:generated --fix then a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; second run ✓ All 15 generated artifacts are up to date.
spec src/api suite… exec vitest run --maxWorkers=2 src/api (locked)Test Files 41 passed (41) · Tests 1349 passed (1349)
@objectstack/client realtime… exec vitest run --maxWorkers=2 src/realtime-api (locked)Test Files 2 passed (2) · Tests 22 passed (22)
@objectstack/client-reacttypecheck (exit 0, 0 error 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.ts is inside src/**/*, 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)
spec tsc --noEmit (src program)exit 0, 0 errors; tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debt check:test-typecheck holds, green above)measured
eslint on the two edited TS filesnpx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsexit 0
dependency closureturbo build of @objectstack/objectql^... + objectql (15 tasks, 0 cached), then @objectstack/plugin-webhooks^... + client + plugin-webhooks (34 tasks, 15 cached), then client-react — all lockedfilter direction UPSTREAM (pkg^... = dependencies), built so the DOWNSTREAM consumers above read fresh dist/*.d.ts
derived gate farmnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9ffb659b (5 paths vs merge base 919beca4): 82 commands29 node scripts/check-* exit 0; 20 pnpm --filter spec/lint gates exit 0 (check:skill-examples first exit 1 for a missing client-react/dist prerequisite, exit 0 after that build: ✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 root pnpm 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 anchored
NOT MEASURED (prerequisite exit 3, not red)pnpm check:dual-build-cjs-loads ("Run pnpm build first. ⛔ This is NOT a pass: nothing was measured." — 27 packages without dist) and pnpm check:type-check-debt (needs every package's built declarations)CI owns the farm
not run locally, declaredpnpm lint (repo-wide eslint, CI-owned); check:react-declaration-parity (needs objectui's manifest, by design)CI

Reverse verification (ablation of the pins; source path — the spec suite imports ./events.zod from src, no dist on its resolution path, so no rebuild is involved)

From the committed state (7dfe65b8, the member in HEAD): removed the bulk organizationId declaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blob ea9532c0…57b23d7a…); ran src/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 inside trap … EXIT INT TERM by git checkout HEAD -- ABSOLUTE_PATH, proven: git hash-object back to the HEAD blob ea9532c0…, git diff HEAD empty, tree clean.

Contract review

Clause ② card (needs:contract-review on 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

…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
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 9c1bcda382067e75e2d69f11086d6c986ccb987apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d70725b061fd9088744b801b07e7ab61bc61ab6 — the merge of head 9ffb659b38c07ae5f83c41f45fdd596729d38b6e into base 9c1bcda382067e75e2d69f11086d6c986ccb987a, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justinos-justin added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026 — with Claude
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justin
os-justin marked this pull request as ready for review September 4, 2026 06:56
@os-justin
os-justin added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit 97bcd99Sep 4, 2026
52 checks passed
@os-justin
os-justin deleted the claude/issue-14971-bulk-data-event-organization branch September 4, 2026 07:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted - #15218

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization
Sep 4, 2026
Merged

feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted#15218
os-justin merged 2 commits into
mainfrom
claude/issue-14971-bulk-data-event-organization

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 term object) and empty-string refusal as DataEventSchema.organizationId, no .default(). DataEventSchema and MetadataEventSchema are untouched. The producer threading (packages/objectql, the publishBulkDataEvent site) 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)

  • Shape, seat ruling: "the bulk event carries ONE organization for the whole batch — a new optional key organizationId: z.string().min(1).optional() on BulkDataEventSchema, the same spelling, position and refusal of the empty string as DataEventSchema.organizationId, no .default(). ⛔ No per-row array, no organizationIds, no second envelope, no change to DataEventSchema."
  • Absence semantics, deliberately divergent from DataEventSchema and 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.
  • Triage (5535736661): the JSDoc states whether a batch's organization is one for the whole batch or per-row — it is one for the whole batch, never per-row, never a list.

Premise check at the producer (verified before writing; premise_still_valid: true, with one qualification)

Premise as ruled: at publishBulkDataEvent the 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/main at 919beca4 (BASE):

  • packages/objectql/src/engine.ts:11545 — the predicate branch calls driver.updateMany(object, ast, …) with the middleware-composed ast; :11608-11612 / :13062-13066 publish data.records.updated|deleted with { matched, context: opCtx.context }; publishBulkDataEvent (:5709) validates with BulkDataEventSchema.parse and carries userId from 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.ts step 3 (:3040-3055): computeRlsFilter(permissionSets, object, operation, context) is AND-composed into opCtx.ast for every dispatch that carries an ast — its own comment measures the dispatch set as "insert/update/delete by-id (no ast) and bulk update/delete (with ast)". computeRlsFilter (:5404-5422) is andComposeLayers(layer0, layer1), Layer 0 first (:5704-5709, from context.tenantId and context.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); singlenull; a true PLATFORM_ADMIN on a posture-permitting object → null. Layer 1 (business RLS, sharing's buildWriteFilter at packages/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). isSystem contexts short-circuit the middleware (security-plugin.ts :2873-2874). The driver's native tenantId scope (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 an isolated write — NULL-organization rows are not reachable through it.
  • Reading: on isolated the premise holds outright — every row a tenant caller's predicate write can touch carries the caller's active organization. On group (ADR-0105 D1/D2: "The active organization keeps its current meaning (default write target, UI context); it no longer bounds read reach in group posture — 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.
  • PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's recorded open question 1 / recommendation A (read from its body): A leaves the bulk contract as is and defers "whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all" to the services half, calling the tenant question "a shape decision of its own, and nothing here pins it either way"; it rejects (B) a per-row organizationIds array 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.tsBulkDataEventSchema block only: one header paragraph (one organization for the whole batch, or nothing; cross-reference to DataEventSchema), the new member with its JSDoc (construction argument, present/absent readings, the consumer sentence, the group qualification) and .describe(). git grep -c organizationId on the file: 2 on BASE → 6 here (header, two JSDoc references, the declaration); control git grep -c object on the same file unchanged in kind (non-zero both sides).
  • packages/spec/src/api/events.test.ts — new describe('BulkDataEventSchema') › describe('organizationId'), 8 pins: absent → not an own property and undefined; present → round-trips; 42invalid_type at ['organizationId']; null → same; ''too_small, minimum: 1; a per-row array → invalid_type (the ruled-out shape); member set = exactly the six prior keys plus organizationId, and organizationIds on neither schema; structural + behavioural identity of the two organizationId declarations (optional over string with 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.
  • Generated followers, regenerated by check:generated --fix (it proved exactly one artifact stale, gen:docs) and the check:authorable-surface tree: 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.json has no BulkDataEvent row and none is invented. authorable-surface.base.json untouched (manual-only anchor; baseRev lag is information).
  • .changeset/bulk-data-event-organization-id.md@objectstack/spec: minor. .changeset/README.md carries only the changesets boilerplate; the level follows the sibling precedent (2aa8456c, the additive DataEvent.organizationId, shipped minor) — an additive published key on a public payload.

MetadataEventSchema (read-and-report only, no edit): members id, type, metadataType, name, packageId?, definition?, userId?, timestampdefinition is the full item body. Producer packages/metadata/src/metadata-manager.ts:696 passes 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 not data.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)

ReadingCommandVerdict
spec events pinspnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/events.test.ts (locked)Tests 29 passed (29) (21 prior + 8 new)
spec buildpnpm --filter @objectstack/spec build (locked)check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present.
generated artifactspnpm --filter @objectstack/spec check:generated --fix then a clean run✗ 1 of 15 artifact(s) stale: content/docs/references/**✓ gen:docs; second run ✓ All 15 generated artifacts are up to date.
spec src/api suite… exec vitest run --maxWorkers=2 src/api (locked)Test Files 41 passed (41) · Tests 1349 passed (1349)
@objectstack/client realtime… exec vitest run --maxWorkers=2 src/realtime-api (locked)Test Files 2 passed (2) · Tests 22 passed (22)
@objectstack/client-reacttypecheck (exit 0, 0 error 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.ts is inside src/**/*, 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)
spec tsc --noEmit (src program)exit 0, 0 errors; tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) and it carries 0 errors (the program's 261 pre-existing errors are the ledgered debt check:test-typecheck holds, green above)measured
eslint on the two edited TS filesnpx eslint packages/spec/src/api/events.zod.ts packages/spec/src/api/events.test.tsexit 0
dependency closureturbo build of @objectstack/objectql^... + objectql (15 tasks, 0 cached), then @objectstack/plugin-webhooks^... + client + plugin-webhooks (34 tasks, 15 cached), then client-react — all lockedfilter direction UPSTREAM (pkg^... = dependencies), built so the DOWNSTREAM consumers above read fresh dist/*.d.ts
derived gate farmnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9ffb659b (5 paths vs merge base 919beca4): 82 commands29 node scripts/check-* exit 0; 20 pnpm --filter spec/lint gates exit 0 (check:skill-examples first exit 1 for a missing client-react/dist prerequisite, exit 0 after that build: ✅ 257 prose examples type-check across 3 surface(s)); 33 of 35 root pnpm 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 anchored
NOT MEASURED (prerequisite exit 3, not red)pnpm check:dual-build-cjs-loads ("Run pnpm build first. ⛔ This is NOT a pass: nothing was measured." — 27 packages without dist) and pnpm check:type-check-debt (needs every package's built declarations)CI owns the farm
not run locally, declaredpnpm lint (repo-wide eslint, CI-owned); check:react-declaration-parity (needs objectui's manifest, by design)CI

Reverse verification (ablation of the pins; source path — the spec suite imports ./events.zod from src, no dist on its resolution path, so no rebuild is involved)

From the committed state (7dfe65b8, the member in HEAD): removed the bulk organizationId declaration; confirmed on disk by anchored counts (declaration spelling 2 → 1, the bulk describe text 1 → 0; blob ea9532c0…57b23d7a…); ran src/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 inside trap … EXIT INT TERM by git checkout HEAD -- ABSOLUTE_PATH, proven: git hash-object back to the HEAD blob ea9532c0…, git diff HEAD empty, tree clean.

Contract review

Clause ② card (needs:contract-review on 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

…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
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 9c1bcda382067e75e2d69f11086d6c986ccb987apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d70725b061fd9088744b801b07e7ab61bc61ab6 — the merge of head 9ffb659b38c07ae5f83c41f45fdd596729d38b6e into base 9c1bcda382067e75e2d69f11086d6c986ccb987a, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justinos-justin added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026 — with Claude
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@os-justin
os-justin marked this pull request as ready for review September 4, 2026 06:56
@os-justin
os-justin added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit 97bcd99Sep 4, 2026
52 checks passed
@os-justin
os-justin deleted the claude/issue-14971-bulk-data-event-organization branch September 4, 2026 07:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-justin@claude