Skip to content

finding: the insert-path readonly strip is a protocol-boundary guard only — engine.insert applies none of it, and create_record's onFieldsDropped channel can never fire for a readonly drop #14147

Description

@os-warren

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:

function stripReadonlyForInsert(schema, data, context) {
  if (context?.isSystem) return data;
  ...
}

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:

const created = await data.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/dulytest/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.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions