Observation from an application (objectstack-ai/duly), filed for triage rather than as a defect — the asymmetry may be intended, but the engine's own prose says otherwise and one consumer is wired for a signal it cannot receive.
Measured against @objectstack/runtime 17.2.0 / @objectstack/objectql 17.2.0 / @objectstack/metadata-protocol 17.2.0, on a real booted kernel with the in-memory driver.
What was measured
duly_task.completed_at is readonly: true.
| write face | context | forged completed_at |
|---|
MetadataProtocolService.createData | none | stripped (row then refused by a validation rule) |
MetadataProtocolService.createData | { isSystem: false } | stripped |
MetadataProtocolService.createData | { isSystem: true } | preserved (intended — the documented exemption) |
engine.insert (the data service) | none | preserved |
The last row is the finding: a non-system caller reaching engine.insert directly writes the read-only column, with no refusal, no WARN, and no onFieldsDropped event.
Why it happens
The insert-path strip is stripReadonlyForInsert, which lives in @objectstack/metadata-protocol and is called from exactly two places — MetadataProtocolService.createData and cloneData:
functionstripReadonlyForInsert(schema,data,context){if(context?.isSystem)returndata;
...
}The update path is not like this: stripReadonlyFields is inside ObjectQL and runs on every engine.update, isSystem-gated. So readonly is enforced in-engine on update and only at the API boundary on insert.
Two things that look unintended
1. The engine's own doc comment claims otherwise.assertReferencesResolve in objectql/dist/core.js describes its non-system scoping as being
like every other write-path guard in this engine (stripReadonlyFields, stripReadonlyForInsert).
stripReadonlyForInsert is not in that engine and is not on engine.insert's path at all. An application author reading that reasonably concludes the engine enforces readonly on insert.
2. create_record is wired for a drop event it can never get. The automation engine's create_record executor (@objectstack/service-automation) calls data.insert — the engine, not the protocol — and passes onFieldsDropped, then surfaces the result as output.droppedFields and node warnings:
constcreated=awaitdata.insert(objectName,fields,{context: dataCtx,onFieldsDropped: (e)=>{dropped.push(e);}});Since no readonly strip runs on that path, that channel cannot report a readonly drop. A flow without runAs: 'system' can set a readonly column at insert and the run reports a clean success.
(In the application that found this, the relevant flow does declare runAs: 'system', so nothing there is currently affected — this is reported as a seam, not an incident.)
The question for triage
Is the insert-side strip deliberately a user-context / API-boundary concern (which #3425's framing suggests), with engine.insert trusted by construction? If so the fix is prose plus the dead onFieldsDropped wiring on create_record. If not, the strip belongs on the engine insert path where its update-side twin already lives.
Either way readonly currently means something different on insert than on update, and the difference is not written down anywhere an application author would look.
Reproduction
Two duly_task inserts with an identical payload carrying a forged completed_at, one through protocol.createData and one through data.insert, both with no context. Pinned as a tripwire in objectstack-ai/duly — test/seed-history.test.ts, the insert-path strip is a BOUNDARY guard block — which is written to go red and be deleted when this is closed.
Observation from an application (
objectstack-ai/duly), filed for triage rather than as a defect — the asymmetry may be intended, but the engine's own prose says otherwise and one consumer is wired for a signal it cannot receive.Measured against
@objectstack/runtime17.2.0 /@objectstack/objectql17.2.0 /@objectstack/metadata-protocol17.2.0, on a real booted kernel with the in-memory driver.What was measured
duly_task.completed_atisreadonly: true.completed_atMetadataProtocolService.createDataMetadataProtocolService.createData{ isSystem: false }MetadataProtocolService.createData{ isSystem: true }engine.insert(thedataservice)The last row is the finding: a non-system caller reaching
engine.insertdirectly writes the read-only column, with no refusal, noWARN, and noonFieldsDroppedevent.Why it happens
The insert-path strip is
stripReadonlyForInsert, which lives in@objectstack/metadata-protocoland is called from exactly two places —MetadataProtocolService.createDataandcloneData:The update path is not like this:
stripReadonlyFieldsis inside ObjectQL and runs on everyengine.update,isSystem-gated. Soreadonlyis enforced in-engine on update and only at the API boundary on insert.Two things that look unintended
1. The engine's own doc comment claims otherwise.
assertReferencesResolveinobjectql/dist/core.jsdescribes its non-system scoping as beingstripReadonlyForInsertis not in that engine and is not onengine.insert's path at all. An application author reading that reasonably concludes the engine enforcesreadonlyon insert.2.
create_recordis wired for a drop event it can never get. The automation engine'screate_recordexecutor (@objectstack/service-automation) callsdata.insert— the engine, not the protocol — and passesonFieldsDropped, then surfaces the result asoutput.droppedFieldsand nodewarnings:Since no readonly strip runs on that path, that channel cannot report a readonly drop. A flow without
runAs: 'system'can set areadonlycolumn at insert and the run reports a clean success.(In the application that found this, the relevant flow does declare
runAs: 'system', so nothing there is currently affected — this is reported as a seam, not an incident.)The question for triage
Is the insert-side strip deliberately a user-context / API-boundary concern (which #3425's framing suggests), with
engine.inserttrusted by construction? If so the fix is prose plus the deadonFieldsDroppedwiring oncreate_record. If not, the strip belongs on the engine insert path where its update-side twin already lives.Either way
readonlycurrently means something different on insert than on update, and the difference is not written down anywhere an application author would look.Reproduction
Two
duly_taskinserts with an identical payload carrying a forgedcompleted_at, one throughprotocol.createDataand one throughdata.insert, both with no context. Pinned as a tripwire inobjectstack-ai/duly—test/seed-history.test.ts, theinsert-path strip is a BOUNDARY guardblock — which is written to go red and be deleted when this is closed.