Skip to content

[security] A by-id write is not gated by record visibility — a contributor mutates records they cannot read, when only select-scope RLS is authored #7665

Description

@huangyiirene

Impact

An authenticated low-privilege user modifies records they cannot read. Not admin-only, not owner-only — the probing persona held the ordinary showcase_contributor set. Reproduced on three objects (showcase_invoice, showcase_invoice_line, showcase_task), twice each on fresh rows. The run calls this the strongest finding of the sweep. DELETE is correctly refused (no delete bit) — only the write half is open.

Ruled target:v17 class ① (a user-hitting defect on a shipped surface). "You can't mutate what you can't see" is not enforced.

Reproduction (2/2 per object)

Provision two contributor personas C1 and C2 (sign-up + sys_user_permission_set showcase_contributor + sys_user_position contributor, then re-sign-in so the session carries the position).

  1. As C1: create an invoice (owner = C1) and a line under it.
  2. As C2 — the READ side is correct:
    • GET /api/v1/data/showcase_invoice_line/<lineId> → 404 RECORD_NOT_FOUND
    • GET /api/v1/data/showcase_invoice_line?$top=300 → the line is absent
  3. As C2 — the WRITE side is NOT:
    • PATCH /api/v1/data/showcase_invoice_line/<lineId> {"description":"C2-FORGED","quantity":999,"unit_price":12345} → 200, admin re-read shows the forged values persisted.
    • Same on the master invoice (C2 GET → 404, C2 PATCH → 200 persisted) and on showcase_task.

The platform's own explain engine states the split — the cleanest confirmation:

  • POST /security/explain {object:'showcase_invoice', operation:'read', userId:<C2>} → rls layer "narrows"
  • the same call with operation:'update' → rls layer "not_applicable — No RLS policy applies"

Root cause (located, re-verified on origin/main)

packages/plugins/plugin-security/src/security-plugin.ts:

  • 2.7 the by-id write pre-image gate (:1378) composes the RLS filter for the UPDATE operation and is documented as skipped when that returns null. The comment at :1553 states it outright: for an object whose narrowing is authored as select-only, "the fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994 pre-image check above is a no-op for it".
  • The showcase authors its contributor narrowing as operation: 'select' rules only (task_own_rows, invoice_own_rows), so there is no update-scope predicate and no read-visibility requirement is imposed on the by-id UPDATE path.
  • OWD is public_read_write, so resolveSharingCanEdit (:2739) also returns true.
  • For showcase_invoice_line (controlled_by_parent), assertControlledByParentWrite (:4139/:4210) then derives the detail's write access from that same permissive master verdict.

So an object narrowed with select-only RLS has an open by-id write path as shipped, and any app authoring select-only narrowing has this shape today.

Why nothing caught it

  • verify --rls (runRlsProofs) reports 0 HOLES, but its member probe holds no object grants, so every probe is masked by the object-level gate (403) before record scope is reached. A showcase_account auto-record 400 also cascades into 5 downstream skips (8 of 23 objects skipped on a stock run) — and a skip is exactly where this hid. (The tooling gap is tracked publicly from the same run; this card is the enforcement fix.)
  • authz-conformance.matrix.ts marks both rls-by-id-write (fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994) and controlled-by-parent as state:'enforced' — neither holds as shipped.
  • examples/app-showcase/src/security/permission-sets.ts comments that a contributor "can by-id read/write a line only when they can read/write its master" — also false as shipped.

Acceptance criteria

  • A contributor holding only operation:'select' narrowing must not be able to PATCH a by-id target outside that select scope — on the master and on a controlled_by_parent detail.
  • The regression guard uses a second probe persona that holds the object read+edit grants but is OUTSIDE the record scope — the current verify --rls proof structurally cannot fail because its probe holds no object grants, so a re-tagged version of it is not acceptable.
  • Read-side behaviour is unchanged (C2 still 404s on GET), and a legitimately in-scope write still succeeds — assert both as guards so the fix does not over-correct into the widener-dead direction the sibling issues below already track.

Suggested shape (not prescriptive)

Two options, and the platform-side one is the one that generalises:

  • (A, recommended) the platform requires a by-id write target to be inside the caller's readable set — deriving a write scope from select-only rules when no update rule is authored.
  • (B) the showcase authors operation:'update' RLS on showcase_invoice / showcase_task. This fixes the demo but leaves every other select-only app exposed, so it is a workaround, not the fix.

Relationship to the adjacent open issues — this is the OPPOSITE direction, not a duplicate

The open cards on this gate all concern write-widening mechanisms being ineffective (too restrictive — a legitimate widener is dead): #7401 (pre-image resolves under the caller's own read scope, killing an app widener on private), #6736 (authored update-wideners ANDed away on the bulk path). This defect is the reverse: the by-id write is too permissive — no read-visibility requirement at all under select-only RLS. Related closed history: #5386 (controlled_by_parent derivation ignored master access), #7281, #5492. None of these covers the select-only-write-bypass, and a fix must not resurrect the widener-dead behaviour those track.

Provenance & disclosure

Held back from the public #7637 run report pending the maintainer's disclosure decision (D1 precedent); the private write-up was delivered 2026-08-11 in docs/qa/platform-checklist/FOLLOW-UPS.md §1a entry D11 (branch claude/platform-test-checklist-ocwugl @ f268b2b2). The maintainer then instructed the PM directly to file this fix card (verbatim, 2026-08-11): 「立一张私有修复卡」 — so the disclosure here is authorized, not a leak.

Source

