Skip to content

Commit 5d156f7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23074-member-envelope
2 parents 8dc94d3 + a22e67b commit 5d156f7

13 files changed

Lines changed: 971 additions & 27 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
A `record:details` section heading can show how many values a multi-value field holds on the current record: `sections[].count: { field }`, with the section's `label` writing the `{{ count }}` hole where the number goes.
7+
8+
Clause-②: yes (widening)
9+
10+
```ts
11+
{
12+
name: 'plan_gap',
13+
label: '{{ count }} features need a higher plan',
14+
fields: ['capabilities_not_on_plan'],
15+
count: { field: 'capabilities_not_on_plan' },
16+
}
17+
```
18+
19+
- **`@objectstack/spec`.** `RecordDetailsProps.sections[]` accepts `count`, a closed object whose one key, `field`, is a bare machine name (snake_case, no dotted path). A section that declares `count` must write the `{{ count }}` hole in its `label`, in every locale of an inline label map. It is refused when it has no `label`, when the label has no hole, when the label writes any other `{{ … }}` hole (another variable, or the count with a formatter), when it writes the single-brace `{count}` / `${count}`, and beside `group` (a group-referencing section's heading is the group's own label). A translated `objects.<object>._sections.<name>.label` writes the same `{{ count }}` hole, and `os i18n extract` seeds it from the authored label. `SECTION_HEADING_COUNT_HOLE` exports the hole's grammar (inner whitespace is free: `{{count}}` is the same hole).
20+
- **`@objectstack/lint`.** The page field-binding pass resolves `count.field` against the bound object, at error severity, on `os validate`, `os lint` and `os build`: a field the object does not declare is `page-field-unknown`, and a declared field that holds one value is the new `page-section-count-not-multi-value`. A multi-value field is `multiselect`, `checkboxes` or `tags`, or a `select`, `lookup`, `user`, `file` or `image` field with `multiple: true`. A section's `fields[]` miss stays a warning.
21+
- **Not rendered yet.** The key is declared ahead of its renderer and its description carries the `[EXPERIMENTAL — not enforced]` marker: until the record detail renderer reads it, the hole renders as literal text.
22+
- **Nothing to migrate.** No stack that parsed before is refused: every new refusal needs the new key. A `{{ count }}` hole in a section that declares no `count` is still accepted.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
`GET /api/v1/auth/me/permissions` answers "no capabilities", not "capabilities unknown", when it fails after the caller is known
6+
7+
Clause-②: no
8+
9+
Once the handler knows who the caller is, it has two ways to fail. When the permission-set resolution rejects, it already answered a closed body: `systemPermissions: []`, so every capability gate closes. Any other throw inside the handler (the effective-object merge, or a resolver that throws synchronously) was caught by an outer `catch` that answered `{ authenticated: true, userId, objects: {}, fields: {} }`. That body has no `systemPermissions`, which is the shape a deployment predating ADR-0066 sends, and the console reads it as "capabilities unknown" and fails open on it. So a server-side fault offered authoring handles the server then refused.
10+
11+
The outer `catch` now answers the closed body, built by the same function the success path uses:
12+
13+
- `authenticated: true`, the caller's `userId`, and `tenantId` (the session's, or `null`), which this body did not carry before;
14+
- `positions`, `permissionSets` and `systemPermissions` as `[]`, and `objects`, `fields` and `tabPermissions` as `{}`.
15+
16+
The log line is unchanged: `[hono] /auth/me/permissions failed` at `warn`, once.
17+
18+
**What does not change.** The success body is byte-identical. The resolver-rejection path still reports the session's positions. A stack with no SecurityPlugin or no metadata service still answers its deliberate open body without `systemPermissions`, since there the server enforces nothing either. A caller with no session still gets `{ authenticated: false }`. No accept set, export or `spec` key moves, and the console needs no change.

