Uh oh!
There was an error while loading. Please reload this page.
feat(spec): DataEvent carries organizationId, the organization the record belongs to - #14635
Conversation
Adds the optional, non-empty organizationId member to DataEventSchema so a tenant-scoped consumer (webhook fan-out, per-organization realtime subscriber) can discriminate an event's tenant without reading the record body. Absent = the record belongs to no organization (single posture, or an organization-less row under a wall); present = exactly that organization. No default, empty string refused: declared = enforced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
gen:schema (authorable-surface/api.json) and gen:docs (content/docs/references/api/events.mdx), as check:generated --fix proved stale; api-surface and the JSON schema manifest were already current. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 53515ad7c61ffc841e830ab924dc00a63c235852 && git checkout 53515ad7c61ffc841e830ab924dc00a63c235852
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 937ec142d5bb6e3f112dae2530842b1292be1cd4 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 && git checkout -B drift-repro 937ec142d5bb6e3f112dae2530842b1292be1cd4 && git merge --no-ff 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2
node scripts/docs-audit/affected-docs.mjs --json 937ec142d5bb6e3f112dae2530842b1292be1cd4
|
os-sam
commented
Sep 2, 2026
Landing provenance ( Generated by Claude Code |
Fixes#14291
Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the
plugin-webhooksfan-out match change and any delivery-row migration are out of scope here and stay on the services card, which remains open (#13566 is not addressed here). Ruled direction (triage on that card, 2026-08-31T12:47Z): producer-side threading, contract-first; no per-event DB lookup on the fan-out path.What changes
packages/spec/src/api/events.zod.ts—DataEventSchemagains exactly one member, placed with the match terms (object/recordId):.describe()and the JSDoc state what it is, when it is absent, and what a consumer may assume (decision 4 below); the schema's header JSDoc records the envelope-vs-payload decision (decision 2).BulkDataEventSchemaandMetadataEventSchemaare untouched (open question 1).git grep -c "organization" -- packages/spec/src/api/events.zod.ts= 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse controlgit grep -c "object"on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).authorable-surface/api.json(+api/DataEvent:organizationId),json-schema.manifest/api.json,api-surface/andcontent/docs/references/api/events.mdxascheck:generated --fixproved stale — listed in "Files" below..changeset/data-event-organization-id.md,@objectstack/spec: minor, no tracker ids in the customer-facing text.packages/spec/src/api/events.test.ts,describe('organizationId')): parses without the key and does not fabricate one; parses with it verbatim; refuses a non-string (invalid_type,expected: 'string', path['organizationId']); refusesnull(same issue); refuses""(too_small,minimum: 1); the member set is the pre-existing ten keys plus this one.The four shape decisions — measured first, then decided
1. Spelling →
organizationIdKey-position declarations in
packages/spec/src(non-test files):organizationId31 in 13 files (api/protocol.zod.ts×10 — the audit/metadata read contracts;cloud/environment.zod.ts×3,cloud/tenant.zod.ts×3,identity/organization.zod.ts×2,data/hook.zod.ts×2,system/metadata-persistence.zod.ts×2,ui/action-params.zod.ts,identity/eval-user.zod.ts,cloud/developer-portal.zod.ts,data/seed-loader.zod.ts, …);organization_id4 in 3 files (every one a mirror of the DB column on a service contract, plus thecontext-tokensmap); bareorganization6 (booleans and an approver slug — never an id);orgId0. Token totals over the same tree:organizationId209 / 30 files,organization_id158 / 43 files (prose and column names),orgId0.data/hook.zod.ts:658andui/action-params.zod.ts:336carry the ruling in their own describe text:organizationIdis "the blessed developer-facing name". AGENTS.md Prime Directive #3: TS payload keys are camelCase. The dominant spelling is followed; nothing is coined.2. Envelope vs scoped metadata → a first-class member of the validated payload
Consumers read: the webhook fan-out (
packages/plugins/plugin-webhooks/src/auto-enqueuer.ts,handleEventandhandleBulkEvent) selects subscriptions onevent.object(envelope) and then, at the same site, reads the payload forrecordId/matched— its own comment states the discipline: "The envelope'spayloadIS the spec'sDataEvent… Read it directly." The client SDK (packages/client/src/realtime-api.ts:171)safeParsesevent.payloadagainstDataEventSchemabefore delivering anything.RealtimeEventPayload(packages/spec/src/contracts/realtime-service.ts:19) is a TypeScript interface —type/object/payload/timestamp— that no parse ever validates. So "a matcher can key on an envelope field without reaching into the payload" is refuted as a requirement: both match sites already reach into the payload, and an envelope member would be a second, unvalidated declaration — declared ≠ enforced by construction. The term is a member ofDataEventSchema, enforced by the sameparseat the publish site that enforcesrecordId.3. Optionality → optional, non-empty, no default — a contract fact, not a transition
Producers and parse sites (non-test): exactly one constructor,
packages/objectql/src/engine.ts:5528(publishDataEvent, reached from create :10156, update :11427, delete :12873) — itDataEventSchema.parses insidetry/catchand on failure logswarnand publishes nothing. One consumer-side parse,packages/client/src/realtime-api.ts:171(safeParse, throws on failure). Fixtures that construct or parse aDataEvent:objectql/src/engine-data-events.test.ts,client/src/realtime-api-data.test.ts, fourplugin-webhooks/src/*.test.ts,spec/src/api/events.test.ts,spec/src/type-alias-convention.pin.test.ts.A REQUIRED key today would (a) make the engine publish zero data events until the engine half lands — silently, at
warn— and (b) red every fixture above. It would also stay wrong after the engine half:postureStampsOrganization('single') === false, so asingle-posture deployment never has an organization to put there, and a required key would force a fabricated one.type-alias-convention.pin.test.ts(Iso90) pinsz.input ≡ z.inferforDataEventSchema, which mechanically forbids a.default(). Hencez.string().min(1).optional():nullis refused (invalid_type) and""is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's owneventRecordId/eventUserIdhelpers (""⇒ absent) and the enqueuer'srow.organization_id ? … : undefined. No.default(); no other member loosened.4. Absence semantics on
singlepostureresolveTenancyPosture()(packages/types/src/env.ts:145):OS_TENANCY_POSTUREwhen set (unknown value throws), else derived fromOS_MULTI_ORG_ENABLED(true⇒isolated, elsesingle).postureEnforcesWall(p) = p !== 'single'(packages/spec/src/security/tenancy-posture.ts:53) andpostureStampsOrganization(p) = postureEnforcesWall(p)(:69). So onsinglethere is no wall and nothing stampsorganization_id: every event is organization-less, permanently. Under a wall,packages/plugins/plugin-security/src/tenant-layer.ts:120-146shows the remaining organization-less cases —tenancyDisabledobjects, objects with noorganization_idcolumn (identity tables), and environment-wide (IS NULL) rows.The describe/JSDoc therefore state: absent = the record belongs to no organization (every event on
single; an organization-less row or an off-wall object under a wall) — a consumer reads it as "not behind any organization wall", never as "unknown, look it up" (the hot-path lookup the ruling excludes). Present = exactly the organization the record belongs to — never the caller's active organization standing in for the row's, never fabricated. The producer obligation is stated beside it: omitting the key on an organization-stamped row is a producer defect fixed at the publish site.Consumer sweep
Direction: downstream consumers of
@objectstack/spec'sDataEvent— the packages that construct or parse it — each run as a package suite at head307b5a69eunderscripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's owndistbuilt with declarations;dist/api/index.d.tscarriesorganizationId: z.ZodOptional<z.ZodString>).@objectstack/objectql— the only producer (engine.ts:5528): full suite, Test Files 262 passed (262) · Tests 4518 passed (4518), exit 0.@objectstack/plugin-webhooks— the fan-out consumer: Test Files 11 passed (11) · Tests 131 passed (131), exit 0 (theERRORlines in its output are its own fail-closed secret-at-rest probes).@objectstack/service-realtime— the transport: Test Files 3 passed (3) · Tests 28 passed (28), exit 0.@objectstack/client— the consumer-side parse site (realtime-api.ts:171): declared narrowing to the two files that exercise it,src/realtime-api-data.test.ts+src/realtime-api.test.ts: Test Files 2 passed (2) · Tests 22 passed (22), exit 0. The rest of the client suite needs the 34-package runtime closure built and does not touchDataEvent; CI runs it.No fixture changed anywhere: the member is optional, so every pre-existing event parses identically.
Gates
Every reading below is at head
307b5a69e(the final commit — nothing was committed after it); exit codes captured before any pipe; verdict lines quoted from the gate's own output.Must-haves
@objectstack/specwhole suite —pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2under the lock: Test Files 453 passed (453) · Tests 12216 passed (12216), exit 0.pnpm --filter @objectstack/spec typecheck(tsc --noEmit && check:scripts-typecheck && check:test-typecheck): exit 0 —check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 262 error(s) … held in test-typecheck-debt.json. Coverage proof:tsc -p tsconfig.test.json --listFileslistssrc/api/events.test.ts(1 hit; the file has no ledger entry) and it contributes 0 of the 262 ledgered errors.pnpm --filter @objectstack/spec check:generatedat307b5a69e:✓ All 15 generated artifacts are up to date.— including✓ check:api-surface,✓ check:authorable-surface,✓ check:docs,✓ check:liveness,✓ check:test-typecheck. (The earlier--fixrun found 1 of 15 stale —check:docs— and regenerated only it.)pnpm check:doc-authoring: exit 0 —✓ doc authoring guard: 14508 customer-facing string(s) across 710 spec sources clean — no internal issue-id references.pnpm check:nul-bytes: exit 0 —check-nul-bytes: OK (scanned 7980 text file(s) …; no raw ASCII control bytes); plus a control-byte self-scan of the three hand-edited files (no hits).Derived family —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(55 commands, derived from the merge base; the later regeneration commit adds files of the same families). Run, all exit 0: the changeset gates (check-adr-0087-registration— "adds no declared-breaking changeset";check-changeset-no-major;check-empty-changeset;check:changeset-gate-self-tests); the spec source auditscheck:generateddeliberately does not run (check:empty-state,check:exported-any,check:dual-source-exports,check:entry-nameability,check:browser-reachable-entries,check:strictness-ledger,check:variant-docs,check:yaml-examples,check:skill-refs,check:llms-txt,check:liveness); the repo gatescheck:cross-package-test-inputs,check:test-source-alias,check:spec-parsed-alias,check:type-check-coverage,check:merge-driver,check:pm-half-states,check-keyed-text-bounds,docs-audit/check-affected-docs,docs-audit/check-drift-comment,check-comment-mask-adoption,check-system-context-census,check-undeclared-dep-imports,check-plugin-teardown-shape,check-shard-attestation,check-ci-filter-parity,pm/check-half-states.check:type-check-debt(needs every dependency's builtdist/*.d.ts) andcheck-test-completeness(parses a turbo test summary that does not exist locally).pnpm lint(repo-wide eslint — CI-owned) and the derived commands that by construction read nothing in this diff (check:dev-prereqs,check:page-declaration-shape,check:engine-double-contract,check:logger-receiver-detach,check:objectql-double-limit,check:objectui-changeset,check:published-files,check:query-options-erasure,check:slot-lookup,check:type-source-resolution,check:where-matcher,check:dual-build-cjs-loads,check:doc-formula-expressions,release-rehearsal-clone --self-test). CI runs the farm.Ablation (source path: the spec suite imports
./events.zodfromsrc, nodiston its resolution path, so no rebuild is involved): removed theorganizationIddeclaration — confirmed on disk (member-line count 1 → 0; blob hash49d0995…→0cbea15…) — and ransrc/api/events.test.ts: 5 failed | 16 passed (21) — exactly the five presence / refusal / member-set pins went red and the "parses without the key" pin stayed green, the predicted direction. Restore bygit checkout HEAD -- <absolute path>insidetrap … EXIT INT TERM, proven: hash back to the HEAD blob,git diff HEADempty, tree clean.Open questions for the contract review
BulkDataEventSchema(data.records.*) has no tenant term here — deliberately. The census names the bulk match site (handleBulkEvent) as keyed on object name alone too, but a singleorganizationIdcannot be honest on that contract: undergroup, Layer 0 isorganization_id IN accessible_org_ids(tenant-layer.ts:139-142, ADR-0105 D1/D2), so one predicate write can touch several organizations' rows and reports only a count. Options: (A) leave the bulk contract as is — the bulk delivery carries no record content, only a count — and let the services half decide whether a bulk event without a tenant term is delivered to organization-scoped subscriptions at all; (B)organizationIds: string[]on the bulk event, which the engine cannot fill from an affected count without a second query (the hot-path read the ruling excludes); (C) the caller's active organization, which mislabels agroupsweep. Recommendation: A — the bulk contract is separate by design; its tenant question is a shape decision of its own, and nothing here pins it either way.MetadataEventSchemacarries no tenant term either; metadata events are consumed by the client SDK'ssubscribeMetadata(server-internal transport today) and not by the webhook fan-out. Not measured further; noted so the review can say whether it wants a card.Files
Head
307b5a69evs basea5b95ee6e:Generated by Claude Code