Skip to content

#15395's engine-side readonly strip runs AFTER the audit binder, so a plain REST caller's created_at survives on an object that declares it readonly #15964

Description

@hotlong

Measured while verifying the cloud-side fallout of #15395 (cloud#2010), on a live rig at framework e581457baaaf.

What a plain REST caller can now do

A normal authenticated REST caller POST /api/v1/data/OBJECT with a
caller-supplied created_at keeps that value on the stored row, on an object
that declares created_at as readonly: true:

POST /api/v1/data/ai_conversations   HTTP 201
  sent    id = conv_REST_FORGED   created_at = 1999-01-01T00:00:00.000Z
  stored  id = jYMDEP-Y3ZIfzpBZ   created_at = 1999-01-01T00:00:00.000Z

Tenant DB, same row:

sqlite> select id, created_at, updated_at from ai_conversations;
jYMDEP-Y3ZIfzpBZ|1999-01-01T00:00:00.000Z|2026-09-05T16:31:57.760Z

Reproduced on three objects in one request sequence (ai_conversations,
ai_messages, ai_eval_cases — all declare created_at with readonly: true).

Two in-experiment controls, so this is not a reading of one field

Same request, same path, same object family:

field declaration sent stored verdict
id readonly: true conv_REST_FORGED jYMDEP-Y3ZIfzpBZ stripped ✓
ai_eval_runs.run_at readonly: true, datetime 1999-01-01T… 2026-09-05T16:31:57.937Z stripped ✓
updated_at readonly: true, datetime 1999-01-01T… 2026-09-05T16:31:57.760Z stripped ✓
created_at readonly: true, datetime 1999-01-01T… 1999-01-01T… kept ✗

So the create-side strip IS running on this path and DOES take other
author-declared readonly datetimes. created_at is the one that gets through.

Mechanism (read from source, not inferred from the symptom)

packages/objectql/src/plugin.ts, the audit binder's beforeInsert stamp:

if (isInsert) {
  record.created_at = record.created_at ?? now;
}
record.updated_at = preserveAudit ? (record.updated_at ?? now) : now;

created_at is stamped with ?? (client-preferred), updated_at with a plain
assignment. Since #15395 the static-readonly strip runs INSIDE engine.insert,
after the beforeInsert hooks, and its #14259 guard treats a key a
beforeInsert hook ASSIGNED as the hook's write rather than a caller forgery
(rowHookWrittenKeys). The ?? therefore launders the caller's value: the hook
"wrote" created_at, so the strip spares it — with the caller's bytes still in it.
updated_at's unconditional assignment overwrites the forgery first, which is
exactly why it is the control here rather than a second symptom.

The deleted ingress copy did not have this problem, and that is the delta. At the
previous pin 5b2ad1b41af2, stripReadonlyForInsert (metadata-protocol) ran
BEFORE the engine and before any hook, and deleted every author-declared
readonly key the caller sent:

for (const name of Object.keys(fields)) {
    if (!fields[name]?.readonly) continue;
    if (RUNTIME_OWNED_FIELD_TYPES.has(String(fields[name]?.type ?? ''))) continue;
    if (!(name in out)) continue;
    delete out[name];
}

created_at is datetime, not a runtime-owned type, so the old ingress copy took
it. The new engine-side strip is strictly better-informed about hooks and
strictly worse here, because the audit binder is one of those hooks.

Why it is worth a card

created_at is the audit stamp. #15395's own doc argues the create-side strip
exists so a forged column cannot be seeded in one POST, and the 2026-08-08 ruling
narrowed preserveAudit to UPDATE precisely so an ordinary REST import could not
"seed the approval/status columns the strip exists to protect". A caller-preferred
created_at is the same shape of hole, reached without any flag at all.

⛔ Not proposing a shape — the two candidates pull opposite ways (make the audit
binder's created_at unconditional like updated_at, and lose the historical
import's ability to reinstate an original timeline; or teach the strip that an
audit-binder stamp which merely PRESERVED a caller value is not a hook write).
That is a maintainer call, which is why this is a card and not a PR.

Scope note

Not addressed in cloud#2010's PR, which is a cloud-side change with no framework
edit and no REST-path edit; the table above is from that PR's negative control.
The id half — the one cloud#2010 was about — is closed on all seven of cloud's
ai_* tables.

Repro

bash scripts/dev-local/run-stack.sh --with-ui --prod-like --seed --keep --workdir DIR   # in cloud
# open an env session via sso-open, then:
POST http://HOST:PORT/api/v1/data/ai_conversations
  {"id":"conv_REST_FORGED","title":"x","created_at":"1999-01-01T00:00:00.000Z"}

Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions