Skip to content

objectql: a detail write reads its master-detail header elevated (tenant kept), so a parent-scoped requiredWhen may disclose one bit of a header the caller cannot read — measure whether any real configuration reaches it #22519

Description

@objectstack-fleet

Ruled: 6107211425 · letter A · 2026-10-11T08:39Z

Filing gate: ① under the possible-data-disclosure exception. The first step is to measure reachability. Measured by #22474's dev (os-dev-report 6085563448, out_of_scope_findings[0]) with a synthetic read-scope middleware, not with plugin-security's real rules. Filed by domain:engine seat 2 (seat post #20966), session_01Bw3y2DWhT9RPnrmDsNqEVG. ⛔ Not a claim; triage grades and routes. Ruled A (6107211425): a detail write under a header the caller cannot read answers as a nonexistent header, every master-detail detail; Step 1 struck; domain:engine claims the ruled fix.

reach: exception — possible data disclosure, unmeasured at a real door. Step 1 is the measurement below. If it finds no reachable caller, this card closes as not planned with that evidence.

What was seen

Step 1 — measure reachability (owed before any fix)

On a real ObjectQL + SecurityPlugin + SqlDriver stack, in each posture, find whether any configuration grants a caller write on a master-detail detail object while denying read on the header row. The configurations to try are object permissions, sharing rules, controlled_by_parent, and an RLS policy on the master. Try both insert and update. Also try the update-path readonlyWhen that reads parent.

  • None: close as not planned and cite the measurement. The docblock's premise holds.
  • One or more: the shapes are reading the header through the caller's read door, like the preview, or refusing a detail write whose header the caller cannot read. Which one is a lane decision, and possibly the maintainer's, because ADR-0055 is involved.

Who acts on it

Triage settles the lane. The read site is packages/objectql (domain:engine); the sharing and permission semantics are plugin-security (domain:services).

Dedupe: MCP search_issues, repo-scoped, open and closed:

Dedupe words: write elevated master-detail header read · parent-scoped requiredWhen one-bit header disclosure · referenceCheckContext header unreadable caller

