feat(spec): DataEvent carries organizationId, the organization the record belongs to - #14635

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization
Sep 2, 2026
Merged

feat(spec): DataEvent carries organizationId, the organization the record belongs to#14635
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14291

Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the plugin-webhooks fan-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.tsDataEventSchema gains exactly one member, placed with the match terms (object / recordId):

organizationId: z.string().min(1).optional()
  • .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).
  • No other member changes; BulkDataEventSchema and MetadataEventSchema are untouched (open question 1).
  • Acceptance re-check: git grep -c "organization" -- packages/spec/src/api/events.zod.ts = 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse control git grep -c "object" on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).
  • Regenerated products (generators only, no hand edits): authorable-surface/api.json (+api/DataEvent:organizationId), json-schema.manifest/api.json, api-surface/ and content/docs/references/api/events.mdx as check:generated --fix proved stale — listed in "Files" below.
  • Changeset: .changeset/data-event-organization-id.md, @objectstack/spec: minor, no tracker ids in the customer-facing text.
  • Pin tests (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']); refuses null (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 → organizationId

Key-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 the context-tokens map); bare organization6 (booleans and an approver slug — never an id); orgId0. Token totals over the same tree: organizationId 209 / 30 files, organization_id 158 / 43 files (prose and column names), orgId 0. data/hook.zod.ts:658 and ui/action-params.zod.ts:336 carry the ruling in their own describe text: organizationId is "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, handleEvent and handleBulkEvent) selects subscriptions on event.object (envelope) and then, at the same site, reads the payload for recordId / matched — its own comment states the discipline: "The envelope's payload IS the spec's DataEvent … Read it directly." The client SDK (packages/client/src/realtime-api.ts:171) safeParses event.payload against DataEventSchema before 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 of DataEventSchema, enforced by the same parse at the publish site that enforces recordId.

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) — it DataEventSchema.parses inside try/catch and on failure logs warn and publishes nothing. One consumer-side parse, packages/client/src/realtime-api.ts:171 (safeParse, throws on failure). Fixtures that construct or parse a DataEvent: objectql/src/engine-data-events.test.ts, client/src/realtime-api-data.test.ts, four plugin-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 a single-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) pins z.input ≡ z.infer for DataEventSchema, which mechanically forbids a .default(). Hence z.string().min(1).optional(): null is refused (invalid_type) and "" is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's own eventRecordId / eventUserId helpers ("" ⇒ absent) and the enqueuer's row.organization_id ? … : undefined. No .default(); no other member loosened.

4. Absence semantics on single posture

resolveTenancyPosture() (packages/types/src/env.ts:145): OS_TENANCY_POSTURE when set (unknown value throws), else derived from OS_MULTI_ORG_ENABLED (trueisolated, else single). postureEnforcesWall(p) = p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53) and postureStampsOrganization(p) = postureEnforcesWall(p) (:69). So on single there is no wall and nothing stamps organization_id: every event is organization-less, permanently. Under a wall, packages/plugins/plugin-security/src/tenant-layer.ts:120-146 shows the remaining organization-less cases — tenancyDisabled objects, objects with no organization_id column (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's DataEvent — the packages that construct or parse it — each run as a package suite at head 307b5a69e under scripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's own dist built with declarations; dist/api/index.d.ts carries organizationId: 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 (the ERROR lines 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 touch DataEvent; 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/spec whole suite — pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under 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 --listFiles lists src/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:generated at 307b5a69e: ✓ All 15 generated artifacts are up to date. — including ✓ check:api-surface, ✓ check:authorable-surface, ✓ check:docs, ✓ check:liveness, ✓ check:test-typecheck. (The earlier --fix run 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 familynode 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 audits check:generated deliberately 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 gates check: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.

  • NOT MEASURED (each exits with its own prerequisite code 3, not a red): check:type-check-debt (needs every dependency's built dist/*.d.ts) and check-test-completeness (parses a turbo test summary that does not exist locally).
  • Not run locally, declared: 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.zod from src, no dist on its resolution path, so no rebuild is involved): removed the organizationId declaration — confirmed on disk (member-line count 1 → 0; blob hash 49d0995…0cbea15…) — and ran src/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 by git checkout HEAD -- <absolute path> inside trap … EXIT INT TERM, proven: hash back to the HEAD blob, git diff HEAD empty, tree clean.

Open questions for the contract review

  1. 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 single organizationId cannot be honest on that contract: under group, Layer 0 is organization_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 a group sweep. 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.
  2. MetadataEventSchema carries no tenant term either; metadata events are consumed by the client SDK's subscribeMetadata (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 307b5a69e vs base a5b95ee6e:

.changeset/data-event-organization-id.md | 34 +++++++++++++++
content/docs/references/api/events.mdx | 1 +
packages/spec/authorable-surface/api.json | 1 +
packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++++++++
packages/spec/src/api/events.zod.ts | 72 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)

Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via DataEventSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DataEventSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 937ec142d5bb6e3f112dae2530842b1292be1cd4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 53515ad7c61ffc841e830ab924dc00a63c235852 — the merge of head 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 into base 937ec142d5bb6e3f112dae2530842b1292be1cd4, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 937ec142d5bb6e3f112dae2530842b1292be1cd4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE): in-seat contract review PASS recorded on the card (#14291, comment 5512440518, maintainer 2026-08-31 ruling as provenance for the in-seat clear); needs:contract-review cleared on both carriers with compared read-back; check-clause2-carriers --pair 14635 exit 0; every check on head 307b5a69e green (41/41 completed, no failure); trial merge CLEAN vs origin/main, zero drift; NOT governed (0/5). Flipped ready and armed auto-merge (squash) in this stroke.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Spec half of #13566 (p0 leak): DataEvent carries an organization term, so tenant-scoped consumers can discriminate the event's tenant

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 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): DataEvent carries organizationId, the organization the record belongs to - #14635

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization
Sep 2, 2026
Merged

feat(spec): DataEvent carries organizationId, the organization the record belongs to#14635
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14291

Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the plugin-webhooks fan-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.tsDataEventSchema gains exactly one member, placed with the match terms (object / recordId):

organizationId: z.string().min(1).optional()
  • .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).
  • No other member changes; BulkDataEventSchema and MetadataEventSchema are untouched (open question 1).
  • Acceptance re-check: git grep -c "organization" -- packages/spec/src/api/events.zod.ts = 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse control git grep -c "object" on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).
  • Regenerated products (generators only, no hand edits): authorable-surface/api.json (+api/DataEvent:organizationId), json-schema.manifest/api.json, api-surface/ and content/docs/references/api/events.mdx as check:generated --fix proved stale — listed in "Files" below.
  • Changeset: .changeset/data-event-organization-id.md, @objectstack/spec: minor, no tracker ids in the customer-facing text.
  • Pin tests (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']); refuses null (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 → organizationId

Key-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 the context-tokens map); bare organization6 (booleans and an approver slug — never an id); orgId0. Token totals over the same tree: organizationId 209 / 30 files, organization_id 158 / 43 files (prose and column names), orgId 0. data/hook.zod.ts:658 and ui/action-params.zod.ts:336 carry the ruling in their own describe text: organizationId is "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, handleEvent and handleBulkEvent) selects subscriptions on event.object (envelope) and then, at the same site, reads the payload for recordId / matched — its own comment states the discipline: "The envelope's payload IS the spec's DataEvent … Read it directly." The client SDK (packages/client/src/realtime-api.ts:171) safeParses event.payload against DataEventSchema before 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 of DataEventSchema, enforced by the same parse at the publish site that enforces recordId.

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) — it DataEventSchema.parses inside try/catch and on failure logs warn and publishes nothing. One consumer-side parse, packages/client/src/realtime-api.ts:171 (safeParse, throws on failure). Fixtures that construct or parse a DataEvent: objectql/src/engine-data-events.test.ts, client/src/realtime-api-data.test.ts, four plugin-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 a single-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) pins z.input ≡ z.infer for DataEventSchema, which mechanically forbids a .default(). Hence z.string().min(1).optional(): null is refused (invalid_type) and "" is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's own eventRecordId / eventUserId helpers ("" ⇒ absent) and the enqueuer's row.organization_id ? … : undefined. No .default(); no other member loosened.

