From 417d44488dcacb8ba1d3125b97f973d51fa573df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:49:58 +0000 Subject: [PATCH 1/3] fix(formula,plugin-security): the cross-class refusal leads with its remedy The matcher's refusal now opens with the fix and fits the REST client message bound whole (494 characters); the explain engine's copy puts the same remedy before its unbounded subject and diagnostic. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- packages/formula/src/matches-filter.ts | 24 +++++++++++-------- .../plugin-security/src/explain-engine.ts | 16 +++++++++---- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/formula/src/matches-filter.ts b/packages/formula/src/matches-filter.ts index 18c4bf2c0c3..708cde7678c 100644 --- a/packages/formula/src/matches-filter.ts +++ b/packages/formula/src/matches-filter.ts @@ -507,19 +507,23 @@ const CROSS_FIELD_CLASS_REFUSAL = Symbol.for('objectstack.formula.crossFieldClas * — driver-sql withholds the same comparison's columns on the read for the * same reason (#7929). The columns, the operator and both declarations travel * on the error for the server log ({@link crossFieldClassRefusalCarriedBy}). + * + * The remedy leads, and the whole message stays under the REST door's client + * message bound (`CLIENT_MESSAGE_MAX` in `@objectstack/rest`: a 4xx message of + * 500 characters or more is cut to 499 plus an ellipsis). The bound cuts the + * TAIL, so a remedy written last never reached the wire. The order is: the + * remedy; what is refused (two columns with no shared class, and the classes); + * why it is refused; why the columns are withheld. The text is fixed, so its + * length is too — a sentence added here must be paid for by a shorter one. */ function crossFieldClassError(refusal: CrossFieldClassRefusal): Error { const err = new Error( - 'A field-to-field comparison ({ "$field": … }) in this filter compares two columns that share no ' + - 'comparison class. Two columns are compared only within one class — a number with a number, text ' + - 'with text, a boolean with a boolean, a date with a date, a datetime with a datetime, a time of day ' + - 'with a time of day — and a file field, a formula field, or a column that holds a list or an object ' + - 'has no class at all, so the platform defines no answer for this comparison. It is refused rather ' + - 'than evaluated: across classes SQL and this evaluator answer differently, and the read path refuses ' + - 'the same comparison, so an answer here would give one access policy two meanings. The columns and ' + - 'the operator are withheld from this message because the filter may be an access policy the caller ' + - 'did not write; the server log names them. In a row-level policy, compare a field only with a field ' + - 'of the same class, or fix the declaration of the one that is declared with the wrong type.', + 'In a row-level policy, compare a field only with a field of the same class, or fix the declaration ' + + 'of the one that is declared with the wrong type. This filter compares two columns that share no ' + + 'class (number, text, boolean, date, datetime, time; file, formula, list and object fields have ' + + 'none). SQL and this evaluator answer it differently, so it is refused, as on the read path. The ' + + 'columns and operator are withheld, as the caller may not have written the policy; the server log ' + + 'names them.', ) as Error & { code?: string; status?: number }; err.code = StandardErrorCode.enum.INVALID_FILTER; err.status = 400; diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 4301f5975ac..b196034a950 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -923,6 +923,14 @@ function refusedPolicyNamesOf( * same caller for the same object publishes the same predicate: `readFilter` * without a `recordId`, the `rls` layer's `rowFilter` with one. So naming the * policy and its two columns here discloses nothing that report does not. + * + * The remedy leads, BEFORE the subject. The REST door bounds a 4xx message + * (`CLIENT_MESSAGE_MAX` in `@objectstack/rest`: 500 characters or more is cut + * to 499 plus an ellipsis), and the subject and the diagnostic have no length + * bound: object, field and policy names declare no maximum, and the subject + * lists every refused policy. So no subject-first order can keep a trailing + * remedy on the wire for every policy; at index 0 it survives any length. The + * reason comes last and is the part a long subject may cut. */ function crossFieldRefusalForExplain( cause: unknown, @@ -939,10 +947,10 @@ function crossFieldRefusalForExplain( : `The row-level security ${policies.length === 1 ? 'policy' : 'policies'} ` + `${policies.map((p) => `'${p}'`).join(', ')} on '${object}'`; const err = new Error( - `${subject} cannot be evaluated: ${refusal.diagnostic}. Enforcement refuses every request this filter ` + - 'scopes instead of judging a record (the find answers INVALID_FILTER / 400), so explain answers with the ' + - 'same refusal and reports no verdict. Compare a field only with a field of the same class, or fix ' + - 'the declaration of the one that is declared with the wrong type.', + 'Compare a field only with a field of the same class, or fix the declaration of the one that is ' + + `declared with the wrong type. ${subject} cannot be evaluated: ${refusal.diagnostic}. Enforcement ` + + 'refuses every request this filter scopes (the find answers INVALID_FILTER / 400), so explain answers ' + + 'with the same refusal and reports no verdict.', ); const { code, status } = cause as { code?: string; status?: number }; return Object.assign(err, { code, status, cause }); From 2cfaa6522fe418fefff0fb41bfd5732856b213d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:53:13 +0000 Subject: [PATCH 2/3] test(formula,plugin-security,rest): pin the cross-class refusal's remedy on the wire The /data insert and find doors and POST /security/explain, through the real security layer on driver-sql: each wire message carries its producer's remedy, a long-names fixture keeps the remedy under the bound, and a short refusal of another class reaches the wire unchanged. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../matches-filter-cross-field-class.test.ts | 25 ++ .../src/explain-cross-class-refusal.test.ts | 13 +- ...ls-check-cross-class-field-refused.test.ts | 3 +- ...s-class-refusal-remedy-on-the-wire.test.ts | 264 ++++++++++++++++++ 4 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts diff --git a/packages/formula/src/matches-filter-cross-field-class.test.ts b/packages/formula/src/matches-filter-cross-field-class.test.ts index e8710fdbc68..55b15fd98a9 100644 --- a/packages/formula/src/matches-filter-cross-field-class.test.ts +++ b/packages/formula/src/matches-filter-cross-field-class.test.ts @@ -161,6 +161,31 @@ describe('matchesFilterCondition — a field compared with a field of no shared expect(crossFieldClassRefusalCarriedBy(new Error('x'))).toBeNull(); }); + it('leads with its remedy and fits the REST client-message bound whole, however long the column names', () => { + // The REST door cuts a 4xx message of 500 characters or more to 499 plus an + // ellipsis (`CLIENT_MESSAGE_MAX`, `@objectstack/rest`): it keeps the HEAD. + const remedy = + 'In a row-level policy, compare a field only with a field of the same class, or fix the declaration of ' + + 'the one that is declared with the wrong type.'; + const long = (stem: string) => `${stem}_${'x'.repeat(120)}`; + const longFields = { [long('stage')]: { type: 'text' }, [long('amount')]: { type: 'number' } }; + const shortErr = refusalOf({ status: { $ne: { $field: 'amount' } } })!; + let longErr: WireBearingError | null = null; + try { + matchesFilterCondition({}, { [long('stage')]: { $ne: { $field: long('amount') } } } as never, { fields: longFields }); + } catch (e) { + longErr = e as WireBearingError; + } + expect({ code: longErr?.code, status: longErr?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + // It names no column, so its length does not depend on theirs. + expect(longErr?.message).toBe(shortErr.message); + expect(shortErr.message.startsWith(remedy)).toBe(true); + expect(shortErr.message.length).toBeLessThan(500); + // After the remedy: what is refused, why, and why the columns are withheld. + const at = (s: string) => shortErr.message.indexOf(s); + expect([at('share no class'), at('so it is refused'), at('withheld')].every((i, n, a) => i > remedy.length && (n === 0 || i > a[n - 1]))).toBe(true); + }); + it('findCrossFieldClassRefusal answers null for a filter whose comparisons all compare', () => { expect(findCrossFieldClassRefusal({ $and: [{ status: { $eq: { $field: 'title' } } }, { amount: { $lt: { $field: 'budget' } } }] }, FIELDS)).toBeNull(); expect(findCrossFieldClassRefusal({ amount: { $lt: { $field: 'status' } } }, FIELDS)).toMatchObject({ diff --git a/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts b/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts index 77c9bf3d620..d180214881d 100644 --- a/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts +++ b/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts @@ -180,15 +180,26 @@ const ROW = { id: 'r1', status: 'open', title: 'x', amount: 5 }; const rlsRecordOf = (d: ExplainDecision) => d.layers.find((l) => l.layer === 'rls')?.record; +/** + * The remedy explain's refusal leads with. It comes before the policy names and + * the diagnostic, which have no length bound, because the REST door keeps only + * a long message's first 499 characters. + */ +const REMEDY = + 'Compare a field only with a field of the same class, or fix the declaration of the one that is declared with ' + + 'the wrong type.'; + /** * Explain's answer is the find's refusal: the same envelope, no decision and so - * no record verdict, and a message that names the policy and both columns. + * no record verdict, and a message that leads with the remedy and names the + * policy and both columns. */ async function expectExplainRefuses(p: Promise, columns: [string, string]): Promise { const r = await refusalOf(p); expect(r).not.toBe('answered'); if (r === 'answered') return; expect({ code: r.code, status: r.status }).toEqual(INVALID); + expect(r.message.startsWith(`${REMEDY} `), r.message).toBe(true); expect(r.message).toContain(`'${POLICY}'`); for (const column of columns) expect(r.message).toContain(`"${column}"`); } diff --git a/packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts b/packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts index 5e8b02ddb9a..21cd16e5120 100644 --- a/packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts @@ -204,7 +204,8 @@ for (const [driverName, makeDriver, available] of DRIVERS) { it(`${c.id} \`${c.predicate}\` — the 400 names neither column; the server log names the policy and both`, async () => { const w = await boot(makeDriver, 'check', c.predicate); const message = await messageOf(w.engine.insert(w.OBJ, NEW, { context: w.caller } as never)); - expect(message).toMatch(/^A field-to-field comparison/); + // The remedy leads: the REST door cuts a long message's tail, never its head. + expect(message).toMatch(/^In a row-level policy, compare a field only with a field of the same class/); for (const column of c.columns) expect(message).not.toContain(column); const lines = w.refusalLines(); diff --git a/packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts b/packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts new file mode 100644 index 00000000000..3c0a8720e33 --- /dev/null +++ b/packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The cross-class field-comparison refusal reaches the WIRE with its remedy — + * through the real `SecurityPlugin` on a real `ObjectQL` over a real + * `SqlDriver`, at the `/data` door and at `POST /api/v1/security/explain`. + * + * ## Why the wire and not the thrown error + * + * Both doors bound a 4xx message: 500 characters or more is cut to 499 plus an + * ellipsis (`CLIENT_MESSAGE_MAX` in `error-response.ts`). The cut keeps the + * HEAD. The two producers of this refusal wrote their remedy LAST — the record + * matcher's message was 972 characters with the remedy from index 825, the + * explain engine's put it after an unbounded subject and diagnostic — so no + * caller of either door ever read it. A thrown-message assertion cannot see + * that; only the message the door answers with can. + * + * ## Which door answers which producer (measured on this stack) + * + * | request | producer | wire | + * |---|---|---| + * | `POST /data/:object` (insert) | the record matcher, as the RLS write check (`@objectstack/formula`) | its whole message | + * | `GET /data/:object` (find) | driver-sql's read refusal of the same comparison | its whole message | + * | `POST /security/explain` | the explain engine's own copy (`@objectstack/plugin-security`) | the first 499 characters | + * + * A find never carries the matcher's message: only the RLS write check and + * explain hand the matcher the declared columns its class rule reads. + * + * ## What is pinned + * + * - each door's wire message carries its producer's remedy; + * - a fixture with long object, policy and field names stays under the bound + * with the remedy intact; + * - the control: a short refusal of another class reaches the wire unchanged. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +/** The record matcher's remedy, as its message opens. */ +const MATCHER_REMEDY = + 'In a row-level policy, compare a field only with a field of the same class, or fix the declaration of the ' + + 'one that is declared with the wrong type.'; +/** The explain engine's remedy, as its message opens — before the policy names. */ +const EXPLAIN_REMEDY = + 'Compare a field only with a field of the same class, or fix the declaration of the one that is declared with ' + + 'the wrong type.'; +/** The door's bound: a message this long or longer is cut to 499 characters plus an ellipsis. */ +const BOUND = 500; + +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; + +interface Fixture { + /** Names the fixture's app. */ + key: string; + object: string; + policy: string; + /** A text column and a number column: two comparison classes. */ + text: string; + number: string; +} + +interface Answer { + status: number; + code: unknown; + message: string; +} + +/** The status, code and message the door answered, in either envelope this family speaks. */ +function answerOf(status: number, body: any): Answer { + const nested = body?.error !== null && typeof body?.error === 'object'; + return { + status, + code: nested ? body.error.code : body?.code, + message: String(nested ? body.error.message : body?.error), + }; +} + +const thrownOf = (p: Promise): Promise<{ code: unknown; status: unknown; message: string }> => + p.then( + () => ({ code: undefined, status: undefined, message: '(admitted)' }), + (e: any) => ({ code: e?.code, status: e?.statusCode ?? e?.status, message: String(e?.message) }), + ); + +async function boot(f: Fixture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any), + true, + ); + await engine.init(); + engine.registerApp({ + id: `com.objectstack.qa.cross-class-remedy-wire-${f.key}`, + name: 'Cross-class refusal remedy on the wire', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { + name: f.object, + label: 'Deal', + sharingModel: 'public_read_write', + fields: { + [f.text]: { name: f.text, type: 'text' }, + [f.number]: { name: f.number, type: 'number' }, + title: { name: 'title', type: 'text' }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + + const set = PermissionSetSchema.parse({ + name: 'qa_cross_class_remedy', + objects: { [f.object]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { name: f.policy, object: f.object, operation: 'all', using: `record.${f.text} != record.${f.number}` }, + ], + }); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [set], + }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, service: unknown) => { services[name] = service; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: set.name }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + await engine.insert(f.object, { id: 'r1', [f.text]: 'open', [f.number]: 5, title: 'x' }, { context: SYS_CTX } as never); + + const caller = { userId: 'usr_member', positions: [], permissions: [set.name], posture: 'MEMBER' }; + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const rest = new RestServer(server as any, new ObjectStackProtocolImplementation(engine as any) as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => caller; + (rest as any).securityServiceProvider = async () => services.security; + rest.registerRoutes(); + + const call = async (method: string, path: string, req: Record): Promise => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path} is mounted`).toBeDefined(); + const res: any = { + statusCode: 200, + write: () => true, end: noop, setHeader: noop, + header: () => res, + status: (code: number) => { res.statusCode = code; return res; }, + json: (body: unknown) => { res.body = body; return res; }, + send: (body: unknown) => { res.body = body; return res; }, + }; + await route!.handler({ params: {}, query: {}, headers: {}, method, ...req } as any, res); + return answerOf(res.statusCode, res.body); + }; + + const newRow = { id: 'r2', [f.text]: 'x', [f.number]: 1, title: 'y' }; + return { + engine, + /** `POST /data/:object` — the insert the policy's `using` judges as its check. */ + insert: () => call('POST', '/api/v1/data/:object', { params: { object: f.object }, body: { ...newRow } }), + insertThrown: () => thrownOf(engine.insert(f.object, { ...newRow }, { context: caller } as never)), + /** `GET /data/:object` — the find the policy scopes. */ + find: (query: Record = {}) => call('GET', '/api/v1/data/:object', { params: { object: f.object }, query }), + findThrown: (where?: unknown) => thrownOf(engine.find(f.object, { where, context: caller } as never)), + /** `POST /security/explain` for the caller's own access to the row. */ + explain: () => call('POST', '/api/v1/security/explain', { body: { object: f.object, operation: 'read', recordId: 'r1' } }), + }; +} + +type World = Awaited>; + +const SHORT: Fixture = { key: 'short', object: 'qa_remedy_deal', policy: 'deal_guard', text: 'status', number: 'amount' }; +/** Names far past any real one: object, field and policy names declare no maximum length. */ +const long = (stem: string) => `${stem}_${'x'.repeat(90)}`; +const LONG: Fixture = { + key: 'long', + object: long('qa_remedy_opportunity_forecast'), + policy: long('opportunity_pipeline_visibility_guard'), + text: long('negotiation_stage_label'), + number: long('forecast_amount_value'), +}; + +describe('the cross-class refusal carries its remedy on the wire', () => { + let short: World; + let longNames: World; + + beforeAll(async () => { + short = await boot(SHORT); + longNames = await boot(LONG); + }); + + afterAll(async () => { + for (const w of [short, longNames]) { + try { await w?.engine.destroy(); } catch { /* noop */ } + } + }); + + it('`/data` insert: the record matcher\'s whole message reaches the wire, remedy first', async () => { + const answer = await short.insert(); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'INVALID_FILTER' }); + expect(answer.message.startsWith(MATCHER_REMEDY), answer.message).toBe(true); + // Whole: under the bound, so nothing is cut — the reason and the withholding sentence arrive too. + expect(answer.message.length).toBeLessThan(BOUND); + expect(answer.message).toBe((await short.insertThrown()).message); + for (const column of [SHORT.text, SHORT.number]) expect(answer.message).not.toContain(column); + }); + + it('`/data` find: the read refusal of the same comparison reaches the wire whole', async () => { + const answer = await short.find(); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'INVALID_FILTER' }); + expect(answer.message.length).toBeLessThan(BOUND); + expect(answer.message).toBe((await short.findThrown()).message); + // driver-sql's read refusal states the same rule: same-class columns only. + expect(answer.message).toContain('compared as the same type class'); + for (const column of [SHORT.text, SHORT.number]) expect(answer.message).not.toContain(column); + }); + + it('`POST /security/explain`: the wire message opens with the remedy, before the policy it names', async () => { + const answer = await short.explain(); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'INVALID_FILTER' }); + expect(answer.message.startsWith(`${EXPLAIN_REMEDY} `), answer.message).toBe(true); + expect(answer.message.length).toBeLessThanOrEqual(BOUND); + expect(answer.message).toContain(`'${SHORT.policy}'`); + }); + + it('long object, policy and field names: explain is cut at the bound with the remedy intact, and the matcher\'s message is unchanged', async () => { + const explain = await longNames.explain(); + expect({ status: explain.status, code: explain.code }).toEqual({ status: 400, code: 'INVALID_FILTER' }); + expect(explain.message.length).toBe(BOUND); + expect(explain.message.endsWith('…')).toBe(true); + expect(explain.message.startsWith(`${EXPLAIN_REMEDY} `), explain.message).toBe(true); + + const insert = await longNames.insert(); + expect({ status: insert.status, code: insert.code }).toEqual({ status: 400, code: 'INVALID_FILTER' }); + // The matcher names no column, so the names do not move its length. + expect(insert.message).toBe((await short.insert()).message); + expect(insert.message.startsWith(MATCHER_REMEDY)).toBe(true); + }); + + it('control — a short refusal of another class reaches the wire unchanged', async () => { + const where = { title: { $bogus: 1 } }; + const thrown = await short.findThrown(where); + expect({ code: thrown.code, status: thrown.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(thrown.message.length).toBeLessThan(BOUND); + const answer = await short.find({ filter: JSON.stringify(where) }); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'INVALID_FILTER' }); + expect(answer.message).toBe(thrown.message); + }); +}); From 5fde18e296c4e2822758d47ea52e1f25df44320a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:58:57 +0000 Subject: [PATCH 3/3] chore(changeset): formula and plugin-security patch for the remedy-first refusal Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../20869-cross-class-refusal-remedy-first.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/20869-cross-class-refusal-remedy-first.md diff --git a/.changeset/20869-cross-class-refusal-remedy-first.md b/.changeset/20869-cross-class-refusal-remedy-first.md new file mode 100644 index 00000000000..e7aafb60640 --- /dev/null +++ b/.changeset/20869-cross-class-refusal-remedy-first.md @@ -0,0 +1,20 @@ +--- +'@objectstack/formula': patch +'@objectstack/plugin-security': patch +--- + +fix(formula,plugin-security): the refusal of a field-to-field comparison across comparison classes now leads with its remedy, so the remedy reaches REST callers (#20869) + +Clause-②: no + +A row-level policy that compares two fields of no shared comparison class (text against a number, or any field against a file field, a formula field, or a field that holds a list or an object) is refused with `INVALID_FILTER` / 400. The REST door keeps a 4xx message under 500 characters by cutting it to its first 499 characters plus an ellipsis. Both messages for this refusal put the remedy last, so the remedy was always cut off, and a caller read the diagnosis but never the fix: + +- The record matcher's message (`@objectstack/formula`, raised by the RLS write check on an insert or update through `/data`) was 972 characters, with the remedy starting at character 825. +- The explain engine's message (`@objectstack/plugin-security`, answered by `GET` / `POST /api/v1/security/explain`) put the remedy after the policy names and the diagnostic. Those have no length limit, so the message was 601 characters with a short policy name and longer with longer names. + +Both messages now start with the remedy. It is the same sentence as before and has only moved: + +- The record matcher's message is 494 characters and reaches the wire whole. In order it says: the remedy; that the two columns share no class, and which classes exist; why the comparison is refused; and why the columns are not named. It still names no column, operator or policy; the server log names them. +- The explain engine's message starts with the remedy, then names the policy and both columns, then gives the reason. Whatever the names' length, the remedy sits in the first 125 characters. With long names the REST door may cut the reason at the end. + +Unchanged: the error code (`INVALID_FILTER`), the status (400), which comparisons are refused, the refusal a find answers with (driver-sql's read refusal, 383 characters, which already reached the wire whole), and every other refusal.