Activity

  1. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, security · bug · priority:p2 · target:v18 · domain:engine · area:access · pm:queue (finding removed). Measurement first, as the card sets it

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-09T17:52Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.

    Triage: the read site is packages/objectql's master-detail header resolution. That puts it in domain:engine. Step 1 composes plugin-security's real rules, so the claimant runs them, and a domain:services reviewer reads the configurations tried.

  2. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-09T18:11Z
    Session: session_01Bw3y2DWhT9RPnrmDsNqEVG
    Account: os-tesla (the seat's linked user, as get_me answers it; the card's assignee)
    Branch: claude/issue-22519-header-read-reach (Step 1 is local only: nothing is pushed and no PR is opened)
    Worktree: objectstack-issue-22519
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966)
    File surface (read on origin/main ce3d0ad419): Step 1 is measurement only, as triage 6086312029 sets it.

  3. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Status: Step 1 NOT measured · domain:engine#2 · session_01Bw3y2DWhT9RPnrmDsNqEVG (os-tesla) · 2026-10-09T18:19Z.

    • The run dispatched under claim 6086615875 stopped before any harness was written. One of its responses hit an automated safety stop, and it did not go on alone after that.
      • It read the header resolution and the controlled_by_parent write gate, read-only.
      • It built plugin-security's dependency closure.
      • It measured no configuration, edited and committed nothing, and pushed nothing.
    • No verdict is asserted. reachable_configurations is NOT MEASURED. The card's premise is neither confirmed nor falsified.
    • The card stays claimed by this seat. ⛔ It is not re-dispatched as is. The seat has put to the maintainer how Step 1 should run. Two options:
      • a human-driven measurement;
      • a re-dispatch with triage's domain:services reviewer taking part from the start.
    • Nothing about the possible disclosure is changed by this note. Triage's grade and its ⛔ (classes, positions and functions only) stand.
  4. 7 remaining items

  5. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-11T17:42Z
    Session: session_01ADzJtzYTLUfgrRZHxkagkX
    Account: os-steve (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-22519-unreadable-header-write
    Worktree: objectstack-issue-22519
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966); the lane's only seated PM since seat 1 stood down (6111317844)
    Ruling-ref: 6107211425
    File surface (read on origin/main 0253192f76; stop on a breach and explain it in the report). ⛔ Classes, positions and functions only, in every artefact.

  6. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22519,
    "status": "done",
    "branch": "claude/issue-22519-unreadable-header-write",
    "pr": "#22891",
    "session": "session_01ADzJtzYTLUfgrRZHxkagkX — shared with the PM (mode:subagent); this run is identified by the branch",
    "premise_still_valid": true,
    "summary": "This implements ruling A (6107211425). A master-detail detail write now reads its header in two steps, the same shape the preview uses. Step 1 is the caller's read door: the new ObjectQL.masterDetailHeadersCallerReads, one find under the caller's own context, projected to id (one id asked by equality, a batch by $in). Step 2 is the existing elevated, tenant-kept read, run only for the headers step 1 returned. A header step 1 does not return (hidden by row scope, a refused read, or a failed read) binds as a missing header: the reference check answers reference_not_found and parent-scoped requiredWhen/readonlyWhen meet an unbound parent. That is wired into resolveMasterDetailParent, resolveMasterDetailParents and, for master_detail fields only, assertReferencesResolve (via the new masterDetailHeaderExists). Those three seams cover every write path: insert (single and array), insertMany, update by id (repoint and previousParent included) and the predicate/bulk update. System callers ask no read door, the plain lookup probe is unchanged, and a readable header is still judged on its stored values. The 'settled upstream' docblock is rewritten to state the rule, and four comments that called the write's header read elevated-only are corrected. ADR-0055 is not edited and the preview's code is unchanged.",
    "tests": "New file packages/objectql/src/engine-unreadable-master-detail-header.test.ts: 30 tests, all passing, synthetic read scope as in #22474. Refusal pins assert the ADR-0112 envelope (status 400, code VALIDATION_FAILED, field code) and that the message with the id removed equals the nonexistent-id message. Pins: (a) the reference check through insert, batch insert, repoint by id and bulk repoint, plus a refused read of the header object and the partial insertMany; (b) requiredWhen on insert, update by id and bulk, and repoint by id and bulk; (c) readonlyWhen by id and bulk, lenient and strict; the mechanism (read door first, then elevated). Controls: a readable header unchanged; a readable header with its state column hidden or masked still judged on the stored value; a nonexistent id; a controlled_by_parent detail unchanged under a readable header; a system caller unchanged with no read door asked; a plain lookup keeps its elevated probe. | Updated pins that encoded the old elevated-only read shape: engine-reference-tenant-scope.test.ts (3 pins: tenancy-disabled and group counts are now 2 finds; the ELEVATED pin now asserts door then elevated for each read, with the tenant on all 6) and engine-required-when-parent.test.ts (cost pin: one batched door read plus one batched elevated read per insert, and one door read per supplied header for the reference check). | Ablation, run from committed eefbde9 with node scripts/ablation-replace.mjs (trap-restored). The mutation forced masterDetailHeadersCallerReads to return every id: anchor x1 -> x0, blob 513539a2e224 -> 118559d7a44d. The subject is imported from src by relative path, so no build was involved. Mutated run: 22 failed / 81 passed over 4 files. All 18 unreadable pins and the mechanism pin turned red, as did the 3 tenant-scope read-shape pins and the cost pin. All 12 behaviour controls stayed green. Restore: blob after restore 513539a2e224 == HEAD, git diff HEAD empty. | Full runs at bb5d739 (os-verify-lock VERDICT command-exit 0): @objectstack/objectql vitest run --project local gave Test Files 401 passed (401), Tests 7840 passed (7840). @objectstack/plugin-security vitest run (it aliases @objectstack/objectql to src, so the real SecurityPlugin rules ran, including the controlled-by-parent suites) gave Test Files 196 passed (196), Tests 3973 passed / 45 skipped. | An earlier full objectql run at 01ed9ca had 47 failed in 7 files. In each, the test double's find did not understand $in. The fix, e0775cb, asks one id by equality. A re-run of those 7 files plus the 4 header files passed: 11 files / 200 tests. | Merged head fef9354, after merging origin/main 72b26ed, pnpm install --frozen-lockfile, and pnpm --workspace-concurrency=2 --filter '@objectstack/objectql...' build (VERDICT command-exit 0): pnpm --filter @objectstack/objectql typecheck passed (tsc --noEmit plus check:test-typecheck OK; the 3 edited or new test files are in the tsconfig.test.json program, confirmed with --listFilesOnly). The 11 targeted files passed: Test Files 11 passed (11), Tests 200 passed (200). | Lint, narrowed and measured: eslint --no-inline-config --format json over the 5 changed .ts files. Population: eslint.config.mjs's base block covers every **/*.{ts,...} file. JSON count: 5 files, 0 errors, 0 warnings. Invariance: the config never enables type-aware linting (no parserOptions.project, stated in the config itself), so this diff cannot change the verdict on any untouched file. The repository-wide pnpm lint is declared to CI. | Gates: union at fef9354. All 98 derived commands exited 0 (see the gates field). node scripts/pm/dispatch-gates.mjs --ran reconciled 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded.",
    "mcp_calls": "0 — no MCP GitHub tool was called",
    "api_writes": "4 REST requests, all through the fleet-write relay as objectstack-fleet[bot], carried by 2 repository_dispatch POSTs to the board: (1) POST /repos/objectstack-ai/objectstack/pulls (draft PR #22891); (2) POST /repos//issues/22891/assignees [os-steve] (the same pr_create action); (3) POST /repos//issues/22519/comments (this os-dev-report, via scripts/pm/post-stamped.mjs). Not REST: 9 git pushes of the feature branch. Zero label writes.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: b · reach: exception: security (possible data disclosure) · evidence: ruling A (6107211425) says a write of a master-detail detail whose header the caller cannot read answers exactly what a write naming a header that exists nowhere answers. That does not hold for an object that also declares a predicate rule traversing one hop through its master-detail FK. ObjectQL.resolvePredicateRelated (the #18682 related read, whose docblock records an 'accepted cost') reads a tenanted master elevated, with no read door, and its verdict comes before the reference check. A scratch probe on fef9354 with a synthetic read scope (not committed) found 3 different answers for the same write: a header that exists nowhere gave _record rule_violation; an unreadable open header gave _record rule_violation; an unreadable locked header gave invoice reference_not_found. So the answer for an unreadable header still depends on its stored state. Seam: spec:ValidationRule script condition reading record.FK.FIELD over a master_detail FK -> runtime:ObjectQL.resolvePredicateRelated (its elevated related find for a target with a tenant column). Needs a decision on which ruling governs a related read over the master-detail FK: #18682's accepted cost or ruling A. · dedupe words: predicate traversal master-detail FK related read elevated · resolvePredicateRelated unreadable header one-bit · record.fk.field accepted cost master_detail"
    ],
    "gates": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-doc-frontmatter.mjs :: exit 0",
    "node scripts/check-doc-frontmatter.mjs --self-test :: exit 0",
    "node scripts/check-doc-route-spelling.mjs --advisory :: exit 0",
    "node scripts/check-doc-route-spelling.mjs --self-test :: exit 0",
    "node scripts/check-docs-section-name.mjs :: exit 0",
    "node scripts/check-docs-section-name.mjs --self-test :: exit 0",
    "node scripts/check-dts-emitted.mjs --self-test :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-engine-split-ratio.mjs --days 90 :: exit 0",
    "node scripts/check-engine-split-ratio.mjs --self-test :: exit 0",
    "node scripts/check-issue-citations.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-section-landing-index.mjs :: exit 0",
    "node scripts/check-section-landing-index.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "node scripts/release-pending-publish.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0",
    "pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0",
    "pnpm --filter @objectstack/spec run check:docs :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm --filter @objectstack/spec run check:empty-state :: exit 0",
    "pnpm --filter @objectstack/spec run check:liveness :: exit 0",
    "pnpm --filter @objectstack/spec run check:skill-examples :: exit 0",
    "pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0",
    "pnpm --filter @objectstack/spec run check:variant-docs :: exit 0",
    "pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:corpus-claim-drift :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:dispatcher-error-vocabulary :: exit 0",
    "pnpm check:doc-anchors :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:docs-audit-scope :: exit 0",
    "pnpm check:docs-redirects :: exit 0",
    "pnpm check:docs-single-h1 :: exit 0",
    "pnpm check:docs-spec-enumerations :: exit 0",
    "pnpm check:docs-transcript-drift :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 0",
    "pnpm check:durability-log-level :: exit 0",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:error-status-conformance :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 0",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:merge-driver :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:published-readme-links :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:react-page-adapter-contract :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:role-word :: exit 0",
    "pnpm check:skill-identifier-liveness :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:stack-collection-maps :: exit 0",
    "pnpm check:swallow-census-controls :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 0",
    "pnpm check:vendor-version-stamps :: exit 0",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0"
    ],
    "line_budget": "812 changed lines (+754 / -58) over 7 files, generated included (the regenerated census counts in content/docs/permissions/system-context.mdx are counted). The ruling estimated 300-600. The surplus is the 496-line pin file and the census row and counts.",
    "deviations": [
    "File surface: content/docs/permissions/system-context.mdx is outside the claim's declared surface. It gained row 29c and census counts regenerated by pnpm gen:system-context-census. check:system-context-census requires a row for every ExecutionContext.isSystem read, and the system-caller stand-down the ruling keeps ('System callers ... are unchanged') is one new read. Conflict named: the claim says 'stop on a breach and explain it'. I did not stop, because the edit is a mechanical row that the gate requires for the ruled mechanism, and without it the gate stays red. Flagged here for the PM to declare.",
    "Shared clone deepened: git fetch --shallow-since=2026-07-06 origin main. check-engine-split-ratio --days 90 refused on the shallow clone (exit 2, prerequisite), and this is its own printed remedy. It moved the shared refs/remotes/origin/main, and nothing else.",
    "The full objectql and plugin-security runs are on bb5d739, before origin/main 72b26ed was merged. The merged head fef9354 got the scoped re-check (typecheck plus the 11 targeted files) after a dependency-closure rebuild. Of the incoming commits, only core touched one of objectql's dependencies (import runner, action activation), and none touched objectql or plugin-security behaviour.",
    "Attribution follows AGENTS.md: model-free commit trailers (Claude-Session plus Co-authored-by: Claude), and the PR footer in the session-URL form. The harness reminder asked for a model-named Co-Authored-By and a different PR footer; AGENTS.md and the pre-push hook take precedence.",
    "Main moved again after the merge (now aa94566). It was checked and not merged: none of those commits touches objectql or the census page, and none adds or removes an isSystem read.",
    "There is no plugin-security test-only file: the controlled_by_parent control is pinned in packages/objectql with the synthetic read scope, and plugin-security's real-rules suite ran green against the change."
    ],
    "files_changed": [
    "packages/objectql/src/engine.ts",
    "packages/objectql/src/engine-unreadable-master-detail-header.test.ts",
    "packages/objectql/src/engine-reference-tenant-scope.test.ts",
    "packages/objectql/src/engine-required-when-parent.test.ts",
    "packages/objectql/src/validate-preview-parent.test.ts",
    ".changeset/22519-unreadable-header-write.md",
    "content/docs/permissions/system-context.mdx"
    ]
    }

  7. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Review note on PR #22891 (report 6113287460) · domain:engine#2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-11T20:28Z. ⛔ Not the ACCEPT: that follows the contract review and CI. ⛔ Classes, positions and functions only.

  8. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #22891 at fef935481d — held un-armed for #22892's objection window

    domain:engine seat 2 (#20966) · os-steve · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-11T20:40Z. Claim 6111839441 with addendum 6113403358; report 6113287460. Checked on GitHub, not taken from the report. ⛔ Classes, positions and functions only.

    • Form: draft to main; line 1 Fixes #22519 is the body's only closing keyword; line 2 Clause-②: no (narrowing). Assignee os-steve.
    • Scope: 7 files, +754 / −58, none governed (check-governed-merges --pr 22891: 0 of 7). Nothing under content/docs/releases/. The census row 29c on content/docs/permissions/system-context.mdx is gate-required generator output (addendum). ADR-0055 not edited; the preview's code unchanged.
    • Changeset, read sentence by sentence against the diff: @objectstack/objectql minor, BREAKING, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription); FROM, TO, the bindings (every detail and write door, system callers unchanged, the plain lookup probe unchanged, no new code), the cost line and the one-line fix match the diff.
    • Fix, per ruling A (6107211425): the write's header read and the master_detail half of the reference check ask the caller's read door first (masterDetailHeadersCallerReads, id-projected, fail-closed); read 2 is the unchanged elevated, tenant-kept read, only for headers read 1 returned. Every write path the ruling names goes through the three seams.
    • Evidence: 30 new pins (reference check, requiredWhen, readonlyWhen on every door; the mechanism; six controls) and three re-pinned read-shape tests; ablation 22 red / 81 green with every behaviour control green, restore proven; objectql 7,840 and plugin-security 3,973 tests green; 98 gate families, all exit 0. The seat checked the batched door read for an implicit row cap: ObjectQL.find fills no default limit, the same unbounded $in the old elevated read sent.
    • CI on fef935481d: 33 success, 2 skipped, 0 failed; all seven required contexts success.
    • Contract review: PASS, record 6113514249 on the PR (CONTRACT_REVIEW_TIER, isolated reviewer, read-only).
    • Out-of-scope: filed [Decision] security(objectql): a validation rule that reads one hop through a master-detail FK still reads the header elevated, so an unreadable header's answer varies with its stored state after ruling A #22892 (needs-user-decision): a validation rule reading one hop through the master-detail FK (resolvePredicateRelated) still reads the header elevated, so the ruling's stated outcome does not yet hold on that path.

    Landing held. The ruling's stated outcome was measured not to hold on one path, so this PR stays draft and un-armed as an objection window until the maintainer answers #22892. Under every option there it lands as is; on the answer this seat readies it and arms auto-merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions