Repository navigation
Commit 88b484e
feat(objectql): resolve picklist references at runtime — served options, additive extensions, write validation, load-time refusal (#21047)
Fixes #19519
Clause-②: no
The runtime layer of the shared `picklist` kind, phase 1 of #18164. The
spec layer (#19518) is already on `main`. A select field that authors
`picklist: 'industry'` is now served with that list's resolved options,
a write is judged against them, and `picklistExtensions` from other
packages merge into the list additively.
Dispatched by the `domain:engine` seat 2 PM under claim 5923202473. Dev
session `session_01MZu5JqVPacMktogpMxq9Xu`.
## What changed
**Load** (`packages/objectql/src/engine.ts`,
`packages/metadata/src/plugin.ts`)
- `picklists` and `picklistExtensions` join `METADATA_ARRAY_KEYS`, first
in the list. Both registration seams (manifest and nested plugin) reach
them through the shared `registerMetadataCollections` body.
- A `picklistExtensions` entry is dispatched to
`SchemaRegistry.registerPicklistExtension`, never registered as an item
of its own.
- The artifact door maps `picklists` to the `picklist` kind, so `GET
/meta/picklist/NAME` serves the list on an artifact boot.
**Merge** (`packages/objectql/src/registry.ts`, new
`picklist-resolution.ts`)
- A list's options are its own, followed by each extension's.
- A value the list already carries is refused with `422
INVALID_METADATA`, naming both declarations. This holds in either
registration order: list then extension, or extension then list. It is
never last-wins.
- A package that registers again replaces its own contribution.
Uninstalling a package removes its values.
**Serve**
- The registry's object fold (`foldExtendersOntoDefinition`, shared by
`resolveObject`, the owner layer and `foldObjectExtendersOnto`) writes
the resolved options onto every field that names a list, and keeps
`picklist` (`PicklistServedFieldSchema`).
- Every object read and the write door therefore read one resolved set.
That covers registry-sourced bodies and `sys_metadata` bodies, which the
protocol folds through `foldObjectExtendersOnto`.
- Resolution is re-derived on every fold, never trusted. A stale served
copy (the boot bridge writes resolved bodies into the metadata service)
gets the current list. When the list is gone, its options are removed,
so the write door refuses rather than accepting a stale value.
- An object with no picklist-bound field is returned by reference, so
its read is byte-identical to before.
- Locale: the existing `translateObject` / `translatePicklist` resolvers
relabel the resolved options per request. The dogfood case drives this
end to end.
**Unknown name: a load-time error** (`packages/objectql/src/plugin.ts`)
- At `kernel:ready` every package has registered, so "not declared" is
final (AGENTS.md, startup registry reads). A packaged field naming a
list nothing declares fails the boot with `INVALID_METADATA`, naming
every such field and its package. A `picklistExtensions` entry extending
such a list fails the same way.
- After that, the vocabulary is sealed. An artifact registered through
the `manifest` service is checked before any of it registers, so a
refused install registers nothing.
- Tenant overlays are never judged at boot: a stored row cannot fail a
boot. The write door still refuses every value of such a field.
**Write validation**
(`packages/objectql/src/validation/record-validator.ts`)
- The door that judges inline `options` judges the resolved set, and the
refusal names the list. The wire code stays `invalid_option`.
- Three message keys carry the sentence, in en, zh-CN, ja-JP and es-ES
(`packages/spec/src/system/validation-message.ts`):
`invalid_option_picklist`, `invalid_option_value_picklist` and
`invalid_option_picklist_unresolved`. They change the message text only
and never reach the wire.
- A picklist-bound field with no resolved options accepts no value. It
is never read as free-form.
**Error codes (H5).** Both refusals reuse `INVALID_METADATA`.
`@objectstack/objectql` already emits it in the ADR-0112 ledger. No new
code, so `Clause-②: no` stands.
**Enumerations and ledgers**
- `scripts/check-stack-collection-maps.mjs`: the `METADATA_ARRAY_KEYS`
PENDING row is retired. The `ARTIFACT_FIELD_TO_TYPE` row now carries
`picklistExtensions` only, with a reason: it is merged by the registry
and is not a kind. `APP_CATEGORY_KEYS` / `SECURITY_FIELDS` keep their
DELIBERATE rows. One is an app-payload heuristic, the other a
four-collection security subset, and neither should list picklists.
- `serializers/typescript-serializer.ts` annotates `picklist` as
`Picklist` (data).
- Liveness: `field.picklist` is `live`, without `authorWarn`. The
`picklist` kind's rows and `translation.picklists` are `live`. Count
shards are regenerated.
## Tests
- `packages/objectql/src/engine-picklist.test.ts` (25), real engine and
registry:
- served shape on two objects;
- load-order independence, and a nested plugin's lists;
- duplicate refusal in four shapes, plus the replay;
- uninstall;
- unknown-name and orphan-extension audit, attributed per package;
- write accept, write refuse naming the list, and the unresolved
refusal;
- option default from the list;
- stale-copy handling, including both `fields` spellings.
- `packages/objectql/src/plugin-picklist-boot-audit.test.ts` (6), on a
real `ObjectKernel`:
- boot refused for an unknown field reference and for an orphan
extension;
- boot accepted when the list arrives from a package registered later;
- post-boot artifacts refused before registering, or accepted.
- `packages/objectql/src/protocol-picklist-served-roundtrip.test.ts`
(2): the protocol read serves the resolved options, and writing the
served body back is refused (see H3).
- `packages/rest/src/import-template-route.test.ts`: the parity battery
gains `picklist_option_default`. The template's object read and the
engine's import door must both see the list's `default: true` option, or
the star and the refusal disagree.
-
`packages/qa/dogfood/test/picklist-shared-across-objects.dogfood.test.ts`
(5), the ADR-0054 runtime proof:
- one list, two objects, two packages composed into one artifact;
- the extension's value is accepted on object A;
- a value outside the set is refused on object B, naming the list;
- `Accept-Language: zh-CN` relabels both objects' options and the served
list item.
-
`packages/metadata/src/serializers/typescript-serializer-annotation.test.ts`
gains the `picklist` representative.
- `packages/lint/src/lint-liveness-properties.test.ts`: the pin "the
shipped field ledger warns on `picklist` alone" is now false, because
this change takes the row out of `authorWarn`. It now expects an empty
set (a test-only edit outside the claimed surface; see Acceptance
notes).
Ablation, run once and not kept: replacing `return
this.resolvePicklistFields(merged);` with `return merged;` in
`registry.ts`, through `scripts/ablation-replace.mjs`, turned
`engine-picklist.test.ts` red (8 failed, 16 passed of 24 at that
commit). The restore was proven: the blob equals HEAD `24f6934320bf` and
`git diff HEAD` is empty.
## Open questions for the seat
1. **H3, the served-body round trip: two readings are live.** Measured
in `protocol-picklist-served-roundtrip.test.ts`: `GET /meta/object/NAME`
serves `picklist` and `options` together, and a `PUT` of that body is
refused `422 INVALID_METADATA` by `FieldSchema`, with its prescription.
- This PR keeps refusing. That is what `PicklistServedFieldSchema`'s doc
declares, and stripping at the write door would be consumer-side
tolerance.
- The cost: a Studio editor that writes back what it read is refused on
any object with a picklist-bound field until it drops `options` (a
Studio-side change, phase 2).
- The alternative is to strip `options` at the write door when they
equal the resolved list. It would be declared and tested, but it is
still a second shape accepted by an authoring door.
2. **`resolveObjectFieldLabels`** (the field-labels endpoint) reads only
the bundle and has no field-to-picklist binding. So inherited picklist
option labels are still missing there. Fixing it means changing
`service-i18n` and the runtime i18n dispatcher, which this claim does
not cover. The served object (`translateObject`) does carry them.
3. **A pending release note, corrected here: needs confirmation.**
`.changeset/19518-picklist-kind.md` (the spec layer's note, still
pending) said: "This release does not resolve the reference. Until the
runtime does, a picklist-bound field is served without options, and the
liveness ledger grades the key `planned` and warns an author who writes
it." With this PR in the same release, that sentence is false. This PR
replaces it with "The runtime resolves the reference onto that served
field; see the picklist runtime entry of this release."
`check-empty-changeset` refuses any change to a changeset this PR did
not add, and stays red until someone confirms the correction. That is
its deliberate-correction path: restoring the old sentence would
republish a false one. The alternative is to restore it and let the
release author reconcile the two notes.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on this branch derived 114 families, and all
114 were run.
- `--ran` reconciliation: 113 run, 1 NOT-MEASURED.
`check:dual-build-cjs-loads` exited 3: its prerequisite is `dist/` for
eight packages this diff does not touch, and CI builds them.
- `check-empty-changeset` exits 1: question 3 above.
- `check:platform-checklist` exits 1: an `ABSENT SYMBOL` for
`plugin-auth`'s `twoFactor` in `areas/identity-auth.json`. It reproduces
identically on `origin/main` at `2f2fa11d7`, so it is not this PR's.
- `check-engine-split-ratio` and `check-plugin-teardown-shape
--self-test` refused on the shallow clone, then passed after the
deepening each prescribes.
- `check:objectql-double-limit` and `check:slot-lookup` caught two new
test doubles in this PR. Both were fixed and re-run green.
Local runs:
- `objectql`: `pnpm test`, 351 files / 6851 tests green, and `pnpm
typecheck` green.
- `metadata`: `pnpm test`, 836 green, and `pnpm typecheck` green.
- Typecheck green for `rest` and `dogfood`.
- `spec` `validation-message.test.ts` green.
- `lint` `lint-liveness-properties.test.ts` green.
- The `metadata-protocol` picklist-adjacent files green.
- `check:liveness`, `check:stack-collection-maps`,
`check:startup-registry-verdict`, `check:durability-log-level`,
`check:doc-authoring` and `check:nul-bytes` green.
- eslint on the 16 touched TS/MJS files: 0 errors, 0 warnings.
## Acceptance notes
- **Outside the claimed surface, changed because this PR makes them
false:**
- the lint pin above;
- one sentence of the pending `.changeset/19518-picklist-kind.md`, which
said this release does not resolve the reference;
- the `translation.json` / `README.md` liveness rows for picklists.
- **Not changed:**
- `metadata-core`'s `MetadataTypeSchema` (it already lacks `seed`,
nothing reads `picklist` through it, and widening a published enum with
no consumer is surface without pull);
- `runtime` `app-plugin.ts` (see above);
- `examples/app-showcase/src/coverage.ts` (its `picklistExtensions`
waiver now reads as stale; demonstrating it in the showcase is
example-app work);
- the platform-checklist items `records-forms.picklist-*` (the checklist
seat re-runs them).
- **Merged `origin/main` once before this PR, cleanly.** It brought the
CLI's author-time picklist reference check from another card. That door
and this runtime audit agree: the CLI refuses within one stack, and the
runtime refuses across packages at boot.
- HMR: a picklist edited while `os dev` runs reaches the registry only
through a full manifest re-registration. The `metadata:reloaded` ingest
re-registers objects, not lists.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 63d1a7c commit 88b484e
26 files changed
Lines changed: 1579 additions & 78 deletions
File tree
- .changeset
- packages
- lint/src
- metadata-protocol/src
- metadata/src
- serializers
- objectql/src
- validation
- qa/dogfood/test
- rest/src
- spec
- liveness
- state-counts
- src/system
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | | - | |
| 14 | + | |
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1432 | 1432 | | |
1433 | 1433 | | |
1434 | 1434 | | |
1435 | | - | |
1436 | | - | |
1437 | | - | |
1438 | | - | |
| 1435 | + | |
| 1436 | + | |
| 1437 | + | |
| 1438 | + | |
| 1439 | + | |
1439 | 1440 | | |
1440 | 1441 | | |
1441 | 1442 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
181 | 187 | | |
182 | 188 | | |
183 | 189 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
49 | 50 | | |
50 | 51 | | |
51 | 52 | | |
| |||
81 | 82 | | |
82 | 83 | | |
83 | 84 | | |
84 | | - | |
| 85 | + | |
85 | 86 | | |
86 | 87 | | |
87 | 88 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
53 | 53 | | |
54 | 54 | | |
55 | 55 | | |
| 56 | + | |
56 | 57 | | |
57 | 58 | | |
58 | 59 | | |
| |||
0 commit comments