4. Absence semantics on single posture

resolveTenancyPosture() (packages/types/src/env.ts:145): OS_TENANCY_POSTURE when set (unknown value throws), else derived from OS_MULTI_ORG_ENABLED (trueisolated, else single). postureEnforcesWall(p) = p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53) and postureStampsOrganization(p) = postureEnforcesWall(p) (:69). So on single there is no wall and nothing stamps organization_id: every event is organization-less, permanently. Under a wall, packages/plugins/plugin-security/src/tenant-layer.ts:120-146 shows the remaining organization-less cases — tenancyDisabled objects, objects with no organization_id column (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's DataEvent — the packages that construct or parse it — each run as a package suite at head 307b5a69e under scripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's own dist built with declarations; dist/api/index.d.ts carries organizationId: 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 (the ERROR lines 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 touch DataEvent; 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/spec whole suite — pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under 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 --listFiles lists src/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:generated at 307b5a69e: ✓ All 15 generated artifacts are up to date. — including ✓ check:api-surface, ✓ check:authorable-surface, ✓ check:docs, ✓ check:liveness, ✓ check:test-typecheck. (The earlier --fix run 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 familynode 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 audits check:generated deliberately 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 gates check: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.

  • NOT MEASURED (each exits with its own prerequisite code 3, not a red): check:type-check-debt (needs every dependency's built dist/*.d.ts) and check-test-completeness (parses a turbo test summary that does not exist locally).
  • Not run locally, declared: 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.zod from src, no dist on its resolution path, so no rebuild is involved): removed the organizationId declaration — confirmed on disk (member-line count 1 → 0; blob hash 49d0995…0cbea15…) — and ran src/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 by git checkout HEAD -- <absolute path> inside trap … EXIT INT TERM, proven: hash back to the HEAD blob, git diff HEAD empty, tree clean.

Open questions for the contract review

  1. 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 single organizationId cannot be honest on that contract: under group, Layer 0 is organization_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 a group sweep. 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.
  2. MetadataEventSchema carries no tenant term either; metadata events are consumed by the client SDK's subscribeMetadata (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 307b5a69e vs base a5b95ee6e:

.changeset/data-event-organization-id.md | 34 +++++++++++++++
content/docs/references/api/events.mdx | 1 +
packages/spec/authorable-surface/api.json | 1 +
packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++++++++
packages/spec/src/api/events.zod.ts | 72 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)

Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via DataEventSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DataEventSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 937ec142d5bb6e3f112dae2530842b1292be1cd4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 53515ad7c61ffc841e830ab924dc00a63c235852 — the merge of head 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 into base 937ec142d5bb6e3f112dae2530842b1292be1cd4, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 937ec142d5bb6e3f112dae2530842b1292be1cd4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE): in-seat contract review PASS recorded on the card (#14291, comment 5512440518, maintainer 2026-08-31 ruling as provenance for the in-seat clear); needs:contract-review cleared on both carriers with compared read-back; check-clause2-carriers --pair 14635 exit 0; every check on head 307b5a69e green (41/41 completed, no failure); trial merge CLEAN vs origin/main, zero drift; NOT governed (0/5). Flipped ready and armed auto-merge (squash) in this stroke.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Spec half of #13566 (p0 leak): DataEvent carries an organization term, so tenant-scoped consumers can discriminate the event's tenant

2 participants

@os-sam@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): DataEvent carries organizationId, the organization the record belongs to - #14635

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization
Sep 2, 2026
Merged

feat(spec): DataEvent carries organizationId, the organization the record belongs to#14635
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14291

Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the plugin-webhooks fan-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.tsDataEventSchema gains exactly one member, placed with the match terms (object / recordId):

organizationId: z.string().min(1).optional()
  • .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).
  • No other member changes; BulkDataEventSchema and MetadataEventSchema are untouched (open question 1).
  • Acceptance re-check: git grep -c "organization" -- packages/spec/src/api/events.zod.ts = 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse control git grep -c "object" on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).
  • Regenerated products (generators only, no hand edits): authorable-surface/api.json (+api/DataEvent:organizationId), json-schema.manifest/api.json, api-surface/ and content/docs/references/api/events.mdx as check:generated --fix proved stale — listed in "Files" below.
  • Changeset: .changeset/data-event-organization-id.md, @objectstack/spec: minor, no tracker ids in the customer-facing text.
  • Pin tests (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']); refuses null (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 → organizationId

Key-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 the context-tokens map); bare organization6 (booleans and an approver slug — never an id); orgId0. Token totals over the same tree: organizationId 209 / 30 files, organization_id 158 / 43 files (prose and column names), orgId 0. data/hook.zod.ts:658 and ui/action-params.zod.ts:336 carry the ruling in their own describe text: organizationId is "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, handleEvent and handleBulkEvent) selects subscriptions on event.object (envelope) and then, at the same site, reads the payload for recordId / matched — its own comment states the discipline: "The envelope's payload IS the spec's DataEvent … Read it directly." The client SDK (packages/client/src/realtime-api.ts:171) safeParses event.payload against DataEventSchema before 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 of DataEventSchema, enforced by the same parse at the publish site that enforces recordId.

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) — it DataEventSchema.parses inside try/catch and on failure logs warn and publishes nothing. One consumer-side parse, packages/client/src/realtime-api.ts:171 (safeParse, throws on failure). Fixtures that construct or parse a DataEvent: objectql/src/engine-data-events.test.ts, client/src/realtime-api-data.test.ts, four plugin-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 a single-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) pins z.input ≡ z.infer for DataEventSchema, which mechanically forbids a .default(). Hence z.string().min(1).optional(): null is refused (invalid_type) and "" is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's own eventRecordId / eventUserId helpers ("" ⇒ absent) and the enqueuer's row.organization_id ? … : undefined. No .default(); no other member loosened.

