Summary
A member with a legitimate create grant on a CHILD object gets HTTP 500 from POST /api/v1/data/<child> — after the row has already been written — whenever the child feeds a roll-up summary on a PARENT record the member may not write. The roll-up recompute runs under the caller's execution context, so updating the parent's summary fields raises PermissionDeniedError, which surfaces as SummaryRecomputeError / ERR_SUMMARY_RECOMPUTE.
The caller sees a 500 for a write that succeeded. A client that retries (or a user who clicks Save again) creates a duplicate row.
Repro (real runtime, examples/app-showcase)
- Boot the showcase, sign in as admin, sign up a plain member (holds only the
everyone baseline showcase_member_default, which grants showcase_task: create + read).
- As that member:
POST /api/v1/data/showcase_task
{ "title": "SummaryBugProbe-1", "project": "<a project owned by someone else>",
"assignee": "<member email>", "status": "todo", "priority": "low" }
- Response: 500
{"error":"Internal server error","code":"INTERNAL_ERROR"}
- As admin:
GET /api/v1/data/showcase_task?filters=[["title","=","SummaryBugProbe-1"]] → 1 row. The write landed.
Server log for that request:
ERROR Insert operation failed {"object":"showcase_task", …
SummaryRecomputeError: Roll-up summary recompute failed after retries for 2 parent record(s);
the triggering records WERE written (summary values may be stale).
failures: [
{ childObject: 'showcase_task', parentObject: 'showcase_project',
parentId: '6nljsw5hhuT_DPUL', field: 'task_count', error: [PermissionDeniedError] },
{ childObject: 'showcase_task', parentObject: 'showcase_project',
parentId: '6nljsw5hhuT_DPUL', field: 'total_estimate', error: [PermissionDeniedError] }
]
code: 'ERR_SUMMARY_RECOMPUTE'
PATCH on the member's own child row fails the same way (500, row updated).
Cause
packages/objectql/src/engine.ts passes the caller's context straight into the recompute:
- L7578 —
const summaryFailures = await this.recomputeSummaries(object, result, null, opCtx.context);
- L8478 / L9174 — the update paths do the same.
So the parent write inherits the caller's permissions. But a roll-up is engine-derived state, not a user write: whether showcase_project.task_count may be refreshed is not a question about the member's grant on showcase_project. The retry loop (framework#3147) cannot help — a permission denial is deterministic, so it burns every retry and then throws.
Impact
Any parent/child pair where the child is more widely writable than the parent — the ordinary shape for line items, tasks, comments, time entries. The member's own create is a legitimate, granted operation; it 500s on a permission check about a record they never asked to touch, and the failure is not atomic (row written, error returned).
Suggested direction
Recompute summaries under a system context (the recompute is engine-owned state, and the permission decision that matters — may the caller write the CHILD — has already been made), and keep SummaryRecomputeError for genuinely transient failures. If a caller-scoped recompute must stay for some reason, a permission denial should not be retried and should not fail the caller's otherwise-successful write.
Summary
A member with a legitimate
creategrant on a CHILD object gets HTTP 500 fromPOST /api/v1/data/<child>— after the row has already been written — whenever the child feeds a roll-up summary on a PARENT record the member may not write. The roll-up recompute runs under the caller's execution context, so updating the parent's summary fields raisesPermissionDeniedError, which surfaces asSummaryRecomputeError/ERR_SUMMARY_RECOMPUTE.The caller sees a 500 for a write that succeeded. A client that retries (or a user who clicks Save again) creates a duplicate row.
Repro (real runtime,
examples/app-showcase)everyonebaselineshowcase_member_default, which grantsshowcase_task: create + read).{"error":"Internal server error","code":"INTERNAL_ERROR"}GET /api/v1/data/showcase_task?filters=[["title","=","SummaryBugProbe-1"]]→ 1 row. The write landed.Server log for that request:
PATCHon the member's own child row fails the same way (500, row updated).Cause
packages/objectql/src/engine.tspasses the caller's context straight into the recompute:const summaryFailures = await this.recomputeSummaries(object, result, null, opCtx.context);So the parent write inherits the caller's permissions. But a roll-up is engine-derived state, not a user write: whether
showcase_project.task_countmay be refreshed is not a question about the member's grant onshowcase_project. The retry loop (framework#3147) cannot help — a permission denial is deterministic, so it burns every retry and then throws.Impact
Any parent/child pair where the child is more widely writable than the parent — the ordinary shape for line items, tasks, comments, time entries. The member's own create is a legitimate, granted operation; it 500s on a permission check about a record they never asked to touch, and the failure is not atomic (row written, error returned).
Suggested direction
Recompute summaries under a system context (the recompute is engine-owned state, and the permission decision that matters — may the caller write the CHILD — has already been made), and keep
SummaryRecomputeErrorfor genuinely transient failures. If a caller-scoped recompute must stay for some reason, a permission denial should not be retried and should not fail the caller's otherwise-successful write.