Repository navigation
Description
Contract-first split of #13566 (p0, security: webhook fan-out matches subscriptions by object name only, so on a walled deployment one organization's record events reach another organization's endpoints — signed with the receiver's secret). Filed by the domain:services seat (session session_01AUF1NoViznQK32gqpK8wS8, seat post #6021) under the cross-seat transfer rule: the repair direction requires a packages/spec increment, and packages/spec is single-owner (domain:spec).
Named reader
The domain:spec seat — this card is the upstream half; #13566 is flipped pm:blocked with Blocked-by: pointing here, and the services-side threading dispatch fires when this closes.
Provenance (rulings and measurements this card inherits)
- Census measured on
main@6b285eca4(13566#issuecomment-5479460377): a tenant org admin can createsys_webhookunder BOTH walled postures; fan-out match sites key on object name alone (auto-enqueuer.ts:833-836,:933-936); the event carries no organization to discriminate on. - Triage direction on Webhook fan-out matches subscriptions by object name only — on a walled deployment one organization's record events reach another organization's webhook endpoints #13566 (2026-08-31T12:47Z, verbatim): 「生产者侧穿线(在引擎 publish 点给
DataEvent带上组织)—— contract-first 方向…⛔ 不建议 fan-out 侧逐事件查库…⚠️ 生产者侧穿线要动packages/spec/src/api/events.zod.ts(该文件今天对organization零命中)⇒ 触 spec ⇒ 走条款②契约复审档位」 - Re-check command for the zero:
git grep -c "organization" origin/main -- packages/spec/src/api/events.zod.ts(reverse control:grep -c "object"on the same file must be non-zero).
Scope (this card ONLY)
Declare the organization term on the published DataEvent contract in packages/spec/src/api/events.zod.ts — the schema increment and its contract prose/tests. Shape questions (name, optionality, whether it is a first-class envelope field or scoped metadata; how absence is read on single-posture deployments where no wall exists) are the spec seat's to decide within the contract-review tier.
⛔ NOT this card: the engine publish-site threading, the plugin-webhooks fan-out match change, and any migration of existing delivery rows — those stay on #13566 (services), dispatched after this lands.
Acceptance (executable)
events.zod.tsdeclares the term; the re-check grep above flips from 0 to non-zero.- Clause-②: yes by the mechanical floor (new published payload key) ⇒
claude-fable-5dispatch,needs:contract-reviewon the draft PR, changesetminor. - Declared = enforced: whatever optionality is declared must be exactly what validation enforces — no consumer-side tolerance.
Priority suggestion: inherits #13566's p0 (this card is the critical path of a live cross-tenant leak — the two-way split re-ran the release-blocker predicate and the leak half cannot ship without this half). Triage to confirm grading and domain:*.
Activity
os-sales commentedon Sep 2, 2026
Triage: lands in
packages/spec/src/api/events.zod.ts⇒domain:spec(single-owner) ·pm:queue·Feature·priority:p0·security; rationale: the domain label is decided by where the fix lands, and this card's entire scope is apackages/speccontract increment — the engine/fan-out halves are explicitly out of scope and stay on #13566 (domain:services, alreadypm:blockedon this card; itspm:*state is the services seat's and is untouched here).Anchoring premise re-verified against
origin/main(@a59f78df3— main has moved since the census's6b285eca4; the premise still holds):packages/spec/src/api/events.zod.tshas zero occurrences oforganization; reverse controlobjecton the same file is non-zero (dozens —DataEventSchema's ownobjectmember among them), so the zero is a reading, not a failed read.DataEventSchemacarriesid/type/object/recordId/changes/before/after/userId/timestampand no tenant term — exactly the gap the parent's census established.Priority p0, confirmed (not merely inherited): the parent's gate was answered in
13566#issuecomment-5479460377— a tenant org admin can createsys_webhookunder BOTH walled postures, making #13566 a live cross-tenant leak (org A's endpoint receives org B's full record content, signed with A's secret). The ruled repair direction (triage on #13566, 2026-08-31T12:47Z: producer-side threading, contract-first; ⛔ no per-event DB lookup on the fan-out hot path) makes this spec increment the critical path — the leak half cannot ship without it. A release-blocking leak's sole upstream dependency grades with it: p0.Native type
Feature, per the mechanical boundary test: this card widens the published surface (a new payload key on theDataEventcontract) ⇒Feature. The defect itself remains typedBugon #13566; this split carries the contract increment, not the misbehavior.Queued, not decision inbox:
pm:queue, notneeds-user-decision. The direction was already ruled by triage on #13566 (12:47Z, quoted verbatim in this card's provenance); the remaining shape questions (field name, optionality, envelope vs. scoped metadata, absence semantics onsingleposture) are explicitly delegated to thedomain:specseat within the contract-review tier, where the Clause-② gate (needs:contract-reviewon the draft PR) is the maintainer's checkpoint. The maintainer-disposition questions that do exist (interim mitigation, released-version impact) live on #13566 and do not block this increment. Note "declared = enforced" in acceptance: whatever optionality is declared must be exactly what validation enforces.Size/model suggestion: Clause-② yes by the mechanical floor (new key on a published payload) ⇒
claude-fable-5mandatory; draft PR carriesneeds:contract-review; changesetminor.Dedup: two semantic searches (organization term on
DataEvent/events.zod.ts; adding a tenant field to the realtime event payload) — no open duplicate. Only matches: #13566 itself (the downstream services half, not a duplicate — it ispm:blockedon this card) and three closed neighbors (#4673, #4639, #4626) about otherevents.zod.tscontracts. This card is the sole spec-side dispatch entry.Generated by Claude Code
claude commentedon Sep 2, 2026
Claim (dev seat,
mode:subagent): implementing #14291 on branchclaude/issue-14291-data-event-organization(pushed empty as the landing marker + write-route probe).Session:
session_017RbbUMnxkUnWhE4j94v8FEWorktree:
objectstack-issue-14291, cut fromorigin/main@a5b95ee(BASE; main moved past the dispatch's2a26536before the worktree was cut —events.zod.tsunchanged between them).Scope: the contract half only —
packages/spec/src/api/events.zod.ts(DataEventSchema), its spec pin tests, the regenerated spec products, oneminorchangeset for@objectstack/spec. Engine threading, the fan-out match change and delivery-row migration stay on #13566. Assignee untouched (PM-owned).Generated by Claude Code
claude commentedon Sep 2, 2026
os-dev-report
{ "issue": 14291, "status": "done", "branch": "claude/issue-14291-data-event-organization", "pr": "https://github.com/objectstack-ai/objectstack/pull/14635", "head": "307b5a69e", "premise_still_valid": true, "summary": "Contract half only, as ruled: DataEventSchema (packages/spec/src/api/events.zod.ts) gains one member, organizationId: z.string().min(1).optional(), with describe + JSDoc stating what it is, when it is absent and what a consumer may assume; the schema header records the envelope-vs-payload decision. Six pin tests added to events.test.ts; products regenerated by the generators (authorable-surface/api.json, content/docs/references/api/events.mdx; api-surface and the JSON-schema manifest were already current); one minor changeset for @objectstack/spec. Acceptance grep flipped 0 (exit 1 on base a5b95ee6e) to 25, reverse control object 14 to 19. Engine threading, fan-out match change and delivery-row migration untouched (stay on #13566). Draft PR #14635 carries needs:contract-review; the card carries it too (both additive REST writes with comparative read-back; the PR's bot size/m label preserved; delayed re-read confirms both). Assignee untouched. Worktree removed cleanly after the PR opened (rm -rf node_modules, git worktree remove without force, exit 0).", "shape_decisions": { "1_spelling": "organizationId. Key-position declarations in packages/spec/src (non-test): organizationId 31 in 13 files (protocol.zod.ts x10, cloud/environment x3, cloud/tenant x3, identity/organization x2, data/hook x2, system/metadata-persistence x2, ui/action-params, identity/eval-user, cloud/developer-portal, data/seed-loader); organization_id 4 in 3 files (all DB-column mirrors on service contracts + the context-token map); bare organization 6 (booleans and an approver slug); orgId 0. Token totals: organizationId 209/30 files, organization_id 158/43, orgId 0. hook.zod.ts:658 and action-params.zod.ts:336 name organizationId the blessed developer-facing name; AGENTS.md PD#3 camelCase.", "2_envelope_vs_payload": "First-class member of the validated DataEventSchema payload, NOT of the RealtimeEventPayload envelope. Consumers measured: auto-enqueuer.ts handleEvent (per-record) and handleBulkEvent (bulk) select on event.object and then READ THE PAYLOAD at the same site for recordId / matched (the #4626 discipline stated in its own comment); client/src/realtime-api.ts:171 safeParses event.payload against DataEventSchema. The envelope (contracts/realtime-service.ts:19) is a TS interface no parse validates, so an envelope member would be declared-not-enforced by construction. The suggestion that a matcher keys on an envelope field without reaching into the payload is refuted as a requirement: both match sites already reach in.", "3_optionality": "Optional, non-empty, no default; a contract fact rather than a transition. Sole producer objectql/src/engine.ts:5528 (publishDataEvent, from create :10156 / update :11427 / delete :12873) parse()s inside try/catch and logs warn on failure, so a REQUIRED key would publish ZERO data events until the engine half lands and would red every fixture (engine-data-events.test.ts, client realtime-api-data.test.ts, four plugin-webhooks tests, spec events.test.ts, type-alias-convention.pin.test.ts). It would stay wrong after: postureStampsOrganization(single) is false, so single posture never has an organization to put there. Iso90 in type-alias-convention.pin.test.ts pins z.input identical to z.infer, which forbids .default() mechanically. null refused (invalid_type, expected string, path organizationId); empty string refused (too_small, minimum 1) so no-organization has exactly one spelling, the absent key (matches the engine's eventRecordId/eventUserId helpers and the enqueuer's own empty-to-undefined read). No other member touched.", "4_absence_on_single": "resolveTenancyPosture() (types/src/env.ts:145): OS_TENANCY_POSTURE else derived from OS_MULTI_ORG_ENABLED (true = isolated, else single; unknown value throws). postureEnforcesWall(p) = p !== 'single' (spec/src/security/tenancy-posture.ts:53); postureStampsOrganization = the same predicate (:69). So on single nothing stamps organization_id and every event is organization-less, permanently. Under a wall, tenant-layer.ts:120-146 shows the remaining organization-less cases: tenancyDisabled objects, objects with no organization_id column, environment-wide IS NULL rows. Declared: absent = the record belongs to no organization, read 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, never fabricated; the producer obligation (omitting it on an org-stamped row is a producer defect fixed at the publish site) is stated beside it." }, "files_changed": [ "packages/spec/src/api/events.zod.ts (+72)", "packages/spec/src/api/events.test.ts (+63)", ".changeset/data-event-organization-id.md (+34, @objectstack/spec minor)", "packages/spec/authorable-surface/api.json (+1, gen:schema)", "content/docs/references/api/events.mdx (+1, gen:docs)" ], "gates": { "at_head": "307b5a69e (final commit; exit codes captured before any pipe; verdict lines quoted)", "spec_suite": "pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 under os-verify-lock.sh: Test Files 453 passed (453), Tests 12216 passed (12216), exit 0", "spec_typecheck": "pnpm --filter @objectstack/spec typecheck: exit 0; check:test-typecheck: OK, 54 file(s) / 262 error(s) held in test-typecheck-debt.json; tsc -p tsconfig.test.json --listFiles lists src/api/events.test.ts (1 hit) with 0 of the 262 errors", "check_generated": "pnpm --filter @objectstack/spec check:generated at 307b5a69e: 'All 15 generated artifacts are up to date' incl. check:api-surface, check:authorable-surface, check:docs, check:liveness, check:test-typecheck; the earlier --fix run found only check:docs stale and regenerated only it", "check_doc_authoring": "pnpm check:doc-authoring: exit 0, '14508 customer-facing string(s) across 710 spec sources clean, no internal issue-id references'", "check_nul_bytes": "pnpm check:nul-bytes: exit 0, 'check-nul-bytes: OK (scanned 7980 text file(s), no raw ASCII control bytes)'; control-byte self-scan of the 3 hand-edited files: no hits", "derived_family": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (55 commands at dc666a9d9; the regeneration commit adds files of the same families). Exit 0 for: check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check:changeset-gate-self-tests, spec check:empty-state/exported-any/dual-source-exports/entry-nameability/browser-reachable-entries/strictness-ledger/variant-docs/yaml-examples/skill-refs/llms-txt/liveness, 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": "check:type-check-debt and check-test-completeness both exit 3 (their own prerequisite-not-met code: unbuilt workspace d.ts / no turbo summary locally): NOT MEASURED, not red", "not_run_declared": "pnpm lint (repo-wide eslint, CI-owned) and the derived commands that read nothing in this diff by construction: 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": "in_progress (draft PR just opened; not polled)" }, "tests": "Consumer sweep, DOWNSTREAM direction, at 307b5a69e under the lock after an ESM-only (OS_SKIP_DTS=1) build of the 15-package closure (spec dist built with declarations; dist/api/index.d.ts carries organizationId as ZodOptional of ZodString, 14 hits in the mjs): objectql full suite Test Files 262 passed (262) / Tests 4518 passed (4518) exit 0; plugin-webhooks 11 passed (11) / 131 passed (131) exit 0; service-realtime 3 passed (3) / 28 passed (28) exit 0; client narrowed to src/realtime-api-data.test.ts + src/realtime-api.test.ts (the parse site) 2 passed (2) / 22 passed (22) exit 0. Ablation (source path, no dist involved, no rebuild): removed the organizationId declaration, confirmed on disk (member-line count 1 to 0; blob 49d0995 to 0cbea15), ran src/api/events.test.ts: 5 failed | 16 passed (21), exactly the five presence/refusal/member-set pins red and the absence pin green (predicted direction); restore by git checkout HEAD on the absolute path inside a trap, proven by hash equal to the HEAD blob and empty git diff HEAD.", "deviations": [ "Worktree cut from origin/main a5b95ee6e, not the dispatch's 2a26536 (main had moved; events.zod.ts identical between them). origin/main later moved 11 more commits; none touched the shared generated products or my files, so no os-regen-merge was owed.", "The first lock-wrapped spec build queued 534s behind sibling issue-13865's heavy.sh (held 500s+) and was then killed by the 10-minute foreground cap mid-DTS (exit 143) after gen:schema, gen:openapi and the ESM tsup had completed; heavy steps were then run as background lock calls with foreground tail --pid waits (no polling loops). One DTS attempt exited 127 (bare -c shell has no tsup on PATH; held the lock 0s) before the pnpm exec form succeeded.", "@objectstack/client suite narrowed to the two realtime files (declared above); its remaining files need the 34-package runtime closure and do not touch DataEvent.", "check:type-check-debt and check-test-completeness NOT MEASURED (exit 3, prerequisite); pnpm lint not run (CI-owned).", "The seat's claim (5511381502) and my claim (5511498481) carry the same session id (subagent under the seat's session); both name the same branch.", "BulkDataEventSchema deliberately not given the term (open question 1), so the census's bulk match site still has no event-side tenant term; the services seat needs that decision before #13566's bulk path can discriminate." ], "mcp_calls": "0 — card/parent bodies and comments read from the public issue-page embedded JSON (zero quota); claim comment, both label writes (with comparative read-back), the draft PR, and the report comment went through repo-scoped REST (probe 200, core 15000/h)", "open_questions": [ { "question": "Should BulkDataEventSchema (data.records.updated/deleted) carry a tenant term too? The census's second match site (handleBulkEvent) keys on object name alone, but under group posture 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; a single organizationId cannot be honest there.", "options": [ "A: leave the bulk contract as is (it carries no record content, only a count) and let the services half decide whether an organization-less bulk event 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-posture sweep" ], "recommendation": "A, because the bulk contract is separate by design (its own event names, no recordId/before/after) and its tenant question is a shape decision of its own; nothing in this PR pins it either way (the events.test.ts comment says so), so the review can add it in-seat or file a follow-up card." }, { "question": "MetadataEventSchema carries no tenant term either (org-overlay metadata exists per ADR-0005); its consumers today are the client SDK's subscribeMetadata over a server-internal transport, not the webhook fan-out. Not measured beyond that.", "options": [ "A: no card until a tenant-scoped metadata consumer exists", "B: file a measurement card" ], "recommendation": "A, because no consumer discriminates metadata events per tenant today and the realtime contract is server-internal by its own admission note; declaring a key nothing reads would be the declared-but-unenforced shape ADR-0049 forbids." } ], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code