Skip to content

fix(objectql): scope the master-detail parent binding and the dangling-reference audit to the organization - #19854

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-19837-parent-binding-tenant-scope
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-19837-parent-binding-tenant-scope

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19837
Clause-②: no

What this changes

Leg 1: the master-detail parent binding. resolveMasterDetailParent (update by id) and resolveMasterDetailParents (insert and bulk update) read the header that a detail object's parent.* predicates (requiredWhen, readonlyWhen) are judged against. Both read it under a bare { isSystem: true }. That context has no tenantId, so the driver found the header in any organization. Both now read under ObjectQL.referenceCheckContext(context), the sudo()-shaped { ...context, isSystem: true } that both reference checks already use (claim-seat ruling):

  • RLS and FLS are still bypassed, because a master-detail lock is a property of the header's state.
  • The caller's tenantId now reaches the driver, and so does the group posture's membership union.
  • A header outside the caller's tenant scope (another organization; under 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 context parameter. The five call sites pass opCtx.context.

Leg 2: the dangling-reference audit, option B (claim-seat ruling, #19808's ACCEPT). inspectDanglingReferences used to probe through referenceExists with no context, so the check ran across every organization. Now each scanned row's reference is probed under that row's own organization:

  • The audit reads the column the object is tenant-scoped by (resolveTenantFieldName), next to the references.
  • It hands that value to the port's probe as a new optional third argument.
  • The engine's port turns the value into the probe's tenantId.
  • A NULL-organization row, a tenancy.enabled: false object, and a federated object's phantom (injected) organization column all probe unscoped, as before.
  • The run's probe memo is keyed per organization.
  • Except under a union posture. Under 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 "The group posture" below). The per-row organization applies in every other posture.

A1: the oracle on insert, re-measured on origin/main after #19836

This uses the harness shape #19808's dev used: a real SecurityPlugin (posture isolated, org-scoping on) over a real ObjectQL over SqlDriver (better-sqlite3 :memory:). The detail field note has requiredWhen: "parent.status == 'locked'". The caller is bound to org X and omits note. The harness was a scratch file under plugin-security/src. It was never committed and was removed after every run. "Pre" means engine.ts at afc3b64928, and "post" means this branch at 5930c9898a.

write names… pre post
org-Y header, locked VALIDATION_FAILED · note required VALIDATION_FAILED · header reference_not_found
org-Y header, open VALIDATION_FAILED · header reference_not_found same
a header id that exists nowhere VALIDATION_FAILED · header reference_not_found same
control: org-X header, locked note required same
control: org-X header, open commits commits
control: NULL-organization header, locked note required same
control: tenancy: { enabled: false } master, locked note required same

A2: 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 isSystem write would. "Bulk" uses where: { id: { $in: [...] } }, multi: true. A scalar where.id routes to the by-id branch even under multi: true.

door, predicate pre: org-Y locked pre: org-Y open post: both
by id, repoint + write memo (readonlyWhen) reference_not_found; onFieldsDropped reports memo reference_not_found; no drop reference_not_found; memo dropped (readonly_when)
same, strictReadonlyWrites ERR_READONLY_FIELD_REJECTED reference_not_found ERR_READONLY_FIELD_REJECTED
by id, stored header, write memo commits, memo kept, drop reported commits, memo written commits, memo kept, drop reported
same, strictReadonlyWrites ERR_READONLY_FIELD_REJECTED commits ERR_READONLY_FIELD_REJECTED
bulk, stored header, write memo memo kept memo written memo kept
by id, repoint, note stays null (requiredWhen) note required reference_not_found reference_not_found
by id, stored header, clear note note required commits commits
bulk, stored header, clear note note required commits commits

Before 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:

