Skip to content

Commit 9525651

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20621-retired-key-type-names-retirement
2 parents e6889c8 + 7fa67da commit 9525651

18 files changed

Lines changed: 727 additions & 104 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
A dataset's date dimension reads the bucket key `@objectstack/core`'s `bucketDateKey` writes, with its year in four digits, and a draft preview keys a row the way the same dataset does once published.
6+
7+
- **Dimension labels (`queryDataset`).** A date dimension's grouped key is labelled as written. The year key `0050` was labelled `1970` (read as epoch seconds, because the year check admitted only 1000..9999), and a month or day key lost its padding (`0050-06` became `50-06`, `0050-06-15` became `50-06-15`). A raw date value is relabelled with the year in four digits too. A year from 1000 to 9999 is labelled as before.
8+
- **Draft preview (`queryDataset` with `previewDrafts`).** Drafted seed rows are keyed by `bucketDateKey` itself, the key the published path's grouping writes. For 0050-06-15 the preview answered `50`, `50-Q2`, `50-06` and `50-06-15`; it now answers `0050`, `0050-Q2`, `0050-06` and `0050-06-15`. A `week` bucket is now the ISO week label (`2026-W25`), no longer the Monday's date (`2026-06-15`), so a weekly `compareTo` in the preview merges each comparison row onto its week, as the published path does. An epoch-milliseconds value is bucketed by its instant (it was the empty bucket), and a `Date` in 0001..0999 by its own year (a `Date` in 0050 keyed `1950`).
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
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)
7+
8+
Clause-②: no
9+
10+
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:
11+
12+
- 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.
13+
- 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.
14+
15+
Both messages now start with the remedy. It is the same sentence as before and has only moved:
16+
17+
- 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.
18+
- 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.
19+
20+
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.

‎.claude/skills/pm-dispatch/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
109109
| `pm:blocking` | 有 open 下游依赖者(自 `Blocked-by:` 索引推导的缓存,⛔ 不手工挂);进选择全序 |
110110
| `pm:retriage` | 向分诊提问(改判、跨域 PR 指定车道、改路由、拆卡、裁 dev 报告留下的分叉),定车道与改路由限未派发卡,异议评论写明所求;与现行 `pm:*` 并存、⛔ 不摘原标;带本标签的 `pm:queue` 卡跳过派发 |
111111
| `finding` | 立卡三类内待首次定级,定级即离标;三类外关 not planned;不占队列不进收件箱 |
112-
| `tooling` | 修复落在门禁/脚本/workflow/技能/席位协议/PM 工具面而非产品包;分诊首触打,与 `domain:*` 同笔;四具名读者 = 候选查询排除、首触即关、舰队一张在飞、普查半态行 |
112+
| `tooling` | 修复落在门禁/脚本/workflow/技能/席位协议/PM 工具面而非产品包;分诊首触打,与 `domain:*` 同笔;三具名读者 = 候选查询排除、首触即关、普查半态行 |
113113
| `target:<major>` | 发版阻塞:每个 backlog 恰好一个生产者 |
114114
| `pm:epic`(父单或 sub-issue) | 已由 epic PM 保留;其它 PM 永不取;⛔ 永不与 `pm:queue` 同挂 |
115115
| `pm:seat` | 座位登记贴:协议载体,不是待分诊的工作 |