4. Absence semantics on single posture

resolveTenancyPosture() (packages/types/src/env.ts:145): OS_TENANCY_POSTURE when set (unknown value throws), else derived from OS_MULTI_ORG_ENABLED (trueisolated, else single). postureEnforcesWall(p) = p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53) and postureStampsOrganization(p) = postureEnforcesWall(p) (:69). So on single there is no wall and nothing stamps organization_id: every event is organization-less, permanently. Under a wall, packages/plugins/plugin-security/src/tenant-layer.ts:120-146 shows the remaining organization-less cases — tenancyDisabled objects, objects with no organization_id column (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's DataEvent — the packages that construct or parse it — each run as a package suite at head 307b5a69e under scripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's own dist built with declarations; dist/api/index.d.ts carries organizationId: 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 (the ERROR lines 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 touch DataEvent; 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/spec whole suite — pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under 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 --listFiles lists src/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:generated at 307b5a69e: ✓ All 15 generated artifacts are up to date. — including ✓ check:api-surface, ✓ check:authorable-surface, ✓ check:docs, ✓ check:liveness, ✓ check:test-typecheck. (The earlier --fix run 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 familynode 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 audits check:generated deliberately 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 gates check: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.

  • NOT MEASURED (each exits with its own prerequisite code 3, not a red): check:type-check-debt (needs every dependency's built dist/*.d.ts) and check-test-completeness (parses a turbo test summary that does not exist locally).
  • Not run locally, declared: 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.zod from src, no dist on its resolution path, so no rebuild is involved): removed the organizationId declaration — confirmed on disk (member-line count 1 → 0; blob hash 49d0995…0cbea15…) — and ran src/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 by git checkout HEAD -- <absolute path> inside trap … EXIT INT TERM, proven: hash back to the HEAD blob, git diff HEAD empty, tree clean.

Open questions for the contract review

  1. 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 single organizationId cannot be honest on that contract: under group, Layer 0 is organization_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 a group sweep. 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.
  2. MetadataEventSchema carries no tenant term either; metadata events are consumed by the client SDK's subscribeMetadata (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 307b5a69e vs base a5b95ee6e:

.changeset/data-event-organization-id.md | 34 +++++++++++++++
content/docs/references/api/events.mdx | 1 +
packages/spec/authorable-surface/api.json | 1 +
packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++++++++
packages/spec/src/api/events.zod.ts | 72 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)

Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via DataEventSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DataEventSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 937ec142d5bb6e3f112dae2530842b1292be1cd4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 53515ad7c61ffc841e830ab924dc00a63c235852 — the merge of head 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 into base 937ec142d5bb6e3f112dae2530842b1292be1cd4, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 937ec142d5bb6e3f112dae2530842b1292be1cd4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE): in-seat contract review PASS recorded on the card (#14291, comment 5512440518, maintainer 2026-08-31 ruling as provenance for the in-seat clear); needs:contract-review cleared on both carriers with compared read-back; check-clause2-carriers --pair 14635 exit 0; every check on head 307b5a69e green (41/41 completed, no failure); trial merge CLEAN vs origin/main, zero drift; NOT governed (0/5). Flipped ready and armed auto-merge (squash) in this stroke.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Spec half of #13566 (p0 leak): DataEvent carries an organization term, so tenant-scoped consumers can discriminate the event's tenant

2 participants

@os-sam@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 > 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): DataEvent carries organizationId, the organization the record belongs to - #14635

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization
Sep 2, 2026
Merged

feat(spec): DataEvent carries organizationId, the organization the record belongs to#14635
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14291

Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the plugin-webhooks fan-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.tsDataEventSchema gains exactly one member, placed with the match terms (object / recordId):

organizationId: z.string().min(1).optional()
  • .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).
  • No other member changes; BulkDataEventSchema and MetadataEventSchema are untouched (open question 1).
  • Acceptance re-check: git grep -c "organization" -- packages/spec/src/api/events.zod.ts = 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse control git grep -c "object" on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).
  • Regenerated products (generators only, no hand edits): authorable-surface/api.json (+api/DataEvent:organizationId), json-schema.manifest/api.json, api-surface/ and content/docs/references/api/events.mdx as check:generated --fix proved stale — listed in "Files" below.
  • Changeset: .changeset/data-event-organization-id.md, @objectstack/spec: minor, no tracker ids in the customer-facing text.
  • Pin tests (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']); refuses null (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 → organizationId

Key-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 the context-tokens map); bare organization6 (booleans and an approver slug — never an id); orgId0. Token totals over the same tree: organizationId 209 / 30 files, organization_id 158 / 43 files (prose and column names), orgId 0. data/hook.zod.ts:658 and ui/action-params.zod.ts:336 carry the ruling in their own describe text: organizationId is "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, handleEvent and handleBulkEvent) selects subscriptions on event.object (envelope) and then, at the same site, reads the payload for recordId / matched — its own comment states the discipline: "The envelope's payload IS the spec's DataEvent … Read it directly." The client SDK (packages/client/src/realtime-api.ts:171) safeParses event.payload against DataEventSchema before 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 of DataEventSchema, enforced by the same parse at the publish site that enforces recordId.

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) — it DataEventSchema.parses inside try/catch and on failure logs warn and publishes nothing. One consumer-side parse, packages/client/src/realtime-api.ts:171 (safeParse, throws on failure). Fixtures that construct or parse a DataEvent: objectql/src/engine-data-events.test.ts, client/src/realtime-api-data.test.ts, four plugin-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 a single-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) pins z.input ≡ z.infer for DataEventSchema, which mechanically forbids a .default(). Hence z.string().min(1).optional(): null is refused (invalid_type) and "" is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's own eventRecordId / eventUserId helpers ("" ⇒ absent) and the enqueuer's row.organization_id ? … : undefined. No .default(); no other member loosened.

