Skip to content

Commit e5a2555

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21646-seed-created-at
2 parents f135b5c + 6ec54f0 commit e5a2555

19 files changed

Lines changed: 1462 additions & 34 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an `object-master-detail-form` detail entry's `sortField` is retired — the console derives the line-position field from the child object and reads no authored value (#21589)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered object-master-detail-form-detail-sort-field-removed, object-master-detail-form-detail-sort-field-retired -->
10+
11+
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
`ComponentPropsMap['object-master-detail-form'].details[].sortField` named the child field the line grid stamps with each line's position on drag-reorder. The console stopped reading it: the field it stamps is derived from the child object, and the pinned console crossed that change while the spec still declared the key. So an authored `sortField` went through `os validate` clean and was dropped, and a drag-reorder stamped the derived field, or none (ADR-0049 enforce-or-remove).
14+
15+
### FROM → TO
16+
17+
| before | what to write instead |
18+
| --- | --- |
19+
| `details: [{ childObject: 'crm_invoice_line', sortField: 'line_no' }]` | delete `sortField`. The grid stamps the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. |
20+
| `sortField` naming a field outside that list | give the child object one of those fields; the line order is kept there. |
21+
| an entry that names `relationshipField` and at least one column and gives every column a `type` | unchanged: the renderer keeps that entry exactly as authored, loads no child schema for it, and stamps no line position, before and after the upgrade alike. |
22+
23+
**The one-line fix: delete `sortField` from every `object-master-detail-form` detail entry.** `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand.
24+
25+
**What an author now sees.** Writing the key fails `tsc` (its input type is the retired-key mark), and `os validate`, `os build` and `os lint` report it as a `component-props-invalid` warning carrying the prescription at `properties.details.N.sortField`. A page that carries it still saves and loads: a page component's `properties` is not parsed on the metadata save or load path.
26+
27+
### The retirement kit
28+
29+
- **Tombstone.** `sortField` is a `retiredKey()` on the strict detail entry. Its prescription prints the derived field names from their one declaration, a module reached by relative import only (`data/inline-grid-sort-fields.ts`), which the derived inline-grid columns read too.
30+
- **D2 conversion `object-master-detail-form-detail-sort-field-removed`** (step 18, retired from the load path): a lossless delete of `sortField` from every `properties.details[]` entry of an `object-master-detail-form`, scoped by component type and by position. Stored `sys_metadata` pages and built artifacts replay it, one notice per entry.
31+
- **D3 entry `object-master-detail-form-detail-sort-field-retired`** carries the judgment the delete cannot make: whether the child object declares the field the line order is kept in.
32+
- **`RETIRED_KEYS_BY_MAJOR[18]`** registers the nested key `ui/ObjectMasterDetailFormProps:details.sortField`.
33+
- **`record:line_items`' answer to `sortField`** no longer sends the author to the detail entry: no block takes an authored `sortField` any more.
34+
- **No deprecation window**: the writer census is zero.
35+
36+
**Measured producers: none.** On origin/main 9a4182a752, no `object-master-detail-form` detail entry in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes `sortField`, against the sibling detail-entry key `addLabel` on the showcase project workspace's entry as the control, through the same instrument. At the objectui pin `89cad75d5570` the only detail entries that write it are probes asserting that nothing reads it. Deployed metadata NOT MEASURED.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a flow's `create_record`, `update_record` and `delete_record` nodes refuse a stored-metadata table as their target (#21624)
6+
7+
Clause-②: no
8+
9+
The two stored-metadata tables (the current metadata bodies and their version history) have one writer for app-authored work: the metadata protocol, where a change is validated and its provenance is recorded. A flow's write nodes wrote those tables directly, outside it. Under `runAs: 'system'` the write ran elevated, so the security middleware never judged it; under `runAs: 'user'` only a composition with the security plugin refused it, as a routable runtime failure with no code. A write node's `filter` was also evaluated against the stored rows, so whether the write acted answered a predicate over the stored body.
10+
11+
**What changes.** A `create_record`, `update_record` or `delete_record` node whose `objectName` is either table is refused before it resolves its filter or its field values and before any engine write, under either run identity. The refusal names the metadata API as the way to change metadata and carries the standard `PERMISSION_DENIED` code, the code the data door answers a non-platform principal's write to these tables with. It is a guard failure: the run fails, nothing downstream of the node runs, and a `fault` edge does not route it. A `try_catch` catch region reads the code on `{$error.code}`. Metadata is changed through the metadata API (`PUT /api/v1/meta/:type/:name`), never through a flow's data nodes.
12+
13+
**What does not change.** Every other object is created, updated and deleted exactly as before. `get_record` keeps serving these tables projected and keyed.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1084,7 +1084,7 @@ Sort field and direction pair
10841084
| **formFields** | `string[]` | optional | Child field names for the per-row expand form. When omitted they are derived from the child object's fields — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is derived, and the per-row form is offered only when `inlineMode` is 'form', where it draws the child object's full field list |
10851085
| **inlineMode** | `Enum<'grid' \| 'form'>` | optional | Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list + per-row full form. When omitted it is resolved from the relationship field's `inlineEdit`, else from the child object's shape — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is resolved, the collection renders as a grid, and the per-row form is offered only when `formFields` lists more fields than `columns` |
10861086
| **amountField** | `string` | optional | Numeric child column summed for the running total and the `totalField` rollup. When omitted it is picked from the grid's number and currency columns: a computed one, else one named `amount`, `total`, `subtotal`, `line_total`, `line_amount` or `net_amount`, else the last currency column, else the last numeric one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored, so nothing is picked. With no `amountField` authored or picked, the sums read a child column named `amount`, and the grid shows a running total only when `totalField` is set |
1087-
| **sortField** | `string` | optional | Child field holding the line sort position, stamped on drag-reorder. When omitted it is the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`, if it has one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored: nothing is derived, the grid stamps no line position, and a drag-reorder is not saved |
1087+
| **sortField** | `never` | optional | [REMOVED] `object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17 (ADR-0087 D2) — the console reads no authored value: the field the line grid stamps with each line's position on drag-reorder is derived from the child object, so an authored `sortField` was accepted and dropped. Delete the key. For a drag-reorder to be saved, give the child object a field named `position` / `sort_order` / `sequence` / `line_no` / `line_number` / `sort`: the renderer stamps the child's first field with one of those names — except on an entry that names `relationshipField` and at least one column and gives every column a `type`, which the renderer keeps exactly as authored and stamps no line position on. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
10881088
| **totalField** | `string` | optional | Parent field to receive the rolled-up sum |
10891089
| **title** | `string` | optional | Section title |
10901090
| **minRows** | `number` | optional | Minimum number of rows |

‎packages/lint/src/validate-component-props.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,32 @@ describe('validateComponentProps — undeclared keys', () => {
9191
}
9292
});
9393

94+
// #21589 — an `object-master-detail-form` detail entry's `sortField` is a
95+
// retiredKey tombstone, one array level down. The same door, the same
96+
// warning: the finding names the entry's key, and the page is never refused.
97+
it('reports a retired detail-entry `sortField` as a warning carrying the prescription, at the entry\'s key', () => {
98+
const findings = validateComponentProps(
99+
stackWith([
100+
{
101+
type: 'object-master-detail-form',
102+
properties: {
103+
objectName: 'invoice',
104+
details: [
105+
{ title: 'Payments', childObject: 'invoice_payment' },
106+
{ title: 'Lines', childObject: 'invoice_line', sortField: 'line_no' },
107+
],
108+
},
109+
},
110+
]),
111+
);
112+
expect(findings).toHaveLength(1);
113+
const [f] = findings;
114+
expect(f.severity).toBe('warning');
115+
expect(f.rule).toBe(COMPONENT_PROPS_INVALID);
116+
expect(f.path).toBe('pages[0].regions[0].components[0].properties.details.1.sortField');
117+
expect(f.message).toContain('`object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17');
118+
});
119+
94120
it('walks components nested inside `properties` (tabs items → children)', () => {
95121
const findings = validateComponentProps(
96122
stackWith([

‎packages/services/service-automation/src/builtin/crud-nodes.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,55 @@ function storedMetadataFilterRefusal(
323323
return undefined;
324324
}
325325

326+
/** [#21624] What each write node would have done, in its refusal's own words. */
327+
const STORED_METADATA_WRITE_VERB = {
328+
create_record: 'create a record in',
329+
update_record: 'update',
330+
delete_record: 'delete from',
331+
} as const;
332+
333+
/**
334+
* [#21624] Refuse a WRITE node aimed at the stored-metadata family
335+
* (`sys_metadata` / `sys_metadata_history`, judged by the family's own
336+
* predicate, {@link isStoredMetadataBodyObject}).
337+
*
338+
* The family has one writer for app-authored work: the metadata protocol,
339+
* where a change is validated and its provenance recorded (the ruling that
340+
* refuses a hook body bound to these tables, or a body's direct write to them,
341+
* applied to its own reason: a flow is app-authored automation too). Under
342+
* `runAs: 'system'` the engine writes elevated and cannot tell this write from
343+
* the platform's own internal writers, so the rule is applied here, at the
344+
* node. ⛔ Not routed through the protocol from inside the node: that would be
345+
* a second write path into the family.
346+
*
347+
* Judged on the object name the engine would be handed, before the node
348+
* resolves its `filter` or its `fields`, so a refused node answers the same
349+
* whatever it names: its filter is never evaluated against a family table
350+
* (the write nodes' evaluate exit) and nothing it would write is computed.
351+
*
352+
* The answer is a guard refusal ({@link refuseNode}: the metadata is wrong, and
353+
* re-running it unchanged never succeeds) carrying the standard catalog's
354+
* `PERMISSION_DENIED`: the code the data door's in-process write path answers
355+
* a non-platform principal's write to these tables with in a secured
356+
* composition, and the code the body-write boundary for the same ruling
357+
* carries. No code is minted. `undefined` for any other object.
358+
*/
359+
function storedMetadataWriteRefusal(
360+
nodeType: keyof typeof STORED_METADATA_WRITE_VERB,
361+
objectName: string,
362+
): (ReturnType<typeof refuseNode> & { code: string }) | undefined {
363+
if (!isStoredMetadataBodyObject(objectName)) return undefined;
364+
return {
365+
...refuseNode(
366+
`${nodeType}: refusing to ${STORED_METADATA_WRITE_VERB[nodeType]} '${objectName}': it holds stored `
367+
+ 'metadata, and a flow may not write it directly, so the write was not run. Change metadata through the '
368+
+ 'metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its '
369+
+ "provenance is recorded. Elevation (`runAs: 'system'`) does not change this.",
370+
),
371+
code: StandardErrorCode.enum.PERMISSION_DENIED,
372+
};
373+
}
374+
326375
/**
327376
* CRUD built-in nodes — `get_record` / `create_record` / `update_record` /
328377
* `delete_record`, wired to the runtime data layer (ObjectQL / IDataEngine).
@@ -478,6 +527,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
478527
const cfg = parsed.config;
479528
const objectName = cfg.objectName;
480529
if (!objectName) return refuseNode('create_record: objectName required');
530+
// [#21624] A stored-metadata family target is refused before
531+
// anything is resolved or written, under either run identity.
532+
const familyRefusal = storedMetadataWriteRefusal('create_record', objectName);
533+
if (familyRefusal) return familyRefusal;
481534

482535
// #19938 / #11182 ruling D — a CEL value envelope in `fields.*` is
483536
// evaluated; every other value interpolates exactly as before.
@@ -627,6 +680,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
627680
const cfg = parsed.config;
628681
const objectName = cfg.objectName;
629682
if (!objectName) return refuseNode('update_record: objectName required');
683+
// [#21624] Before the filter is resolved, so a family target's
684+
// filter is never evaluated, under either run identity.
685+
const familyRefusal = storedMetadataWriteRefusal('update_record', objectName);
686+
if (familyRefusal) return familyRefusal;
630687

631688
// `filters` → `filter` converted at load (ADR-0087 D2); read canonical.
632689
const filterResult = resolveNodeFilter(
@@ -721,6 +778,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
721778
const cfg = parsed.config;
722779
const objectName = cfg.objectName;
723780
if (!objectName) return refuseNode('delete_record: objectName required');
781+
// [#21624] Before the filter is resolved, so a family target's
782+
// filter is never evaluated, under either run identity.
783+
const familyRefusal = storedMetadataWriteRefusal('delete_record', objectName);
784+
if (familyRefusal) return familyRefusal;
724785

725786
// `filters` → `filter` converted at load (ADR-0087 D2); read canonical.
726787
// The highest-stakes of the three: an erased condition here is the

0 commit comments

Comments
 (0)