‎.claude/skills/pm-dispatch/references/execution-duties.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
- 工作项面:卡片与分诊评论列的每条工作项逐条对树核验是否仍未完成,⛔ 不对卡核验。
2020
- 每条工作项写进派发令时都是 dev 首先证伪的前提。
2121
- `batch` 封顶全部子代理,等待不填槽;同批独立按文件面不相交判,⛔ 不按包、不按 check 名。
22-
- 舰队至多一张 `tooling` 卡带 `pm:dispatched`,第二张等;带 `Unblocks:` 者继承产品级不计数。
2322
- single-writer 路径 = `SINGLE_CLAIM_PATHS` 所枚举;共享其它路径是普通并发,后落地方解冲突。
2423
- 第 N 单派发前读 `scripts/pm/os-verify-lock.sh --status`:到达深度 ≥ `LOCK_DEPTH_HOLD`(= 2)即等。
2524
- 到达深度 = `queue N:` 行数 + 1(待派 dev 的运行算作到达);`state:` holder 与 `parked` 行不计。
@@ -43,6 +42,7 @@
4342
- 阻塞项无主 ⇒ 被挡席认领做掉,不限大小;在该卡走完整认领、尊重其热文件串行队。
4443
- 阻塞项在飞 ⇒ 等:`pm:blocking` 在其车道排最前、等待者写该卡;p0/p1 优先级沿链传递。
4544
- 取卡前置 = `docs/NORTH-STAR.md`「优先级」第 3 条:本车道队列开放 P0/P1 每次取卡现读。
45+
- 开放 = 未派发(无 `pm:dispatched`、无 assignee);在飞的 P0/P1 不挡任何候选,只余取卡全序。
4646
- 取卡全序:维护者直派插队卡(出处三件)> 契约面卡(判据见 `references/lanes/spec.md`)> 标签序。
4747
- 标签序:`priority:p0` > `pm:blocking` > 功能点位次 > `target:` 板上项 > p1 > p2 > p3 > 无级。
4848
- `pm:blocking` 级内先按解锁扇出(从 `Blocked-by:` 反向索引现算,⛔ 扇出数不落标签)。

‎.claude/skills/pm-dispatch/references/triage-duties.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
- 定义项取北极星「做出来的是什么」或仪器卡的车队决定;无则关;`none` 非分诊缺口。
6262
- 清单项三态定级(会 fail / 步骤够不到 / 无项断言)与定级判据见 `references/filing-gate.md`。
6363
- 「无则关」= 首触即关 not_planned,带理由与入队两条件,⛔ 不定 p3、不 hold;下行同此。
64-
- 所入车道队列 P0/P1 开着时,无解锁对象的 p2/p3 `tooling` 卡同样关。
64+
- 所入车道队列 P0/P1 开着(= 未派发,在飞不算)时,无解锁对象的 p2/p3 `tooling` 卡同样关。
6565
- 路由是分诊的技术判断,⛔ 永不升级哪个仓的问题;父单是协调节点,永不派发。
6666
- 父单已有子结构的:父单队列标签即可,分诊逐个展开路由、补 `Blocked-by:` 排序。
6767
- 跨层功能点(≥2 层)立父单带 `area:*`+`pm:epic`+清单项级,逐层子单按 `Blocked-by:` 排。

‎packages/formula/src/matches-filter-cross-field-class.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,31 @@ describe('matchesFilterCondition — a field compared with a field of no shared
161161
expect(crossFieldClassRefusalCarriedBy(new Error('x'))).toBeNull();
162162
});
163163