‎content/docs/references/ui/component.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1694,7 +1694,7 @@ View filter rule
16941694
| :--- | :--- | :--- | :--- |
16951695
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
16961696
| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
1697-
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
1697+
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor?, count? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). `count: { field }` shows how many values a multi-value field holds in the heading, through its `{{ count }}` hole. |
16981698
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
16991699
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
17001700
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
@@ -1720,6 +1720,7 @@ View filter rule
17201720
| **icon** | `string` | optional | Heading icon, as a lucide icon name (kebab-case, e.g. `building-2`). A value that is not an ASCII identifier (emoji, CJK text) renders as literal text beside the heading instead. Shown where the section heading renders: a titled section, or any collapsible section. |
17211721
| **description** | `string` | optional | Sub-heading text rendered under the section heading (plain string — the renderer applies no translation to it, unlike `label`). Renders on a titled or collapsible section; a collapsible section hides it while collapsed. |
17221722
| **headerColor** | `Enum<'muted' \| 'muted/50' \| 'accent' \| 'primary/10' \| 'secondary/10' \| 'destructive/10'>` | optional | Section-header background tint, from the closed six-token vocabulary rendered by objectui's `record:details` header (`muted` \| `muted/50` \| `accent` \| `primary/10` \| `secondary/10` \| `destructive/10`). A value outside the enum is refused at authoring time rather than silently not painting. Omit for an untinted header. |
1723+
| **count** | `{ field: string }` | optional | [EXPERIMENTAL — not enforced] Show how many values a multi-value field holds on the current record in this section's heading: `{ field: 'tags' }`, with `label` writing the `{{ count }}` hole where the number goes (`'{{ count }} tags'`). The field must be a multi-value field the bound object declares — `multiselect`, `checkboxes`, `tags`, or a `select` / `lookup` / `user` / `file` / `image` field with `multiple: true` — and an absent or empty value counts 0. Every locale of an inline label map, and a translated `objects.<object>._sections.<name>.label`, writes the same `{{ count }}` hole. Not allowed beside `group`. Declared ahead of its renderer: until the record detail renderer reads it, the hole renders as literal text. |
17231724

17241725
### Nested Shape: `RecordDetailsProps.aria`
17251726

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 212 | 201 | 4 | 0 | 7 |
24+
| `ui/` | 213 | 202 | 4 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3636
| `app.zod.ts` | 19 |
3737
| `bulk-action.zod.ts` | 4 |
3838
| `chart.zod.ts` | 8 |
39-
| `component.zod.ts` | 79 |
39+
| `component.zod.ts` | 80 |
4040
| `dashboard.zod.ts` | 11 |
4141
| `dataset.zod.ts` | 4 |
4242
| `i18n.zod.ts` | 1 |
@@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 59 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **212** |
49+
| **total** | **213** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 212**, in 4 file(s).
57+
**7 strip of 213**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 59 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **212** |
65+
| **total** | **7** | **213** |
6666

6767
| Bucket | Sites |
6868
|---|---|

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ export {
532532
PAGE_FIELD_UNKNOWN,
533533
PAGE_FIELD_UNPROVISIONED,
534534
PAGE_SECTION_GROUP_UNKNOWN,
535+
PAGE_SECTION_COUNT_NOT_MULTI_VALUE,
535536
} from './validate-page-field-bindings.js';
536537
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';
537538

‎packages/lint/src/validate-page-field-bindings.test.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import {
99
PAGE_FIELD_UNKNOWN,
1010
PAGE_FIELD_UNPROVISIONED,
1111
PAGE_SECTION_GROUP_UNKNOWN,
12+
PAGE_SECTION_COUNT_NOT_MULTI_VALUE,
13+
componentSectionCountRefs,
1214
} from './validate-page-field-bindings.js';
1315
import { indexUnprovisionedAnchors } from './system-fields.js';
16+
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
1417

1518
const baseStack = () => ({
1619
objects: [
@@ -220,6 +223,107 @@ describe('validatePageFieldBindings — dangling `section.group` (#13855)', () =
220223
});
221224
});
222225

