Skip to content

Commit dbc77fa

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-vgdwpp
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibz8wBXN5R4YRtm5e5r
2 parents 2c3f9f5 + 3739d8d commit dbc77fa

39 files changed

Lines changed: 1235 additions & 50 deletions
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/plugin-audit': minor
4+
'@objectstack/metadata-protocol': minor
5+
'@objectstack/objectql': minor
6+
'@objectstack/client': minor
7+
---
8+
9+
feat(spec): a comment's author is told which @mentioned users will not be notified, and a mention added by editing is notified
10+
11+
Clause-②: yes
12+
Metadata-protocol: added
13+
14+
The `@mention` recipient gate withholds a notification from a mentioned user who cannot read the comment or its record. Until now it only logged that, so the author believed the person had been told. The comment still saves with every mention; now the requester's own answer says who will not be notified.
15+
16+
- **Spec.** `CreateDataResponseSchema` and `UpdateDataResponseSchema` declare an optional, response-only `withheldMentions`: the ids of the users this write @mentioned whose notification the mention gate withheld. Ids only, never the reason. Only a `sys_comment` write fills it, only on the requester's own answer to that request, and the key is omitted when nobody was withheld. It is not a field of `sys_comment` and is never stored, since a stored member would tell every reader who cannot read the thread. `WriteObservabilityOptions` declares the in-process listener that carries it, `onMentionsWithheld(userIds)`, beside `onFieldsDropped` and `onValidationAdvisory`. Like them it never crosses the wire.
17+
- **plugin-audit.** The mention writer reports the gate's own verdict to that listener, which it reads off its hook's `ctx.input.options` (the write's own options bag). Readability is not derived a second time, and who the gate admits is unchanged. The writer now also runs on `sys_comment` `afterUpdate`, so a mention added by editing a comment is judged and notified like one written with it. An edit judges only the mentions it added; a mention the comment already carried is not judged or notified again. The editor, who may be a parent-record editor rather than the author, is the actor of an edit's notifications: they are named in the title and never notified themselves. `MentionGateInput` takes an optional `onWithheld(userId)` callback.
18+
- **metadata-protocol.** `createData` and `updateData` pass the listener and answer the ids as `withheldMentions`. A nested write that a hook or a flow makes inside this one carries its own options, so its verdicts never reach this answer.
19+
- **client.** `CreateDataResult` and `UpdateDataResult` declare `withheldMentions?: string[]`, mirroring the two schemas.
20+
- **objectql.** `engine.update` accepts the `onMentionsWithheld` option. The engine never calls it; it carries it to the `after*` hooks with the rest of the caller's bag.
21+
22+
Nothing to do for existing clients: a client that does not read `withheldMentions` sees the same answer as before.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec): a report chart can split its bars by a second dataset dimension with `chart.seriesBy`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
`ReportChartSchema` declares one optional key, `seriesBy`: the name of a second dimension of the report's bound dataset. It splits the chart's one `yAxis` measure into a series per value of that dimension, so a report can draw a bar per owner split by stage. It is the report's spelling of a dashboard widget's two-entry `dimensions` selection. With `seriesBy` set, a `chart.series[]` entry's `name` names a value of that dimension, because each derived series is one value.
12+
13+
- **Lint.** `os validate`, `os build` and the report publish door resolve `chart.seriesBy` against the dataset's declared dimensions exactly as they resolve `chart.xAxis`: an undeclared name is a `chart-dimension-unknown` error with the nearest declared dimension suggested. When `seriesBy` is set, `chart.series[].name` is no longer judged against the chart's measure, since it names a dimension value.
14+
- **Liveness.** The key is graded `planned` with an author warning: this is the contract half, and the report chart still draws one series until the renderer reads `seriesBy` in a later change.
15+
16+
Nothing to do for existing reports. A chart without `seriesBy` parses and draws exactly as before.