4. Absence semantics on single posture

resolveTenancyPosture() (packages/types/src/env.ts:145): OS_TENANCY_POSTURE when set (unknown value throws), else derived from OS_MULTI_ORG_ENABLED (trueisolated, else single). postureEnforcesWall(p) = p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53) and postureStampsOrganization(p) = postureEnforcesWall(p) (:69). So on single there is no wall and nothing stamps organization_id: every event is organization-less, permanently. Under a wall, packages/plugins/plugin-security/src/tenant-layer.ts:120-146 shows the remaining organization-less cases — tenancyDisabled objects, objects with no organization_id column (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's DataEvent — the packages that construct or parse it — each run as a package suite at head 307b5a69e under scripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's own dist built with declarations; dist/api/index.d.ts carries organizationId: 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 (the ERROR lines 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 touch DataEvent; 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/spec whole suite — pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under 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 --listFiles lists src/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:generated at 307b5a69e: ✓ All 15 generated artifacts are up to date. — including ✓ check:api-surface, ✓ check:authorable-surface, ✓ check:docs, ✓ check:liveness, ✓ check:test-typecheck. (The earlier --fix run 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 familynode 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 audits check:generated deliberately 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 gates check: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.

  • NOT MEASURED (each exits with its own prerequisite code 3, not a red): check:type-check-debt (needs every dependency's built dist/*.d.ts) and check-test-completeness (parses a turbo test summary that does not exist locally).
  • Not run locally, declared: 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.zod from src, no dist on its resolution path, so no rebuild is involved): removed the organizationId declaration — confirmed on disk (member-line count 1 → 0; blob hash 49d0995…0cbea15…) — and ran src/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 by git checkout HEAD -- <absolute path> inside trap … EXIT INT TERM, proven: hash back to the HEAD blob, git diff HEAD empty, tree clean.

Open questions for the contract review

  1. 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 single organizationId cannot be honest on that contract: under group, Layer 0 is organization_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 a group sweep. 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.
  2. MetadataEventSchema carries no tenant term either; metadata events are consumed by the client SDK's subscribeMetadata (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 307b5a69e vs base a5b95ee6e:

.changeset/data-event-organization-id.md | 34 +++++++++++++++
content/docs/references/api/events.mdx | 1 +
packages/spec/authorable-surface/api.json | 1 +
packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++++++++
packages/spec/src/api/events.zod.ts | 72 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)

Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via DataEventSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DataEventSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 937ec142d5bb6e3f112dae2530842b1292be1cd4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 53515ad7c61ffc841e830ab924dc00a63c235852 — the merge of head 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 into base 937ec142d5bb6e3f112dae2530842b1292be1cd4, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 937ec142d5bb6e3f112dae2530842b1292be1cd4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE): in-seat contract review PASS recorded on the card (#14291, comment 5512440518, maintainer 2026-08-31 ruling as provenance for the in-seat clear); needs:contract-review cleared on both carriers with compared read-back; check-clause2-carriers --pair 14635 exit 0; every check on head 307b5a69e green (41/41 completed, no failure); trial merge CLEAN vs origin/main, zero drift; NOT governed (0/5). Flipped ready and armed auto-merge (squash) in this stroke.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Spec half of #13566 (p0 leak): DataEvent carries an organization term, so tenant-scoped consumers can discriminate the event's tenant

2 participants

@os-sam@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): DataEvent carries organizationId, the organization the record belongs to - #14635

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization
Sep 2, 2026
Merged

feat(spec): DataEvent carries organizationId, the organization the record belongs to#14635
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14291

Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the plugin-webhooks fan-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.tsDataEventSchema gains exactly one member, placed with the match terms (object / recordId):

organizationId: z.string().min(1).optional()
  • .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).
  • No other member changes; BulkDataEventSchema and MetadataEventSchema are untouched (open question 1).
  • Acceptance re-check: git grep -c "organization" -- packages/spec/src/api/events.zod.ts = 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse control git grep -c "object" on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).
  • Regenerated products (generators only, no hand edits): authorable-surface/api.json (+api/DataEvent:organizationId), json-schema.manifest/api.json, api-surface/ and content/docs/references/api/events.mdx as check:generated --fix proved stale — listed in "Files" below.
  • Changeset: .changeset/data-event-organization-id.md, @objectstack/spec: minor, no tracker ids in the customer-facing text.
  • Pin tests (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']); refuses null (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 → organizationId

Key-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 the context-tokens map); bare organization6 (booleans and an approver slug — never an id); orgId0. Token totals over the same tree: organizationId 209 / 30 files, organization_id 158 / 43 files (prose and column names), orgId 0. data/hook.zod.ts:658 and ui/action-params.zod.ts:336 carry the ruling in their own describe text: organizationId is "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, handleEvent and handleBulkEvent) selects subscriptions on event.object (envelope) and then, at the same site, reads the payload for recordId / matched — its own comment states the discipline: "The envelope's payload IS the spec's DataEvent … Read it directly." The client SDK (packages/client/src/realtime-api.ts:171) safeParses event.payload against DataEventSchema before 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 of DataEventSchema, enforced by the same parse at the publish site that enforces recordId.

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) — it DataEventSchema.parses inside try/catch and on failure logs warn and publishes nothing. One consumer-side parse, packages/client/src/realtime-api.ts:171 (safeParse, throws on failure). Fixtures that construct or parse a DataEvent: objectql/src/engine-data-events.test.ts, client/src/realtime-api-data.test.ts, four plugin-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 a single-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) pins z.input ≡ z.infer for DataEventSchema, which mechanically forbids a .default(). Hence z.string().min(1).optional(): null is refused (invalid_type) and "" is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's own eventRecordId / eventUserId helpers ("" ⇒ absent) and the enqueuer's row.organization_id ? … : undefined. No .default(); no other member loosened.

