Filed from a downstream app (steedos-labs/hotcrm-heimao), tracked there as #49. Measured on @objectstack/* 17.1.0, not inferred.
The shape
An app wants a column that is computed and never hand-written — the requirement it implements says so in as many words ("通过界面、批量导入、数据接口任何途径都不能人工写入"). The column is maintained by a cross-object hook.
The two halves cannot both be had today:
|
result |
Author editable: false for the persona on that column |
✅ direct PATCH is refused, 403 PERMISSION_DENIED |
| …and the hook that maintains the column |
❌ dies with [Security] Field write denied: not permitted to edit [...] |
The hook runs as the operator, so the field permission that blocks the operator blocks the hook. The guard and the legitimate writer are the same door.
In the app that hit this, the dying hook is onError: 'abort', so the failure surfaces as the approval save being refused — the write-back is what stamps an approved grade onto the account.
Measured, on a real RestServer with the real handlers
Seven computed columns on the app's account object, one PATCH per column through registerRoutes()-mounted handlers:
- before: all seven
200, values persisted — the symptom
- with
editable: false: all seven 403 PERMISSION_DENIED; positive control — PATCH to an ordinary column, same persona, same handler, 200 and persisted
- the chain: inserting an approved rating in the same run threw
[Security] Field write denied: not permitted to edit [current_grade, current_total_score, last_rating_date], account left unstamped
Why the app cannot work around it
1. A hook cannot obtain an elevated context. Hooks ship as lowered, sandboxed bodies. In the real QuickJS sandbox:
Object.keys(ctx.api) → __txBegin,__txCommit,__txRollback,object,transaction
typeof ctx.api.sudo → 'undefined'
HookSchema rejects runAs, isSystem, elevated, sudo, context, session as unrecognized keys. Passing { context: { isSystem: true } } through the scoped repository's update options is refused — ObjectRepository.update spreads options and then writes context: this.context last, overwriting it.
⚠️ A trap worth fixing regardless of what you decide here. The in-process ScopedContext does expose sudo (typeof === 'function'), and app hook tests commonly call hook.handler(ctx) natively. So an implementation written as ctx.api.sudo() passes the app's test suite and is a TypeError in production — where, under onError: 'abort', it means "approvals stop working". Green tests, dead feature.
2. There is no unforgeable write-origin discriminator. A spy hook on beforeUpdate recorded the context for a direct write and for a hook-nested write. Identical on every candidate key: same ctx keys, same session keys (accessToken,organizationId,positions,userId), same userId, session.isSystem null on both, provenance null on both, same dispatch. The only difference is the payload's own field names — caller-controlled, so not a security boundary.
This is the same gap #13644 reports from another angle ("apps can only tell the engine's set_null cleanup write from a user edit by … sniffing write shape"). The platform already has the idiom for solving it — flowRunId, which the approvals record lock uses to recognise writes made by a given run — but there is no hook-side equivalent.
The ask
Primary: give hooks a declared elevation knob — runAs on HookSchema, the way FlowSchema already has it, and/or install sudo on the sandbox's ctx.api.
This makes "this column is system-computed" expressible in one place and enforced, which is the declared-equals-enforced rule apps are asked to build on. Today an author can declare editable: false and silently kill their own automation.
Noted for whoever picks this up: the contract prose calls sudo's absence from the hook surface a deliberate maintainer-level decision, so this is a request the platform has framed and not yet decided — which is why it wants a card rather than a workaround.
Secondary: make modifyAllRecords apply at field level. Broader, and it would let an admin persona through without touching the hook surface.
Note: elevation does not cost the operator
Worth stating because the opposite belief nearly stopped this investigation early. A write with { ...callerCtx, isSystem: true } leaves updated_by = the caller, identical to a plain user-context write. sys_stamp_audit_update gates on session?.userId with no isSystem test; plugin-audit's writeAudit reads sess.userId ?? provenance.attributedUserId with no isSystem gate; and service-automation's resolveRunDataContext says it deliberately (#5494): "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."
So elevating a hook would not trade the audit trail for the write block. A separate stale-doc issue is filed for the contract prose that says otherwise.
Filed from a downstream app (
steedos-labs/hotcrm-heimao), tracked there as #49. Measured on@objectstack/*17.1.0, not inferred.The shape
An app wants a column that is computed and never hand-written — the requirement it implements says so in as many words ("通过界面、批量导入、数据接口任何途径都不能人工写入"). The column is maintained by a cross-object hook.
The two halves cannot both be had today:
editable: falsefor the persona on that columnPATCHis refused,403 PERMISSION_DENIED[Security] Field write denied: not permitted to edit [...]The hook runs as the operator, so the field permission that blocks the operator blocks the hook. The guard and the legitimate writer are the same door.
In the app that hit this, the dying hook is
onError: 'abort', so the failure surfaces as the approval save being refused — the write-back is what stamps an approved grade onto the account.Measured, on a real
RestServerwith the real handlersSeven computed columns on the app's account object, one
PATCHper column throughregisterRoutes()-mounted handlers:200, values persisted — the symptomeditable: false: all seven403 PERMISSION_DENIED; positive control —PATCHto an ordinary column, same persona, same handler,200and persisted[Security] Field write denied: not permitted to edit [current_grade, current_total_score, last_rating_date], account left unstampedWhy the app cannot work around it
1. A hook cannot obtain an elevated context. Hooks ship as lowered, sandboxed bodies. In the real QuickJS sandbox:
HookSchemarejectsrunAs,isSystem,elevated,sudo,context,sessionas unrecognized keys. Passing{ context: { isSystem: true } }through the scoped repository's update options is refused —ObjectRepository.updatespreads options and then writescontext: this.contextlast, overwriting it.ScopedContextdoes exposesudo(typeof === 'function'), and app hook tests commonly callhook.handler(ctx)natively. So an implementation written asctx.api.sudo()passes the app's test suite and is aTypeErrorin production — where, underonError: 'abort', it means "approvals stop working". Green tests, dead feature.2. There is no unforgeable write-origin discriminator. A spy hook on
beforeUpdaterecorded the context for a direct write and for a hook-nested write. Identical on every candidate key: samectxkeys, same session keys (accessToken,organizationId,positions,userId), sameuserId,session.isSystemnull on both,provenancenull on both, samedispatch. The only difference is the payload's own field names — caller-controlled, so not a security boundary.This is the same gap #13644 reports from another angle ("apps can only tell the engine's
set_nullcleanup write from a user edit by … sniffing write shape"). The platform already has the idiom for solving it —flowRunId, which the approvals record lock uses to recognise writes made by a given run — but there is no hook-side equivalent.The ask
Primary: give hooks a declared elevation knob —
runAsonHookSchema, the wayFlowSchemaalready has it, and/or installsudoon the sandbox'sctx.api.This makes "this column is system-computed" expressible in one place and enforced, which is the declared-equals-enforced rule apps are asked to build on. Today an author can declare
editable: falseand silently kill their own automation.Noted for whoever picks this up: the contract prose calls
sudo's absence from the hook surface a deliberate maintainer-level decision, so this is a request the platform has framed and not yet decided — which is why it wants a card rather than a workaround.Secondary: make
modifyAllRecordsapply at field level. Broader, and it would let an admin persona through without touching the hook surface.Note: elevation does not cost the operator
Worth stating because the opposite belief nearly stopped this investigation early. A write with
{ ...callerCtx, isSystem: true }leavesupdated_by= the caller, identical to a plain user-context write.sys_stamp_audit_updategates onsession?.userIdwith noisSystemtest;plugin-audit'swriteAuditreadssess.userId ?? provenance.attributedUserIdwith noisSystemgate; andservice-automation'sresolveRunDataContextsays it deliberately (#5494): "elevation is not anonymity … isSystem alone decides authorization, while the user drives the platform's attribution stamps."So elevating a hook would not trade the audit trail for the write block. A separate stale-doc issue is filed for the contract prose that says otherwise.