226+
describe('validatePageFieldBindings — the field a section heading counts (#23013)', () => {
227+
/** `baseStack()` plus multi-value fields on `crm_lead`. */
228+
const countStack = () => {
229+
const stack = baseStack();
230+
Object.assign((stack.objects[0] as { fields: Record<string, unknown> }).fields, {
231+
tags: { type: 'tags' },
232+
capabilities_not_on_plan: { type: 'multiselect', options: [{ label: 'SSO', value: 'sso' }] },
233+
watchers: { type: 'user', multiple: true },
234+
regions: { type: 'select', multiple: true, options: [{ label: 'EU', value: 'eu' }] },
235+
});
236+
return stack;
237+
};
238+
239+
/** One counted section; `fields` lists the counted field like a real page does. */
240+
const countedPage = (field: string) => pageWith([{
241+
type: 'record:details',
242+
properties: {
243+
sections: [{ name: 'plan_gap', label: '{{ count }} items', fields: [field], count: { field } }],
244+
},
245+
}]);
246+
const COUNT_PATH = 'pages[0].regions[0].components[0].properties.sections[0].count.field';
247+
248+
it('is clean on every multi-value shape: an option-list type, and a multi-capable type with `multiple: true`', () => {
249+
for (const field of ['tags', 'capabilities_not_on_plan', 'watchers', 'regions']) {
250+
expect(validatePageFieldBindings({ ...countStack(), pages: [countedPage(field)] }), field).toEqual([]);
251+
}
252+
});
253+
254+
it('GATES on a counted field the object does not declare — reusing `page-field-unknown`', () => {
255+
const findings = validatePageFieldBindings({
256+
...countStack(),
257+
pages: [pageWith([{
258+
type: 'record:details',
259+
properties: { sections: [{ label: '{{ count }} items', fields: ['tags'], count: { field: 'capabilitie' } }] },
260+
}])],
261+
});
262+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
263+
[PAGE_FIELD_UNKNOWN, 'error', COUNT_PATH],
264+
]);
265+
expect(findings[0].message).toContain('"capabilitie"');
266+
});
267+
268+
it('GATES on a counted field that holds one value, naming its type', () => {
269+
for (const [field, type] of [['status', 'select'], ['name', 'text'], ['account', 'lookup']] as const) {
270+
const findings = validatePageFieldBindings({ ...countStack(), pages: [countedPage(field)] });
271+
expect(findings.map((f) => [f.rule, f.severity, f.path]), field).toEqual([
272+
[PAGE_SECTION_COUNT_NOT_MULTI_VALUE, 'error', COUNT_PATH],
273+
]);
274+
expect(findings[0].message, field).toContain(`\`${type}\``);
275+
}
276+
});
277+
278+
it('judges a registry-injected column by its own definition: `created_at` is single-valued', () => {
279+
const findings = validatePageFieldBindings({
280+
...countStack(),
281+
pages: [pageWith([{
282+
type: 'record:details',
283+
properties: { sections: [{ label: '{{ count }} items', fields: ['tags'], count: { field: 'created_at' } }] },
284+
}])],
285+
});
286+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
287+
[PAGE_SECTION_COUNT_NOT_MULTI_VALUE, 'error', COUNT_PATH],
288+
]);
289+
});
290+
291+
it('keeps the severity split: a section `fields[]` miss advises while the counted miss gates', () => {
292+
const findings = validatePageFieldBindings({
293+
...countStack(),
294+
pages: [pageWith([{
295+
type: 'record:details',
296+
properties: { sections: [{ label: '{{ count }} items', fields: ['ghost'], count: { field: 'ghost' } }] },
297+
}])],
298+
});
299+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
300+
[PAGE_FIELD_UNKNOWN, 'warning', 'pages[0].regions[0].components[0].properties.sections[0].fields[0]'],
301+
[PAGE_FIELD_UNKNOWN, 'error', COUNT_PATH],
302+
]);
303+
});
304+
305+
it('says nothing about an object this stack does not define', () => {
306+
const findings = validatePageFieldBindings({
307+
...countStack(),
308+
pages: [{ ...countedPage('anything'), object: 'other_pkg__thing' }],
309+
});
310+
expect(findings).toEqual([]);
311+
});
312+
313+
it('survives off-spec input: a non-object `count`, a non-string `field`, a section that is not an object', () => {
314+
const refs = componentSectionCountRefs('record:details', {
315+
sections: ['overview', { count: 'tags' }, { count: { field: 7 } }, { count: { field: 'tags' } }],
316+
}, 'p');
317+
expect(refs).toEqual([{ name: 'tags', path: 'p.sections[3].count.field' }]);
318+
});
319+
320+
it('reaches the gating reference-integrity suite every CLI command runs', () => {
321+
const findings = validateReferenceIntegrity({ ...countStack(), pages: [countedPage('status')] });
322+
expect(findings.filter((f) => f.rule === PAGE_SECTION_COUNT_NOT_MULTI_VALUE).map((f) => f.severity))
323+
.toEqual(['error']);
324+
});
325+
});
326+
223327
describe('validatePageFieldBindings — related lists bind the related object', () => {
224328
it('checks columns against objectName, not the page object', () => {
225329
const findings = validatePageFieldBindings({

0 commit comments

Comments
 (0)