4. Absence semantics on single posture

resolveTenancyPosture() (packages/types/src/env.ts:145): OS_TENANCY_POSTURE when set (unknown value throws), else derived from OS_MULTI_ORG_ENABLED (trueisolated, else single). postureEnforcesWall(p) = p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53) and postureStampsOrganization(p) = postureEnforcesWall(p) (:69). So on single there is no wall and nothing stamps organization_id: every event is organization-less, permanently. Under a wall, packages/plugins/plugin-security/src/tenant-layer.ts:120-146 shows the remaining organization-less cases — tenancyDisabled objects, objects with no organization_id column (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's DataEvent — the packages that construct or parse it — each run as a package suite at head 307b5a69e under scripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's own dist built with declarations; dist/api/index.d.ts carries organizationId: 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 (the ERROR lines 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 touch DataEvent; 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/spec whole suite — pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under 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 --listFiles lists src/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:generated at 307b5a69e: ✓ All 15 generated artifacts are up to date. — including ✓ check:api-surface, ✓ check:authorable-surface, ✓ check:docs, ✓ check:liveness, ✓ check:test-typecheck. (The earlier --fix run 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 familynode 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 audits check:generated deliberately 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 gates check: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.

  • NOT MEASURED (each exits with its own prerequisite code 3, not a red): check:type-check-debt (needs every dependency's built dist/*.d.ts) and check-test-completeness (parses a turbo test summary that does not exist locally).
  • Not run locally, declared: 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.zod from src, no dist on its resolution path, so no rebuild is involved): removed the organizationId declaration — confirmed on disk (member-line count 1 → 0; blob hash 49d0995…0cbea15…) — and ran src/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 by git checkout HEAD -- <absolute path> inside trap … EXIT INT TERM, proven: hash back to the HEAD blob, git diff HEAD empty, tree clean.

Open questions for the contract review

  1. 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 single organizationId cannot be honest on that contract: under group, Layer 0 is organization_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 a group sweep. 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.
  2. MetadataEventSchema carries no tenant term either; metadata events are consumed by the client SDK's subscribeMetadata (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 307b5a69e vs base a5b95ee6e:

.changeset/data-event-organization-id.md | 34 +++++++++++++++
content/docs/references/api/events.mdx | 1 +
packages/spec/authorable-surface/api.json | 1 +
packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++++++++
packages/spec/src/api/events.zod.ts | 72 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)

Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via DataEventSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DataEventSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 937ec142d5bb6e3f112dae2530842b1292be1cd4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 53515ad7c61ffc841e830ab924dc00a63c235852 — the merge of head 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 into base 937ec142d5bb6e3f112dae2530842b1292be1cd4, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 937ec142d5bb6e3f112dae2530842b1292be1cd4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE): in-seat contract review PASS recorded on the card (#14291, comment 5512440518, maintainer 2026-08-31 ruling as provenance for the in-seat clear); needs:contract-review cleared on both carriers with compared read-back; check-clause2-carriers --pair 14635 exit 0; every check on head 307b5a69e green (41/41 completed, no failure); trial merge CLEAN vs origin/main, zero drift; NOT governed (0/5). Flipped ready and armed auto-merge (squash) in this stroke.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Spec half of #13566 (p0 leak): DataEvent carries an organization term, so tenant-scoped consumers can discriminate the event's tenant

2 participants

@os-sam@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): DataEvent carries organizationId, the organization the record belongs to - #14635

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization
Sep 2, 2026
Merged

feat(spec): DataEvent carries organizationId, the organization the record belongs to#14635
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14291

Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the plugin-webhooks fan-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.tsDataEventSchema gains exactly one member, placed with the match terms (object / recordId):

organizationId: z.string().min(1).optional()
  • .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).
  • No other member changes; BulkDataEventSchema and MetadataEventSchema are untouched (open question 1).
  • Acceptance re-check: git grep -c "organization" -- packages/spec/src/api/events.zod.ts = 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse control git grep -c "object" on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).
  • Regenerated products (generators only, no hand edits): authorable-surface/api.json (+api/DataEvent:organizationId), json-schema.manifest/api.json, api-surface/ and content/docs/references/api/events.mdx as check:generated --fix proved stale — listed in "Files" below.
  • Changeset: .changeset/data-event-organization-id.md, @objectstack/spec: minor, no tracker ids in the customer-facing text.
  • Pin tests (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']); refuses null (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 → organizationId

Key-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 the context-tokens map); bare organization6 (booleans and an approver slug — never an id); orgId0. Token totals over the same tree: organizationId 209 / 30 files, organization_id 158 / 43 files (prose and column names), orgId 0. data/hook.zod.ts:658 and ui/action-params.zod.ts:336 carry the ruling in their own describe text: organizationId is "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, handleEvent and handleBulkEvent) selects subscriptions on event.object (envelope) and then, at the same site, reads the payload for recordId / matched — its own comment states the discipline: "The envelope's payload IS the spec's DataEvent … Read it directly." The client SDK (packages/client/src/realtime-api.ts:171) safeParses event.payload against DataEventSchema before 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 of DataEventSchema, enforced by the same parse at the publish site that enforces recordId.

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) — it DataEventSchema.parses inside try/catch and on failure logs warn and publishes nothing. One consumer-side parse, packages/client/src/realtime-api.ts:171 (safeParse, throws on failure). Fixtures that construct or parse a DataEvent: objectql/src/engine-data-events.test.ts, client/src/realtime-api-data.test.ts, four plugin-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 a single-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) pins z.input ≡ z.infer for DataEventSchema, which mechanically forbids a .default(). Hence z.string().min(1).optional(): null is refused (invalid_type) and "" is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's own eventRecordId / eventUserId helpers ("" ⇒ absent) and the enqueuer's row.organization_id ? … : undefined. No .default(); no other member loosened.