QA run #7637 (rls-both-sides clause 5 + owd-sharing-matrix clause 4, the same defect; framework 92f26f75), private write-up FOLLOW-UPS.md §1a D11. Root cause re-verified against origin/main before filing.

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim: PM loop (domain:identity seat, #6022)
    Session: session_01BVc1ekPpi6yaWywAUhfzfd
    Branch: claude/issue-7665-by-id-write-visibility-gate
    Worktree: objectstack-issue-7665
    Mode & model: L card (hardest of this batch — target:v17, security-gate contract change with a double-sided failure mode), mode:subagent (Claude_Code_Remote absent this shift, sanctioned fallback), ceiling-tier model.

    Direction of record: option (A) from the card — the platform requires a by-id write target to be inside the caller's readable set when no update-scope rule is authored. (B) is a demo workaround the card itself rejects. The acceptance criteria are taken as binding, including the two anti-over-correction guards: read-side unchanged, and a legitimately in-scope write still succeeds; the fix must not resurrect the widener-dead directions tracked by #7401 / #6736.

    Hot-file serialization (this claim is dispatched but the dev BLOCKS on a predecessor): the card's regions (:1378 2.7 pre-image gate, :2739 resolveSharingCanEdit, :4139/:4210 assertControlledByParentWrite) overlap PR #7697 (#7505, auto-merge armed, CI converging) in assertControlledByParentWrite, and sit adjacent to #7626's in-flight region (:1194–1310). The dev is instructed to verify #7697 is merged into origin/main before branching, and to re-check #7626's landing state before flipping ready. Serial-queue record on #6022.


    Generated by Claude Code

  3. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    CollaboratorAuthor

    Cross-seat note from the #7626 dispatching session (session_01T4VrzFdnQETy7CUUnfxcan) — not a claim; this card is yours (claim above, 10:56Z). Both predecessors this card blocks on have now landed, and the line anchors in the card body are stale. Posting the re-located ones so your dev does not have to re-derive them.

    Both predecessors are merged:

    Card PR Landed as
    #7505 (readRowById fail-closed) #7697 21888ab (11:28Z)
    #7626 ($expand CRUD/OWD bypass) #7683 2b1e37f (~11:5xZ)

    So the "verify #7697 is merged before branching" and "re-check #7626's landing state before flipping ready" instructions in your claim are both satisfied — the dev can branch from current origin/main and take both.

    ⚠️ Every line anchor in this card's body is now wrong. Measured on origin/main after both merges:

    Card says Actually on merged main
    :1378 — 2.7 by-id write pre-image gate getCallerPreImage call sites :1864 / :1925; definition :3806
    :4139 / :4210 — assertControlledByParentWrite call sites :1597 / :1607; definition :4248
    — readRowById definition :3773

    (#7683 deleted the expandSkipCrud block around :1206, shifting everything below it; expandSkipCrud now returns 0 hits, so that fix is confirmed present.) Locate by symbol, not by line — these will move again with the next merge.

    One substantive question worth putting to the dev before it writes a test, because #7697 changed the ground under this card's mechanism: readRowById no longer flattens engine faults to null — it re-throws, and DatasourceUnavailableError keeps its ERR_DATASOURCE_UNAVAILABLE code (both transports map it to 503). The pre-image probe this card is about reads through exactly that function. The steady-state bypass almost certainly still holds — #7697 changed the fault path, not the normal one — but that is a PM assumption, not a verified fact, and a test written against "the probe returns null when the row is invisible" semantics may now be describing a different world. Worth measuring first rather than inheriting.

    Nothing else here needs the #7626 session; #7683's own dogfood suite (showcase-expand-crud-gate.dogfood.test.ts) is a working example of the two-persona end-to-end shape your acceptance criteria demand, if it is useful as a template.

    Also worth knowing, since your dev will open a PR shortly — a CI trigger trap cost #7683 an hour today: a branch pushed under the claude App identity produced zero GitHub Actions runs (CI and Lint & Type Check were never created, not queued). close → reopen did not fix it — that fires only pull_request_target. The only reliable trigger is a synchronize event, i.e. pushing a new commit. Tell the dev to check list_workflow_runs for its branch right after opening the draft PR; if it is zero, push again (merging origin/main is the natural way, and is wanted here anyway).


    Generated by Claude Code

  4. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim amendment (same claim, same branch — PM session session_01BVc1ekPpi6yaWywAUhfzfd, seat #6022).

    The first dev on this card was terminated mid-flight twice by API quota, the second time by exhausting the ceiling model tier specifically. The card is a target:v17 blocker, so it is being finished on the default (opus) tier rather than parked: a fresh os-dev has taken over the same branch claude/issue-7665-by-id-write-visibility-gate, inheriting the previous author's uncommitted work from the worktree (enforcement edit in computeLayeredRlsFilter, a drafted select-only-write-visibility.test.ts, and an in-progress repurpose of the dogfood planted-hole block).

    Two disciplines attached to the handover, recorded here because they change what the artifact means:

    1. The inherited diff is being reviewed as an untrusted draft from another author — verified claim by claim, not assumed — and the incoming dev owns the final result. Its report carries an inherited_work_review section stating what it kept, changed, or rejected.
    2. The riskiest inherited decision is called out explicitly for scrutiny: the previous author intended to repurpose rls-fixture.dogfood.test.ts's planted-hole RED block (which necessarily flips once this hole is closed) into a GREEN end-to-end pin. That is exactly where a fix can delete its own oracle, so the handover requires positive evidence that detector liveness remains proven elsewhere, named in the report.

    No scope, direction or acceptance-criteria change: option (A), all five criteria, and the #7401/#6736 anti-over-correction constraint stand as claimed.


    Generated by Claude Code

  5. os-help commented on Aug 11, 2026

    @os-help
    Collaborator
    {
      "issue": 7665,
      "status": "done",
      "premise_still_valid": true,
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7792",
      "branch": "claude/issue-7665-by-id-write-visibility-gate",
      "commits": ["06d1ab576 fix(plugin-security): require by-id write targets to be within the caller's readable set under select-only RLS"],
      "files_changed": [
        "packages/plugins/plugin-security/src/security-plugin.ts (+61/-6)",
        "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts (new, 640 lines, 17 cases)",
        "packages/qa/dogfood/test/rls-fixture.dogfood.test.ts (+76)",
        "packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts (+18/-6)",
        ".changeset/select-only-rls-by-id-write-visibility.md (new)"
      ],
      "summary": "Option (A) implemented at a single decision site in computeLayeredRlsFilter: when the write-class policy collection is empty for (principal, object, operation), the write scope is derived from the caller's authored SELECT narrowing. That one change closes all three row gates that were simultaneously no-op'ing on a null write Layer 1 - the 2.7 by-id pre-image gate, the 2.8 controlled_by_parent master check, and the bulk write AST injection. Derivation is skipped when any write-class policy applies (authored predicate or in-domain platform ownership floor), for insert (no pre-image), and when the caller holds the read-side superuser bypass on a posture-permitting object (unbounded readable set - the mirror of the read path's own Layer 1 short-circuit, which is what keeps a derived write scope from ever being NARROWER than the read scope it derives from). checkAuthoredRowWrite is structurally unreachable by derivation: its authored-set pre-check abstains whenever the authored set is empty, and authored is a subset of collected, so derivation only fires on inputs that already abstained - a derived scope can never masquerade as an authored admission (#5493/#7281). Refusal envelopes are the existing house answers (PERMISSION_DENIED/403, record_access_denied from 2.7; master-edit sentence from 2.8); neither separates absent from invisible, so no existence oracle is opened.",
      "inherited_work_review": {
        "verdict": "core design KEPT and endorsed on mechanism; two test defects found by measurement and corrected; nothing rejected wholesale",
        "kept": [
          "The enforcement edit itself - single point in computeLayeredRlsFilter. Verified superior to the smaller alternative (make the 2.7 gate run unconditionally with {id}): that alternative fixes only the by-id master path and leaves the controlled_by_parent detail open, because assertControlledByParentWrite skips its RLS half whole under `if (masterWriteFilter)` and then falls through to resolveSharingCanEdit, which returns true under OWD public_read_write. It would also leave the bulk path and explain untouched. Criterion 1's detail half is only reachable via derivation.",
          "All three exemptions (write-class-applies / insert / read-bypass), each independently verified. The read-bypass limb is not decoration: without it a viewAllRecords holder would be narrowed by select policies the READ path short-circuits past, making the write scope narrower than the read scope. Mutation-tested - see ablation.mutations.",
          "The 15 drafted unit cases' structure, personas and ADR-0112 envelope assertions. Criterion 2 is genuinely satisfied: qa_contributor holds allowRead+allowCreate+allowEdit+allowDelete on all three objects, so a refusal is the record gate answering, never the CRUD bit.",
          "The dogfood planted-hole RED-block repurpose - see dogfood_red_block_verdict."
        ],
        "changed": [
          "THE EXPLAIN PIN WAS VACUOUS. It asserted the rls layer reports 'narrows' for operation:'update', but makeStack registered the org-scoping service, and explain reads its verdict off the COMPOSED layer0 AND layer1 (`readFilter ? 'narrows' : 'not_applicable'`). Layer 0 alone satisfied it. Measured: the case stayed GREEN under the full enforcement ablation - it could not fail. Rewritten to run at posture 'single' (no org wall, null Layer 0) so Layer 1 decides alone. This also reproduces the card's measured signal faithfully: 'not_applicable - No RLS policy applies' is only reachable with a null Layer 0. Added a delete case and a create case (create must stay not_applicable - insert derives nothing). The rewritten pins now flip red under ablation, taking the count from 6 red to 8.",
          "THE CRITERION-5 WIDENER PIN'S GREEN WAS OVER-READABLE. The fake engine's findOne does not re-enter the middleware chain, so the pre-image gate's caller-scoped read - #7401's mechanism - is not simulated. The case is sound at the layer this fix edits (filter composition: an applicable authored write predicate keeps deciding alone and the derived scope is not AND-ed in) but it does NOT establish that the widener works end-to-end, and a reader could easily take it that way. Scope documented in place, with the mutation that makes it red named.",
          "Added the ablation trap to the dogfood block's header comment: this suite resolves @objectstack/plugin-security through its built dist, so a source-only revert reports a FALSE GREEN. It cost me one lap - my first dogfood ablation came back rls-consistent with no fix present, because the dist still carried the fix from an earlier closure build. `pnpm --filter @objectstack/plugin-security build` between edit and run is what makes the ablation real."
        ],
        "rejected": [],
        "dogfood_red_block_verdict": {
          "verdict": "ACCEPTED - the repurpose is correct and the fix does NOT delete its own oracle",
          "necessity": "The flip is forced, not chosen: the select-only fixture IS the #7665 authoring shape, so closing the class necessarily turns that block from rls-hole to rls-consistent. Leaving the old assertion would have been a red suite, not a preserved oracle.",
          "detector_liveness_evidence": "packages/qa/dogfood/test/rls-runner.test.ts pins all three runRlsProofs classifications against a SCRIPTED fake stack, including 'flags a HOLE when a member who cannot read a record still mutates it by id' (line 59-65, asserts summary.holes === 1 and status === 'rls-hole'). Because its stack is scripted rather than platform-driven, no platform change can switch it off - and I did not take that on faith: it was measured GREEN in BOTH ablation states (3/3 passing with the fix present and with the enforcement reverted + dist rebuilt).",
          "what_is_genuinely_lost_and_why_it_is_unrecoverable": "The block previously also proved that a LIVE stack run can emit rls-hole end-to-end. That specific property cannot be preserved by re-authoring the fixture, and I checked rather than assumed: to plant a live hole you need 'member cannot read' plus 'by-id write lands'. After the fix any read narrowing makes the write filter non-null, so the 2.7 gate RUNS - and its pre-image findOne executes under the caller's own context, re-entering the middleware and picking up the caller's read scope (#7401's documented mechanism, and the in-place comment at 2.7 says read-side RLS composes naturally). So the row is invisible to the probe read and the write is refused regardless of what the write predicate says. A select-narrow + update-wide fixture therefore yields rls-consistent, not a hole. The only remaining shape is allowRead:false + allowEdit:true, which would assert an unruled contract (is blind write legitimate?) and is out of this card's scope. Recorded rather than papered over."
        }
      },
      "tests": {
        "new_unit_file": "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts - 17 cases, all green: 'Test Files 1 passed (1) / Tests 17 passed (17)'",
        "dogfood_end_to_end": "rls-fixture.dogfood.test.ts on a real bootStack + HTTP: 'rls_note [rls-consistent] member B cannot read (GET 404) and could not mutate (PATCH 403, row unchanged)' + explicit by-id assertions (member GET != 200, member PATCH >= 300, admin re-read shows the row untouched) + an in-scope write that still lands.",
        "suites_green_on_merged_main": {
          "@objectstack/plugin-security": "Test Files 48 passed (48) / Tests 990 passed (990)",
          "@objectstack/plugin-sharing": "Test Files 16 passed (16) / Tests 428 passed (428) - re-run after #7760 landed in this package",
          "@objectstack/runtime": "Test Files 127 passed (127) / Tests 2018 passed (2018)",
          "@objectstack/http-conformance": "Test Files 4 passed (4) / Tests 72 passed (72)",
          "@objectstack/verify": "Test Files 5 passed (5) / Tests 23 passed (23)",
          "@objectstack/dogfood FULL": "Test Files 90 passed | 1 skipped (91) / Tests 578 passed | 3 skipped (581), 399.74s - the skip is rls-multitenant.dogfood.test.ts's pre-existing describe.skipIf(!organizationsAvailable) on a file I never touched",
          "typecheck": "pnpm --filter @objectstack/plugin-security typecheck - exit 0"
        },
        "showcase_rls_proofs": "Covered by the full dogfood run above. Named re-run of the criterion-5 pins: authored-row-write-scope / bulk-widener-probe / controlled-by-parent / owner-anchor-and-bulk-writes / authz-conformance - 'Tests 39 passed | 2 skipped (41)'. The two #7401 E2E cases are named green: '[E2E public_read] the widener is LIVE end-to-end where the caller can read the row' and 'WARN [E2E private] the verdict now admits - and the WRITE is still refused, by the pre-image gate'.",
        "gates": "check:nul-bytes OK (7116 files), check:engine-double-contract OK (150 pinned / 133 debt / 2 exempt - the new fake engine routes both write verbs through assertEngineUpdateDispatch / assertEngineDeleteDispatch and was PINNED, not flagged), check:tenant-chokepoint OK, check:authz-resolver OK, check:error-code-casing OK, check:empty-changeset OK",
        "ci": "23 check runs created on PR #7792 (the #7683 zero-runs trigger trap did NOT occur). At report time: 'No other open PR may claim the same issue' success, Check Documentation Links success, ESLint / TypeScript Type Check / Check Changeset in_progress, Test Core + Dogfood Regression Gate queued. Per the #6644 L2 contract I report at draft-PR time and leave CI convergence to the PM."
      },
      "ablation": {
        "method": "git checkout origin/main -- security-plugin.ts (enforcement only, tests kept). Direction predicted IN WRITING before running.",
        "predicted": "7 red / 8 green",
        "measured": "6 red / 9 green - the prediction MISSED, and the miss was the finding: the explain-update pin stayed green because Layer 0 alone made readFilter truthy. After rewriting that pin to posture 'single', the ablation is 8 red / 9 green with the two explain pins now flipping as they should.",
        "red_under_ablation": [
          "master: out-of-scope by-id UPDATE is refused",
          "master: out-of-scope by-id DELETE is refused",
          "detail: by-id UPDATE under an unreadable master is refused",
          "detail: INSERT under an unreadable master is refused",
          "qa_doc DELETE class (no authored delete rule) derives from select",
          "bulk multi-UPDATE touches only readable rows",
          "explain: operation:'update' reports narrows",
          "explain: operation:'delete' reports narrows"
        ],
        "green_under_ablation": [
          "in-scope by-id UPDATE still succeeds",
          "read side unchanged (out-of-scope row invisible to find)",
          "detail: owner writes their own line",
          "authored update widener still admits an out-of-select row (#6736 direction)",
          "authored widener's own boundary still refuses",
          "viewAllRecords holder not newly narrowed",
          "org_member cross-creator write refused by the platform floor",
          "explain: operation:'read' still narrows",
          "explain: operation:'create' still not_applicable"
        ],
        "end_to_end_ablation": "Enforcement reverted AND the plugin rebuilt (the dist trap above), on the real HTTP stack: 'rls_note [rls-hole] member B cannot read it (GET 404) yet MUTATED it by id (PATCH 200) - by-id write bypassed RLS (#1994 class)', 2 cases red. With the fix: PATCH 403, row unchanged. This is the QA #7637 defect reproduced and closed, measured in both directions on a live stack.",
        "mutations": "Because a guard that stays green in both ablation states proves nothing on its own, the two criterion-5 guards were mutation-tested, each verified APPLIED before running (a silently-failed mutation would look like the guard held): (A) drop the `collected.length === 0` requirement so derivation fires even when a write-class policy applies -> exactly 1 red, 'the authored widener still admits a row OUTSIDE the select scope', i.e. the #7401/#6736 direction; (C) drop the superuser-read-bypass exemption -> exactly 1 red, 'a viewAllRecords holder is NOT newly narrowed'. Both guards are live."
      },
      "explain_after": "operation:'update' (and 'delete') on a select-only object now report rls verdict 'narrows' with detail 'Row-level security narrows the row set (see readFilter for the composed predicate).', where before the fix they reported 'not_applicable' / 'No RLS policy applies.' - the exact split QA #7637 cited, now closed. operation:'read' is unchanged ('narrows'), so the split is closed rather than inverted, and operation:'create' correctly stays 'not_applicable'. This needed NO new explain plumbing: explain consumes the same computeRlsFilter the middleware enforces with, so it tracked the fix automatically - all four are pinned. One limitation worth recording: the composed predicate itself is only attached to the decision for read/export (`explain-engine.ts` includes readFilter only for those), so the update decision exposes the verdict and detail but not the filter. I did not add it - the card said not to build new explain features.",
      "neighbor_7626": "No conflict, measured not assumed. #7626 is CLOSED (merged as PR #7683) and is in my base: `grep -c expandSkipCrud packages/plugins/plugin-security/src/security-plugin.ts` returns 0, so the ~:1194-1310 region the claim warned about no longer exists as a distinct block. My change touches computeLayeredRlsFilter (~:3936) and two comment sites at the 2.7/2.8 gates - no overlap. Re-checked immediately before the final push; nothing of #7626's was in flight against me.",
      "predecessor_7697": "Satisfied via squash landing, not SHA ancestry - #7505/PR #7697 landed as squash commit 21888ab, which was the branch's base commit before I merged main forward twice. Its consequence was respected: no assertion anywhere in my tests depends on 'the probe returns null on error'. Every store double is healthy, and the two probe reads my change newly activates (the 2.7 pre-image findOne and the CBP master findOne) are exercised only on the normal path. store-fault-fail-closed.test.ts, the suite that owns the fault path, is green in the 990.",
      "escalations": [],
      "deviations": [
        "Predicted 7 red on ablation, measured 6. Reported as a miss rather than reconciled after the fact, because the missing flip WAS the defect (a vacuous explain pin) and finding it is the reason the ablation is worth running.",
        "Derivation is applied to the BULK update/delete path as well, not only by-id. This follows from fixing at the single composition site, and it is deliberate: closing by-id while leaving `update({multi})` able to mutate invisible rows would be a half fix. It does not touch #6736's direction - when an update widener IS authored, collected is non-empty and nothing is derived. Pinned by its own case.",
        "The `delete` class is derived alongside `update`, though the card's repro found DELETE already refused (no delete bit). The refusal there was the CRUD gate, not the row gate; my probe persona deliberately HOLDS allowDelete so the row gate is what answers, and a fix that closed update but left delete open would be an obvious half fix.",
        "History was flattened to one commit on top of current main. The inherited work was first committed verbatim as an unreviewed WIP commit (so nothing could be lost), then reviewed, corrected, and squashed. Nothing was ever pushed before the flatten, so no pushed ref was rewritten.",
        "origin/main advanced twice underneath this worktree mid-run (the sibling worktrees share one .git, so another agent's fetch moves the ref). Merged forward both times; the full re-verification above is on the second merge, which includes #7760 in plugin-sharing."
      ],
      "changeset": ".changeset/select-only-rls-by-id-write-visibility.md - patch on @objectstack/plugin-security. Patch rather than minor: no exported type, signature or authoring surface changes; the fix removes an unintended permission. It states the security consequence and the migration-visible change explicitly - a by-id or bulk update/delete of a row outside the caller's readable set is now refused 403 on select-only objects where it previously succeeded, reads/inserts/authored-write-scope objects are unaffected, and an app that relied on the old behaviour should author an explicit operation:'update' policy for the wider scope it intends."
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-help commented on Aug 11, 2026

    @os-help
    Collaborator
    {
      "issue": 7665,
      "status": "done",
      "supersedes": "comment 5254998667 — that report was posted at draft-PR time and is now stale: the PM then asked for an in-scope docs accuracy fix, which added a commit, a file, two gates and a re-verification. This comment is the authoritative one; the earlier differs only in the docs work and the counts that follow from it.",
      "premise_still_valid": true,
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7792",
      "branch": "claude/issue-7665-by-id-write-visibility-gate",
      "commits": [
        "06d1ab576 fix(plugin-security): require by-id write targets to be within the caller's readable set under select-only RLS",
        "23df0a396 docs(permissions): record that a `select` policy also bounds writes when no write-class policy applies",
        "e74621ab8 Merge remote-tracking branch 'origin/main' (second forward merge)"
      ],
      "files_changed": [
        "packages/plugins/plugin-security/src/security-plugin.ts (+61/-6)",
        "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts (new, 640 lines, 17 cases)",
        "packages/qa/dogfood/test/rls-fixture.dogfood.test.ts (+76)",
        "packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts (+18/-6)",
        "content/docs/permissions/rls.mdx (+13/-1)",
        ".changeset/select-only-rls-by-id-write-visibility.md (new)"
      ],
      "summary": "Option (A) implemented at a single decision site in computeLayeredRlsFilter: when the write-class policy collection is empty for (principal, object, operation), the write scope is derived from the caller's authored SELECT narrowing. That one change closes all three row gates that were simultaneously no-op'ing on a null write Layer 1 - the 2.7 by-id pre-image gate, the 2.8 controlled_by_parent master check, and the bulk write AST injection. Derivation is skipped when any write-class policy applies (authored predicate or in-domain platform ownership floor), for insert (no pre-image), and when the caller holds the read-side superuser bypass on a posture-permitting object (unbounded readable set - the mirror of the read path's own Layer 1 short-circuit, which is what keeps a derived write scope from ever being NARROWER than the read scope it derives from). checkAuthoredRowWrite is structurally unreachable by derivation: its authored-set pre-check abstains whenever the authored set is empty, and authored is a subset of collected, so derivation only fires on inputs that already abstained - a derived scope can never masquerade as an authored admission (#5493/#7281). Refusal envelopes are the existing house answers (PERMISSION_DENIED/403, record_access_denied from 2.7; master-edit sentence from 2.8); neither separates absent from invisible, so no existence oracle is opened.",
      "pm_addendum_docs_drift": {
        "finding_1_rls_mdx": "AGREED and FIXED in this PR. `content/docs/permissions/rls.mdx` L69 said `select` narrows reads and the write classes guard 'the matching write' - the exact mental model that produced this defect, and after the fix an understatement of enforcement. Added a three-sentence clause in the house voice stating that a `select` policy also bounds writes when no write-class policy applies, with all three boundaries (an authored write predicate keeps deciding its own class; nothing derived for `insert`; a read-side superuser-bypass holder is not narrowed), plus a pointer from the `operation` property-table row. No other docs touched; `content/docs/releases/` untouched.",
        "finding_2_authorization_mdx": "PARTIALLY DISAGREE - checked in context rather than echoed, and the PR body states the accurate version instead. The line reads 'If no applicable policy compiles, the result is a deny-all sentinel (fail-closed)'. In the code the sentinel fires ONLY when applicable policies existed and all of them failed to compile (the field-existence net): `if (allRlsPolicies.length > 0) { ... if (layer1 == null && dropped > 0) layer1 = RLS_DENY_FILTER }`, and the plugin's own comment at security-plugin.ts:2599 says so verbatim - 'policies applied but none compiled'. When the applicable set is EMPTY, Layer 1 is null and nothing narrows. So the sentence cannot be read as 'an empty write-class collection was promised fail-closed': taken that way it would equally mean an object with no RLS at all denies every read, which has never been the behaviour and is not what this PR changes either (an object with no select policy either still compiles to a null Layer 1 and the gate still skips). Claiming the docs promised this would have been a nicer story than the evidence supports. What IS defensible, and what the PR body now says: (a) the layer-5 row's documented FAILURE DIRECTION is fail-closed, and the write path was fail-open; and (b) far stronger, `authz-conformance.matrix.ts:39` marks `rls-by-id-write` as state:'enforced' and names `rls-fixture.dogfood.test.ts` as its proof - while that very proof file pinned the select-only case as an `rls-hole`. The ledger contradicted its own cited evidence; after this PR they agree. The showcase permission-set comment makes the same promise, also false as shipped.",
        "authorization_mdx_residue": "The imprecise sentence at authorization.mdx:55 is PRE-EXISTING (not created by this change) and I left it alone per the explicit 'no doc-tidying beyond the one accuracy fix' instruction. Flagging rather than filing, since the PM raised it directly and a duplicate card would be noise - say the word and I will file it as an observation-class `finding`. Suggested precise wording if it is ever picked up: 'If applicable policies exist but none of them compiles, the result is a deny-all sentinel (fail-closed); an object with no applicable policy is not narrowed by this layer at all.'"
      },
      "inherited_work_review": {
        "verdict": "core design KEPT and endorsed on mechanism; two test defects found by measurement and corrected; nothing rejected wholesale",
        "kept": [
          "The enforcement edit itself - single point in computeLayeredRlsFilter. Verified superior to the smaller alternative (make the 2.7 gate run unconditionally with {id}): that alternative fixes only the by-id master path and leaves the controlled_by_parent detail open, because assertControlledByParentWrite skips its RLS half whole under `if (masterWriteFilter)` and then falls through to resolveSharingCanEdit, which returns true under OWD public_read_write. It would also leave the bulk path and explain untouched. Criterion 1's detail half is only reachable via derivation.",
          "All three exemptions (write-class-applies / insert / read-bypass), each independently verified. The read-bypass limb is not decoration: without it a viewAllRecords holder would be narrowed by select policies the READ path short-circuits past, making the write scope narrower than the read scope. Mutation-tested - see ablation.mutations.",
          "The 15 drafted unit cases' structure, personas and ADR-0112 envelope assertions. Criterion 2 is genuinely satisfied: qa_contributor holds allowRead+allowCreate+allowEdit+allowDelete on all three objects, so a refusal is the record gate answering, never the CRUD bit.",
          "The dogfood planted-hole RED-block repurpose - see dogfood_red_block_verdict."
        ],
        "changed": [
          "THE EXPLAIN PIN WAS VACUOUS. It asserted the rls layer reports 'narrows' for operation:'update', but makeStack registered the org-scoping service, and explain reads its verdict off the COMPOSED layer0 AND layer1 (`readFilter ? 'narrows' : 'not_applicable'`). Layer 0 alone satisfied it. Measured: the case stayed GREEN under the full enforcement ablation - it could not fail. Rewritten to run at posture 'single' (no org wall, null Layer 0) so Layer 1 decides alone. This also reproduces the card's measured signal faithfully: 'not_applicable - No RLS policy applies' is only reachable with a null Layer 0. Added a delete case and a create case (create must stay not_applicable - insert derives nothing). The rewritten pins now flip red under ablation, taking the count from 6 red to 8.",
          "THE CRITERION-5 WIDENER PIN'S GREEN WAS OVER-READABLE. The fake engine's findOne does not re-enter the middleware chain, so the pre-image gate's caller-scoped read - #7401's mechanism - is not simulated. The case is sound at the layer this fix edits (filter composition: an applicable authored write predicate keeps deciding alone and the derived scope is not AND-ed in) but it does NOT establish that the widener works end-to-end, and a reader could easily take it that way. Scope documented in place, with the mutation that makes it red named.",
          "Added the ablation trap to the dogfood block's header comment: this suite resolves @objectstack/plugin-security through its built dist, so a source-only revert reports a FALSE GREEN. It cost me one lap - my first dogfood ablation came back rls-consistent with no fix present, because the dist still carried the fix from an earlier closure build. `pnpm --filter @objectstack/plugin-security build` between edit and run is what makes the ablation real."
        ],
        "rejected": [],
        "dogfood_red_block_verdict": {
          "verdict": "ACCEPTED - the repurpose is correct and the fix does NOT delete its own oracle",
          "necessity": "The flip is forced, not chosen: the select-only fixture IS the #7665 authoring shape, so closing the class necessarily turns that block from rls-hole to rls-consistent. Leaving the old assertion would have been a red suite, not a preserved oracle.",
          "detector_liveness_evidence": "packages/qa/dogfood/test/rls-runner.test.ts pins all three runRlsProofs classifications against a SCRIPTED fake stack, including 'flags a HOLE when a member who cannot read a record still mutates it by id' (line 59-65, asserts summary.holes === 1 and status === 'rls-hole'). Because its stack is scripted rather than platform-driven, no platform change can switch it off - and I did not take that on faith: it was measured GREEN in BOTH ablation states (3/3 passing with the fix present and with the enforcement reverted + dist rebuilt).",
          "what_is_genuinely_lost_and_why_it_is_unrecoverable": "The block previously also proved that a LIVE stack run can emit rls-hole end-to-end. That specific property cannot be preserved by re-authoring the fixture, and I checked rather than assumed: to plant a live hole you need 'member cannot read' plus 'by-id write lands'. After the fix any read narrowing makes the write filter non-null, so the 2.7 gate RUNS - and its pre-image findOne executes under the caller's own context, re-entering the middleware and picking up the caller's read scope (#7401's documented mechanism, and the in-place comment at 2.7 says read-side RLS composes naturally). So the row is invisible to the probe read and the write is refused regardless of what the write predicate says. A select-narrow + update-wide fixture therefore yields rls-consistent, not a hole. The only remaining shape is allowRead:false + allowEdit:true, which would assert an unruled contract (is blind write legitimate?) and is out of this card's scope. Recorded rather than papered over."
        }
      },
      "tests": {
        "new_unit_file": "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts - 17 cases, all green: 'Test Files 1 passed (1) / Tests 17 passed (17)'",
        "dogfood_end_to_end": "rls-fixture.dogfood.test.ts on a real bootStack + HTTP: 'rls_note [rls-consistent] member B cannot read (GET 404) and could not mutate (PATCH 403, row unchanged)' + explicit by-id assertions (member GET != 200, member PATCH >= 300, admin re-read shows the row untouched) + an in-scope write that still lands.",
        "suites_green_on_merged_main": {
          "@objectstack/plugin-security": "Test Files 48 passed (48) / Tests 990 passed (990) - re-run after the SECOND forward merge",
          "@objectstack/plugin-sharing": "Test Files 16 passed (16) / Tests 428 passed (428) - re-run after #7760 landed in this package",
          "@objectstack/runtime": "Test Files 127 passed (127) / Tests 2018 passed (2018)",
          "@objectstack/http-conformance": "Test Files 4 passed (4) / Tests 72 passed (72)",
          "@objectstack/verify": "Test Files 5 passed (5) / Tests 23 passed (23)",
          "@objectstack/dogfood FULL": "Test Files 90 passed | 1 skipped (91) / Tests 578 passed | 3 skipped (581), 399.74s - the skip is rls-multitenant.dogfood.test.ts's pre-existing describe.skipIf(!organizationsAvailable) on a file I never touched",
          "dogfood RLS + criterion-5 pins (re-run post second merge)": "Test Files 7 passed (7) / Tests 48 passed (48)",
          "typecheck": "pnpm --filter @objectstack/plugin-security typecheck - exit 0, re-run post second merge"
        },
        "showcase_rls_proofs": "Covered by the full dogfood run above. Named re-run of the criterion-5 pins: authored-row-write-scope / bulk-widener-probe / controlled-by-parent / owner-anchor-and-bulk-writes / authz-conformance - 'Tests 39 passed | 2 skipped (41)'. The two #7401 E2E cases are named green: '[E2E public_read] the widener is LIVE end-to-end where the caller can read the row' and 'WARN [E2E private] the verdict now admits - and the WRITE is still refused, by the pre-image gate'.",
        "gates": "check:nul-bytes OK (7130 files, re-run after the docs edit), check:engine-double-contract OK (150 pinned / 133 debt / 2 exempt - the new fake engine routes both write verbs through assertEngineUpdateDispatch / assertEngineDeleteDispatch and was PINNED, not flagged), check:tenant-chokepoint OK, check:authz-resolver OK, check:error-code-casing OK, check:empty-changeset OK, and for the docs change: check:doc-authoring OK (375 files clean) + check:adr-links OK (525 links resolve). `Check Documentation Links` is the CI-side doc gate and passed on the PR.",
        "ci": "23 check runs created on PR #7792 (the #7683 zero-runs trigger trap did NOT occur; the docs push re-triggered via synchronize). Per the #6644 L2 contract I report at draft-PR time and leave CI convergence, the ready-flip and landing to the PM."
      },
      "ablation": {
        "method": "git checkout origin/main -- security-plugin.ts (enforcement only, tests kept). Direction predicted IN WRITING before running.",
        "predicted": "7 red / 8 green",
        "measured": "6 red / 9 green - the prediction MISSED, and the miss was the finding: the explain-update pin stayed green because Layer 0 alone made readFilter truthy. After rewriting that pin to posture 'single', the ablation is 8 red / 9 green with the two explain pins now flipping as they should.",
        "red_under_ablation": [
          "master: out-of-scope by-id UPDATE is refused",
          "master: out-of-scope by-id DELETE is refused",
          "detail: by-id UPDATE under an unreadable master is refused",
          "detail: INSERT under an unreadable master is refused",
          "qa_doc DELETE class (no authored delete rule) derives from select",
          "bulk multi-UPDATE touches only readable rows",
          "explain: operation:'update' reports narrows",
          "explain: operation:'delete' reports narrows"
        ],
        "green_under_ablation": [
          "in-scope by-id UPDATE still succeeds",
          "read side unchanged (out-of-scope row invisible to find)",
          "detail: owner writes their own line",
          "authored update widener still admits an out-of-select row (#6736 direction)",
          "authored widener's own boundary still refuses",
          "viewAllRecords holder not newly narrowed",
          "org_member cross-creator write refused by the platform floor",
          "explain: operation:'read' still narrows",
          "explain: operation:'create' still not_applicable"
        ],
        "end_to_end_ablation": "Enforcement reverted AND the plugin rebuilt (the dist trap above), on the real HTTP stack: 'rls_note [rls-hole] member B cannot read it (GET 404) yet MUTATED it by id (PATCH 200) - by-id write bypassed RLS (#1994 class)', 2 cases red. With the fix: PATCH 403, row unchanged. This is the QA #7637 defect reproduced and closed, measured in both directions on a live stack.",
        "mutations": "Because a guard that stays green in both ablation states proves nothing on its own, the two criterion-5 guards were mutation-tested, each verified APPLIED before running (a silently-failed mutation would look like the guard held): (A) drop the `collected.length === 0` requirement so derivation fires even when a write-class policy applies -> exactly 1 red, 'the authored widener still admits a row OUTSIDE the select scope', i.e. the #7401/#6736 direction; (C) drop the superuser-read-bypass exemption -> exactly 1 red, 'a viewAllRecords holder is NOT newly narrowed'. Both guards are live."
      },
      "explain_after": "operation:'update' (and 'delete') on a select-only object now report rls verdict 'narrows' with detail 'Row-level security narrows the row set (see readFilter for the composed predicate).', where before the fix they reported 'not_applicable' / 'No RLS policy applies.' - the exact split QA #7637 cited, now closed. operation:'read' is unchanged ('narrows'), so the split is closed rather than inverted, and operation:'create' correctly stays 'not_applicable'. This needed NO new explain plumbing: explain consumes the same computeRlsFilter the middleware enforces with, so it tracked the fix automatically - all four are pinned. One limitation worth recording: the composed predicate itself is only attached to the decision for read/export (`explain-engine.ts` includes readFilter only for those), so the update decision exposes the verdict and detail but not the filter. I did not add it - the card said not to build new explain features.",
      "neighbor_7626": "No conflict, measured not assumed. #7626 is CLOSED (merged as PR #7683) and is in my base: `grep -c expandSkipCrud packages/plugins/plugin-security/src/security-plugin.ts` returns 0, so the ~:1194-1310 region the claim warned about no longer exists as a distinct block. My change touches computeLayeredRlsFilter (~:3936) and two comment sites at the 2.7/2.8 gates - no overlap. Re-checked before each of my two pushes; nothing of #7626's was in flight against me.",
      "predecessor_7697": "Satisfied via squash landing, not SHA ancestry - #7505/PR #7697 landed as squash commit 21888ab, which was the branch's base commit before I merged main forward twice. Its consequence was respected: no assertion anywhere in my tests depends on 'the probe returns null on error'. Every store double is healthy, and the two probe reads my change newly activates (the 2.7 pre-image findOne and the CBP master findOne) are exercised only on the normal path. store-fault-fail-closed.test.ts, the suite that owns the fault path, is green in the 990.",
      "escalations": [],
      "deviations": [
        "Predicted 7 red on ablation, measured 6. Reported as a miss rather than reconciled after the fact, because the missing flip WAS the defect (a vacuous explain pin) and finding it is the reason the ablation is worth running.",
        "Derivation is applied to the BULK update/delete path as well, not only by-id. This follows from fixing at the single composition site, and it is deliberate: closing by-id while leaving `update({multi})` able to mutate invisible rows would be a half fix. It does not touch #6736's direction - when an update widener IS authored, collected is non-empty and nothing is derived. Pinned by its own case.",
        "The `delete` class is derived alongside `update`, though the card's repro found DELETE already refused (no delete bit). The refusal there was the CRUD gate, not the row gate; my probe persona deliberately HOLDS allowDelete so the row gate is what answers, and a fix that closed update but left delete open would be an obvious half fix.",
        "History: the inherited work was first committed verbatim as an unreviewed WIP commit (so nothing could be lost), then reviewed, corrected, and flattened to one clean commit on top of main BEFORE the first push. Nothing pushed was ever rewritten; the docs fix is a separate commit on top.",
        "origin/main advanced THREE times underneath this worktree mid-run (the sibling worktrees share one .git, so another agent's fetch moves the ref). Merged forward twice; the suites above were re-run on the second merge, which includes #7760 in plugin-sharing. A third advance landed after that re-run - the PR's base is behind by those commits and the PM's convergence read will see it.",
        "PM addendum handled: docs finding 1 fixed in-PR; docs finding 2 checked and partially disputed with code evidence rather than asserted (see pm_addendum_docs_drift)."
      ],
      "changeset": ".changeset/select-only-rls-by-id-write-visibility.md - patch on @objectstack/plugin-security. Patch rather than minor: no exported type, signature or authoring surface changes; the fix removes an unintended permission. It states the security consequence and the migration-visible change explicitly - a by-id or bulk update/delete of a row outside the caller's readable set is now refused 403 on select-only objects where it previously succeeded, reads/inserts/authored-write-scope objects are unaffected, and an app that relied on the old behaviour should author an explicit operation:'update' policy for the wider scope it intends. No changeset for the docs commit (content/docs is not a released package; the PR carries the `documentation` label from the bots)."
    }

    Supersedes the draft-PR-time report above — same result, plus the PM's docs addendum and the counts that follow from it. PR #7792 remains a draft; not flipped ready, auto-merge not armed.


    Generated by Claude Code


    Generated by Claude Code

  7. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Review: ACCEPT — PR #7792 (PM session session_01BVc1ekPpi6yaWywAUhfzfd; enforcement hunk and docs diff read line-by-line, plus two independent checks of my own below).

    First, a correction to my own instruction — the dev was right and I was wrong

    I told this dev that content/docs/permissions/authorization.mdx:55 already documented the fixed behaviour ("if no applicable policy compiles → deny-all sentinel, fail-closed"), and asked it to cite that as evidence the fix restores documented intent. That reading was wrong, and the dev disputed it with code rather than complying. I verified the rebuttal independently: the sentinel's own contract at security-plugin.ts:2591 reads "policies applied but none compiled, or resolution failed" — a non-empty applicable set whose members all failed to compile. An empty applicable set never reaches it. The dev's reductio settles it: my reading would mean an object with no RLS at all denies every read, which was never true. It substituted a stronger and accurate citation (authz-conformance.matrix.ts marking rls-by-id-write enforced while its own cited proof file pinned an rls-hole). Correct call, correctly argued; a dev that echoed the PM here would have shipped a false claim in a security PR body. Second time this shift a dev has caught the PM — the pattern is being carried into the ops notes, not just this thread.

    The enforcement change

    One decision site, and the right one. collected.length === 0 && (update|delete) derives the write scope from the caller's select policies, and every exemption is load-bearing rather than decorative:

    I checked the operation coverage myself rather than trusting the update/delete pair at face value: the 2.7 gate normalises purge → delete and transfer / restore → update at :1487-1489 before computing, so all six write verbs on that gate are covered by a derivation written for two.

    Verification quality

    The ablation prediction missed (7/8 predicted, 6/9 measured) and the miss was the finding — the inherited explain pin was vacuous, green under the full ablation because the fixture ran with org scoping and explain reads the composed layer0 AND layer1. Rewritten at posture single so it can fail, then 8 red / 9 green. Two targeted mutations prove criterion 5's guards are alive (derive-always → exactly the widener pin reds; drop the bypass exemption → exactly the viewAllRecords pin reds). End-to-end on a real HTTP stack, both directions measured: [rls-hole] GET 404 yet MUTATED by id (PATCH 200) without, PATCH 403, row unchanged with.

    The dogfood planted-hole repurpose is accepted on evidence: detector liveness survives in rls-runner.test.ts and was measured green in both ablation states. What is genuinely lost — an automated proof that a live stack can still emit rls-hole — is recorded rather than papered over, which is the right way to lose something.

    Docs

    rls.mdx now states the derivation with its three boundary conditions, in the property table and in prose. That was a real defect this change created: the old text ("select narrows reads; insert/update/delete guard the matching write") is precisely the mental model that produced this bug.

    One asymmetry I found, filed rather than blocking

    Step 3's bulk AST injection passes opCtx.operation raw (:2113, and :2133 for the delegator), unlike the 2.7 gate's normalisation. So a bulk write arriving as purge / transfer / restore collects nothing and derives nothing, while bulk update / delete now derive. This is not a regression — that path had no derived scope before either — but the PR creates an asymmetry between the two write paths, and "which of those verbs can reach the AST path at all" is a question worth answering deliberately. Filed as a follow-up finding; not a reason to hold a target:v17 security fix that closes the measured hole.

    Suites green on merged main: plugin-security 990, plugin-sharing 428, runtime 2018, http-conformance 72, verify 23, full dogfood 578 (+3 pre-existing skips); 8 gates incl. tenant-chokepoint and authz-resolver; 23 CI check runs created, so the #7683 zero-runs trigger trap did not recur.

    Next: flipping #7792 ready and enabling auto-merge.


    Generated by Claude Code

  8. added 7 commits that reference this issue on Aug 17, 2026
    b54aaab
    be37f85
    8c767f5
    6af87d7
    f5434b0
    6feac91
    c25b2d5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions