@@ -2063,6 +2063,63 @@ function droppedFieldEvents(
20632063 return events;
20642064}
20652065
2066+ /**
2067+ * [#21682] The keys the CALLER sent on each row of an insert, recorded at
2068+ * `insert`'s entry, BEFORE the middleware chain runs, and keyed by the row
2069+ * OBJECT.
2070+ *
2071+ * `insert` takes its caller snapshot (`suppliedPerRow`) inside the middleware
2072+ * chain's innermost step, so by then a write middleware may already have
2073+ * filled the payload: `@objectstack/organizations` fills an absent
2074+ * `organization_id` with the active organization, and
2075+ * `@objectstack/plugin-security` fills an absent `owner_id` with the acting
2076+ * user. Both write IN PLACE, onto the very row objects recorded here. Without
2077+ * this record the snapshot reads those fills as keys the caller sent, so the
2078+ * static-`readonly` strip took the platform's own `organization_id` and
2079+ * `droppedFields` reported it, on every walled create that named no
2080+ * organization. The console announces every non-empty `droppedFields` as a
2081+ * warning toast. This is the insert-side twin of the update path's #8093
2082+ * (ADDRESSING IS NOT PAYLOAD): a value the platform put on the payload is
2083+ * not one the caller lost.
2084+ *
2085+ * Keyed by IDENTITY, not by index, so the answer survives a middleware that
2086+ * reorders a batch. A row a middleware REPLACED wholesale has no entry, and
2087+ * {@link callerSuppliedRow} then keeps every key it carries: the verdict from
2088+ * before this record existed. That is the over-reporting direction, never the
2089+ * under-stripping one.
2090+ *
2091+ * ⛔ No name list. Which keys are the platform's is answered by WHEN they
2092+ * appeared, so a new stamping middleware is covered without being named here.
2093+ */
2094+ function callerKeySets(data: unknown): WeakMap<object, ReadonlySet<string>> {
2095+ const sets = new WeakMap<object, ReadonlySet<string>>();
2096+ for (const row of Array.isArray(data) ? data : [data]) {
2097+ if (row !== null && typeof row === 'object' && !sets.has(row)) {
2098+ sets.set(row, new Set(Object.keys(row)));
2099+ }
2100+ }
2101+ return sets;
2102+ }
2103+
2104+ /**
2105+ * One row of `insert`'s caller snapshot: a shallow COPY of the row as the
2106+ * middleware chain handed it on, keeping only the keys the caller sent
2107+ * (`sent`, from {@link callerKeySets}).
2108+ *
2109+ * Only WHICH keys is narrowed. A kept key keeps the value the chain handed
2110+ * on, so every key the caller did send is judged exactly as it was before
2111+ * #21682. `sent` undefined means "this row has no record" and keeps every key.
2112+ */
2113+ function callerSuppliedRow(row: unknown, sent: ReadonlySet<string> | undefined): Record<string, unknown> {
2114+ const copy: Record<string, unknown> = { ...((row ?? {}) as Record<string, unknown>) };
2115+ if (sent) {
2116+ for (const key of Object.keys(copy)) {
2117+ if (!sent.has(key)) delete copy[key];
2118+ }
2119+ }
2120+ return copy;
2121+ }
2122+
20662123/**
20672124 * Evaluate formula virtual fields against the raw rows a driver handed back —
20682125 * the read path (`find` / `findOne`) and, since #5504, the write path's
@@ -12817,6 +12874,10 @@ export class ObjectQL implements IObjectQLEngine {
1281712874 data = normalizeBlankTypedValues(this._registry.getObject(object), data);
1281812875 data = normalizeNumericStringValues(this._registry.getObject(object), data);
1281912876
12877+ // [#21682] What the CALLER sent, recorded before any write middleware
12878+ // fills the payload. See `callerKeySets`.
12879+ const callerKeys = callerKeySets(data);
12880+
1282012881 const opCtx: OperationContext = {
1282112882 object,
1282212883 operation: 'insert',
@@ -12844,6 +12905,14 @@ export class ObjectQL implements IObjectQLEngine {
1284412905 // untouched, hooks run after and may override.
1284512906 const nowSnap = new Date();
1284612907 const isBatch = Array.isArray(opCtx.data);
12908+ // [#21682] Each row's caller key set, looked up NOW, on the row objects
12909+ // the middleware chain handed on. The computed-field door below may
12910+ // replace a row with a copy. Index-aligned from here on: every pass
12911+ // between here and the snapshot keeps the rows' order and count.
12912+ const callerKeysPerRow: Array<ReadonlySet<string> | undefined> =
12913+ (isBatch ? (opCtx.data as unknown[]) : [opCtx.data]).map(
12914+ (row) => (row !== null && typeof row === 'object' ? callerKeys.get(row) : undefined),
12915+ );
1284712916 // [#8682] The declared-field door — see `undeclaredWriteFieldErrors` for
1284812917 // what used to run below it for a request that was already refused.
1284912918 // FIRST, so nothing downstream (defaults, summary seeding, the hooks, the
@@ -12879,10 +12948,17 @@ export class ObjectQL implements IObjectQLEngine {
1287912948 }
1288012949 // [#4441] The RAW caller payload per row — before `applyFieldDefaults`
1288112950 // resolves any `defaultValue` / `current_user` token and before the
12882- // beforeInsert hooks stamp `owner_id` / `organization_id` /
12883- // `created_by`. The reference check consults it to decide WHAT THE
12884- // CALLER ACTUALLY SENT, so neither a platform stamp nor a backfilled
12885- // default is ever reported as the caller's bad reference.
12951+ // beforeInsert hooks stamp `created_by`. The reference check consults it
12952+ // to decide WHAT THE CALLER ACTUALLY SENT, so neither a platform stamp
12953+ // nor a backfilled default is ever reported as the caller's bad
12954+ // reference.
12955+ //
12956+ // [#21682] ...and without the keys a write MIDDLEWARE filled, which ran
12957+ // before this step: `organization_id` (`@objectstack/organizations`) and
12958+ // `owner_id` (`@objectstack/plugin-security`) are filled there, not by a
12959+ // hook. Each row keeps only the keys the caller sent (`callerKeySets`,
12960+ // recorded at entry), so the strips below report and take only those.
12961+ // The values stay as the chain handed them on.
1288612962 //
1288712963 // [#6339] It carries the caller's VALUES, and it is taken HERE — ahead of
1288812964 // the hooks — as an explicit shallow COPY. Both halves are load-bearing:
@@ -12904,7 +12980,7 @@ export class ObjectQL implements IObjectQLEngine {
1290412980 // (#5591).
1290512981 const suppliedPerRow: Array<Record<string, unknown>> =
1290612982 (isBatch ? (opCtx.data as any[]) : [opCtx.data]).map(
12907- (row) => ({ ...(( row ?? {}) as Record<string, unknown>) } ),
12983+ (row, i ) => callerSuppliedRow( row, callerKeysPerRow[i] ),
1290812984 );
1290912985 // [#20082] The write's ONE permission resolution, shared by every consumer
1291012986 // below that needs the map: the CEL defaults here, the re-default after
0 commit comments