4. Absence semantics on single posture

resolveTenancyPosture() (packages/types/src/env.ts:145): OS_TENANCY_POSTURE when set (unknown value throws), else derived from OS_MULTI_ORG_ENABLED (trueisolated, else single). postureEnforcesWall(p) = p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53) and postureStampsOrganization(p) = postureEnforcesWall(p) (:69). So on single there is no wall and nothing stamps organization_id: every event is organization-less, permanently. Under a wall, packages/plugins/plugin-security/src/tenant-layer.ts:120-146 shows the remaining organization-less cases — tenancyDisabled objects, objects with no organization_id column (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's DataEvent — the packages that construct or parse it — each run as a package suite at head 307b5a69e under scripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's own dist built with declarations; dist/api/index.d.ts carries organizationId: 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 (the ERROR lines 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 touch DataEvent; 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/spec whole suite — pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under 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 --listFiles lists src/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:generated at 307b5a69e: ✓ All 15 generated artifacts are up to date. — including ✓ check:api-surface, ✓ check:authorable-surface, ✓ check:docs, ✓ check:liveness, ✓ check:test-typecheck. (The earlier --fix run 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 familynode 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 audits check:generated deliberately 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 gates check: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.

  • NOT MEASURED (each exits with its own prerequisite code 3, not a red): check:type-check-debt (needs every dependency's built dist/*.d.ts) and check-test-completeness (parses a turbo test summary that does not exist locally).
  • Not run locally, declared: 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.zod from src, no dist on its resolution path, so no rebuild is involved): removed the organizationId declaration — confirmed on disk (member-line count 1 → 0; blob hash 49d0995…0cbea15…) — and ran src/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 by git checkout HEAD -- <absolute path> inside trap … EXIT INT TERM, proven: hash back to the HEAD blob, git diff HEAD empty, tree clean.

Open questions for the contract review

  1. 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 single organizationId cannot be honest on that contract: under group, Layer 0 is organization_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 a group sweep. 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.
  2. MetadataEventSchema carries no tenant term either; metadata events are consumed by the client SDK's subscribeMetadata (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 307b5a69e vs base a5b95ee6e:

.changeset/data-event-organization-id.md | 34 +++++++++++++++
content/docs/references/api/events.mdx | 1 +
packages/spec/authorable-surface/api.json | 1 +
packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++++++++
packages/spec/src/api/events.zod.ts | 72 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)

Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via DataEventSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DataEventSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 937ec142d5bb6e3f112dae2530842b1292be1cd4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 53515ad7c61ffc841e830ab924dc00a63c235852 — the merge of head 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 into base 937ec142d5bb6e3f112dae2530842b1292be1cd4, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 937ec142d5bb6e3f112dae2530842b1292be1cd4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE): in-seat contract review PASS recorded on the card (#14291, comment 5512440518, maintainer 2026-08-31 ruling as provenance for the in-seat clear); needs:contract-review cleared on both carriers with compared read-back; check-clause2-carriers --pair 14635 exit 0; every check on head 307b5a69e green (41/41 completed, no failure); trial merge CLEAN vs origin/main, zero drift; NOT governed (0/5). Flipped ready and armed auto-merge (squash) in this stroke.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Spec half of #13566 (p0 leak): DataEvent carries an organization term, so tenant-scoped consumers can discriminate the event's tenant

2 participants

@os-sam@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): DataEvent carries organizationId, the organization the record belongs to - #14635

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization
Sep 2, 2026
Merged

feat(spec): DataEvent carries organizationId, the organization the record belongs to#14635
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14291

Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the plugin-webhooks fan-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.tsDataEventSchema gains exactly one member, placed with the match terms (object / recordId):

organizationId: z.string().min(1).optional()
  • .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).
  • No other member changes; BulkDataEventSchema and MetadataEventSchema are untouched (open question 1).
  • Acceptance re-check: git grep -c "organization" -- packages/spec/src/api/events.zod.ts = 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse control git grep -c "object" on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).
  • Regenerated products (generators only, no hand edits): authorable-surface/api.json (+api/DataEvent:organizationId), json-schema.manifest/api.json, api-surface/ and content/docs/references/api/events.mdx as check:generated --fix proved stale — listed in "Files" below.
  • Changeset: .changeset/data-event-organization-id.md, @objectstack/spec: minor, no tracker ids in the customer-facing text.
  • Pin tests (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']); refuses null (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 → organizationId

Key-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 the context-tokens map); bare organization6 (booleans and an approver slug — never an id); orgId0. Token totals over the same tree: organizationId 209 / 30 files, organization_id 158 / 43 files (prose and column names), orgId 0. data/hook.zod.ts:658 and ui/action-params.zod.ts:336 carry the ruling in their own describe text: organizationId is "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, handleEvent and handleBulkEvent) selects subscriptions on event.object (envelope) and then, at the same site, reads the payload for recordId / matched — its own comment states the discipline: "The envelope's payload IS the spec's DataEvent … Read it directly." The client SDK (packages/client/src/realtime-api.ts:171) safeParses event.payload against DataEventSchema before 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 of DataEventSchema, enforced by the same parse at the publish site that enforces recordId.

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) — it DataEventSchema.parses inside try/catch and on failure logs warn and publishes nothing. One consumer-side parse, packages/client/src/realtime-api.ts:171 (safeParse, throws on failure). Fixtures that construct or parse a DataEvent: objectql/src/engine-data-events.test.ts, client/src/realtime-api-data.test.ts, four plugin-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 a single-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) pins z.input ≡ z.infer for DataEventSchema, which mechanically forbids a .default(). Hence z.string().min(1).optional(): null is refused (invalid_type) and "" is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's own eventRecordId / eventUserId helpers ("" ⇒ absent) and the enqueuer's row.organization_id ? … : undefined. No .default(); no other member loosened.

