fix(objectql): publish the record's organization on every DataEvent - #15220
Conversation
…14970) `DataEventSchema.organizationId` was declared and published by the spec half but populated by nothing, so every `data.record.*` event went out with the key absent — which the contract requires a consumer to read as "this record is behind no organization wall". `publishDataEvent` now resolves it from the row itself: the written record on `created`, the post-state on `updated`, and the by-id branch's already-read pre-image on `deleted`, so no per-event read is bought. The record's organization, never `ExecutionContext.tenantId` — that is the caller's active org, and the two diverge on exactly the system/unscoped write this key most needs to label correctly. Absence keeps one spelling: the key is omitted, never `''` (which the schema refuses outright, dropping the whole event) and never an explicit `undefined` (which survives `parse` as a present key). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b72cd3f95f66f9f79774593380963082b4e1f026 && git checkout b72cd3f95f66f9f79774593380963082b4e1f026
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c1bcda382067e75e2d69f11086d6c986ccb987a 4eaa3b81424a984543170be7d54ef6f6f1f7511a && git checkout -B drift-repro 9c1bcda382067e75e2d69f11086d6c986ccb987a && git merge --no-ff 4eaa3b81424a984543170be7d54ef6f6f1f7511a
node scripts/docs-audit/affected-docs.mjs --json 9c1bcda382067e75e2d69f11086d6c986ccb987a |
…blish-data-event-organization
…ne line shift Mechanical repair by `node scripts/check-system-context-census.mjs --fix`, the only correct writer for this table. Pure line rot: the `eventOrganizationId` helper and its threading shifted every later line in `packages/objectql/src/engine.ts`, so 14 anchors (15 citation sites — one source line is cited twice) pointed at the wrong lines. No population and no classification change: still 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read — the same figures as before the shift. `--fix` did not refuse, and the diff is digits and nothing else (12 lines added, 12 removed, identical once digits are stripped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
|
Interim review note — ⛔ not a verdict. CI has not converged on PM seat ✅ The anchor repair is verified independently, by arithmetic rather than by trustThe
|
…15220) * fix(objectql): publish the record's organization on every DataEvent (#14970) `DataEventSchema.organizationId` was declared and published by the spec half but populated by nothing, so every `data.record.*` event went out with the key absent — which the contract requires a consumer to read as "this record is behind no organization wall". `publishDataEvent` now resolves it from the row itself: the written record on `created`, the post-state on `updated`, and the by-id branch's already-read pre-image on `deleted`, so no per-event read is bought. The record's organization, never `ExecutionContext.tenantId` — that is the caller's active org, and the two diverge on exactly the system/unscoped write this key most needs to label correctly. Absence keeps one spelling: the key is omitted, never `''` (which the schema refuses outright, dropping the whole event) and never an explicit `undefined` (which survives `parse` as a present key). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(permissions): re-anchor the system-context census after the engine line shift Mechanical repair by `node scripts/check-system-context-census.mjs --fix`, the only correct writer for this table. Pure line rot: the `eventOrganizationId` helper and its threading shifted every later line in `packages/objectql/src/engine.ts`, so 14 anchors (15 citation sites — one source line is cited twice) pointed at the wrong lines. No population and no classification change: still 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read — the same figures as before the shift. `--fix` did not refuse, and the diff is digits and nothing else (12 lines added, 12 removed, identical once digits are stripped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
…e-delivery rejections (objectstack-ai#15276) * fix(runtime): type the packages-domain `protocol` service handle so undeclared request keys are compile errors (objectstack-ai#15215) * wip(runtime): type the packages-domain protocol service handle Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * wip(runtime): add the packages-domain protocol handle typing pin Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore(changeset): patch note for the packages-domain protocol handle typing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(permissions): re-anchor the system-context census rows moved by the typing block Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(permissions): regenerate the system-context census from the merged tree Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(objectql): publish the record's organization on every DataEvent (objectstack-ai#15220) * fix(objectql): publish the record's organization on every DataEvent (objectstack-ai#14970) `DataEventSchema.organizationId` was declared and published by the spec half but populated by nothing, so every `data.record.*` event went out with the key absent — which the contract requires a consumer to read as "this record is behind no organization wall". `publishDataEvent` now resolves it from the row itself: the written record on `created`, the post-state on `updated`, and the by-id branch's already-read pre-image on `deleted`, so no per-event read is bought. The record's organization, never `ExecutionContext.tenantId` — that is the caller's active org, and the two diverge on exactly the system/unscoped write this key most needs to label correctly. Absence keeps one spelling: the key is omitted, never `''` (which the schema refuses outright, dropping the whole event) and never an explicit `undefined` (which survives `parse` as a present key). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(permissions): re-anchor the system-context census after the engine line shift Mechanical repair by `node scripts/check-system-context-census.mjs --fix`, the only correct writer for this table. Pure line rot: the `eventOrganizationId` helper and its threading shifted every later line in `packages/objectql/src/engine.ts`, so 14 anchors (15 citation sites — one source line is cited twice) pointed at the wrong lines. No population and no classification change: still 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read — the same figures as before the shift. `--fix` did not refuse, and the diff is digits and nothing else (12 lines added, 12 removed, identical once digits are stripped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com> * ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches (objectstack-ai#15224) * ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches Flips .github/workflows/merged-branch-reaper.yml from report-only to the scheduled weekly deletion the maintainer ruled on 2026-09-04 (issue objectstack-ai#12771, decision batch objectstack-ai#30), reaffirming the 2026-08-31 ruling under the base-ref guard PR objectstack-ai#15144 landed. Deletion is a SEPARATE job (`reap`), because `permissions:` is scoped per job. `sweep` keeps `contents: read` + `pull-requests: read` and remains structurally incapable of deleting a ref; `reap` holds the only `contents: write` in the file, consumes the `reapable` list `sweep` publishes as a job output, and computes no classification of its own. Fences: - `reap` never runs on `pull_request` — the self-exercising run stays a dry run — and its `if:` is an allowlist of `schedule` plus a `workflow_dispatch` on which the operator explicitly set `dry_run: false`. - the new `dry_run` workflow_dispatch input defaults to true, so the manual path is fail-closed. - the base-ref guard, `PREFIX`, `BASE_REF`, the grace window, the schedule and the `is-ancestor` prohibition are all untouched. - the whole deletion list is printed to the run log before the first delete. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * test(reaper): pin the deletion hand-off and fence the delete job structurally The contract harness drives the `sweep` classifier and can say nothing about the job that deletes — deletion deliberately lives outside the extracted script, so what the harness judges stays a classification rather than an action. Two additions close that gap. 1. The hand-off. `sweep` now publishes `reapable_branches`, the machine-readable half of the list it prints, and `reap` consumes that and nothing else. Scenarios G1/G2/R1 pin that the list EQUALS the reapable bucket — same members, same order — over a population carrying one branch in every bucket, and mutations M13/M14 drive both directions red (held branches leaking in; the list not published at all). 2. The fence. `reapFenceFailures()` parses the shipped YAML and asserts the delete job's structure: its `if:` excludes `pull_request` and gates `workflow_dispatch` on `inputs.dry_run == false`; it declares `contents: write` and is the ONLY job in the file that does; the top-level grant stays `contents: read`; it still `needs: sweep`. New self-test battery 6 drives six mutations of the workflow text to red, each asserting its anchor was present first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * ci(reaper): put every excluded bucket on the run-log audit line The maintainer's ruling names the run log as the audit trail, and the notice line named three of the seven buckets — reapable, mergedElsewhere, noPr. The other four (open, closedUnmerged, grace, protectedBranch) lived only in the step summary and the uploaded artifact, so the log alone could not answer "what did it hold back, and why". Also retires two strings that stopped being true when the reaper was armed: the summary heading said "DRY RUN. Nothing was deleted." of a run that may now delete in a later job, and the notice said "Nothing was deleted" of the whole run rather than of this job. Both now speak for the `sweep` job only, which is the thing they were ever really asserting — its token grant is `contents: read` and that has not changed. No classification changed: the buckets, the guard, the grace window and the step outputs are byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(service-automation): compile the test layer with tsc, and repair the TS2341 x3 it hid (objectstack-ai#15152) * wip: onboard service-automation typecheck, fix TS2341 residue * wip: onboarding gate registry entry + changeset * fix(scripts): re-measure this entry's provenance totals on the merged tree The `service-knowledge` onboarding landed on `main` between this entry's first reading and this merge, so every absolute in its provenance block (programs, pairs, packages, clean count) was a number about a tree that no longer exists. Re-taken with `--list` on the merge commit itself, all four rows plus the before/after pair, by varying only what the `typecheck` script names: no `typecheck` script absent 120 programs / 293 pairs names tsconfig.json absent 120 programs / 293 pairs names tsconfig.test PRESENT 121 programs / 302 pairs names both (the card) PRESENT 121 programs / 302 pairs before 59 of 78 packages, 120 programs, 293 pairs, 19 clean after 60 of 78 packages, 121 programs, 302 pairs, 18 clean The deltas this block actually claims (+1 package, +1 program, +9 pairs, one per dep) are unchanged; only the absolutes moved, and the block now says which merge moved them. The sibling entries' own blocks keep their own historical readings untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com> * docs(platform-objects): widen sys_email.error description to cover pre-delivery rejections `sys_email.error` was declared as "Transport error message when status=failed", but since objectstack-ai#14371 EmailService.recordRejectedMessage also writes status=failed rows for messages rejected by normalizeMessage before they reach a transport (prefixed "rejected before delivery: ..."). The declared field help was narrower than what the column actually holds. Widen the description (wording settled in triage, issue comment 5504375428) and regenerate the platform-objects i18n bundle with its own tooling (node scripts/check-i18n-bundles.mjs --write) rather than hand-editing the generated file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(platform-objects): add changeset for sys_email.error description widening Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Litant Ying <litant.dev@proton.me> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: os-sales <sales@objectstack.ai>
Fixes #14970
DataEventSchema.organizationIdhas been declared and published since the spec half landed (PR #14635, squash2aa8456cf), and its TSDoc states the obligation on the producer's side, verbatim:The engine populated it on no event at all. Every
data.record.created/updated/deletedwent out with the key absent, which the same TSDoc requires a consumer to read as "not behind any organization wall" — so an organization-stamped row was published as an unwalled one, and a tenant-scoped fan-out had nothing to discriminate on. That is the producer half of the confirmed p0 cross-tenant leak; the landed spec term and the ready consumer piece were both inert without it.What changed
publishDataEventresolves the organization from the row itself and conditionally spreads the key into theDataEventSchema.parse({...})call besideuserId/changes/after. A new module-scope helpereventOrganizationId(objectSchema, row)sits with the file's other event helpers (eventRecordId,eventRecordBody,eventUserId) and is the only place the resolution happens, so the three actions cannot drift apart.The row is passed explicitly as a new
input.organizationRowrather than inferred fromafter, because the delete path is the one with noafterand would otherwise silently publish the key absent — the regression this card is most likely to grow later.createdafterupdatedresult)afterdeletedpriorRecord)beforeDeleteever fires⇒ No per-event read is bought anywhere. This is a threading job, not a resolution job: the key exists precisely to keep a per-event lookup off the fan-out path, and triage ruled that read out for the consumer side on 2026-08-31.
Three properties that are load-bearing rather than incidental
ExecutionContext.tenantIdis the caller's active organization — the sensebuildHookUserdeliberately publishes asctx.user.organizationId. The two coincide on an ordinary tenant write and diverge on a system or unscoped one, where substituting it would mislabel an administrator's write into another organization as belonging to the administrator's. The row's own tenant column is the only source consulted.null, not'', not an explicitundefined. Measured against the built spec:''is rejected (too_small), so producing one would have thrown inside the publish site and dropped the event entirely — a silence worse than an absent key; and a key set to an explicitundefinedsurvivesparseas a present key. Hence the conditional spread, and hence the gate living in the resolver rather than in the error handler.resolveTenantFieldName, i.e. thetenancy.enabled: falseopt-out, then a declaredtenancy.tenantField, then the kernel-injectedorganization_id— so the event cannot name an organization for a column the engine does not actually scope by. Note the two spellings differ and are easy to conflate: the column is snake_caseorganization_id, the published key is camelCaseorganizationId.A malformed tenant column is treated as absent, not coerced: a bare
String(value)would turnfalseinto a perfectly validmin(1)string, which is the "never fabricated" clause's exact failure mode. The value passes the write path's owncarriesOrganizationpredicate and then the same coercion laddereventRecordIdalready uses for the other id on this event.Verification
All numbers below are from commit
4eaa3b81—928306e9merged withorigin/mainat9c1bcda3, plus the anchor repair described below.origin/maindid not touchengine.tsbetween the two, so the only line shift in that file is this PR's.8 new pins in⚠️ A green suite proves nothing on its own here — the failure mode is "the key is absent on every event", and a pin that only asserts absent when there is no organization passes happily against it. So every positive pin writes a row into an organization the caller is not standing in (
packages/objectql/src/engine-data-events.test.ts, covering all three actions.isSystemcaller withtenantId: 'org_platform', row stampedorg_acme), and asserts both spellings on the same event; absence is asserted ashasOwnProperty === false, never as=== undefined.Two ablations prove the pins discriminate. Both were run from the committed state, each leg proven on disk by an occurrence count before the run, with an absolute-path
traprestore verified by blob hash. The suite imports./engine.jsrelative tosrc/, andpackages/objectql/distdid not exist for these runs — so no build step stood between the mutation and the measurement.input.context.tenantId, the caller's orgAblation 1 is the important reading: the 5 reds are exactly the positive pins, and the 3 absence pins stayed green. A suite that had only asserted absence would have reported 25/25 green against the live p0 defect. Ablation 2 turns all 8 red, including the absence pins (
expected true to be false— the caller's org stamped onto a row that has none), so ruling 3 is pinned in both directions. Restore verified: blob back to860d4962,git diff HEADempty.Suites and gates, all at
928306e9:pnpm --filter @objectstack/objectql exec vitest run— 269 files / 4626 tests passedpnpm --filter @objectstack/objectql typecheck— pass, includingcheck:test-typecheck(44 files, 69 pinned signatures held; no new debt). Both edited files confirmed present in a real tsc program via--listFiles, so the green is a measurement rather than an empty one.pnpm --filter @objectstack/plugin-webhooks exec vitest run— 11 files / 131 tests passed (the fan-out consumer this card unblocks)pnpm lint— the full repo scan, exit 0 in 55s; no narrowing claimednode scripts/pm/dispatch-gates.mjswith no path arguments (its git-derived change set — 4 paths vs merge base9c1bcda38— is authoritative): 59 pass, 3 unmeasurable in this container, none of them touching this diff — see belownode scripts/check-system-context-census.mjs— clean: 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-readNot measured, and why
Three derived gates could not be measured here, and none of them is a red on this change:
check:dual-build-cjs-loadsandcheck:published-readme-exportsboth need a fullpnpm build. Both print their own cause. For the second, every finding is "type entry ... does not exist. Build first", and the count falls monotonically as packages are built — 96 of 96 initially, then 27 of 27 oncespec,objectqland theclient-reactclosure were built, with zero namingobjectql. Building@objectstack/objectqlalone dropped its own findings 10 to 0. So the cause is unambiguously build state, not this diff, which adds zero README lines and zero exports.check:dual-build-cjs-loadsreproduces the identicalPREREQUISITE NOT METon a tree without this change.check:react-declaration-parityneeds an objectuisdui.manifest.jsonand a browser dump.CI builds fresh and runs the farm exactly once, which is where all three get their real reading.
Anchor re-derivation (patch round)
content/docs/permissions/system-context.mdxis a generated line-anchor table intopackages/objectql/src/engine.ts. InsertingeventOrganizationIdand its threading shifted every later line, rotting 14 anchors (15 citation sites — one source line is cited from two rows) and reddeningcheck-system-context-censuswith 28 problems.Repaired mechanically, by the tool's own writer:
node scripts/check-system-context-census.mjs --fix. It did not refuse, and this is pure line rot with no population or classification change — three independent readings say so:site-without-a-rowplus 4ledger-row-unusedequals the 14anchor-is-not-a-read-site, the signature of a shift rather than a population change;⛔ The file was not hand-edited, and nothing about the elevation surface moved.
Scope
Out of scope, deliberately: #13566 (the
domain:servicesfan-out filter, which consumes the key this PR produces) andBulkDataEventSchema, which still carries no organization term —publishBulkDataEventis untouched, and the bulk fan-out path stays as it is. That is a separate spec-shape decision and nothing new was learned about it here.packages/spec/**is untouched. No schema, no accepted shape and no public export moves: the key was already declared, already validated and already part of what consumers parse. Only the implementation changed, from omitting a declared key to populating it — which is why this carries apatchchangeset.Generated by Claude Code