‎.changeset/28018-kanban-lanes.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec): a kanban board can choose which lanes it draws, and in what order, `kanban.lanes`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
A board grouped on a select field drew one lane per option, always, so a lane the view's own filter keeps empty (a task board that filters out completed tasks still drawing its Completed lane) could not be left off, and the lanes could not be put in any order but the options' own. `KanbanConfigSchema` (the list view's `kanban` block) gains one optional key:
12+
13+
- **`lanes: string[]`.** The lanes the board draws, in this order, as stored option values of the `groupByField` field (the option `value`, not its label). For example `lanes: ['not_started', 'in_progress', 'waiting']` on a task board grouped by `status`.
14+
- **A choice of lanes, not a record filter.** A record whose value is not listed lands in the board's `Uncategorized` lane, as a record whose value matches no lane already does. To keep records off the board, filter the view.
15+
- **Absent means one lane per option.** The key has no spec-side default, so a board stored before it existed parses to exactly the block it stored and draws the lanes it always did.
16+
- **Refused at parse:** an empty list (omit the key instead), and an entry that is not shaped like a stored option value (a display label such as `'In Progress'`).
17+
- **Refused against the object** (`@objectstack/lint`, so `os validate`, `os build` and the runtime publish gate): a lane that is not an option of the group field (`list-view-kanban-lane-unknown`, which names the nearest option), and a value listed twice (`list-view-kanban-lane-repeated`, at the second entry). A misspelled lane would otherwise draw a lane no card can land in while every card with the option it meant falls into `Uncategorized`. Judged against the group field's inline `options`; a field whose options come from a shared `picklist`, and a group field with no options (a boolean, a lookup), are not judged. `os explain list-view-kanban-lane-unknown` (and `-repeated`) carries the reasoning.
18+
19+
The Console renderer reads the key in a follow-up change; until then a board that declares it still draws one lane per option.

‎content/docs/api/data-api.mdx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,23 @@ Create a new record.
232232
**Body**: `{ name: "Acme Corp", industry: "Technology" }`
233233
**Response**: `{ object: "account", id: "1", record: { ... } }`
234234

235+
A `sys_comment` create that @mentions users the server will not notify (the
236+
mention gate withheld them, most often because they cannot read the comment or
237+
its record) also answers `withheldMentions`: those users' ids, with no reason, on
238+
the requester's own answer only. The comment is saved
239+
with every mention, and nothing about the withheld users is stored on it. With
240+
nobody withheld, the key is absent.
241+
235242
### `PATCH /data/:object/:id`
236243

237244
Update an existing record (partial update).
238245

239246
**Body**: `{ industry: "Healthcare" }`
240247
**Response**: `{ object: "account", id: "1", record: { ... } }`
241248

249+
A `sys_comment` update answers `withheldMentions` the same way, for the mentions
250+
the update added. A mention the comment already carried is not judged again.
251+
242252
### `DELETE /data/:object/:id`
243253

244254
Delete a record.

‎content/docs/kernel/contracts/data-engine.mdx‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,11 +327,13 @@ interface EngineUpdateOptions {
327327

328328
### WriteObservabilityOptions
329329

330-
The write methods (`insert` / `update`) additionally accept three **in-process**
330+
The write methods (`insert` / `update`) additionally accept four **in-process**
331331
options. Two govern what happens when caller-supplied write fields are legally
332332
stripped from the payload before the driver write: observe the strip
333333
(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). The
334334
third, `onValidationAdvisory`, reports the write's advisory validation-rule hits.
335+
The fourth, `onMentionsWithheld`, reports the users a comment write @mentioned who
336+
will not be notified.
335337

336338
The strips these two options cover are the engine's legal ones:
337339

@@ -357,6 +359,7 @@ on `insert` too since the 2026-09-03 ruling — see **On `insert`** below.
357359
interface WriteObservabilityOptions {
358360
onFieldsDropped?: (event: DroppedFieldsEvent) => void;
359361
onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void;
362+
onMentionsWithheld?: (userIds: readonly string[]) => void;
360363
strictReadonlyWrites?: boolean; // refuse instead of stripping. Default: false
361364
}
362365

@@ -454,6 +457,24 @@ The DataProtocol's `createData`, `updateData` and `cloneData` pass this listener
454457
and answer the hits as `warnings` (`ValidateDataIssue` entries carrying `rule`
455458
and `severity`), present only when there is at least one hit.
456459

460+
#### `onMentionsWithheld` — who a comment's @mentions will not reach
461+
462+
A `sys_comment` write notifies each user it @mentions, but only a user who can
463+
read the comment and its record: the mention recipient gate withholds the rest.
464+
This listener is told the ids of the users the gate withheld on the write the
465+
options were passed to, whatever its reason: ids only, never the reason. The engine never calls it.
466+
It reaches the mention writer the way every member of this bag reaches an
467+
`after*` hook, on `ctx.input.options`, and the writer calls it once per written
468+
comment that withheld anyone. On an `update`, only a mention the update added is
469+
judged; a mention the comment already carried was judged when it was written. A
470+
nested write that a hook or a flow makes inside this one carries its own options,
471+
so its verdicts never reach this listener.
472+
473+
The DataProtocol's `createData` and `updateData` pass this listener and answer the
474+
ids as `withheldMentions`, on the requester's own answer to that request only and present only when at
475+
least one user was withheld. Nothing is stored on the comment: a stored member
476+
would tell every reader of the comment who cannot read its thread.
477+
457478
<Callout type="warn">
458479
**Layering — this is the engine seam, and since the 2026-09-03 ruling the only
459480
one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`,
@@ -465,7 +486,7 @@ declares one; none carries a `readonly` policy of its own any more — see
465486

466487
<Callout type="warn">
467488
`WriteObservabilityOptions` is a **TS-contract-level, in-process-only** bag —
468-
all three members. It is deliberately not part of the serializable Zod options schemas:
489+
all four members. It is deliberately not part of the serializable Zod options schemas:
469490
a function is unrepresentable in JSON Schema and cannot be serialized, so remote
470491
callers (REST, the client SDK) never receive these events; and putting
471492
`strictReadonlyWrites` in the serializable bag would let any client toggle

‎content/docs/references/api/protocol.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
556556
| **record** | `Record<string, any>` | ✅ | The created record, including server-generated fields (created_at, owner). |
557557
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. |
558558
| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. |
559+
| **withheldMentions** | `string[]` | optional | The ids of the users this write @mentioned whose mention notification was WITHHELD by the mention recipient gate, so they will not be notified: the gate's own verdict on THIS write, ids only, never the reason. Only a `sys_comment` write fills it, and only on the requester's own answer to the request that made the write; on an update, only a mention the update added is judged. The comment was saved with every mention; the requester is told here that the named users will not be notified. Response-only: never stored on the comment (a stored member would tell every reader who cannot read the thread). Present ONLY when at least one mention was withheld; otherwise the key is omitted. |
559560

560561
### Nested Shape: `CreateDataResponse.droppedFields[number]`
561562

@@ -1863,7 +1864,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
18631864
| **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration |
18641865
| **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) |
18651866
| **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration |
1866-
| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
1867+
| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
18671868
| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout |
18681869
| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
18691870
| **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration |
@@ -1942,7 +1943,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
19421943
| **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration |
19431944
| **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) |
19441945
| **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration |
1945-
| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
1946+
| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
19461947
| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout |
19471948
| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
19481949
| **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration |
@@ -3041,6 +3042,7 @@ Uninstall package response
30413042
| **record** | `Record<string, any>` | ✅ | Updated record |
30423043
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` or a TRUE `readonlyWhen` predicate. Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. |
30433044
| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. |
3045+
| **withheldMentions** | `string[]` | optional | The ids of the users this write @mentioned whose mention notification was WITHHELD by the mention recipient gate, so they will not be notified: the gate's own verdict on THIS write, ids only, never the reason. Only a `sys_comment` write fills it, and only on the requester's own answer to the request that made the write; on an update, only a mention the update added is judged. The comment was saved with every mention; the requester is told here that the named users will not be notified. Response-only: never stored on the comment (a stored member would tell every reader who cannot read the thread). Present ONLY when at least one mention was withheld; otherwise the key is omitted. |
30443046

30453047
### Nested Shape: `UpdateDataResponse.droppedFields[number]`
30463048

0 commit comments

Comments
 (0)