4. Absence semantics on single posture

resolveTenancyPosture() (packages/types/src/env.ts:145): OS_TENANCY_POSTURE when set (unknown value throws), else derived from OS_MULTI_ORG_ENABLED (trueisolated, else single). postureEnforcesWall(p) = p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53) and postureStampsOrganization(p) = postureEnforcesWall(p) (:69). So on single there is no wall and nothing stamps organization_id: every event is organization-less, permanently. Under a wall, packages/plugins/plugin-security/src/tenant-layer.ts:120-146 shows the remaining organization-less cases — tenancyDisabled objects, objects with no organization_id column (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's DataEvent — the packages that construct or parse it — each run as a package suite at head 307b5a69e under scripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's own dist built with declarations; dist/api/index.d.ts carries organizationId: 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 (the ERROR lines 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 touch DataEvent; 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/spec whole suite — pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under 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 --listFiles lists src/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:generated at 307b5a69e: ✓ All 15 generated artifacts are up to date. — including ✓ check:api-surface, ✓ check:authorable-surface, ✓ check:docs, ✓ check:liveness, ✓ check:test-typecheck. (The earlier --fix run 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 familynode 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 audits check:generated deliberately 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 gates check: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.

  • NOT MEASURED (each exits with its own prerequisite code 3, not a red): check:type-check-debt (needs every dependency's built dist/*.d.ts) and check-test-completeness (parses a turbo test summary that does not exist locally).
  • Not run locally, declared: 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.zod from src, no dist on its resolution path, so no rebuild is involved): removed the organizationId declaration — confirmed on disk (member-line count 1 → 0; blob hash 49d0995…0cbea15…) — and ran src/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 by git checkout HEAD -- <absolute path> inside trap … EXIT INT TERM, proven: hash back to the HEAD blob, git diff HEAD empty, tree clean.

Open questions for the contract review

  1. 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 single organizationId cannot be honest on that contract: under group, Layer 0 is organization_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 a group sweep. 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.
  2. MetadataEventSchema carries no tenant term either; metadata events are consumed by the client SDK's subscribeMetadata (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 307b5a69e vs base a5b95ee6e:

.changeset/data-event-organization-id.md | 34 +++++++++++++++
content/docs/references/api/events.mdx | 1 +
packages/spec/authorable-surface/api.json | 1 +
packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++++++++
packages/spec/src/api/events.zod.ts | 72 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)

Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via DataEventSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DataEventSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 937ec142d5bb6e3f112dae2530842b1292be1cd4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 53515ad7c61ffc841e830ab924dc00a63c235852 — the merge of head 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 into base 937ec142d5bb6e3f112dae2530842b1292be1cd4, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 937ec142d5bb6e3f112dae2530842b1292be1cd4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE): in-seat contract review PASS recorded on the card (#14291, comment 5512440518, maintainer 2026-08-31 ruling as provenance for the in-seat clear); needs:contract-review cleared on both carriers with compared read-back; check-clause2-carriers --pair 14635 exit 0; every check on head 307b5a69e green (41/41 completed, no failure); trial merge CLEAN vs origin/main, zero drift; NOT governed (0/5). Flipped ready and armed auto-merge (squash) in this stroke.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Spec half of #13566 (p0 leak): DataEvent carries an organization term, so tenant-scoped consumers can discriminate the event's tenant

2 participants

@os-sam@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): DataEvent carries organizationId, the organization the record belongs to - #14635

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization
Sep 2, 2026
Merged

feat(spec): DataEvent carries organizationId, the organization the record belongs to#14635
os-sam merged 2 commits into
mainfrom
claude/issue-14291-data-event-organization

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14291

Contract half of the webhook cross-organization delivery defect. The engine publish-site threading, the plugin-webhooks fan-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.tsDataEventSchema gains exactly one member, placed with the match terms (object / recordId):

organizationId: z.string().min(1).optional()
  • .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).
  • No other member changes; BulkDataEventSchema and MetadataEventSchema are untouched (open question 1).
  • Acceptance re-check: git grep -c "organization" -- packages/spec/src/api/events.zod.ts = 0 on the base (a5b95ee, exit 1) → 25 on this branch; reverse control git grep -c "object" on the same file = 14 → 19 (non-zero both sides, so the zero was a reading).
  • Regenerated products (generators only, no hand edits): authorable-surface/api.json (+api/DataEvent:organizationId), json-schema.manifest/api.json, api-surface/ and content/docs/references/api/events.mdx as check:generated --fix proved stale — listed in "Files" below.
  • Changeset: .changeset/data-event-organization-id.md, @objectstack/spec: minor, no tracker ids in the customer-facing text.
  • Pin tests (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']); refuses null (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 → organizationId

Key-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 the context-tokens map); bare organization6 (booleans and an approver slug — never an id); orgId0. Token totals over the same tree: organizationId 209 / 30 files, organization_id 158 / 43 files (prose and column names), orgId 0. data/hook.zod.ts:658 and ui/action-params.zod.ts:336 carry the ruling in their own describe text: organizationId is "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, handleEvent and handleBulkEvent) selects subscriptions on event.object (envelope) and then, at the same site, reads the payload for recordId / matched — its own comment states the discipline: "The envelope's payload IS the spec's DataEvent … Read it directly." The client SDK (packages/client/src/realtime-api.ts:171) safeParses event.payload against DataEventSchema before 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 of DataEventSchema, enforced by the same parse at the publish site that enforces recordId.

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) — it DataEventSchema.parses inside try/catch and on failure logs warn and publishes nothing. One consumer-side parse, packages/client/src/realtime-api.ts:171 (safeParse, throws on failure). Fixtures that construct or parse a DataEvent: objectql/src/engine-data-events.test.ts, client/src/realtime-api-data.test.ts, four plugin-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 a single-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) pins z.input ≡ z.infer for DataEventSchema, which mechanically forbids a .default(). Hence z.string().min(1).optional(): null is refused (invalid_type) and "" is refused (too_small), so "no organization" has exactly one spelling — the key is absent. That matches the engine's own eventRecordId / eventUserId helpers ("" ⇒ absent) and the enqueuer's row.organization_id ? … : undefined. No .default(); no other member loosened.

