Skip to content

Commit ec292cf

Browse files
feat(spec): a metadata-form row each for sixteen field and action keys (#19332, flight G1b) (#20428)
Part of #19332 Flight G1b of ruling 5861442317. Clause-②: no ## What Sixteen live keys that the field and action schemas declare had no form row, so an author could reach them only through the Source tab. Each now has exactly one row. Each row copies a row that a registered form already has for the same node shape, and the four-locale catalogue rows are in the same PR. This is the `field.*` / `action.*` half of the ruling's G1; G1a (the object and permission rows) landed as `7db1332f`. | key | form, section | control | the row it copies | gate | |:--|:--|:--|:--|:--| | `field.visibleWhen` | `field.form.ts`, Advanced | `type: 'code'`, `language: 'expression'` | `object.form.ts:347` `fields.visibleWhen` (same key, same node) | none | | `field.readonlyWhen` | `field.form.ts`, Advanced | same | `object.form.ts:348` | none | | `field.requiredWhen` | `field.form.ts`, Advanced | same | `object.form.ts:349` | none | | `field.lookupFilters` | `field.form.ts`, Configuration | `widget: 'json'`, no inline options | `object.form.ts:253` `fields.lookupFilters` (same key, same node) | lookup / master_detail | | `field.lookupColumns` | `field.form.ts`, Configuration | `widget: 'json'` | the same `lookupFilters` row | lookup / master_detail | | `field.dependsOn` | `field.form.ts`, Configuration | `widget: 'json'` | the same `lookupFilters` row | lookup, master_detail, select, multiselect, radio, checkboxes | | `field.relatedListColumns` | `field.form.ts`, Configuration | `widget: 'string-tags'` | `app.form.ts:102` `requiredPermissions` | lookup / master_detail | | `field.accept` | `field.form.ts`, Configuration | `widget: 'string-tags'` | `app.form.ts:102` | the five media types (`maxSize`'s gate) | | `field.currencyConfig` | `field.form.ts`, Configuration | `type: 'composite'`: a declared `currencyMode` select (`dynamic` / `fixed`) and `defaultCurrency` text | `object.form.ts:453` `access` | `currency` | | `field.storage` | `field.form.ts`, Advanced | `type: 'composite'`: a declared `notNull` boolean | `object.form.ts:453` `access` | none | | `field.requiredPermissions` | `field.form.ts`, Advanced | `widget: 'string-tags'` | `app.form.ts:102` | none | | `action.patch` | `action.form.ts`, Behavior | `widget: 'json'` | `object.form.ts:424` `validations` | `data.operation == 'update'` (`undoable`'s gate) | | `action.description` | `action.form.ts`, Behavior | `widget: 'textarea'` | `page.form.ts:43` `description` (same `I18nLabel` node) | none | | `action.errorMessage` | `action.form.ts`, Behavior | plain row | `action.form.ts` `successMessage` (its twin, same node) | none | | `action.requiredPermissions` | `action.form.ts`, Placement | `widget: 'string-tags'` | `app.form.ts:102` | none | | `action.bodyExtra` | `action.form.ts`, Advanced | `widget: 'json'` | `object.form.ts:424` `validations` | `data.type == 'api'` (`bodyShape`'s gate) | The ruling's widget rules, as applied here: - `lookupColumns` and `dependsOn` are arrays of a union (a field name, or an object entry). They take `json` and never `string-tags`. The tag widget is a chip input for strings only, so it cannot show or edit a stored object entry. - `accept`, `relatedListColumns` and both `requiredPermissions` are plain `string[]`, so they take `string-tags`. - The field-name lists are free text. No field picker is offered: nothing on a field draft gives a picker a catalogue to read. - `currencyConfig` is `{currencyMode, defaultCurrency}` today. Its `precision` key, which the analysis table listed, was removed before this flight. Both `currencyMode` members are spellable option values. Every gate is a meaningfulness gate, taken from the runtime reader: the file-constraint pass for `accept`, the lookup picker and the four option widgets for `dependsOn`, and the related-list derivation for `relatedListColumns`. There are two exceptions. The parse refuses `patch` without `operation: 'update'`. It also refuses `bodyExtra` beside that operation. The help text says what the runtime does with each value, including what absence resolves to. Each claim was checked against its reader: - `readonlyWhen`: `stripReadonlyWhenFields` (objectql `validation/rule-validator.ts`) drops an update's change to a locked field and keeps the stored value. It does not refuse the write. - `requiredWhen` is a transition gate: `evaluateValidationRules` refuses a write only when the record complied before it. The parse refuses it beside `storage.notNull` (`field.zod.ts`, the ADR-0113 check). - `field.requiredPermissions` is all-of. `foldFieldRequiredPermissions` (plugin-security) sets a field to unreadable and uneditable unless every listed capability is held. A `maskingRule` softens the read into a partial mask. - `action.requiredPermissions` is all-of too. `actionPermissionError` (runtime `action-execution.ts`) reports the missing subset. - `accept`: `assertFileConstraints` (service-storage) re-checks it on write against the stored `sys_file`. - `patch`, `bodyExtra`, `description`, `errorMessage` and `currencyConfig`: the texts restate each key's own schema description. No schema's accepted input changes, and no export changes. What changes is the form payload that `getMetaTypes()` serves, and the translation keys that `os i18n extract` walks. ## Where a misspelt field name is refused (dispatch assumption 2) The ruling says 「a misspelling is refused loudly at parse」. G1a measured its two lists as refused at the publish door and by `os validate`. **For this flight's four lists, no authoring door judges the names**: not the Zod parse, not the publish door, not `os validate`. So the help text claims no refusal. From the code: - No rule in `packages/lint/src` reads `relatedListColumns`, `lookupColumns` or `dependsOn`. - `lookupFilters` is walked by the filter-token, empty-combinator and preset-comparand rules. Those judge values. The preset rule's own header leaves a mistyped field to the `*-filter-field-unknown` rules, and those exist only for datasets, dashboards and widgets. What happens at run time instead. These are code readings; none was run end to end. - `relatedListColumns`: the related list puts the authored columns in its `$select` (objectui `RelatedList.tsx` `selectFields`, at the pin). The REST read ingress refuses an unknown projection field with 400 `INVALID_FIELD` (`assertProjectionFieldsExist`). NOT MEASURED: whether objectui's FLS column gate drops an unknown name first. - `lookupFilters[].field`: the picker lowers it to a `$filter` on the referenced object. The read ingress refuses an unknown filter field (`assertFilterFieldsExist`). - `lookupColumns`: the record-picker dialog draws the column and sends no projection, so a mistyped name renders an empty column. - `dependsOn`: `LookupField` gates the picker until each named value is set. A mistyped name is never set, so the picker stays gated ("select X first"). ## objectui widgets, read at the `.objectui-sha` pin (`f8a9d0fb0`, source only, no browser) G1a found that both halves of the ruling's premise hold at the pin: `string-tags` reads a non-array as an empty list, and `field-multi` binds nothing on an object draft. This flight reuses that reading. The faces G1a did not use were read in `SchemaForm.tsx` `resolveFieldFace` and `widgets.tsx`: - **`type: 'code'`** is the registered `CodeWidget`. It reads a non-string value with `String(value)` (`widgets.tsx:3076-3086`). A stored ADR-0089 envelope would therefore show as `[object Object]`, and an edit writes a bare string over it. objectui's own `expression-envelope.ts` says a persisted artifact carries the envelope, and its `ConditionWidget` comment records this exact failure as fixed for that widget only. The three predicate rows copy the object designer's rows, which have the same bound. See Acceptance notes. - **`widget: 'textarea'`** over an `I18nLabel` is a passthrough hint. A stored string, or a create, resolves to the string branch and gets a textarea. A stored locale map resolves to its object branch, which has no `properties`, so it opens the raw JSON editor. It is never read as text. - **`widget: 'json'` over a string-keyed record** (`patch`, `bodyExtra`) opens the raw JSON editor. - **`widget: 'json'` over an array of a union** (`lookupColumns`, `dependsOn`): - A create, or a list whose first entry is a string, opens the raw JSON editor. - A list whose first entry is an object edits as rows. - In that row face, untouched string rows survive. An edited string row is spread into an object, which the strict entry schema refuses loudly at save. - **Composite sub-rows** are declared, so their labels and help text reach the catalogues. Schema defaults (`dynamic`, `CNY`) are placeholders and are never written on mount. ## Residue of the reconciliation gate I ran the gate's own helper block (`metadata-form-zod-reconciliation.test.ts` lines 1-808, copied verbatim into a scratch probe that was never committed; prefix sha256 `91478ba8d05c…`). It ran at the root coordinate over every registered type, in a second scratch worktree. The probe asserted three controls in the same run: - lit: `name` is offered by 17 of 17 forms; - dark: the fabricated `field.zzFabricated19332G1b` is in no residue; - a named lit key: `field.accept` before, `field.inlineColumns` after. | tree | object-rooted residue | per type | |:--|:--|:--| | merge base `789b2ae54` | **22** | object 5 · field 12 · action 5 | | this branch (form files byte-equal to `3190a969`) | **6** | object 5 · field 1 · action 0 | Removed: the sixteen keys above. Added: none. `view` reads 42 on both trees and is outside this direction (#19330 A). The dispatch predicted 23 → 7. The difference is `action.aria`, which was retired as a tombstone (`dcd3bcea`) after G1a measured 23, so the residue left is exactly the six G2 keys. ## Pins this PR moves - `field-panel-echo-decisions.test.ts`: - The unwalked composite children go from 5 to 8 (`currencyConfig.currencyMode`, `currencyConfig.defaultCurrency`, `storage.notNull`). - The skipped parents become `currencyConfig`, `summaryOperations`, `storage`. - Their six string leaves are authored in all three locales, so the echo rule reads them as translated. - `object-lifecycle-panel-echo-decisions.test.ts`: the translated-label positive control goes from 614 to 633, which is nineteen new row labels. - `packages/lint/src/validate-predicate-path-refs.test.ts` (**outside the claim's named file surface**; see below): the shipped-form predicate census goes from 73 to 81, and the literal-comparison count from 53 to 56. This was measured, not inferred. I differenced the shipped corpus against the merge base by FORM :: FIELD :: SOURCE: eight entries were added and none removed (`field` accept, currencyConfig, dependsOn, lookupColumns, lookupFilters, relatedListColumns; `action` patch, bodyExtra). Three of the eight compare against a quoted literal. The claim named the two form files, the catalogues, the echo-decision pins and `.changeset/`. The lint census is a measured population pin that these rows move mechanically: the #19331 rows moved the same pin. It went red on this branch at 73 vs 81 and 53 vs 56. So it moves here, with its reason written beside it. No forbidden file was touched: not `object.form.ts`, not the reconciliation ledger, and not any `*.zod.ts`. ## Verification Every test run went through `scripts/pm/os-verify-lock.sh`, and each reported `VERDICT command-exit 0`. | run | result | |:--|:--| | `pnpm --filter @objectstack/spec test` | Test Files 561 passed, 1 skipped (562) · Tests 16534 passed, 1 skipped, 1 todo (16536). The skipped file was not identified; the suite's only file-level skips are the environment `skipIf` guards on the `publish-smoke` script tests | | `pnpm --filter @objectstack/spec test:repo` | Test Files 35 passed (35) · Tests 634 passed (634) | | `pnpm --filter @objectstack/platform-objects test` | Test Files 55 passed (55) · Tests 911 passed (911) | | `pnpm --filter @objectstack/lint test` | Test Files 115 passed (115) · Tests 5314 passed (5314) | | spec / platform-objects / lint `typecheck` | each exits 0 (`check:test-typecheck` OK: 53/253/140, 1/3/2 and 2/6/2 files/errors/signatures held) | | cli unit `test/i18n-coverage.test.ts` and `test/i18n-duplicate-demand.test.ts` (they read the form registry) | 2 files, 27 tests passed | | metadata-protocol `protocol.meta-types-*` (the three files that read the registry) | 3 files, 38 tests passed | | `pnpm check:i18n` | `OK (9 package(s) — all bundles in sync, no undeclared authoring keys)` | | `pnpm --filter @objectstack/spec check:generated` | all 15 generated artifacts up to date | **Catalogues.** I regenerated them with `node scripts/check-i18n-bundles.mjs --write`, wrote the 114 translated leaves (38 en leaves, three locales) by hand, and ran `--write` again. The second write kept every translated value, and no source-hash row was left. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 86 commands at `169b0cc7`. All 86 ran, and each exit code was written to disk before it was read. `--ran` reports: 86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN. Two gates first exited 3 on a prerequisite; each rerun exited 0, and the rerun's code is the one recorded: - `check:dual-build-cjs-loads`: three packages had no `dist/`. After building them it reported `104 published require entry point(s) across 66 package(s) load`. - `check:type-check-debt`: its prerequisite build failed on a `plugin-auth` declaration build. I had run a second turbo build in the same worktree at the same time, and it rewrote `@objectstack/rest`'s `dist/` mid-flight. Rerun alone, it reported `4 ledger entr(ies) re-measured … none above its recorded number`. No ablation was run. This PR adds rows and moves population pins; it adds no guard. The residue measured before and after is the measurement of the delta. `origin/main` is two commits past this branch's base (`50e273fd`, `40b315b0`). Neither touches a file in this diff, so I did not merge it. ## Acceptance notes - **The ruling's 「refused loudly at parse」 does not hold for this flight's four field-name lists.** No authoring door judges their names, as described above. The rows are still free text, as ruled. A reference-integrity rule for field-level lists would be the loud door. It is reported to the seat, not filed here. - **`type: 'code'` cannot show a stored expression envelope** (`CodeWidget`, `String(value)`). The flaw is the same on the object designer's `fields.visibleWhen` / `readonlyWhen` / `requiredWhen` / `expression` rows and on `hook.condition`. The fix that closes the whole class is in objectui: `CodeWidget` could read and write through the same `expressionSource` / `writeExpressionSource` pair `ConditionWidget` uses. It is reported to the seat, not filed here. - `lookupColumns` / `dependsOn` in the row face: an edited string entry in an object-first mixed list is refused loudly at save. This is the generic objectui repeater, not a silent loss. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e01d347 commit ec292cf

10 files changed

Lines changed: 465 additions & 5 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/platform-objects": patch
4+
---
5+
6+
Clause-②: no
7+
8+
Sixteen live structured metadata keys are authorable in the metadata form: eleven on the field form — `accept`, `currencyConfig`, `dependsOn`, `lookupColumns`, `lookupFilters`, `readonlyWhen`, `relatedListColumns`, `requiredPermissions`, `requiredWhen`, `storage`, `visibleWhen` — and five on the action form — `bodyExtra`, `description`, `errorMessage`, `patch`, `requiredPermissions`. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each now has exactly one row, whose control copies a row a registered form already carries for the same node shape:
9+
10+
- `visibleWhen`, `readonlyWhen`, `requiredWhen` — `type: 'code'`, `language: 'expression'`, the object designer's per-field rows for the same three keys.
11+
- `lookupFilters` — `widget: 'json'`, the object designer's per-field row for the same key; no inline operator list, because `notIn` is not a spellable option value.
12+
- `lookupColumns`, `dependsOn` — `widget: 'json'`, **never** `string-tags`: each is an array of a union (a field name, or an object entry), and the tag widget is a chip input for strings only, which cannot show or edit a stored object entry.
13+
- `accept`, `relatedListColumns`, and both `requiredPermissions` — `widget: 'string-tags'`, the app form's `requiredPermissions` row: a chip input over a plain `string[]`.
14+
- `currencyConfig`, `storage` — a `composite` with declared sub-rows (`currencyMode` as a `dynamic` / `fixed` select, `defaultCurrency`; `notNull`), the shape of the object form's `access` row.
15+
- `patch`, `bodyExtra` — `widget: 'json'` over a string-keyed record.
16+
- `description` — `widget: 'textarea'`, the page form's `description` row, over the same `I18nLabel` node; `errorMessage` — a plain row, the twin of `successMessage`.
17+
18+
Each type-specific row is gated to the types its runtime reader serves: the media types for `accept`, `currency` for `currencyConfig`, `lookup` / `master_detail` for the picker and related-list rows, those two plus the four option types for `dependsOn`, `operation: 'update'` for `patch` (the parse refuses it anywhere else), and `type: 'api'` for `bodyExtra`. The help text states what the runtime does with each value, including what absence resolves to. The four field-name lists (`relatedListColumns`, `lookupColumns`, `lookupFilters[].field`, `dependsOn`) are free text: no authoring door judges their names today — not the schema parse, not the publish door and not `os validate` — so the help text claims no such refusal.
19+
20+
⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly<Record<string, FormView>>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles, whose 38 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills.
21+
22+
⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers only.

‎packages/lint/src/validate-predicate-path-refs.test.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,16 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
596596
// refuses one, so the form offers the control only where it draws. The same
597597
// card REMOVED the joined-block `chart` repeater column, which carried no
598598
// predicate, so it leaves this census untouched.
599-
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(73);
599+
// It is 81 today, an ADDITION of EIGHT: #19332 (flight G1b) gave sixteen
600+
// live structured field and action keys a form row each, and eight of those
601+
// rows carry a meaningfulness gate — the key is read only for some field
602+
// types or action shapes. Measured, not inferred: the shipped corpus was
603+
// differenced against the merge base `789b2ae54` by
604+
// `<form>::<field>::<source>`, 73 → 81, eight added and NONE removed —
605+
// `field :: accept | currencyConfig | dependsOn | lookupColumns |
606+
// lookupFilters | relatedListColumns`, plus `action :: patch` and
607+
// `action :: bodyExtra`. The other eight rows carry no predicate.
608+
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(81);
600609

601610
const findings = validatePredicatePathRefs(corrupted);
602611
expect(findings).toHaveLength(predicates);
@@ -687,7 +696,12 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
687696
// quoted literal and is not rewritten.
688697
// It is 53 today: #20161's `report :: chart` gate is `data.type != 'joined'`,
689698
// a `!=` against a single-quoted literal.
690-
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(53);
699+
// It is 56 today: three of #19332 G1b's eight new predicates compare
700+
// against a single-quoted literal (`field :: currencyConfig` on
701+
// `data.type == 'currency'`, `action :: patch` on
702+
// `data.operation == 'update'`, `action :: bodyExtra` on
703+
// `data.type == 'api'`); the other five are `in`-list gates.
704+
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(56);
691705

692706
const rhsFindings = validatePredicatePathRefs(corrupted)
693707
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,18 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
464464
label: "Scale",
465465
helpText: "Number of decimal digits"
466466
},
467+
currencyConfig: {
468+
label: "Currency Config",
469+
helpText: "Which currency this field is in. Unset: dynamic mode. The stored value is a bare number in either mode."
470+
},
471+
"currencyConfig.currencyMode": {
472+
label: "Currency Mode",
473+
helpText: "dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency."
474+
},
475+
"currencyConfig.defaultCurrency": {
476+
label: "Default Currency",
477+
helpText: "The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Defaults to CNY. Not read in dynamic mode."
478+
},
467479
step: {
468480
label: "Step",
469481
helpText: "Step increment for the slider (default 1). Renderer-only: the write path does not reject a value off the step grid."
@@ -472,6 +484,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
472484
label: "Max Size",
473485
helpText: "Maximum permitted file size in BYTES (positive integer). Enforced server-side on write against the stored file size — a file with no recorded size cannot fail it."
474486
},
487+
accept: {
488+
label: "Accept",
489+
helpText: "Permitted upload types, as MIME types, type/* wildcards or .ext suffixes (e.g. image/*, .pdf). Offered to the file picker and re-checked server-side on write against the stored file. Unset: any type."
490+
},
475491
dimensions: {
476492
label: "Dimensions",
477493
helpText: "Vector dimensionality — an integer from 1 to 10000 (e.g. 1536 for OpenAI embeddings)."
@@ -538,10 +554,26 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
538554
label: "Lookup Page Size",
539555
helpText: "Rows per page in the record-picker dialog — a positive integer; default 10."
540556
},
557+
lookupColumns: {
558+
label: "Lookup Columns",
559+
helpText: "Columns of the record-picker table: field names of the referenced object, or {field, label, width, type} entries (e.g. [\"name\", {\"field\": \"status\", \"label\": \"Stage\"}]). Unset: derived from the referenced object."
560+
},
561+
lookupFilters: {
562+
label: "Lookup Filters",
563+
helpText: "Base filter on the picker's candidates, as {field, operator, value} rules on the referenced object — operator one of eq, ne, gt, lt, gte, lte, contains, in, notIn (e.g. [{\"field\": \"status\", \"operator\": \"eq\", \"value\": \"active\"}]). Applied to every picker surface, ANDed with any dependsOn filter."
564+
},
565+
dependsOn: {
566+
label: "Depends On",
567+
helpText: "Fields on the same record this field's choices depend on: the form holds this field until each is set, and re-evaluates it when one changes. A lookup filters its candidates by them — a name filters the same-named field of the referenced object, {field, param} names a different one. On an option field list the parent field names; the per-option rule lives in each option's visibleWhen."
568+
},
541569
relatedListTitle: {
542570
label: "Related List Title",
543571
helpText: "Title for this relationship's related list on the parent's detail page."
544572
},
573+
relatedListColumns: {
574+
label: "Related List Columns",
575+
helpText: "Columns of this relationship's related list on the parent's detail page, as field names of this (the child) object, e.g. name, status. Unset: derived from the child object. Names only — labels, cell types and formatting come from the child's field definitions."
576+
},
545577
inlineTitle: {
546578
label: "Inline Title",
547579
helpText: "Title for the inline master-detail grid on the parent record."
@@ -586,6 +618,14 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
586618
label: "External Id",
587619
helpText: "Mark as external ID for upsert operations"
588620
},
621+
storage: {
622+
label: "Storage",
623+
helpText: "Physical storage constraints (ADR-0113): the DDL the write contract deliberately does not imply. Unset: none requested."
624+
},
625+
"storage.notNull": {
626+
label: "Not Null",
627+
helpText: "Emit a database NOT NULL on the column. Unset, the column stays nullable even under required — the engine enforces required on write. Declaring it over existing null rows is a destructive migration gated by schema drift (backfill first). Refused beside requiredWhen."
628+
},
589629
readonly: {
590630
label: "Readonly",
591631
helpText: "Field is read-only in forms"
@@ -602,10 +642,26 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
602642
label: "Sortable",
603643
helpText: "Allow sorting lists by this field"
604644
},
645+
visibleWhen: {
646+
label: "Visible When",
647+
helpText: "CEL predicate over the record (e.g. record.type == 'invoice') — the form shows this field only while it is TRUE."
648+
},
649+
readonlyWhen: {
650+
label: "Readonly When",
651+
helpText: "CEL predicate over the record (e.g. record.status == 'paid') — the field is read-only while it is TRUE, enforced server-side: an update's change to a locked field is dropped and the stored value kept. Reads the record's own columns; objectstack validate refuses a read through a reference field."
652+
},
653+
requiredWhen: {
654+
label: "Required When",
655+
helpText: "CEL predicate over the record — the field is required while it is TRUE, enforced server-side as a transition gate: a write that leaves the value missing is refused when the record complied before it, so a row already missing the value keeps passing unrelated edits. For a rule every write must meet, use a validations script rule. Refused beside storage.notNull."
656+
},
605657
maskingRule: {
606658
label: "Masking Rule",
607659
helpText: "Partial masking: preset ('phone', 'id_card', 'bank_account', 'email', 'name') or {\"keepHead\": n, \"keepTail\": m}. Masked for callers not holding this field's requiredPermissions"
608660
},
661+
requiredPermissions: {
662+
label: "Required Permissions",
663+
helpText: "Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to read or edit this field (ADR-0066 D3). Without them the value is masked on read (partially, when a maskingRule is set) and edits are denied. Empty or unset: no capability gate."
664+
},
609665
internal: {
610666
label: "Internal",
611667
helpText: "Never return this field's value on the generic data path: the engine omits the key from find/findOne results and from the create and update response bodies, on the default projection and when a client names the field in ?select=. Storage, filtering and indexing are untouched."
@@ -1535,6 +1591,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
15351591
label: "Operation",
15361592
helpText: "Declarative single-record field write: 'update' applies `patch`, merged under the collected `params`, to the current record AS THE CALLER — never system-elevated, so the caller's permissions, the object's hooks and its validations all fire as for a user edit."
15371593
},
1594+
patch: {
1595+
label: "Patch",
1596+
helpText: "Static field values the update writes to the current record, e.g. {\"status\": \"done\"} — merged UNDER the values `params` collects, so a param of the same name wins. Written as the caller: the object's permissions, hooks and validations apply as for a user edit."
1597+
},
15381598
undoable: {
15391599
label: "Undoable",
15401600
helpText: "Offer an Undo affordance after this update succeeds. The undo captures the prior value of every field the action writes — the merged bag, `patch` under the collected `params`. An action with no `operation` declares no write set, so there is nothing to capture."
@@ -1543,6 +1603,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
15431603
label: "Execution",
15441604
helpText: "The bulk dispatch contract this action's body is written for: 'perRecord' sends one dispatch per selected row carrying that row's recordId; 'aggregate' sends ONE dispatch for the whole selection, with every id in params._selectedIds. Omitted, the action is dispatched per record."
15451605
},
1606+
description: {
1607+
label: "Description",
1608+
helpText: "Explanatory line under the title of this action's param dialog. On an action that collects params, the confirm question goes here rather than in confirmText — one dialog, not two. Not ai.description, which is the text an AI agent reads."
1609+
},
15461610
confirmText: {
15471611
label: "Confirm Text",
15481612
helpText: "Confirmation message (e.g., \"Are you sure?\")"
@@ -1551,6 +1615,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
15511615
label: "Success Message",
15521616
helpText: "Success message after completion"
15531617
},
1618+
errorMessage: {
1619+
label: "Error Message",
1620+
helpText: "Error message shown when the action fails, in place of the raw error."
1621+
},
15541622
refreshAfter: {
15551623
label: "Refresh After",
15561624
helpText: "Refresh the list/page after action completes"
@@ -1587,6 +1655,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
15871655
label: "Requires Feature",
15881656
helpText: "Public auth feature flag gating this action. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key."
15891657
},
1658+
requiredPermissions: {
1659+
label: "Required Permissions",
1660+
helpText: "Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action route refuses anyone else with 403 (script, flow and modal actions, and the MCP/AI path), and the button is hidden from them. A type api action calls its endpoint directly, so that endpoint must re-check them."
1661+
},
15901662
ai: {
15911663
label: "Ai",
15921664
helpText: "AI exposure (opt-in): set ai.exposed=true and write ai.description (≥40 chars) to make this callable by agents."
@@ -1602,6 +1674,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
16021674
bodyShape: {
16031675
label: "Body Shape",
16041676
helpText: "Request body structure (flat or nested)"
1677+
},
1678+
bodyExtra: {
1679+
label: "Body Extra",
1680+
helpText: "Static request-body fields for this api action, merged last so they override the collected params (e.g. {\"resend\": true}). Page-variable tokens (page.NAME in double braces) are resolved by the runtime. The payload goes here, never in params."
16051681
}
16061682
}
16071683
},

0 commit comments

Comments
 (0)