164+
it('leads with its remedy and fits the REST client-message bound whole, however long the column names', () => {
165+
// The REST door cuts a 4xx message of 500 characters or more to 499 plus an
166+
// ellipsis (`CLIENT_MESSAGE_MAX`, `@objectstack/rest`): it keeps the HEAD.
167+
const remedy =
168+
'In a row-level policy, compare a field only with a field of the same class, or fix the declaration of ' +
169+
'the one that is declared with the wrong type.';
170+
const long = (stem: string) => `${stem}_${'x'.repeat(120)}`;
171+
const longFields = { [long('stage')]: { type: 'text' }, [long('amount')]: { type: 'number' } };
172+
const shortErr = refusalOf({ status: { $ne: { $field: 'amount' } } })!;
173+
let longErr: WireBearingError | null = null;
174+
try {
175+
matchesFilterCondition({}, { [long('stage')]: { $ne: { $field: long('amount') } } } as never, { fields: longFields });
176+
} catch (e) {
177+
longErr = e as WireBearingError;
178+
}
179+
expect({ code: longErr?.code, status: longErr?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
180+
// It names no column, so its length does not depend on theirs.
181+
expect(longErr?.message).toBe(shortErr.message);
182+
expect(shortErr.message.startsWith(remedy)).toBe(true);
183+
expect(shortErr.message.length).toBeLessThan(500);
184+
// After the remedy: what is refused, why, and why the columns are withheld.
185+
const at = (s: string) => shortErr.message.indexOf(s);
186+
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);
187+
});
188+
164189
it('findCrossFieldClassRefusal answers null for a filter whose comparisons all compare', () => {
165190
expect(findCrossFieldClassRefusal({ $and: [{ status: { $eq: { $field: 'title' } } }, { amount: { $lt: { $field: 'budget' } } }] }, FIELDS)).toBeNull();
166191
expect(findCrossFieldClassRefusal({ amount: { $lt: { $field: 'status' } } }, FIELDS)).toMatchObject({

‎packages/formula/src/matches-filter.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -507,19 +507,23 @@ const CROSS_FIELD_CLASS_REFUSAL = Symbol.for('objectstack.formula.crossFieldClas
507507
* — driver-sql withholds the same comparison's columns on the read for the
508508
* same reason (#7929). The columns, the operator and both declarations travel
509509
* on the error for the server log ({@link crossFieldClassRefusalCarriedBy}).
510+
*
511+
* The remedy leads, and the whole message stays under the REST door's client
512+
* message bound (`CLIENT_MESSAGE_MAX` in `@objectstack/rest`: a 4xx message of
513+
* 500 characters or more is cut to 499 plus an ellipsis). The bound cuts the
514+
* TAIL, so a remedy written last never reached the wire. The order is: the
515+
* remedy; what is refused (two columns with no shared class, and the classes);
516+
* why it is refused; why the columns are withheld. The text is fixed, so its
517+
* length is too — a sentence added here must be paid for by a shorter one.
510518
*/
511519
function crossFieldClassError(refusal: CrossFieldClassRefusal): Error {
512520
const err = new Error(
513-
'A field-to-field comparison ({ "$field": … }) in this filter compares two columns that share no ' +
514-
'comparison class. Two columns are compared only within one class — a number with a number, text ' +
515-
'with text, a boolean with a boolean, a date with a date, a datetime with a datetime, a time of day ' +
516-
'with a time of day — and a file field, a formula field, or a column that holds a list or an object ' +
517-
'has no class at all, so the platform defines no answer for this comparison. It is refused rather ' +
518-
'than evaluated: across classes SQL and this evaluator answer differently, and the read path refuses ' +
519-
'the same comparison, so an answer here would give one access policy two meanings. The columns and ' +
520-
'the operator are withheld from this message because the filter may be an access policy the caller ' +
521-
'did not write; the server log names them. In a row-level policy, compare a field only with a field ' +
522-
'of the same class, or fix the declaration of the one that is declared with the wrong type.',
521+
'In a row-level policy, compare a field only with a field of the same class, or fix the declaration ' +
522+
'of the one that is declared with the wrong type. This filter compares two columns that share no ' +
523+
'class (number, text, boolean, date, datetime, time; file, formula, list and object fields have ' +
524+
'none). SQL and this evaluator answer it differently, so it is refused, as on the read path. The ' +
525+
'columns and operator are withheld, as the caller may not have written the policy; the server log ' +
526+
'names them.',
523527
) as Error & { code?: string; status?: number };
524528
err.code = StandardErrorCode.enum.INVALID_FILTER;
525529
err.status = 400;

‎packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,15 +180,26 @@ const ROW = { id: 'r1', status: 'open', title: 'x', amount: 5 };
180180

181181
const rlsRecordOf = (d: ExplainDecision) => d.layers.find((l) => l.layer === 'rls')?.record;
182182

183+
/**
184+
* The remedy explain's refusal leads with. It comes before the policy names and
185+
* the diagnostic, which have no length bound, because the REST door keeps only
186+
* a long message's first 499 characters.
187+
*/
188+
const REMEDY =
189+
'Compare a field only with a field of the same class, or fix the declaration of the one that is declared with ' +
190+
'the wrong type.';
191+
183192
/**
184193
* Explain's answer is the find's refusal: the same envelope, no decision and so
185-
* no record verdict, and a message that names the policy and both columns.
194+
* no record verdict, and a message that leads with the remedy and names the
195+
* policy and both columns.
186196
*/
187197
async function expectExplainRefuses(p: Promise<unknown>, columns: [string, string]): Promise<void> {
188198
const r = await refusalOf(p);
189199
expect(r).not.toBe('answered');
190200
if (r === 'answered') return;
191201
expect({ code: r.code, status: r.status }).toEqual(INVALID);
202+
expect(r.message.startsWith(`${REMEDY} `), r.message).toBe(true);
192203
expect(r.message).toContain(`'${POLICY}'`);
193204
for (const column of columns) expect(r.message).toContain(`"${column}"`);
194205
}

‎packages/plugins/plugin-security/src/explain-engine.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,14 @@ function refusedPolicyNamesOf(
923923
* same caller for the same object publishes the same predicate: `readFilter`
924924
* without a `recordId`, the `rls` layer's `rowFilter` with one. So naming the
925925
* policy and its two columns here discloses nothing that report does not.
926+
*
927+
* The remedy leads, BEFORE the subject. The REST door bounds a 4xx message
928+
* (`CLIENT_MESSAGE_MAX` in `@objectstack/rest`: 500 characters or more is cut
929+
* to 499 plus an ellipsis), and the subject and the diagnostic have no length
930+
* bound: object, field and policy names declare no maximum, and the subject
931+
* lists every refused policy. So no subject-first order can keep a trailing
932+
* remedy on the wire for every policy; at index 0 it survives any length. The
933+
* reason comes last and is the part a long subject may cut.
926934
*/
927935
function crossFieldRefusalForExplain(
928936
cause: unknown,
@@ -939,10 +947,10 @@ function crossFieldRefusalForExplain(
939947
: `The row-level security ${policies.length === 1 ? 'policy' : 'policies'} ` +
940948
`${policies.map((p) => `'${p}'`).join(', ')} on '${object}'`;
941949
const err = new Error(
942-
`${subject} cannot be evaluated: ${refusal.diagnostic}. Enforcement refuses every request this filter ` +
943-
'scopes instead of judging a record (the find answers INVALID_FILTER / 400), so explain answers with the ' +
944-
'same refusal and reports no verdict. Compare a field only with a field of the same class, or fix ' +
945-
'the declaration of the one that is declared with the wrong type.',
950+
'Compare a field only with a field of the same class, or fix the declaration of the one that is ' +
951+
`declared with the wrong type. ${subject} cannot be evaluated: ${refusal.diagnostic}. Enforcement ` +
952+
'refuses every request this filter scopes (the find answers INVALID_FILTER / 400), so explain answers ' +
953+
'with the same refusal and reports no verdict.',
946954
);
947955
const { code, status } = cause as { code?: string; status?: number };
948956
return Object.assign(err, { code, status, cause });

‎packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ for (const [driverName, makeDriver, available] of DRIVERS) {
204204
it(`${c.id} \`${c.predicate}\` — the 400 names neither column; the server log names the policy and both`, async () => {
205205
const w = await boot(makeDriver, 'check', c.predicate);
206206
const message = await messageOf(w.engine.insert(w.OBJ, NEW, { context: w.caller } as never));
207-
expect(message).toMatch(/^A field-to-field comparison/);
207+
// The remedy leads: the REST door cuts a long message's tail, never its head.
208+
expect(message).toMatch(/^In a row-level policy, compare a field only with a field of the same class/);
208209
for (const column of c.columns) expect(message).not.toContain(column);
209210

210211
const lines = w.refusalLines();

0 commit comments

Comments
 (0)