4. Absence semantics on single posture

resolveTenancyPosture() (packages/types/src/env.ts:145): OS_TENANCY_POSTURE when set (unknown value throws), else derived from OS_MULTI_ORG_ENABLED (trueisolated, else single). postureEnforcesWall(p) = p !== 'single' (packages/spec/src/security/tenancy-posture.ts:53) and postureStampsOrganization(p) = postureEnforcesWall(p) (:69). So on single there is no wall and nothing stamps organization_id: every event is organization-less, permanently. Under a wall, packages/plugins/plugin-security/src/tenant-layer.ts:120-146 shows the remaining organization-less cases — tenancyDisabled objects, objects with no organization_id column (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's DataEvent — the packages that construct or parse it — each run as a package suite at head 307b5a69e under scripts/pm/os-verify-lock.sh, after an ESM-only build (OS_SKIP_DTS=1, 15 packages) of their dependency closure (spec's own dist built with declarations; dist/api/index.d.ts carries organizationId: 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 (the ERROR lines 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 touch DataEvent; 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/spec whole suite — pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under 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 --listFiles lists src/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:generated at 307b5a69e: ✓ All 15 generated artifacts are up to date. — including ✓ check:api-surface, ✓ check:authorable-surface, ✓ check:docs, ✓ check:liveness, ✓ check:test-typecheck. (The earlier --fix run 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 familynode 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 audits check:generated deliberately 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 gates check: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.

  • NOT MEASURED (each exits with its own prerequisite code 3, not a red): check:type-check-debt (needs every dependency's built dist/*.d.ts) and check-test-completeness (parses a turbo test summary that does not exist locally).
  • Not run locally, declared: 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.zod from src, no dist on its resolution path, so no rebuild is involved): removed the organizationId declaration — confirmed on disk (member-line count 1 → 0; blob hash 49d0995…0cbea15…) — and ran src/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 by git checkout HEAD -- <absolute path> inside trap … EXIT INT TERM, proven: hash back to the HEAD blob, git diff HEAD empty, tree clean.

Open questions for the contract review

  1. 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 single organizationId cannot be honest on that contract: under group, Layer 0 is organization_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 a group sweep. 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.
  2. MetadataEventSchema carries no tenant term either; metadata events are consumed by the client SDK's subscribeMetadata (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 307b5a69e vs base a5b95ee6e:

.changeset/data-event-organization-id.md | 34 +++++++++++++++
content/docs/references/api/events.mdx | 1 +
packages/spec/authorable-surface/api.json | 1 +
packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++++++++
packages/spec/src/api/events.zod.ts | 72 +++++++++++++++++++++++++++++++
5 files changed, 171 insertions(+)

Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via DataEventSchema (symbol, a top-level const))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DataEventSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 937ec142d5bb6e3f112dae2530842b1292be1cd4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 53515ad7c61ffc841e830ab924dc00a63c235852 — the merge of head 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 into base 937ec142d5bb6e3f112dae2530842b1292be1cd4, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 937ec142d5bb6e3f112dae2530842b1292be1cd4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE): in-seat contract review PASS recorded on the card (#14291, comment 5512440518, maintainer 2026-08-31 ruling as provenance for the in-seat clear); needs:contract-review cleared on both carriers with compared read-back; check-clause2-carriers --pair 14635 exit 0; every check on head 307b5a69e green (41/41 completed, no failure); trial merge CLEAN vs origin/main, zero drift; NOT governed (0/5). Flipped ready and armed auto-merge (squash) in this stroke.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Spec half of #13566 (p0 leak): DataEvent carries an organization term, so tenant-scoped consumers can discriminate the event's tenant

2 participants

@os-sam@claude