Skip to content

Commit 19e58e2

Browse files
feat(spec): curated activityMilestones, publicSharing, userActions and inlineColumns form rows (#19332, flight G2b) (#20485)
Part of #19332 Flight G2b of ruling 5861442317. Clause-②: no ## Status: draft, no open gap The first round stopped at one red test outside the claim's surface, `packages/lint/src/validate-predicate-path-refs.test.ts`, the lint census of every predicate the shipped metadata forms carry. The claim both admitted mechanically moved population pins and forbade `packages/lint/**`. The seat answered A and amended claim `5873857698` in place (its "Amended 2026-09-28T17:27Z" line): that one file joined the surface for its census pin only. The patch round landed it in `16037890` (**Pins moved** below), and the file reads 54 of 54. Under the claim's second amendment ("Amended 2026-09-28T17:51Z"), `2bcad436` corrects one G2a text: the `indexes.fields` sub-row's help text (and its code comment and four catalogue leaves) now reads "Saving does not check them; publishing and os validate refuse a name that is not a field of this object. A field that is not a stored column (a formula, say) makes the SQL driver skip the whole index, with a warning in the server log." Each clause was measured on this tree after `4b2d9041` (#20479): `ObjectSchema.safeParse` accepts a misspelt column; `os validate`'s rules and the runtime publish gate (`runRuntimeAuthoringRules`, type `object`) both return `object-field-ref-unknown` at `error` on it; and an in-memory SQLite `SqlDriver` sync skips an index on a formula field (and one on a misspelt name) with `[sql-driver] skipping declared index … column(s) not materialized` at `warn`, while the index on a stored column is created. ## What Four live keys had no form row, so an author could reach them only through the Source tab. Each is now a row with hand-written sub-rows, as the ruling says: 「**G2 (…) — hand-written curated sub-rows**, plus … one nested `subset` row for `inlineColumns`」. The four-locale catalogue rows are in this PR. | key | form, section | row, sub-rows (face) | the row each copies | |:--|:--|:--|:--| | `object.activityMilestones` | `object.form.ts`, Advanced, after `validations` | `type: 'repeater'`: `field` (`widget: 'text'`, required), `value`, `summary` (text, required), `type` (text) | the `fieldGroups` repeater face (declared, labelled sub-rows); the text sub-rows copy the plain text rows in Basics | | `object.publicSharing` | `object.form.ts`, Advanced, after `requiredPermissions` | `type: 'composite'`: `enabled` (switch), `allowedAudiences` and `allowedPermissions` (`widget: 'multiselect'` with inline options), `maxExpiryDays` (number, `min: 1`), `redactFields` (`widget: 'string-tags'`), `eligibility` (`type: 'code'`, `language: 'expression'`) | the `access` / `lifecycle` composite face; `enabled` the `enable` toggles; the two lists the multiselect objectui derives for an array of enum (the derived `appearance.allowedVisualizations` on the view and page forms); `redactFields` the `highlightFields` row; `eligibility` the `fields.visibleWhen` predicate rows | | `object.userActions` | `object.form.ts`, Advanced, under `managedBy` | `type: 'composite'`: `create`, `import`, `edit`, `delete` (`widget: 'json'`), `exportCsv` (switch) | the composite face; the four union keys the G1a `requiredPermissions` row (`json` on a union); `exportCsv` the `enable` toggles | | `field.inlineColumns` | `field.form.ts`, Configuration, between `inlineTitle` and `inlineAmountField`, gated `data.type == 'master_detail'` like both | `type: 'repeater'` over a curated subset: `name` (text, required), `label` (text), `width` (number), `defaultHidden` (switch) | the `fieldGroups` repeater face; the gate copies its two sibling rows | **Shapes (dispatch assumption 2), confirmed on this base:** `activityMilestones` is `z.array(strictObject(…))` at `object.zod.ts:2093`, four keys; `publicSharing` is a `strictObject` at `:2286`, six keys; `userActions` a `strictObject` at `:1769`, five keys; `inlineColumns` is `z.array(InlineGridColumnSchema)` at `field.zod.ts:1460`, the item schema at `:890`, twenty keys. The gate's own `keysOf` read the same sets. **Ledger:** one nested `subset` row at `field` / `inlineColumns` in `metadata-form-zod-reconciliation.test.ts`. Its shape is the `object` / `fields` subset row at the top of the ledger (and its two depth-two children `fields.options`, `fields.summaryOperations`), which is the ledger's `subset` precedent. The other three keys need no row: every key they declare is offered. **Row titles:** the two new repeaters' row schemas carry a JSON Schema `title` on every property (**Row titles** below). ## Faces that needed a reason - **`activityMilestones.field` pins `widget: 'text'`.** Read at the `.objectui-sha` pin on `main`, `dd3f7e1b`: with no `widget`, `SchemaForm`'s `resolveFieldWidget` runs its name conventions, and `detectFieldRefWidget` turns a string property named `field` into the `field-ref` picker whenever `widgetContext.objectFields` is present. `ResourceEditPage` always hands that over as a load state, and on an object draft it is `idle` (the draft names no `object` / `objectName` / `data.object` / `interfaceConfig.source`). `FieldRefWidget` then renders a select offering only "None", so a new milestone could not name its field. An explicit `widget` skips the conventions, and `text` is a passthrough hint, so the face is a plain input. - **`redactFields` pins `widget: 'string-tags'`** for the same reason: a string list named `...Fields` becomes `field-multi` by the same convention. - **`userActions.create` / `import` / `edit` / `delete` take `widget: 'json'`**, the ruling's union rule (「Union-typed values take `json`」). Each is a boolean or a strict `{ enabled, visibleWhen, disabledWhen }` object. At the pin, `json` is a passthrough hint: `resolveFieldFace` picks the stored value's union branch. A new entry or a stored boolean renders the switch (the first arm), and a stored object renders its three keys as a nested form, whose `setField` merges each edit into it. No face writes one arm over the other. The object arm is written in source and edited here once stored. - **`allowedAudiences` / `allowedPermissions` take `widget: 'multiselect'`** with inline options. Every member is a spellable option value. `MultiSelectWidget` writes `undefined` when every choice is cleared, so the form cannot store the empty list `getPolicy` reads as "any audience". - **The objectui faces re-checked here are the ones G2a did not use**, read at `dd3f7e1b` (G2a read the repeater face at `f8a9d0fb`): the declared composite (`CompositeField`, `pickSubSchema` reading `properties[NAME]` after `inlineSchemaRefs`), the multiselect widget, the switch and number branches of the scalar chain, and the `json` hint on a union sub-row. Code readings only, no browser run. ## `inlineColumns`: the curated subset - **Offered:** `name`, plus the three keys that apply to a column of any type, `label`, `width` and `defaultHidden`. An entry that names only a field is what the key's own describe recommends, because objectui's `hydrateColumns` completes it from the child field. - **Deliberately not offered (recorded in the `subset` row):** - `type`: declaring it opts the column out of that hydration. - `options`, `reference`, `displayField`, `idField`, `autofill`, `multiple`, `accept`, `prefix`, `step`, `scale`, `computed`, `expr`: each applies to one cell type only. A column takes its type from the child field at render, and no sub-row `visibleWhen` here can see it, so each would be offered on every column. - `required`, `readonlyWhen`, `requiredWhen`: hydration copies them from the child field, where the rule the server enforces lives. - The nested reading below shows the row is load-bearing: without it, the gate's `zodOnly` for `field.inlineColumns` is exactly those sixteen keys. ## Where a misspelt field name is refused, read from the code The ruling's 「a misspelling is refused loudly at parse」 does not hold for three of this flight's four name positions. Each help text claims only what is measured. | position | parse | publish door / `os validate` | runtime with a miss | help text claims | |:--|:--|:--|:--|:--| | `publicSharing.redactFields[]` | accepts | **refused**: `validate-object-field-refs` owns it at `error` (`runtimeTypes` includes `object`); probe below | the redaction never binds (fails open) | "refused at publish" | | `activityMilestones[].field` | accepts (probe) | not judged: `validate-object-field-refs` leaves it out by name; probe below | `matchMilestone` compares `after[field] === value`, so the milestone never fires | "Nothing checks it when you save or publish … never fires" | | a `{token}` in `activityMilestones[].summary` | accepts | not judged | `renderMilestoneSummary` renders it empty | "a token that names no field renders empty" | | `inlineColumns[].name` | accepts (probe) | not judged; probe below | `hydrateColumns` leaves an unknown name unhydrated, a plain text column | "Nothing checks it when you save or publish … renders a plain text column" | Seat 2's #20479 (for #20432), which landed on `main` as `4b2d9041` during this flight, extends `validate-object-field-refs` to four field-level lists and `indexes[].fields`. Read on `origin/main`, its list positions still do not include `activityMilestones[].field` or `inlineColumns[].name`, so these texts stay true (**Out-of-scope finding** below). ## Other help-text claims, each read from its consumer - `activityMilestones`: an update that moves the field into the value writes the summary in place of the field-change entry, and the first match wins (`audit-writers.ts` `matchMilestone`). The comparison is strict, and `value` is a string, so a milestone on a number or boolean field never fires. A lookup, master-detail or user token shows the referenced title (`REFERENCE_FIELD_TYPES`). An unset `type` is `updated`: the update branch starts from `activityTypeFor('update')` and a milestone replaces it only when it names one. (The schema's describe says the default is "completed"; see Acceptance notes.) - `publicSharing` (`share-link-service.ts`): `enabled` is re-read on every redemption; unset audiences default to `['link_only']` and permissions to `['view']`; `createLink` refuses any other with 422. Every audience still needs the token: `resolveToken` adds a signed-in check for `signed_in` and an allowlist check for `email`. `maxExpiryDays` defaults to 365, and a link created without an expiry is stored with none (`expiresAt ?? null`), so the cap does not force one. `eligibility` binds `record`, is checked at mint and at every redemption, and a predicate that does not compile or faults refuses. - `userActions` (`resolveCrudAffordances`): the per-bucket defaults in the help text are `CRUD_AFFORDANCE_DEFAULTS` verbatim. On an `engine-owned` or `append-only` object, turning a verb on also passes plugin-security's `assertEngineOwnedWriteAllowed`, so users can make that write through the data API. - `inlineColumns`: read only when the field sets `inlineEdit` (`attachInlineSubforms`). Unset, `deriveColumns` curates past six columns into the column chooser. `defaultHidden` never hides a required column (`GridField`: `c.defaultHidden && !c.required`). ## Row titles (admitted by the claim from the start) - **The guard.** `repeater-item-titles.test.ts` (#17232) requires a JSON Schema `title` on every authorable property of every repeater's row schema, and forbids a ledger entry. Both new repeaters are new carriers: `object:activityMilestones` and `field:inlineColumns`. - **The change.** 24 `.meta({ title })` calls, and nothing else in either file: - `object.zod.ts`, the `activityMilestones` entry: `field` 'Field', `value` 'Value', `summary` 'Summary', `type` 'Type'. - `field.zod.ts`, `InlineGridColumnSchema`, all twenty properties: Name, Label, Type, Width, Required, Options, Prefix, Step, Reference, Display Field, ID Field, Multiple, Accept, Default Hidden, Computed, Expression, Scale, Autofill, Read-only When, Required When. - The four offered sub-rows' titles equal their declared labels. - **Byte proof.** Stripping exactly the added calls line by line gives each file's base blob byte for byte: `object.zod.ts` sha256 prefix `8979b5feea7ed0ff` both ways (4 removed), `field.zod.ts` `24713de3b50d5f71` both ways (20 removed). - **Reverse verification.** Run through `scripts/ablation-replace.mjs` in wrap mode, on the committed state. Deleting the `Summary` title reads `object:activityMilestones … expected [ 'summary' ] to deeply equal []`, 1 failed of 29. Deleting the `Default Hidden` title reads the same for `field:inlineColumns` with `[ 'defaultHidden' ]`. The tool proved each mutation landed (anchor 1 → 0, blob changed) and each restore (blob equals HEAD, `git diff HEAD` empty). - **No accept set moves.** `check:generated` reads all 15 artifacts up to date on this head, `check:authorable-surface` and `check:api-surface` included. ## Residue of the reconciliation gate (dispatch assumption 1) The test file's own helper block was copied verbatim into a probe that was never committed, and run with the gate's own functions. At base it is lines 1-838, sha256 prefix `5e04d44fc5c5edb3`, the prefix G2a read. On this branch it is lines 1-851, and it differs from the base block only by the 13 inserted ledger lines. Residue = offerable root keys − offered − root `omit` rows, per type, with `view` apart. Controls, asserted inside the probe: lit, `name` is offered by 17 of 17 forms; dark, `object.zzFabricated19332G2b` and `object.name` are in no residue. | tree | residue | per type | view | |:--|:--|:--|:--| | base `e956924e` | **4** | object 3, field 1 | 42 | | this branch (`82c5b111`, forms and ledger as on the head) | **0** | none | 42 | Removed: `object.activityMilestones`, `object.publicSharing`, `object.userActions`, `field.inlineColumns`. Added: none. Nested reading on the branch, through the gate's own `reconcileNestedLists`: - `field.inlineColumns` reads `zodOnly = []` with the `subset` row, and without it `zodOnly` = `accept, autofill, computed, displayField, expr, idField, multiple, options, prefix, readonlyWhen, reference, required, requiredWhen, scale, step, type`. - `object.activityMilestones`, `object.publicSharing` and `object.userActions` read `formOnly = [] · retired = [] · zodOnly = []` with or without any row of their own. ## Pins moved (measured, mechanical) | file | pin | from → to | why | |:--|:--|:--|:--| | `object-collapsed-sections-echo-decisions.test.ts` | collapsed-section leaves / `advanced` | 69 → 105 / 60 → 96 | three new Advanced rows with fifteen sub-rows: 18 rows, 36 leaves | | `object-lifecycle-panel-echo-decisions.test.ts` | translated `.label` control, per locale | 634 → 657 | 23 new row labels | | `field-panel-echo-decisions.test.ts` | the field form's repeater row properties / walked parents | 6 → 10 / `['options']` → `['options', 'inlineColumns']` | the new `inlineColumns` repeater and its four children, all translated | | `packages/lint/src/validate-predicate-path-refs.test.ts` (admitted by the claim's 17:27Z amendment) | predicates / literal comparisons | 81 → 82 / 56 → 57 | the one new predicate, `field :: inlineColumns` on `data.type == 'master_detail'`. Measured, not inferred: the shipped corpus, keyed `FORM::FIELD::SOURCE`, was enumerated at the merge base `e956924e` (81 predicates, 56 comparisons) and on this branch (82, 57), and the difference is exactly that one entry added and none removed | ## Verification Test runs went through `scripts/pm/os-verify-lock.sh`. The table is the first round's, at `66b73be5`, and the lint row is the patch round's, at `16037890`. After the merge and the G2a text correction, these re-ran at the final head `2bcad436`: `pnpm --filter @objectstack/spec test` `Test Files 572 passed (572)` · `Tests 16791 passed \| 1 todo (16792)`; `pnpm --filter @objectstack/platform-objects test` `Test Files 55 passed (55)` · `Tests 911 passed (911)` (the text change moves no pin); lint `validate-predicate-path-refs.test.ts` + `validate-object-field-refs.test.ts` 2 files, 114 passed; `pnpm check:i18n` OK (9 packages in sync) after the three translated leaves were authored and a second `--write` left no source-hash row; `pnpm --filter @objectstack/spec check:generated` `All 15 generated artifacts are up to date`. | run | result | |:--|:--| | `pnpm --filter @objectstack/spec test` | `Test Files 569 passed (569)` · `Tests 16698 passed \| 1 todo (16699)` | | `pnpm --filter @objectstack/spec test:repo` | `Test Files 38 passed (38)` · `Tests 690 passed (690)` | | `repeater-item-titles.test.ts` + `metadata-form-zod-reconciliation.test.ts` | `repeater-item-titles.test.ts` 29 + `metadata-form-zod-reconciliation.test.ts` 57: `Test Files 2 passed (2)` · `Tests 86 passed (86)` | | `pnpm --filter @objectstack/platform-objects test` | `Test Files 55 passed (55)` · `Tests 911 passed (911)` (before the pin moves: 4 failed, the three pins above) | | `pnpm --filter @objectstack/spec typecheck` / platform-objects `typecheck` | exit 0 both; `check:test-typecheck: OK` (53 file(s) / 251 error(s) / 138 pinned; 1 / 3 / 2) | | `pnpm check:i18n` | `check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)` | | `pnpm --filter @objectstack/spec check:generated` | `All 15 generated artifacts are up to date` | | metadata-protocol `src/protocol.meta-types-*.test.ts` | 4 files, 58 passed | | cli unit `test/i18n-coverage.test.ts`, `test/i18n-duplicate-demand.test.ts` | 2 files, 27 passed | | lint `src/validate-predicate-path-refs.test.ts` at `16037890` | `Test Files 1 passed (1)` · `Tests 54 passed (54)` (2 failed before the pin move, the two pins above) | Catalogues: `node scripts/check-i18n-bundles.mjs --write` regenerated the 46 `en` leaves (23 rows, a label and a help text each). The 138 translated leaves were then authored in zh-CN, ja-JP and es-ES, with no `en` echo. A second `--write` kept every translated value and left no source-hash row. Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 87 commands at the final head `2bcad436` (change set vs merge base `9801da12`, after the merge `e809f0bd`: the 14 files of this diff). That is the first round's 86 plus `check:docs-transcript-drift`, which the lint test file brings in. All 87 ran on that head, and each exit code went to disk before it was read. In every round `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: nine packages had no `dist/` in the fresh worktree); later gates in the same run built them, and the rerun exited 0 (`104 published require entry point(s) across 66 package(s) load`), which is the code `ran.list` records. `--ran` reports: `87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED (a DERIVED zero — all 87 recorded an exit code and none of them is 3)`. Reach probe (built `@objectstack/spec` and `@objectstack/lint` of this tree, never committed): an object with `activityMilestones: [{ field: 'statsu', … summary: 'Done: {titel}' }]` and `publicSharing.redactFields: ['titel']`, and a child `master_detail` field with `inlineColumns: [{ name: 'quantiy' }]`. `ObjectSchema.safeParse` and `FieldSchema.safeParse` both succeed. The reference-integrity suite, which the publish door and `os validate` run, returns exactly one finding, `object-field-ref-unknown @ objects[0].publicSharing.redactFields[0]` (the lit control), and none for the milestone field, the token or the column. ## Acceptance notes - **`activityMilestones[].type`'s describe says the default is "completed".** The runtime writes `updated`: the update branch's `activityTypeFor('update')`, replaced only by a milestone that names a type. The help text states the runtime. The showcase milestone names `type: 'completed'` explicitly, so no measured author relies on the describe. Carrier: none. - **The `public` audience's TSDoc (`object.zod.ts`) says "search engines may index; no token check".** `resolveToken` has no branch for `public`: it redeems like `link_only`, token required. The option label says only "Public", and the help text says every audience needs the link. Carrier: none. - **`maxExpiryDays` does not force an expiry.** A link created without one never expires. That matches the key's describe ("Reject links with expiry beyond this many days"), and the help text says it outright. Whether a capped object should require an expiry is a product question. Carrier: none. - **An untouched `userActions` switch reads off** even where the `managedBy` default offers the entry, a switch having no unset state. The composite's help text names the defaults. Carrier: none. - **Existing object-form rows named `field` meet the same `field-ref` convention.** `lifecycle.ttl.field` has `type: 'text'` and no `widget`, so by the reading above it renders the "None"-only picker on an object draft. This is a code reading at `dd3f7e1b`, not browser-run, and it is outside this flight's rows. (`fields.summaryOperations.field` sits inside the `fields` row, which the Studio object page hides as canvas-owned.) Carrier: none. - **Concurrency.** `origin/main` was merged once, with `scripts/pm/os-regen-merge.sh`, at `9801da12` (`e809f0bd`), because #20456's `e967cbd2` edited three `view` `why` texts in the reconciliation ledger. It merged without conflict, `main`'s side was taken for every generated artifact it moved, and `check:generated` then read all 15 up to date. `origin/main` has moved since (to `3062e500`), not onto a file of this diff. Seat 2's #20475 regenerates `en.metadata-forms.generated.ts` too and is not on `main` yet: ordinary concurrency. - **G2a's `indexes.fields` help text went stale when #20479 landed, and is corrected here** (Status, second paragraph), under the claim's 17:51Z amendment. No other G2a row changes. ## Out-of-scope finding (folded into #20432 by the seat; not filed by this run) - **class c · reach: the save door and the publish door, measured (probe above).** `activityMilestones[].field` and `inlineColumns[].name` name fields of the owning object, and no authoring door judges them. A misspelt milestone field silently never fires, and a misspelt column renders as plain text. `validate-object-field-refs` leaves the first out by name. Its extension #20479, landed as `4b2d9041`, reaches four field-level lists and `indexes[].fields`, but neither of these. - Dedupe words: `activityMilestones field unknown` · `inlineColumns name unknown field` · `milestone never fires misspelt field` · `inline grid column reference integrity`. --- _Generated by [Claude Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45f428d commit 19e58e2

14 files changed

Lines changed: 628 additions & 42 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
Clause-②: no
7+
8+
Four more live structured keys are authorable in the metadata forms: `activityMilestones`, `publicSharing` and `userActions` on the object form, and `inlineColumns` on the field form. 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 is now a row whose sub-rows are declared by hand rather than derived from the schema:
9+
10+
- `activityMilestones` (object form, Advanced, beside `validations`) — a `type: 'repeater'` over the milestone's four keys: `field` (`widget: 'text'`, required), `value` and `summary` (text, required) and `type` (text). `field` pins its widget because the console turns a string sub-row named `field` into a field picker whose catalogue an object draft never fills.
11+
- `publicSharing` (object form, Advanced, after `requiredPermissions`) — a `type: 'composite'` over all six keys of the share-link policy: `enabled` (switch), `allowedAudiences` and `allowedPermissions` (`widget: 'multiselect'` over their enum members), `maxExpiryDays` (number, at least 1), `redactFields` (`widget: 'string-tags'`) and `eligibility` (`type: 'code'`, `language: 'expression'`).
12+
- `userActions` (object form, Advanced, under `managedBy`) — a `type: 'composite'` over the five affordance keys. `create`, `import`, `edit` and `delete` are each a boolean **or** a `{ enabled, visibleWhen, disabledWhen }` object, so they take `widget: 'json'`: the console renders a switch for a new entry or a stored boolean, and the object's own keys for a stored object, and never writes one arm over the other. `exportCsv` is a switch.
13+
- `inlineColumns` (field form, Configuration, beside `inlineTitle`, shown on `master_detail` fields) — a `type: 'repeater'` over a **curated subset** of the twenty keys an inline grid column accepts: `name` (required), `label`, `width` and `defaultHidden`. The metadata-form reconciliation ledger records the nested `subset` row and names what is left to source and why: `type` opts a column out of hydration from the child field, the type-specific keys cannot be gated on a type the column takes from the child field at render, and the rules are copies of the child field's own.
14+
15+
The help text states what the runtime does with each value, read from its consumer, and claims a refusal only where one exists. A misspelt `publicSharing.redactFields` entry is refused at publish and by `os validate`. `activityMilestones[].field`, a `{token}` in its `summary`, and `inlineColumns[].name` are judged by no authoring door, and their help texts say so and name what happens instead: the milestone never fires, the token renders empty, the column renders as plain text.
16+
17+
The two new repeaters' row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: the four keys of an `activityMilestones` entry, and all twenty keys of `InlineGridColumnSchema`. Each title is a `.meta({ title })` call and nothing more.
18+
19+
⛔ **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 (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 46 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills.
20+
21+
⛔ **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 and one nested ledger row only.

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,16 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
605605
// `field :: accept | currencyConfig | dependsOn | lookupColumns |
606606
// lookupFilters | relatedListColumns`, plus `action :: patch` and
607607
// `action :: bodyExtra`. The other eight rows carry no predicate.
608-
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(81);
608+
// It is 82 today, an ADDITION of ONE: #19332 (flight G2b) gave four live
609+
// structured keys a form row each, and one of them carries a meaningfulness
610+
// gate — the field form's `inlineColumns` repeater, read only on a
611+
// `master_detail` field, like its `inlineTitle` / `inlineAmountField`
612+
// siblings. Measured, not inferred: the shipped corpus was differenced
613+
// against the merge base `e956924e` by `<form>::<field>::<source>`,
614+
// 81 → 82, `field :: inlineColumns :: data.type == 'master_detail'` added
615+
// and NONE removed. The other three rows and all their sub-rows carry no
616+
// predicate.
617+
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(82);
609618

610619
const findings = validatePredicatePathRefs(corrupted);
611620
expect(findings).toHaveLength(predicates);
@@ -701,7 +710,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
701710
// `data.type == 'currency'`, `action :: patch` on
702711
// `data.operation == 'update'`, `action :: bodyExtra` on
703712
// `data.type == 'api'`); the other five are `in`-list gates.
704-
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(56);
713+
// It is 57 today: #19332 G2b's one new predicate, `field :: inlineColumns`
714+
// on `data.type == 'master_detail'`, compares against a single-quoted
715+
// literal.
716+
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(57);
705717

706718
const rhsFindings = validatePredicatePathRefs(corrupted)
707719
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);

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

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,26 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
293293
label: "Validations",
294294
helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)"
295295
},
296+
activityMilestones: {
297+
label: "Activity Milestones",
298+
helpText: "Timeline entries fired by a field reaching a value (ADR-0052 §5b.2): when an update moves the watched field into the value, the audit plugin writes the milestone's summary to the record's activity timeline instead of the field-change entry. The first milestone that matches wins."
299+
},
300+
"activityMilestones.field": {
301+
label: "Field",
302+
helpText: "Name of the field to watch on this object (e.g. status). Nothing checks it when you save or publish: a name that is not a field of this object never fires."
303+
},
304+
"activityMilestones.value": {
305+
label: "Value",
306+
helpText: "The stored value the field must change into, compared exactly as text — for a select field the option value, not its label (e.g. done). A milestone on a number or boolean field never fires."
307+
},
308+
"activityMilestones.summary": {
309+
label: "Summary",
310+
helpText: "Timeline text (e.g. \"Deal won: {name}\"). A {field_name} token takes the record's value after the update, and the token of a lookup, master-detail or user field shows the referenced record's title; a token that names no field renders empty."
311+
},
312+
"activityMilestones.type": {
313+
label: "Type",
314+
helpText: "Activity type of the timeline entry: a built-in kind such as completed, or your own word, stored as written. Unset: updated."
315+
},
296316
datasource: {
297317
label: "Datasource",
298318
helpText: "Target datasource ID (default: \"default\")"
@@ -307,7 +327,7 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
307327
},
308328
"indexes.fields": {
309329
label: "Fields",
310-
helpText: "Column names of this object, in key order (e.g. status, owner). Nothing checks them when you save or publish: a name that is not a stored column makes the SQL driver skip the whole index, with a warning in the server log."
330+
helpText: "Column names of this object, in key order (e.g. status, owner). Saving does not check them; publishing and os validate refuse a name that is not a field of this object. A field that is not a stored column (a formula, say) makes the SQL driver skip the whole index, with a warning in the server log."
311331
},
312332
"indexes.unique": {
313333
label: "Unique",
@@ -333,10 +353,62 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
333353
label: "Required Permissions",
334354
helpText: "Capabilities (permission-set systemPermissions) a caller must hold to reach this object, checked in addition to CRUD grants (ADR-0066 D3). A list gates every operation; a {read, create, update, delete} map gates only the operations it lists. Absent or empty: no capability gate."
335355
},
356+
publicSharing: {
357+
label: "Public Sharing",
358+
helpText: "Share-link policy: whether records of this object can be published through a link that anyone holding it opens, and on what terms. Separate from sharingModel, which shares with named users and teams. Unset or off: no link can be created, and none opens."
359+
},
360+
"publicSharing.enabled": {
361+
label: "Enabled",
362+
helpText: "Allow share links for this object's records. Checked on every redemption: switching it off stops every existing link from opening, and switching it back on serves them again. Off (the default): nothing else here applies."
363+
},
364+
"publicSharing.allowedAudiences": {
365+
label: "Allowed Audiences",
366+
helpText: "Audiences a new link may name; any other is refused. Unset: link only. Every audience still needs the link itself: signed in also needs a signed-in user, and email also needs the recipient's address on the link's list."
367+
},
368+
"publicSharing.allowedPermissions": {
369+
label: "Allowed Permissions",
370+
helpText: "Permission levels a new link may grant; any other is refused. Unset: view only."
371+
},
372+
"publicSharing.maxExpiryDays": {
373+
label: "Max Expiry Days",
374+
helpText: "Latest expiry a new link may request, in days from now; a later one is refused. Unset: 365. It does not force an expiry: a link created without one never expires."
375+
},
376+
"publicSharing.redactFields": {
377+
label: "Redact Fields",
378+
helpText: "Field names of this object removed from every record a link serves, whatever the audience; the owner's own access is unaffected. A name that is not a field of this object is refused at publish."
379+
},
380+
"publicSharing.eligibility": {
381+
label: "Eligibility",
382+
helpText: "CEL predicate over the record (e.g. record.status == 'published'): a link is created only while it is TRUE, and an existing link stops opening once its record no longer qualifies. A predicate that does not compile, or faults, refuses the link."
383+
},
336384
managedBy: {
337385
label: "Managed By",
338386
helpText: "Lifecycle bucket: platform (user CRUD), config (admin authored), system-data (platform-defined schema with admin/user-writable data), engine-owned (no user writes), append-only (audit), better-auth (identity). UI clients derive their CRUD affordances from it, so it decides what a user is offered on records of this object."
339387
},
388+
userActions: {
389+
label: "User Actions",
390+
helpText: "Which generic entries (New, Import, Edit, Delete, Export) UI clients offer on this object's records, overriding the managedBy default one entry at a time. An unset entry keeps that default: platform offers all five; config and system-data all but Import; engine-owned, append-only and better-auth only Export. An untouched switch writes nothing, so it reads off even where the default offers the entry. On an engine-owned or append-only object, turning an entry on also lets users make that write through the data API. Users still need the matching permission."
391+
},
392+
"userActions.create": {
393+
label: "Create",
394+
helpText: "The New button: on shows it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate the button on the record in scope, evaluated once per toolbar (the host record on a related list)."
395+
},
396+
"userActions.import": {
397+
label: "Import",
398+
helpText: "The CSV import entry: on shows it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate the entry on the record in scope, evaluated once per toolbar."
399+
},
400+
"userActions.edit": {
401+
label: "Edit",
402+
helpText: "Editing existing records, inline and in the form: on offers it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate each row on its own record."
403+
},
404+
"userActions.delete": {
405+
label: "Delete",
406+
helpText: "Row and bulk delete: on offers it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate each row on its own record."
407+
},
408+
"userActions.exportCsv": {
409+
label: "Export CSV",
410+
helpText: "The CSV export entry. Unset: shown, since every managedBy bucket offers export."
411+
},
340412
editMode: {
341413
label: "Edit Mode",
342414
helpText: "Edit-interaction intent for records of this object. Absent, the renderer picks its own default. Cross-renderer intent, not styling."
@@ -622,6 +694,26 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
622694
label: "Inline Title",
623695
helpText: "Title for the inline master-detail grid on the parent record."
624696
},
697+
inlineColumns: {
698+
label: "Inline Columns",
699+
helpText: "Columns of the inline grid on the parent's form, in display order; used only when this field sets inlineEdit, which is written in source. Unset: derived from this object's editable fields, and past six the rest start in the grid's column chooser. An entry that names only a field takes its type, options and rules from that field; the other column keys, type first, are written in source."
700+
},
701+
"inlineColumns.name": {
702+
label: "Name",
703+
helpText: "Field of this (the child) object that the column shows and edits (e.g. quantity). Nothing checks it when you save or publish: a name that is not a field of this object renders a plain text column."
704+
},
705+
"inlineColumns.label": {
706+
label: "Label",
707+
helpText: "Column header. Unset: the field's own label."
708+
},
709+
"inlineColumns.width": {
710+
label: "Width",
711+
helpText: "Fixed column width in pixels. Unset: sized by the cell type, with text columns flexing and number, date and select columns staying narrow."
712+
},
713+
"inlineColumns.defaultHidden": {
714+
label: "Default Hidden",
715+
helpText: "Start the column in the grid's column chooser instead of on screen; the user can show it. A column whose field is required is always shown."
716+
},
625717
inlineAmountField: {
626718
label: "Inline Amount Field",
627719
helpText: "Numeric child field summed for the inline grid total."

0 commit comments

Comments
 (0)