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
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/OBJECTwith acaller-supplied
created_atkeeps that value on the stored row, on an objectthat declares
created_atasreadonly: true:Tenant DB, same row:
Reproduced on three objects in one request sequence (
ai_conversations,ai_messages,ai_eval_cases— all declarecreated_atwithreadonly: true).Two in-experiment controls, so this is not a reading of one field
Same request, same path, same object family:
idreadonly: trueconv_REST_FORGEDjYMDEP-Y3ZIfzpBZai_eval_runs.run_atreadonly: true, datetime1999-01-01T…2026-09-05T16:31:57.937Zupdated_atreadonly: true, datetime1999-01-01T…2026-09-05T16:31:57.760Zcreated_atreadonly: true, datetime1999-01-01T…1999-01-01T…So the create-side strip IS running on this path and DOES take other
author-declared
readonlydatetimes.created_atis 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:created_atis stamped with??(client-preferred),updated_atwith a plainassignment. Since #15395 the static-
readonlystrip runs INSIDEengine.insert,after the beforeInsert hooks, and its #14259 guard treats a key a
beforeInserthook 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 isexactly 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) ranBEFORE the engine and before any hook, and deleted every author-declared
readonlykey the caller sent:created_atisdatetime, not a runtime-owned type, so the old ingress copy tookit. 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_atis the audit stamp. #15395's own doc argues the create-side stripexists so a forged column cannot be seeded in one POST, and the 2026-08-08 ruling
narrowed
preserveAuditto UPDATE precisely so an ordinary REST import could not"seed the approval/status columns the strip exists to protect". A caller-preferred
created_atis 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_atunconditional likeupdated_at, and lose the historicalimport'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
idhalf — the one cloud#2010 was about — is closed on all seven of cloud'sai_*tables.Repro
Generated by Claude Code