Repository navigation
fix(objectql): scope the master-detail parent binding and the dangling-reference audit to the organization - #19854
Conversation
…s tenant scope
resolveMasterDetailParent / resolveMasterDetailParents read the header with a
bare { isSystem: true }, so a parent.* requiredWhen / readonlyWhen predicate
was judged against another organization's header. They now read under
ObjectQL.referenceCheckContext(context), the sudo()-shaped elevation both
reference checks share; the five call sites pass opCtx.context.
Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
…ization inspectDanglingReferences probed through referenceExists with no context, so a stored cross-organization reference resolved against the other organization and was never reported. The audit now reads each scanned row's tenant column and hands that organization to the probe; a NULL-organization row, or an object with no tenant column, still probes unscoped. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
… per-row organization Leg 1: every measured pair (insert, update by id, bulk; requiredWhen and readonlyWhen; repoint and stored header) answers the same for an org-Y locked and open header, with lit same-org, NULL-org and tenancy-disabled controls and a wiring check on the header read's context. Leg 2: the audit reports a stored cross-organization reference; same-org, NULL-org, tenancy-disabled and own-org controls are not reported; the audit module hands each row's organization to the probe and keys its memo on it. The group-posture consequence is pinned so it moves only by decision. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
…r id A scalar where.id routes to the by-id branch even under multi: true, so the bulk cases never reached resolveMasterDetailParents; an id $in does. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
…tion argument optional A required third parameter would break a caller that invokes an exported DanglingReferenceAuditPort.probe with two arguments; optional keeps every existing port and caller well-typed, and the engine reads a missing value as unscoped. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 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 — 17 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 1969643772a66fcd0db81d7797b7b982af14c2ec && git checkout 1969643772a66fcd0db81d7797b7b982af14c2ec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2548ba57deb83062cc94ea91a644309092251a0f 8b5c2cee52238d338430665e508ecdd205a31e43 && git checkout -B drift-repro 2548ba57deb83062cc94ea91a644309092251a0f && git merge --no-ff 8b5c2cee52238d338430665e508ecdd205a31e43
node scripts/docs-audit/affected-docs.mjs --json 2548ba57deb83062cc94ea91a644309092251a0f |
…on posture Under group (postureUsesUnionScope over the engine's posture in force) the write rule's reach is the writer's membership set, which the stored row does not record, so a per-row probe would be stricter than the rule and report healthy group data every sweep. The probe stays unscoped there, and the docblock states the blind spot that leaves. Every other posture keeps the row's own organization. The group test is flipped to the decided behaviour, with an isolated lit control over the same rows. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
… not collide with an open neighbour Inserted beside normalizeTenancyPosture it conflicted textually with open PR #19728, which inserts a different name at that exact line. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed head = branch head = PR head (no drift). Merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL: defect ① (a). The changeset's "One case to check" paragraph and title line assert an unqualified audit report that is false under the Generated by Claude Code |
…; pin the group parent binding The changeset now says the audit reports cross-organization references outside the group posture only, and that a header is treated as missing when it is outside the caller's tenant scope (under group, outside the membership set). The parent-binding docblock names the group union; the audit's blind-spot list names pre-guard writes. A new leg-1 case pins that under group a header inside the caller's membership set binds and the union reaches the header read, with a single-organization member as the lit control. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed head = branch head = PR head (no drift). Merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19837
Clause-②: no
What this changes
Leg 1: the master-detail parent binding.
resolveMasterDetailParent(update by id) andresolveMasterDetailParents(insert and bulk update) read the header that a detail object'sparent.*predicates (requiredWhen,readonlyWhen) are judged against. Both read it under a bare{ isSystem: true }. That context has notenantId, so the driver found the header in any organization. Both now read underObjectQL.referenceCheckContext(context), thesudo()-shaped{ ...context, isSystem: true }that both reference checks already use (claim-seat ruling):tenantIdnow reaches the driver, and so does thegroupposture's membership union.group, one outside the caller's membership set) binds as absent. That is the same binding a header that exists nowhere gets.Both methods take a new
contextparameter. The five call sites passopCtx.context.Leg 2: the dangling-reference audit, option B (claim-seat ruling, #19808's ACCEPT).
inspectDanglingReferencesused to probe throughreferenceExistswith no context, so the check ran across every organization. Now each scanned row's reference is probed under that row's own organization:resolveTenantFieldName), next to the references.probeas a new optional third argument.tenantId.tenancy.enabled: falseobject, and a federated object's phantom (injected) organization column all probe unscoped, as before.group(postureUsesUnionScope(this.resolveEnginePosture()), read once per run) the probe stays unscoped, as before this PR. That is the seat's decision from the patch round (see "Thegroupposture" below). The per-row organization applies in every other posture.A1: the oracle on insert, re-measured on
origin/mainafter #19836This uses the harness shape #19808's dev used: a real
SecurityPlugin(postureisolated,org-scopingon) over a realObjectQLoverSqlDriver(better-sqlite3:memory:). The detail fieldnotehasrequiredWhen: "parent.status == 'locked'". The caller is bound to org X and omitsnote. The harness was a scratch file underplugin-security/src. It was never committed and was removed after every run. "Pre" meansengine.tsatafc3b64928, and "post" means this branch at5930c9898a.lockedVALIDATION_FAILED·noterequiredVALIDATION_FAILED·headerreference_not_foundopenVALIDATION_FAILED·headerreference_not_foundVALIDATION_FAILED·headerreference_not_foundlockednoterequiredopenlockednoterequiredtenancy: { enabled: false }master,lockednoterequiredA2: the update doors (NOT MEASURED on the card)
The caller is bound to org X and updates their own org-X detail rows. "Stored" means that the row already holds an org-Y header, as a row written before #19808 or by an
isSystemwrite would. "Bulk" useswhere: { id: { $in: [...] } }, multi: true. A scalarwhere.idroutes to the by-id branch even undermulti: true.lockedopenmemo(readonlyWhen)reference_not_found;onFieldsDroppedreportsmemoreference_not_found; no dropreference_not_found;memodropped (readonly_when)strictReadonlyWritesERR_READONLY_FIELD_REJECTEDreference_not_foundERR_READONLY_FIELD_REJECTEDmemomemokept, drop reportedmemowrittenmemokept, drop reportedstrictReadonlyWritesERR_READONLY_FIELD_REJECTEDERR_READONLY_FIELD_REJECTEDmemomemokeptmemowrittenmemokeptnotestays null (requiredWhen)noterequiredreference_not_foundreference_not_foundnotenoterequirednotenoterequiredBefore the fix, every pair differed on at least one channel a caller can see. After it, every pair answers the same.
A3: does "absent" close the oracle on every door?
Yes, measured on every door. An org-Y header now takes the same path as a header that does not exist. The two predicate kinds already handle an unbound
parent:requiredWhenis fail-open (Parent-scopedrequiredWhenis unenforced server-side — the same gap #4889 closed forreadonlyWhen, one slot over #4977 ruling: logged, skipped). On insert and on a repoint, the header id is in the payload, soassertReferencesResolve([finding] a lookup's existence check reads under a bare system context with no tenant — an org-bound caller can store a reference to another organization's row, and tell "exists elsewhere" from "missing" #19808) refuses it withreference_not_found. On a stored header, the id is not in the payload, so nothing is refused and the write commits. Either way, locked and open give the same answer.readonlyWhenis fail-closed (Parent-scopedreadonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889: LOCKED). The field is dropped, or refused under strict mode, whatever state the header is in.The only differences left are server-side
warnlines ("requiredWhen … not bound … skipped", "readonlyWhen parent lookup…"). They do not reach the caller, and they read the same for both states.What this costs, stated in the changeset: a row that already stores a header from another organization now edits as if its header were missing. Its
parent-scopedreadonlyWhenfields stay locked, and itsparent-scopedrequiredWhenrules are not enforced. Outsidegroup, leg 2 now reports exactly these rows.A4: the audit (leg 2)
Rows were inserted under
isSystem, because the write path now refuses a non-system cross-organization reference.inspectDanglingReferences({ objects: ['m_contact'] })was run over 6 rows in the same harness (postureisolated; undergroupthe audit probes unscoped, see below):danglingtenancy.enabled: falserowA5: class sweep of
{ isSystem: true }reads inengine.tscheck-system-context-censuscounts READS ofisSystem(110 sites, unchanged by this diff), not constructions, so I enumerated every non-commentisSystem: trueconstruction inengine.ts(12 sites) and checked whether its target id comes from the caller:resolveMasterDetailParent/resolveMasterDetailParentsreferenceExistsviareferenceCheckContext, andcascadeDeleteRelationssudo()-shaped)recomputeSummaries{ ...(execCtx ?? {}), isSystem: true }ObjectRepository.execute,ScopedContext.sudo()buildSession(...(execCtx.isSystem ? { isSystem: true } : {}))probeInstallOrganizationssys_organization,limit: 2, no idbuildHookApifallbackreadMigrationFlagVerified,recordObservedDeviation(read + write),retractCreationAttestation(read + write)sys_migrationflag row by migration idNo other site in
engine.tshas this shape, so nothing else is fixed here and no class (a) item is filed from the sweep. For completeness I also looked at the bare constructions elsewhere inpackages/objectql/src: the audit's own row read,scan-value-shapes, the lifecycle sweep andsummary-backfill. All of them are boot or sweep reads with no caller id.Tests
packages/objectql/src/engine-reference-tenant-scope.test.ts(the #19808 file, with the same tenant-scoping driver double):describe, 9 cases:locked, org-Yopenand a header that exists nowhere give the same envelope:resolveThrownHttpErrorreads{ status: 400, code: 'VALIDATION_FAILED' }and the fields are[{ field: 'header', code: 'reference_not_found' }]. The messages also match once the id is removed.readonlyWhenon a stored org-Y header, by id and bulk: the stored value, theonFieldsDroppedreport and the strict envelope{ status: 500, code: 'ERR_READONLY_FIELD_REJECTED' }are all equal for both headers.requiredWhenon a stored org-Y header, by id and bulk: both commit.lockedheader still givesnoterequired, and an org-Xopenone commits. An org-Xlockedheader still locksreadonlyWhen, and anopenone lets the write through. A NULL-organization header binds. A tenancy-disabled master stamped with another organization binds, and its read carries notenantId.group: a member of org X and org Y inserting against the org-Ylockedheader getsnoterequired(the header binds inside the membership set), and the header read reaches the driver withtenantIds= both organizations. The lit control, a member of org X alone, getsheaderreference_not_found.find(insert) andfindOne(update). Their operation context is{ isSystem: true, tenantId, userId }and the driver seestenantId.tenantIdis the row's organization.group: a cross-organization reference to an EXISTING row is not reported, because the probe is unscoped (norts_accountprobe carries atenantId), while an id that exists nowhere still is. A lit control runs the same stored rows underisolated, where both are reported. The "audit REPORTS" case now namesisolatedexplicitly instead of inheriting the posture from the environment.packages/objectql/src/integrity/dangling-reference-audit.row-organization.test.ts(new, 5 cases) covers the audit-module half:nullor''probes unscoped;tenancy.enabled: falseobject probes unscoped;tenancy.tenantFieldis read, and projected when nothing else asked for it;Ablation. Run on committed HEAD
5930c9898athroughscripts/ablation-replace.mjs(WRAP mode), inside a script with its ownEXIT/INT/TERMtrap that restores fromHEADand checks the blob hash. The tests import./engine.jsfrom source, so nodistbuild is involved. The markers are comments, so they cannot change behaviour.resolveMasterDetailParentback to the bare{ isSystem: true }. The anchor went 1 → 0 on disk and the marker 0 → 1. Result: 4 failed / 27 passed (update-by-id repoint, storedreadonlyWhen, storedrequiredWhen, wiring).resolveMasterDetailParentsback to the bare elevation. The anchor went 1 → 0 and the marker 0 → 1. Result: 3 failed / 28 passed (insert, bulk storedrequiredWhen, wiring).26421788b4after the patch round): the engine's probe port back to no per-row context in every posture. The anchor went 1 → 0 and the marker 0 → 1. Result: 2 failed / 29 passed: theisolated"audit REPORTS" case, and thegroupcase'sisolatedlit control (:424, expected['ct_grp','ct_gone'], received['ct_gone']). Thegrouphalf stays green under this mutation, as it should, because the mutation is the decidedgroupbehaviour. The blob was restored toa69246368bc5. Legs 1a and 1b were not re-run, because the leg-1 code is unchanged since5930c9898a.grouppin, run on8b5c2cee52):resolveMasterDetailParentsback to the bare elevation. The anchor went 1 → 0 and the marker 0 → 1. The new pin went red at itstenantIdsassertion (:656, expected both organizations, received[]). Itsnoterequiredenvelope stays green under the mutation, because an unscoped read also finds the org-Y header, so thetenantIdsassertion is the one that discriminates. The blob was restored to956383b5ce5a.dce546fe7f5d) andgit diff HEADis empty. The control run afterwards passed 31/31. Every leg went red, as predicted.Local verification (head
5930c9898a)Re-run on
8b5c2cee52after the second patch round: the two touched test files passed (32 tests), the objectql suite passed (305 files / 5089 tests),typecheckexited 0 with the ledger unchanged,check-system-context-censusexited 0,check:query-options-erasureexited 0, andnode scripts/check-issue-citations.mjsexited 0 (14 citations, all resolve), andnode scripts/check-changeset-no-major.mjs --base origin/mainexited 0. The full 64-command sweep and the--ranverdict below are from5930c9898a; they were not re-run.pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 305 files / 5088 tests passed.pnpm --filter @objectstack/objectql typecheck: exit 0.check:test-typecheckis OK with the ledger unchanged (40 files / 234 errors / 65 signatures).tsc --listFilesOnly -p tsconfig.test.jsonlists both test files.controlled_by_parentsuites (8 files / 154 tests) passed against source objectql (aliased).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 64 commands from the tree at5930c9898a(merge baseafc3b6492). All were run, with each exit code captured before any pipe; 62 exited 0. The--ranverdict:64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).check-system-context-censusexit 0 (110 elevation read sites in 20 packages across 45 files),check-platform-object-tenancy-censusexit 0 (84 platform-namespace objects),check:org-identifierexit 0, andcheck:query-options-erasureexit 0 (test surface 236, at the ceiling; non-test 67 sites, none new).check:dual-build-cjs-loads(exit 3, PREREQUISITE NOT MET: it needs every package'sdist). As a narrower check,require()of the rebuiltpackages/objectql/dist/index.jsanddist/core.jsloads (exit 0).check:type-check-debt(exit 3, PREREQUISITE NOT MET, for the same reason). objectql's own layers are covered bytypecheckabove.eslint --no-inline-config --format jsonover the 4 touched.tsfiles counted 4 files linted, 0 errors and 0 warnings, and none of the files is ignored.eslint.config.mjssets noparserOptions.projectand registers no typed rule, so the lint is not type-aware and this diff cannot change the verdict on any file it does not touch. The repo-widepnpm lintis CI's.showcase-readonly-when-parent,federated-sweep-projections): NOT MEASURED locally, because they need the full showcase build. TheDogfood Regression Gatein CI runs them.Deviations from the declared file surface
, opCtx.context. The two methods had no context at all, so the ruled cure could not be written inside them alone. Each edit is one argument on a line that already called the method.referenceExists's docblock: one comment paragraph was rewritten. It said that the audit calls the probe with no context, which leg 2 makes false. No code changed there.postureUsesUnionScopeis imported intoengine.tsfrom@objectstack/spec/security(patch round), placed at the end of that import list so it does not collide with feat(formula,objectql): read one hop through a lookup in a validation predicate #19728.integrity/dangling-reference-audit.ts: the port interface, one helper and the scan loop. This is "the audit's probe port" that the claim names.git merge-tree --write-treewas run in a throwaway bare repo that shares the object store and has noos-regendriver registered. At8b5c2cee52it is clean against feat(formula,objectql): read one hop through a lookup in a validation predicate #19728's head3b9c5f2fca(exit 0) and againstorigin/main2548ba57de(exit 0). The first patch-round push collided textually with feat(formula,objectql): read one hop through a lookup in a validation predicate #19728 on the@objectstack/spec/securityimport, so26421788b4putspostureUsesUnionScopeat the end of that list. fix(objectql): a failedfindreports atwarn, noterror— the rethrow tells the caller #19840 has merged.packages/plugins/plugin-security/src(outside the surface) for the length of each run. It was never committed, and a trap removed it.The
groupposture (decided)Under
group, the write rule's reach is the writer's whole membership set (accessible_org_idsbecomestenantIds), and the stored row does not record it. A probe scoped to the row's own organization would therefore be stricter than the rule. It would report every cross-organization reference a member of both organizations legitimately wrote, on every lifecycle sweep of healthy data. The seat decided: under a union posture the probe stays unscoped, as before this PR, and every other posture keeps option B. The first contract review (FAIL, comment 5794714685) found the changeset's audit sentences unqualified by posture; the second patch round (8b5c2cee52) qualifies them.The blind spot this leaves is stated in the
inspectDanglingReferencesdocblock and the changeset. Undergroup, a stored reference into an organization that no writer of that row could reach (a write made before #19808's guard existed, anisSystemwrite, or a membership since revoked) resolves and is not reported. Only a reference that resolves nowhere is.Acceptance notes
referenceCheckContext's docblock still describes only the pre-delete check, even though it now serves four sites. This is left alone because it is outside the region.resolveEnginePosture()(the injected provider, thenOS_TENANCY_POSTURE).buildDriverOptionswidens to the union only when an injected provider returns'group'. So in a lean embedding with an env-onlygroupposture, the audit probes unscoped while the write rule stays equality-scoped: less strict, never stricter. This is not changed here, and nothing is filed, because the reach is not measured.ERR_READONLY_FIELD_REJECTEDresolves to status 500 throughresolveThrownHttpError, because the error declares no status. The REST mapping was not measured, and nothing is filed.Generated by Claude Code