The only differences left are server-side warn lines ("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-scoped readonlyWhen fields stay locked, and its parent-scoped requiredWhen rules are not enforced. Outside group, 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 (posture isolated; under group the audit probes unscoped, see below):

stored row pre post
org-X row → org-Y account not reported reported in dangling
org-X row → an id that exists nowhere reported reported
org-X row → org-X account not reported not reported
org-X row → a tenancy.enabled: false row not reported not reported
org-Y row → org-Y account not reported not reported
NULL-organization row → org-Y account not reported not reported

A5: class sweep of { isSystem: true } reads in engine.ts

check-system-context-census counts READS of isSystem (110 sites, unchanged by this diff), not constructions, so I enumerated every non-comment isSystem: true construction in engine.ts (12 sites) and checked whether its target id comes from the caller:

site (symbol) shape same defect?
resolveMasterDetailParent / resolveMasterDetailParents bare, header id from the caller's payload or row yes, fixed here
referenceExists via referenceCheckContext, and cascadeDeleteRelations spread (sudo()-shaped) no, tenant kept
recomputeSummaries { ...(execCtx ?? {}), isSystem: true } no, tenant kept
ObjectRepository.execute, ScopedContext.sudo() spread of the caller's or scoped context no, tenant kept
buildSession (...(execCtx.isSystem ? { isSystem: true } : {})) copies the caller's own flag onto the hook session no, not an elevation
probeInstallOrganizations sys_organization, limit: 2, no id no, no caller id
buildHookApi fallback used only when there is no caller context no, no caller
readMigrationFlagVerified, recordObservedDeviation (read + write), retractCreationAttestation (read + write) sys_migration flag row by migration id no, engine-owned id

No other site in engine.ts has 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 in packages/objectql/src: the audit's own row read, scan-value-shapes, the lifecycle sweep and summary-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):

  • Leg 1, new describe, 9 cases:
    • Insert: org-Y locked, org-Y open and a header that exists nowhere give the same envelope: resolveThrownHttpError reads { status: 400, code: 'VALIDATION_FAILED' } and the fields are [{ field: 'header', code: 'reference_not_found' }]. The messages also match once the id is removed.
    • Update by id, repoint: same envelope for both headers.
    • readonlyWhen on a stored org-Y header, by id and bulk: the stored value, the onFieldsDropped report and the strict envelope { status: 500, code: 'ERR_READONLY_FIELD_REJECTED' } are all equal for both headers.
    • requiredWhen on a stored org-Y header, by id and bulk: both commit.
    • Lit controls: an org-X locked header still gives note required, and an org-X open one commits. An org-X locked header still locks readonlyWhen, and an open one lets the write through. A NULL-organization header binds. A tenancy-disabled master stamped with another organization binds, and its read carries no tenantId.
    • group: a member of org X and org Y inserting against the org-Y locked header gets note required (the header binds inside the membership set), and the header read reaches the driver with tenantIds = both organizations. The lit control, a member of org X alone, gets header reference_not_found.
    • Wiring: the header reads are find (insert) and findOne (update). Their operation context is { isSystem: true, tenantId, userId } and the driver sees tenantId.
  • Leg 2: [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's pin, "the audit keeps its unscoped probe", was written so that it moves only by decision, and the decision is now made. It is replaced by three cases:
    • A stored cross-organization reference is reported, and the probe's driver tenantId is the row's organization.
    • Lit controls: same-organization, NULL-organization, tenancy-disabled and own-organization references are not reported, and in the same run an id that exists nowhere is reported.
    • group: a cross-organization reference to an EXISTING row is not reported, because the probe is unscoped (no rts_account probe carries a tenantId), while an id that exists nowhere still is. A lit control runs the same stored rows under isolated, where both are reported. The "audit REPORTS" case now names isolated explicitly 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:

Ablation. Run on committed HEAD 5930c9898a through scripts/ablation-replace.mjs (WRAP mode), inside a script with its own EXIT/INT/TERM trap that restores from HEAD and checks the blob hash. The tests import ./engine.js from source, so no dist build is involved. The markers are comments, so they cannot change behaviour.

  • 1a: resolveMasterDetailParent back 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, stored readonlyWhen, stored requiredWhen, wiring).
  • 1b: resolveMasterDetailParents back to the bare elevation. The anchor went 1 → 0 and the marker 0 → 1. Result: 3 failed / 28 passed (insert, bulk stored requiredWhen, wiring).
  • 2 (re-run on 26421788b4 after 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: the isolated "audit REPORTS" case, and the group case's isolated lit control (:424, expected ['ct_grp','ct_gone'], received ['ct_gone']). The group half stays green under this mutation, as it should, because the mutation is the decided group behaviour. The blob was restored to a69246368bc5. Legs 1a and 1b were not re-run, because the leg-1 code is unchanged since 5930c9898a.
  • 1c (the group pin, run on 8b5c2cee52): resolveMasterDetailParents back to the bare elevation. The anchor went 1 → 0 and the marker 0 → 1. The new pin went red at its tenantIds assertion (:656, expected both organizations, received []). Its note required envelope stays green under the mutation, because an unscoped read also finds the org-Y header, so the tenantIds assertion is the one that discriminates. The blob was restored to 956383b5ce5a.
  • After each leg the blob equals HEAD (dce546fe7f5d) and git diff HEAD is empty. The control run afterwards passed 31/31. Every leg went red, as predicted.

Local verification (head 5930c9898a)

Re-run on 8b5c2cee52 after the second patch round: the two touched test files passed (32 tests), the objectql suite passed (305 files / 5089 tests), typecheck exited 0 with the ledger unchanged, check-system-context-census exited 0, check:query-options-erasure exited 0, and node scripts/check-issue-citations.mjs exited 0 (14 citations, all resolve), and node scripts/check-changeset-no-major.mjs --base origin/main exited 0. The full 64-command sweep and the --ran verdict below are from 5930c9898a; 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-typecheck is OK with the ledger unchanged (40 files / 234 errors / 65 signatures). tsc --listFilesOnly -p tsconfig.test.json lists both test files.
  • Downstream: the plugin-security master-detail / controlled_by_parent suites (8 files / 154 tests) passed against source objectql (aliased).
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 64 commands from the tree at 5930c9898a (merge base afc3b6492). All were run, with each exit code captured before any pipe; 62 exited 0. The --ran verdict: 64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).
    • Named by the dispatch: check-system-context-census exit 0 (110 elevation read sites in 20 packages across 45 files), check-platform-object-tenancy-census exit 0 (84 platform-namespace objects), check:org-identifier exit 0, and check:query-options-erasure exit 0 (test surface 236, at the ceiling; non-test 67 sites, none new).
    • NOT MEASURED: check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET: it needs every package's dist). As a narrower check, require() of the rebuilt packages/objectql/dist/index.js and dist/core.js loads (exit 0).
    • NOT MEASURED: check:type-check-debt (exit 3, PREREQUISITE NOT MET, for the same reason). objectql's own layers are covered by typecheck above.
  • Lint, narrowed and proven: eslint --no-inline-config --format json over the 4 touched .ts files counted 4 files linted, 0 errors and 0 warnings, and none of the files is ignored. eslint.config.mjs sets no parserOptions.project and 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-wide pnpm lint is CI's.
  • Dogfood (showcase-readonly-when-parent, federated-sweep-projections): NOT MEASURED locally, because they need the full showcase build. The Dogfood Regression Gate in CI runs them.

Deviations from the declared file surface

The group posture (decided)

Under group, the write rule's reach is the writer's whole membership set (accessible_org_ids becomes tenantIds), 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 inspectDanglingReferences docblock and the changeset. Under group, a stored reference into an organization that no writer of that row could reach (a write made before #19808's guard existed, an isSystem write, 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.
  • The audit keys the union exception on resolveEnginePosture() (the injected provider, then OS_TENANCY_POSTURE). buildDriverOptions widens to the union only when an injected provider returns 'group'. So in a lean embedding with an env-only group posture, 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.
  • The engine envelope for ERR_READONLY_FIELD_REJECTED resolves to status 500 through resolveThrownHttpError, because the error declares no status. The REST mapping was not measured, and nothing is filed.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 68 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 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 2548ba57deb83062cc94ea91a644309092251a0f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1969643772a66fcd0db81d7797b7b982af14c2ec — the merge of head 8b5c2cee52238d338430665e508ecdd205a31e43 into base 2548ba57deb83062cc94ea91a644309092251a0f, 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 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

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

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 26421788b475f8dd6336158c551ad3a2ce1008d6

Reviewed head = branch head = PR head (no drift). Merge-base afc3b64928; 5 files, +617/−30, exactly the declared surface (engine.ts, integrity/dangling-reference-audit.ts, two test files, the changeset).

① Derived judgments

  • (a) FALSE under group. The changeset's "One case to check" says a detail record pointing at a header in another organization "now edits as if its header were missing … The dangling-reference audit below now reports such records". At head, inspectDanglingReferences computes unionScope = postureUsesUnionScope(this.resolveEnginePosture()) and, when true, hands referenceExists no context, so a header that exists in any organization resolves. Under group those records are exactly the declared blind spot: NOT reported. The sentence carries no posture qualifier; the title line ("…and the dangling-reference audit reports cross-organization references") has the same defect. The paragraph's first sentence is also over-broad under group: leg 1 spreads the caller context, so a header inside the editor's accessible_org_ids still binds. Consumer-facing changeset prose false in a posture: defect, fix in ③-2.
  • (b) ACCURATE. The group blind spot in the docblock is correct and never stricter than the guard: buildDriverOptions widens to tenantIds only on an injected 'group' provider with a non-empty accessible_org_ids, else equality. Minor incompleteness: the parenthetical omits writes made before [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's guard existed.
  • (c) TRUE. carriesOrganization rejects undefined, null and '', so the port passes no context and no tenantId reaches the driver; same for tenancy.enabled: false and a federated phantom column. Pinned in both test files.
  • (d) TRUE. probe(target, id, organization?: string | null): a two-parameter implementer stays assignable, a two-argument call stays valid, and an implementer ignoring the argument behaves as before. The two pre-existing in-repo ports are untouched and Test Core is green.
  • (e) TRUE by construction, unpinned. All parent-binding sites and all assertReferencesResolve doors pass opCtx.context through referenceCheckContext → find/findOne → buildDriverOptions, the single place tenantIds is set, so a group member binds a header in any organization of their membership set. No leg-1 case runs under group (③-6).
  • Test names, docblocks and the remaining changeset statements (platform-global / federated / NULL-organization headers still bind; RLS still ignored; the audit runs with the lifecycle sweep) are true at head. The resolveMasterDetailParent docblock omits the group union (③-4).

② Semver level

@objectstack/objectql: patch with Clause-②: no HOLDS. DanglingReferenceAuditPort is exported, and ?organization is a type-level widening of one signature. But clause ② is about what consumers can hand the package; this is an OUTBOUND datum the audit hands to a port it calls. It adds no authorable key, no accepted payload key, no export and no error code, and the class is the #19800 tenant-isolation restoration precedent. Check Changeset is green on the current pair.

③ Boundary flags

  1. Posture-read divergence (dev-noted): ACCEPTABLE for this PR. The audit keys on resolveEnginePosture(); buildDriverOptions widens only on a raw injected 'group'. The divergent cell (no provider, env group) under-reports relative to the guard: less strict, never stricter, read-only, no exposure, in a configuration ADR-0105 D12 does not entitle.
  2. Changeset prose false under group (① a): MUST FIX BEFORE LANDING, prose only. Qualify the title line and the "One case to check" audit sentence with "outside the group posture", and say a header outside the caller's tenant scope (under group, outside their membership set) is treated as absent.
  3. Stale PR-body statements (the seat edits them; not verdict input): the "Open question" section, Leg 2's missing union exception, the Tests line on the group pin, A3's "Leg 2 now reports exactly these rows", ablation leg 2 and local verification cited at 5930c9898a, A4's unstated harness posture (isolated), and Deviations omitting the postureUsesUnionScope import.
  4. resolveMasterDetailParent docblock omits the group union: ACCEPTABLE; fold into the fix commit.
  5. Blind-spot parenthetical omits writes before [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: ACCEPTABLE.
  6. No leg-1 pin under group: ACCEPTABLE; add in the fix round.
  7. Under group the probe memo stays keyed per organization while the engine ignores it: perf only, ACCEPTABLE.
  8. Checks at review time: 32 of 33 latest-per-name success or skipped; Type Check · workspace still in_progress.

Implemented-by: claude/issue-19837-parent-binding-tenant-scope
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

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 group posture at head. The minimal fix is ③-2, a prose-only changeset edit. A patch round is in flight; a fresh review runs on its head.


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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8b5c2cee52238d338430665e508ecdd205a31e43

Reviewed head = branch head = PR head (no drift). Merge-base afc3b64928; 5 files, +645/−30, exactly the declared surface. main's only engine.ts drift since the merge-base is three hunks in reportFindFailure (#19840), disjoint from every region this PR edits. Since the FAILed head 26421788b4 (record 5794714685), the diff is changeset prose, engine.ts docblocks only (no non-comment line changed), and one new test. The earlier defect is gone: the title line and the "One case to check" paragraph are posture-qualified.

① Derived judgments

  • (a) Title line. The audit half is TRUE in every posture: postureUsesUnionScope is posture === 'group', so single and isolated probe under the row's organization and only group stays unscoped. The leg-1 shorthand ("another organization's header") is defined by the body's own parenthetical (under group, outside the caller's membership set). TRUE.
  • (b) "One case to check": TRUE. Outside group the probe runs under the detail row's organization and the other organization's header is not found, so the record is reported. Under group the probe is unscoped and the header resolves, so it is not reported, exactly as the paragraph now says.
  • (c) Leg-1 sentence on headers outside the caller's scope: TRUE at every door. Both resolvers read under referenceCheckContext(context) = { ...context, isSystem: true }. buildDriverOptions forwards tenantId, and adds tenantIds only on an injected 'group' provider with a non-empty accessible_org_ids. The exemptions hold as stated: tenancy.enabled: false, federated, NULL-organization, RLS bypass, and a system caller with no tenantId. All five call sites pass opCtx.context.
  • (d) Audit paragraph: TRUE in all four cells (NULL organization, empty stamp, tenancy-disabled and federated phantom all probe unscoped; the memo key cannot collide). "Implementations that ignore it keep working": TRUE. probe(target, id, organization?: string | null) keeps two-parameter implementers assignable; the two pre-existing in-repo ports are untouched and every Type Check gate is green.
  • (e) New group leg-1 test: meaningful. The positive half separates union from equality (note required, plus tenantIds on the single header find; the recorded ablation received []). The org-X-only lit control answers header reference_not_found and would also red under the bare read.
  • (f) Audit group test: meaningful. No rts_account probe carries a tenantId, and the same rows re-run under isolated are reported.
  • (g) Docblocks and test names: TRUE. The PR body is true except "9 cases" for the leg-1 describe, which now has 10.

② Semver level

@objectstack/objectql: patch with Clause-②: no HOLDS. The diff adds no export (DanglingReferenceAuditPort was already exported), no authorable or accepted key, no error code, and no public signature change on ObjectQL. The only type-level change is an OPTIONAL third parameter on a port the package CALLS: an outbound datum, not a widened accept set. The class is a tenant-isolation restoration, as PR #19800 was. Check Changeset and check-changeset-no-major are green.

③ Boundary flags

  1. Posture-read divergence: ACCEPTABLE. Every cell was walked (provider 'group', env-only group, an unnormalised provider value, a throwing env). There is no cell where the audit is stricter than the guard; the less-strict cells are read-only and sit in a lean embedding with no Layer 0 wall above the driver.
  2. DanglingReferenceAuditPort.probe docblock ("The engine probes under that organization") lacks the group qualifier: ACCEPTABLE. The deciding site states the exception, and a custom port replaces the engine's.
  3. Title-line leg-1 shorthand under group: ACCEPTABLE, defined two sentences later.
  4. NULL-organization detail row in "One case to check": ACCEPTABLE; the audit paragraph states that rule.
  5. PR body "9 cases" (10 at head): ACCEPTABLE, not consumer-facing.
  6. Test Core (1/6) red at review time: @objectstack/spec error-catalog-docs.test.ts threw ENOENT … packages/spec/tsup.config.bundled_*.mjs from walk() in scripts/check-error-status-conformance.mjs, a readdir-then-stat race with a concurrent tsup build. Neither file is in this diff, and the same shard was green on 26421788b4. NOT a defect of the diff; one re-run is required before landing.
  7. The unreleased [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 changeset on main says the audit "still checks existence across all organizations". In the same release as this changeset, the pair reads as a contradiction to an upgrader. OPTIONAL, docs-only, outside this PR's surface.
  8. Under group the memo stays keyed per organization while the probe is unscoped: perf only, ACCEPTABLE.
  9. single posture with explicitly stamped rows from writers carrying no tenantId: the audit probes them scoped while their writes were checked unscoped. Not claimed anywhere: ACCEPTABLE.

Implemented-by: claude/issue-19837-parent-binding-tenant-scope
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 13:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 2bbb462 Sep 23, 2026
58 of 60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19837-parent-binding-tenant-scope branch September 23, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant