Skip to content

feat(objectql): resolve picklist references at runtime — served options, additive extensions, write validation, load-time refusal - #21047

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-19519-picklist-runtime
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-19519-picklist-runtime

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

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

claude added 11 commits October 1, 2026 02:11
… audit, write door and import-template parity

Claude-Session: https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu
Co-authored-by: Claude <noreply@anthropic.com>
…g changeset no longer says the reference is unresolved

Claude-Session: https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu
Co-authored-by: Claude <noreply@anthropic.com>
…— served, written, refused, relabelled

Claude-Session: https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu
Co-authored-by: Claude <noreply@anthropic.com>
…n the served read; dogfood serves the list item translated

Claude-Session: https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu
Co-authored-by: Claude <noreply@anthropic.com>
…a list nothing declares

Claude-Session: https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu
Co-authored-by: Claude <noreply@anthropic.com>
…ook services up by contract type

Claude-Session: https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata, @objectstack/objectql, @objectstack/spec, touching 63 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/field.json, packages/spec/liveness/picklist.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6073bb96b878eb2980724568ee526157b29ab141.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 7 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/field.json, packages/spec/liveness/picklist.json, …) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6073bb96b878eb2980724568ee526157b29ab141 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6eda0de7185377662375bf076d335d149133e38 — the merge of head 42d750f197f8c38669ef91ec918d672d6db8adbc into base 6073bb96b878eb2980724568ee526157b29ab141, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c6eda0de7185377662375bf076d335d149133e38 && git checkout c6eda0de7185377662375bf076d335d149133e38
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6073bb96b878eb2980724568ee526157b29ab141 42d750f197f8c38669ef91ec918d672d6db8adbc && git checkout -B drift-repro 6073bb96b878eb2980724568ee526157b29ab141 && git merge --no-ff 42d750f197f8c38669ef91ec918d672d6db8adbc

node scripts/docs-audit/affected-docs.mjs --json 6073bb96b878eb2980724568ee526157b29ab141

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6073bb96b878eb2980724568ee526157b29ab141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 1, 2026 04:08
…od proof; pin object.fields.picklist as a picklist reference site

Claude-Session: https://claude.ai/code/session_01MZu5JqVPacMktogpMxq9Xu
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4bb0dadcbf74720e148c2183c0f689e83e9af737
Local-runs: none

PR #21047 (card #19519, the runtime sub-issue of #18164), reviewed read-only. Inputs: the card body and all seven comments (pointers 5912707861 and 5916040159, triage 5921928091, claim 5923202473, dispatch record 5923224221, the os-dev-report 5924129203 and the seat answer 5924290772); the parent's rulings 5755653853 and 5904864936 and the design of record 5715762696 they cite; the PR body, its 25-file list and the net diff against the merge base with main (b253fadfb7d1); the 34 check-runs on the head. Not read: the dispatch order or the dispatching seat's conclusions.

Checks on the head

  • Green, required: TypeScript Type Check (with the four Type Check · gates), Build Core (which hosts check:dual-build-cjs-loads, the one family the dev could not measure locally), Dogfood Regression Gate and its three shards, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, and Test Core shards 1, 2, 3, 4 and 6. Green, advisory: Spec property liveness, Check PR Size, Check Documentation Links, the claim and single-writer guards, Auto Label, Dogfood Verify CLI, Vercel.
  • Test Core (5/6): in_progress at review time. Not a verdict either way.
  • Red, by design: Check Changeset (pr-automation.yml), step "Reject an empty-frontmatter changeset added by this PR". Its annotation names the finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712 foreign-changeset rule on .changeset/19518-picklist-kind.md and prints both classes; this diff is the DELIBERATE CORRECTION class. Judged in ② below. That workflow triggers on pull_request only and has no merge_group leg.
  • Red, this PR's: Lint & Repo Gates (required) failed at step 10, "ADR anchors + number uniqueness (governed code names its decision)", which is pnpm check:adr-anchors; steps 11 to 192 were skipped (the job's own tail annotation: never_ran=183 failed=1 ran=8). Not a merge-base signature: the ten preceding pull_request runs of lint.yml (36787041198 down to 36779752495) all pass step 10. The job log is not readable from this container, so the cause is derived from the tree rather than quoted: the diff adds the repository's only tracked-file citation of ADR-0136 (packages/qa/dogfood/test/picklist-shared-across-objects.dogfood.test.ts, line 4, "ADR-0136 D2.4"); docs/adr/ at this head holds 0135-identity-and-access-architecture.md and 0137-predicate-fault-semantics-are-contract.md and no 0136 record; the merge base has zero tracked files citing that number; the gate's third rule fails any bare ADR-NNNN in a tracked file that names no record, and its UNRESOLVED_ADR_CITATIONS allowlist is empty. The number was copied in good faith from the card body and the parent's rulings, which are issue text the gate never scans. Consequence beyond the one red: ESLint, the stack-collection-maps gate this diff edits, the error-code provenance and casing guards and every other Lint family are unmeasured by CI on this head; the dev's local greens for them are self-report.

① Derived judgments

Every accept-set or public-surface change the diff implies, named right or wrong against the card, the rulings and the spec on main.

  1. METADATA_ARRAY_KEYS gains picklists and picklistExtensions (engine.ts), first in the list; an extension entry is routed to SchemaRegistry.registerPicklistExtension and never registered as an item. Both keys are already declared on the stack shape by picklist metadata kind — spec: picklist collection, Field.select({ picklist }), server-resolved options, translation face (phase 1 of objectstack#18164) #19518; the runtime now reads them. No accept-set change. RIGHT.
  2. ARTIFACT_FIELD_TO_TYPE.picklists = 'picklist' (metadata/plugin.ts): the artifact door registers a kind already in DEFAULT_METADATA_TYPE_REGISTRY, so GET /meta/picklist/NAME answers through the generic meta route with the PicklistSchema item; picklistExtensions stays unmapped because it is not an item. No new kind, no new route. RIGHT. The matching check-stack-collection-maps.mjs edits (the METADATA_ARRAY_KEYS PENDING row retired, the artifact-door row narrowed to picklistExtensions with its reason) read true of the diff; that gate did not run on this head (step 27 skipped).
  3. Served field shape: picklist kept and the resolved options written on, in the one fold every object read and the write door share (foldExtendersOntoDefinition, foldObjectExtendersOnto, including a body the registry never saw); re-derived on every fold, stale options dropped when the list is gone, by reference when no field names a list. That is PicklistServedFieldSchema from picklist metadata kind — spec: picklist collection, Field.select({ picklist }), server-resolved options, translation face (phase 1 of objectstack#18164) #19518; no new served key. RIGHT.
  4. Writing the served body back is still refused 422 INVALID_METADATA by FieldSchema (both keys together), nothing stripped on the write side. Ruling 5904864936 declares the reference "mutually exclusive with options"; the seat answer takes Q1 A; the contract-first rule forbids a write-side strip. RIGHT.
  5. Additive merge: a repeated value is refused INVALID_METADATA naming both declarations, in either registration order, never last-wins; same-package re-registration is a replay; uninstall removes that package's values. Design of record item 4 ("additive only") and the card's scope item 1. RIGHT.
  6. Unknown name: a load-time refusal at kernel:ready naming each field and its package, and each orphan extension; the vocabulary is then sealed and a late artifact through the manifest service is judged before anything of it registers; tenant overlays and tenant-authored bodies are never judged at boot. Card scope item 2, and the startup-registry rule (seal, then judge). Only a field that authors picklist, a key no released app can carry yet, can trigger it, so no existing boot changes. RIGHT.
  7. Error codes: both refusals reuse INVALID_METADATA; the ADR-0112 ledger (packages/spec/src/api/error-code-ledger.zod.ts) already lists it in the @objectstack/objectql block (and in metadata-protocol's). No new code is minted; the report's H5 claim is verified. RIGHT.
  8. Write door: the record validator judges a picklist-bound field against the resolved options, the refusal names the list, an unresolved list accepts no provided value (single and multi-value branches), and an omitted value still passes because isMissing returns before the option check. Wire code stays invalid_option; messageParams.picklist feeds only the rendered sentence (the params of the message render), not the returned error object (field, code, message, constraint, options). No new served key on the wire. RIGHT.
  9. BUILTIN_VALIDATION_MESSAGES (spec non-test source, exported from @objectstack/spec/system) gains invalid_option_picklist, invalid_option_value_picklist and invalid_option_picklist_unresolved in four locales. The export's type (a record of records of strings) does not change; the translation override face messages['validation.field.*'] already accepts any key; no schema accepts or refuses anything new. Message text, not a contract key: not a Clause-② widening, but the path arm that owes this review. RIGHT.
  10. Liveness ledger: field.picklist from planned with authorWarn to live (evidence registry.ts#resolvePicklistOptions); the nine picklist rows and translation.picklists to live; count shards regenerated; Spec property liveness is green on the head. The ledger ships in the spec tarball (liveness is in files), so os lint and os validate stop warning on picklist, which the changeset's Ledger bullet says. The lint-liveness-properties.test.ts pin flip and the README row follow mechanically. RIGHT.
  11. An option marked default: true on the list reaches the engine's insert default and the import template through the resolved options; the rest parity battery gains picklist_option_default (pointer 5916040159). RIGHT.
  12. The TypeScript serializer annotates picklist as Picklist (data); Data.Picklist is exported by picklist.zod.ts. RIGHT.
  13. New public API of @objectstack/objectql: SchemaRegistry (exported from the barrel) gains registerPicklistExtension, resolvePicklistOptions, findUnresolvedPicklistReferences and findOrphanPicklistExtensions; picklist-resolution.ts is not exported. Package API widening, graded minor in ②; not a spec-contract widening. RIGHT.
  14. Locale: the existing translateObject and translatePicklist resolvers relabel the resolved options and the served list item per request, driven end to end by the dogfood case. RIGHT.
  15. WRONG: the dogfood file's header cites ADR-0136 D2.4, a number with no record under docs/adr/. That is the check:adr-anchors red above. On the next head: cite the record that actually holds the decision, qualify a sibling registry's number the way the gate recognises, or drop the bare number. The card body and the parent's rulings carry the same bare number; that is a note for the seat, not this PR's to fix.
  16. GAP: pointer 5912707861 lists the metadata-protocol reference sites as an enumeration that does not list picklist. No metadata-protocol file is in the diff and the report does not say whether the derived index (reference-sites.ts, which walks DEFAULT_METADATA_TYPE_REGISTRY and each type's schema by property name) already yields object.fields{}.picklist as a site for target picklist; its derivation pin does not name picklist. Escalated in ③.

② Semver level

Changeset .changeset/19519-picklist-runtime.md: @objectstack/objectql: minor, @objectstack/metadata: patch, @objectstack/spec: patch; body carries Clause-②: no.

Clause-②: no — holds on this diff. No schema key, error code, accepted shape or spec export is added; the one spec non-test file (validation-message.ts) changes message text; the claim 5923202473 and the PR body declare no consistently.

  • objectql minor: RIGHT (new public registry methods and new runtime behaviour, nothing removed).
  • spec patch: RIGHT (message text and a ledger regrade; no schema, accept-set, error-code or export change).
  • metadata patch: ACCEPTED, lenient. The artifact door newly registers a kind and the serializer annotates it; minor is the conventional grade for new door behaviour. Both notes compile into one release and the declaration is no, so this is not a refusal ground; it is named here.
  • No other released package publishes: the rest, lint and dogfood changes are test files.

Deliberate correction, owed in this record. The corrected note is .changeset/19518-picklist-kind.md, pending and unreleased on the merge base (the spec layer's note). Old sentence: "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." New sentence: "The runtime resolves the reference onto that served field; see the picklist runtime entry of this release." Judged clause by clause against this head:

  • "The runtime resolves the reference onto that served field": TRUE. SchemaRegistry.resolvePicklistFields in the object fold writes the list's resolved options onto every field that names it (registry.ts), pinned by engine-picklist.test.ts and the dogfood case, and the Dogfood Regression Gate is green on the head.
  • "see the picklist runtime entry of this release": TRUE. .changeset/19519-picklist-runtime.md is added in the same diff, so changeset version compiles both notes into one release; the entry exists at this head.
  • Each of the old sentence's three facts is reversed by this diff: the resolver exists, the served field carries options, and field.json grades the key live without authorWarn. Restoring the old sentence would publish three false statements.
  • The red is by design: the gate's own annotation names the DELIBERATE CORRECTION class and its remedy ("do NOT restore it"), and the gate has no merge_group leg. Correction CONFIRMED on the text. Because this record's verdict is FAIL on an unrelated ground, the record that lets the PR ride this red into the queue is the PASS on the corrected head; if neither note changes, this judgment carries over to it unchanged.

③ Boundary flags

Open questions of report 5924129203, ruled by the seat answer 5924290772:

  • Q1 (H3, the served-body write-back): ruled A. The diff keeps refusing (protocol-picklist-served-roundtrip.test.ts), matching ruling 5904864936. Answered. The named cost (Studio's object designer round trip) sits on objectui#10202, phase 2.
  • Q2 (the pending note): ruled A through this record. Judged in ②: confirmed on the text.
  • Q3 (resolveObjectFieldLabels): ruled B; the diff touches neither service-i18n nor the dispatcher. Answered.

Pointer 5912707861 (runtime enumerations and gaps):

  • METADATA_ARRAY_KEYS, ARTIFACT_FIELD_TO_TYPE, typescript-serializer.ts, the stack-collection-maps PENDING rows, the served-shape round trip and the field.picklist ledger row: done, judged in ①.
  • runtime app-plugin.ts APP_CATEGORY_KEYS / SECURITY_FIELDS: unchanged; the report explains both carry DELIBERATE rows (H2 partly falsified). Answered.
  • metadata-core types.ts enum: unchanged; the report explains it is not lockstep (it lacks seed) and has no picklist reader. Answered; carried as a finding with no carrier.
  • metadata-protocol reference sites: NOT answered. ESCALATED to the seat: confirm on the card that the derived index already yields object.fields{}.picklist as a site for target picklist (the admin "Used by" panel and its delete safety read it), or file the follow-up. Not a blocker for this card's scope; it must not stay silent.
  • resolveObjectFieldLabels: Q3 above.

Pointer 5916040159 (import-template parity): the picklist_option_default row is added and the mirror reads the served field. Answered.

Dev flags in the PR body and the report:

  • Outside the claimed surface, changed because the diff makes them false: the lint pin, one sentence of the 19518 note, the translation and README ledger rows. Each is the mechanical consequence of the ledger flip or of ②. Accepted.
  • Not changed, with reasons: the metadata-core enum, app-plugin.ts, examples/app-showcase/src/coverage.ts (its picklistExtensions waiver now reads stale; example-app work), the records-forms.picklist-* checklist items (the checklist seat re-runs them). Accepted as acceptance notes.
  • HMR: metadata:reloaded re-ingests objects, not picklists or extensions (not measured). Accepted as noted; a carrier is owed once os dev users bind picklists.
  • check:platform-checklist red on main: [finding] check:platform-checklist is red on main: identity-auth.json anchors auth-plugin.ts#twoFactor, which #20429 turned into an inline nested key #20464, not this PR's and not in per-PR CI. Accepted.
  • check:dual-build-cjs-loads NOT-MEASURED locally: hosted by Build Core in CI, green on the head. Measured.
  • The report's claim that all 114 derived gate families ran green locally except the two named is contradicted on the head for check:adr-anchors, and unverified for the 183 Lint families CI skipped after it. The next head re-measures them.

Required on the next head: resolve the ADR-0136 citation in the dogfood test; leave both changeset notes as they are; push; a fresh record on that head.

Implemented-by: claude/issue-19519-picklist-runtime
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: FAIL


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 42d750f197f8c38669ef91ec918d672d6db8adbc
Local-runs: none

PR #21047 (card #19519, the runtime sub-issue of #18164), reviewed read-only as a DELTA from the previous head 4bb0dadcbf74720e148c2183c0f689e83e9af737, whose at-tier record is PR comment 5924563914 (FAIL on one ground: the bare ADR-0136 citation that turned check:adr-anchors red). Inputs: the card body and all eight comments (the two pointers 5912707861 and 5916040159, triage 5921928091, claim 5923202473, dispatch record 5923224221, the round-0 report 5924129203, the seat answer 5924290772 and the patch-round report 5924919167); the PR body as it now reads, its 26-file list and the net diff against the new merge base with main (8f784959cf7e597e4582a70b93633b10eddb2dcf); the 42 check-runs on the head. Not read: the dispatch order or the dispatching seat's conclusions.

The delta, 4bb0dadcb..42d750f19, is two commits: the fix ed5853cd5 (two files: the dogfood header's citation, and an eleven-line pin in packages/metadata-protocol/src/reference-sites.derivation.test.ts) and a merge of origin/main that moved the merge base from b253fadfb to 8f784959c. The merge is clean: git diff-tree --cc on the merge commit prints no conflict hunk, and the one file that differs from both parents, packages/rest/src/import-template-route.test.ts, is git's auto-merge of main's #20977 battery rewrite with this PR's parity row, whose text is unchanged and sits 67 lines lower. Of the 26 files in the net diff, 24 have a net hunk byte-identical to the previous head's against its base (index lines aside); the rest test's hunk is identical in content and differs only in position; the 26th is the new pin. Main's incoming commits touch none of the PR's source files (engine.ts, registry.ts, plugin.ts, record-validator.ts, picklist-resolution.ts, metadata/plugin.ts, the serializer); its AGENTS.md edit is the merge-tree probe recipe, and its check-empty-changeset.mjs edit is the self-test battery for #20976, with the two-class refusal text unchanged.

Checks on the head

  • Required, all seven green (conclusion: success): Lint & Repo Gates (run 36814102433), TypeScript Type Check with the four Type Check · gates, Test Core with all six shards (shard 5/6, in_progress on the prior record, is now success), Dogfood Regression Gate with its three shards, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The Lint job's green means step 10 (check:adr-anchors) and the 183 families CI skipped behind it on the prior head are now measured by CI, not self-reported.
  • Advisory green: Spec property liveness, Check Documentation Links, Check PR Size, Auto Label, Dogfood Verify CLI, Flag docs affected by code changes, filter, and the three claim, single-writer and part-of guards (each ran twice, both green). Skipped by paths filter or opt-in: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in); and the second Auto Label and Check PR Size, which the edited re-fire excludes by design.
  • Red by design: Check Changeset (run 36814102459, job 110215299915). Its annotation on .changeset/19518-picklist-kind.md is the finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712 foreign-changeset refusal, printing both classes and the DELIBERATE CORRECTION remedy, which reads: do NOT restore it, say so on the PR and get it confirmed. Judged in ②.
  • in_progress, not a verdict: a second Check Changeset (run 36818752192), fired by the pull_request: edited event when the PR body was edited after the patch-round report. Not waited for and not polled; this record draws nothing from it.

① Derived judgments

The delta's two changes, then the carried judgments.

  1. Dogfood header citation (packages/qa/dogfood/test/picklist-shared-across-objects.dogfood.test.ts, line 4): ADR-0136 D2.4 is replaced by the ADR-0054 runtime proof. docs/adr/0054-runtime-proof-for-authorable-surface.md exists at this head (Accepted) and its decision, the prove-it-runs leg, is that a live authorable property is proven by a dogfood test that authors it against the real in-process stack and asserts the runtime result; that is exactly what this file does for field.picklist's flip to live. The head's tracked files cite ADR-0136 zero times; UNRESOLVED_ADR_CITATIONS is untouched; check:adr-anchors is green inside the Lint job. The field.json row carries no proof binding, and none is owed: ADR-0054's ratchet binds only the classes in proof-registry.mts, field.picklist is not one, and Spec property liveness is green; the row's note names the dogfood test as its end-to-end evidence. Prior item 15 (WRONG) is resolved. RIGHT.
  2. The escalated reference-sites item (prior item 16, pointer 5912707861). The pointer listed metadata-protocol's reference sites as an enumeration that did not list picklist. It is not an enumeration: deriveReferenceSites (reference-sites.ts) walks every type in DEFAULT_METADATA_TYPE_REGISTRY through its JSON schema, descending properties, items and additionalProperties, and rule 1 binds a property to target T when its name spells T and its value is name-shaped. picklist has been a declared type since picklist metadata kind — spec: picklist collection, Field.select({ picklist }), server-resolved options, translation face (phase 1 of objectstack#18164) #19518; FieldSchema.picklist is a SnakeCaseIdentifierSchema string with no enum, const or format, so carriesNames holds; the walk reaches it under object.fields and records fromType object, property picklist, target picklist. There is no row to add, and the report's measured byTarget.get('picklist') (field, object, translation.picklists) agrees with the rule. The pin: expect(sitesFor('picklist')).toContainEqual({ fromType: 'object', property: 'picklist' }). sitesFor projects each site to { fromType, property }, the same helper the file's earlier pins use, so the deep-equality match is well-formed. It is falsifiable: it reds if picklist leaves the registry, if the walk stops descending into fields, or if the property is respelled. It pins a property rather than a path, which is the index's own declared design, and ObjectSchema declares no top-level picklist, so the only source of that site is fields{}.picklist: the pin is as precise as the index allows. The report says it was not ablated; its red direction follows from the rule (an undeclared type has no spelling in the singular map and yields no site), so no probe is spent on it here. Adequate. The pointer's item is answered and closed.
  3. Items 1 to 14 of the prior record carry over, each still RIGHT, because the net hunk of every file they judge is byte-identical between the two bases and main's incoming commits touch none of those files. Restated for a reader of this record alone:
    • METADATA_ARRAY_KEYS gains picklists and picklistExtensions, an extension entry routed to SchemaRegistry.registerPicklistExtension and never registered as an item; keys picklist metadata kind — spec: picklist collection, Field.select({ picklist }), server-resolved options, translation face (phase 1 of objectstack#18164) #19518 already declared on the stack shape; no accept-set change. RIGHT.
    • ARTIFACT_FIELD_TO_TYPE.picklists = 'picklist': the artifact door registers a kind already in the registry; picklistExtensions stays unmapped because it is not an item; no new kind, no new route. RIGHT. The check-stack-collection-maps.mjs edits (PENDING row retired, artifact-door row narrowed to picklistExtensions with its reason) read true of the diff, and that gate is now measured green by the Lint job.
    • Served shape: picklist kept and resolved options written on, in the one fold every object read and the write door share, re-derived per fold, stale options dropped when the list is gone, by reference when no field names a list. PicklistServedFieldSchema; no new served key. RIGHT.
    • Writing the served body back stays refused 422 INVALID_METADATA by FieldSchema; nothing stripped write-side. Ruling 5904864936 (mutually exclusive with options); seat Q1 A. RIGHT.
    • Additive merge: a repeated value refused INVALID_METADATA naming both declarations, in either registration order, never last-wins; same-package re-registration replays; uninstall removes that package's values. RIGHT.
    • Unknown name: a load-time refusal at kernel:ready naming each field, each orphan extension and its package; vocabulary sealed after; a late artifact judged before anything of it registers; tenant overlays never judged at boot. Only a field authoring picklist can trigger it, so no existing boot changes. RIGHT.
    • Error codes: both refusals reuse INVALID_METADATA, already in the ADR-0112 ledger's @objectstack/objectql block; nothing minted. RIGHT.
    • Write door: the record validator judges a picklist-bound field against the resolved options, names the list, accepts nothing for an unresolved list, and lets an omitted value pass through isMissing; wire code stays invalid_option; messageParams.picklist feeds the rendered sentence only. RIGHT.
    • BUILTIN_VALIDATION_MESSAGES gains three keys in four locales; the export's type is unchanged and no schema accepts or refuses anything new. Message text, not a contract key. RIGHT.
    • Liveness ledger: field.picklist to live without authorWarn; the picklist rows and translation.picklists to live; count shards regenerated; Spec property liveness green; os lint and os validate stop warning on the key, as the changeset says. The lint pin flip and README row follow mechanically. RIGHT.
    • A list option marked default: true reaches the engine's insert default and the import template through the resolved options; the rest parity battery's picklist_option_default row survives main's fix(rest): the import template answers to the import door's gates, not the export's (#20896) #20977 rewrite with its text unchanged and Test Core green on the merged head (pointer 5916040159). RIGHT.
    • The TypeScript serializer annotates picklist as Picklist (data). RIGHT.
    • New public API of @objectstack/objectql: SchemaRegistry gains registerPicklistExtension, resolvePicklistOptions, findUnresolvedPicklistReferences, findOrphanPicklistExtensions; picklist-resolution.ts is not exported. Package API widening graded minor in ②; not a spec-contract widening. RIGHT.
    • Locale: translateObject and translatePicklist relabel the resolved options and the served list item per request; the dogfood case drives it end to end and the Dogfood gate is green on this head. RIGHT.

Nothing in the diff is judged WRONG on this head, and no gap remains open.

② Semver level

Changeset .changeset/19519-picklist-runtime.md: blob 2cb5af66447e on both heads, byte-identical. @objectstack/objectql: minor, @objectstack/metadata: patch, @objectstack/spec: patch; body carries Clause-②: no, and so does line 2 of the PR body.

Clause-②: no — holds on the net diff against the new base. No schema key, error code, accepted shape or spec export is added; the one spec source file (validation-message.ts) changes message text; the claim, the PR body and the changeset declare no consistently.

  • objectql minor: RIGHT (new public registry methods and new runtime behaviour, nothing removed).
  • spec patch: RIGHT (message text and a ledger regrade; no schema, accept-set, error-code or export change).
  • metadata patch: ACCEPTED, lenient, as on the prior head (the artifact door newly registers a kind; minor would be the conventional grade; both notes compile into one release and the declaration is no, so not a refusal ground).
  • No other released package publishes: the rest, lint, metadata-protocol and dogfood changes are test files.

Deliberate correction, owed again on this head. The corrected note is .changeset/19518-picklist-kind.md, pending and unreleased. Byte identity, measured: the note's blob is d59900409296 on both the previous head and this one, and its base blob is 01dae8d8f138 on both merge bases and on origin/main as fetched for this review, so the one-line hunk this PR carries is byte-identical to the one the prior record judged. Old sentence: "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." New sentence: "The runtime resolves the reference onto that served field; see the picklist runtime entry of this release." The prior record's judgment therefore carries over, and it is re-read true on this head: SchemaRegistry.resolvePicklistFields writes the resolved options onto the served field (registry.ts, pinned by engine-picklist.test.ts and the dogfood case, Dogfood gate green); the 19519-picklist-runtime entry is added in the same diff so changeset version compiles both into one release; field.json grades the key live without authorWarn. Restoring the old sentence would publish three false statements. Correction CONFIRMED.

The three facts the queue rule needs:

  1. The gate is red by design: the Check Changeset annotation on this head names the DELIBERATE CORRECTION class and its remedy (do NOT restore it; get it confirmed on the PR), and this diff is that class, not a COLLISION (the PR adds its own note under a name that cannot collide).
  2. It does not run on merge_group: .github/workflows/pr-automation.yml at this head triggers on pull_request only (types opened, synchronize, reopened, labeled, unlabeled, edited); it is absent from the set of workflows that carry a merge_group leg; and Check Changeset is not one of the seven required contexts.
  3. This record, posted on PR feat(objectql): resolve picklist references at runtime — served options, additive extensions, write validation, load-time refusal #21047, is the PR comment that records it.

Also read: #20976, on main between the two bases, now runs the ADR-0087 disposition and major-bump steps past the designed red. The job's annotations name one failing step only, the foreign-changeset refusal. By the text, the 19518 note is Clause-②: yes (widening) with @objectstack/spec: minor (a widening is not breaking, so no ADR-0087 disposition is owed), and the 19519 note bumps nothing to major. No contradiction is read from those steps.

③ Boundary flags

Patch-round report 5924919167: open_questions is empty. Q1 A, Q2 A and Q3 B from seat answer 5924290772 stand; the diff on those three points is unchanged from the prior head, where each was judged answered.

Dev flags in the patch-round report:

  • "The PR body still reads ADR-0136 D2.4 once; suggested seat edit": the body now reads "the ADR-0054 runtime proof" in the dogfood bullet and carries zero occurrences of ADR-0136. That edit is what fired the second Check Changeset run noted above. Closed.
  • "The new pin was not ablated": judged in ① item 2; accepted without a probe.
  • NOT-MEASURED locally, PREREQUISITE NOT MET: check:dual-build-cjs-loads is hosted by Build Core, green; check:type-check-debt is hosted by Type Check · debt ledger, green. Both measured by CI on this head.
  • Non-zero, kept: check-empty-changeset, judged in ②; check:platform-checklist, red on a clean origin/main worktree in both rounds (the twoFactor ABSENT SYMBOL in areas/identity-auth.json), which is [finding] check:platform-checklist is red on main: identity-auth.json anchors auth-plugin.ts#twoFactor, which #20429 turned into an inline nested key #20464, already open, and kept out of per-PR CI by checklist-status.yml. Not this PR's. Accepted.
  • Lint-family local greens (check:adr-anchors, check:error-code-casing, check:error-code-provenance, check:stack-collection-maps, eslint on the 17 touched files): no longer self-report; the Lint job is green on this head.

The escalated item from the prior record, the metadata-protocol reference sites: answered on the card by the report's measurement and pinned in the diff; judged adequate in ①. Closed; nothing further to escalate.

The prior record's "Required on the next head" is met in full: the ADR-0136 citation is resolved; both changeset notes are untouched (blobs equal across heads); the branch was pushed; this is the fresh record on that head.

Carried from the prior record as acceptance notes, unchanged by the delta: the three out-of-surface edits (the lint pin, the one 19518 sentence, the translation and README ledger rows), each the mechanical consequence of the ledger flip or of ②; the not-changed list with its reasons (metadata-core's enum, runtime app-plugin.ts, the coverage.ts waiver in examples/app-showcase, the records-forms.picklist-* checklist items for the checklist seat to re-run); and the HMR note (metadata:reloaded re-ingests objects, not picklists or extensions; not measured; a carrier is owed once os dev users bind picklists). The named cost of Q1 A, Studio's object-designer round trip, sits on objectui#10202 (phase 2), as the seat answer records.

Implemented-by: claude/issue-19519-picklist-runtime
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS

Merged via the queue into main with commit 88b484e Oct 1, 2026
42 of 44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19519-picklist-runtime branch October 1, 2026 06:08
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…orWarn opt-in, and never shows the ledger note (objectstack-ai#16094) (objectstack-ai#21092)

Fixes objectstack-ai#16094

Clause-②: no
Ruling-ref: 5560227939

## What changes

`shouldWarn` in `packages/lint/src/lint-liveness-properties.ts` now
admits a ledger row whose status is `dead`, `live-elsewhere` or
`experimental`, or that opts in with `authorWarn: true`. Before, only
`experimental` warned without an opt-in. `describe()`'s mapping to
`liveness-dead-property` / `liveness-live-elsewhere-property` is
unchanged. Before the change, both of those rule ids were exported and
could never be produced: across the shipped ledgers, not one `dead` or
`live-elsewhere` row set `authorWarn`.

`checkItem` changes only for rows the ruling newly admits. A row that
warns only because of its `dead` / `live-elsewhere` verdict (no
`authorWarn`) shows its `authorHint`, else the verdict's existing
default hint, and never its ledger `note`. Rows that opt in with
`authorWarn`, and `experimental` rows, keep their hint exactly as
before. This is the seat's Q3 answer (card comment 5925292339).

Other changes:

- **Stale comments.** Comments the flip made false are corrected in the
same file. The one stale line above the `lintLivenessProperties`
registry entry in `authoring-rules.ts` is corrected too.
- **Changeset.** One `minor` changeset for `@objectstack/lint`, carrying
`Clause-②: no` in its body.

## Recount on `main`

Measured at base `6073bb96b8`, all depths:

| Rows | Count |
|---|---|
| `dead` | 109 |
| `live-elsewhere` | 1 |
| Of those 110, `retiredKey` tombstones (`check:liveness --json`) | 97 |
| Authorable | 13 (12 `dead`, 1 `live-elsewhere`) |
| Authorable and reachable through the rule's walk | 4 |

The 4 reachable rows are `view.name`, `view.label`,
`permission.rowLevelSecurity.label` and
`permission.rowLevelSecurity.description`.

The other 9 authorable rows sit in types the walk never visits:

- `connector.metadata`
- `manifest.runtime` and `manifest.integrity`
- six `realtime_subscription` rows

The warn map grows by 105 entries, at depth 1 or less. None of the 105
carries an `authorHint`, and 80 of their notes cite a tracker id. That
is why the hint selection changed in this PR.

## The live-elsewhere end-to-end pin: measured impossible, replaced by
an equivalent invariant plus a reach sentinel

The ruling asks for an end-to-end pin proving
`liveness-live-elsewhere-property` is produced against the shipped
ledger from its one `live-elsewhere` row. That row is
`manifest.runtime`.

**Measured: no stack can produce that id through
`lintLivenessProperties`.**

- `manifest` is not in `TYPE_COLLECTIONS`, and it is not one of the
bespoke walks (objects/fields, translation bundles).
- `stack.manifest` is a single object, not a collection.
- `authorWarnedProperties` has one caller outside its package,
`packages/cli/src/utils/i18n-extract.ts`, and that caller asks only
about `translation`. No translation row is newly admitted.

**The invariant that replaces the literal pin is equivalent for the
ruling's purpose, plus a reach sentinel.** Three pins on the shipped
ledger hold it:

1. The shipped row is admitted:
`authorWarnedProperties('manifest').has('runtime')`.
2. The shipped row maps to `LIVENESS_LIVE_ELSEWHERE_PROPERTY`:
`checkItemAgainstWarnMap` over the row as read from the shipped
`manifest.json`.
3. **No walk visits `manifest`.** `lintLivenessProperties({ manifest: {
runtime, integrity } })` says nothing about either key. This pin goes
red the day any walk visits `manifest`, and its comment names
`manifest.integrity`.

Pin 3 matters because `manifest.integrity` is `dead` in the ledger, yet
`os plugin publish` reads its map and refuses on a digest mismatch
(`packages/cli/src/commands/plugin/publish.ts:134`). A manifest walk
added before that row is re-graded would tell authors a gate-read key is
inert. Pin 3 makes that walk a deliberate, visible change.

Because `manifest` is not walked, the flip does not surface
`manifest.integrity` to any author.

## `--strict`, and why `Clause-②` stays `no`

Nothing is refused, and nothing changes without `--strict`. `os lint
--strict` and `os validate --strict` go from exit 0 to exit 1 on a stack
that was otherwise warning-clean and authors a view container
`label`/`name` or an RLS policy `label`/`description`.

Measured with the CLI built from source on fixture stacks, before (base)
and at this head:

| fixture | `os lint` | `os lint --strict` | `os validate` | `os
validate --strict` |
|---|---|---|---|---|
| clean | 0 → 0 | 0 → 0 | 0 → 0 | 0 → 0 |
| view container `label` | 0 → 0 | **0 → 1** | 0 → 0 | **0 → 1** |
| RLS policy `label` + `description` | 0 → 0 | **0 → 1** | 0 → 0 | **0 →
1** |
| raw config (no `defineStack`) with tombstoned `list.striped` | 0 → 0 |
**0 → 1** | 1 → 1 | 1 → 1 |

The `Clause-②` criterion is "widens the accept set or enlarges the
public surface". A new warning does neither, and both rule ids were
already exported. The seat ruled on this as Q1 (card comment
5925292339), citing two precedents:
`.changeset/20654-flow-credential-literal-advisory.md` and lint
`d753744`.

Other doors:

- `os build` has no warning-promoting flag.
- The runtime publish door runs this rule for `email_template` /
`mapping` / `datasource` only, and none of those gains a row.
- The `os lint --eval` corpus has no views and no RLS.
- `check:i18n-coverage` counts only `i18n/` rules.
- No repo gate asserts a zero-warning count on the examples.

**Bump: minor.** The at-tier contract review (`5925918475`) set this,
and it corrects the earlier `patch`. No export is added or removed, and
the default-face accept set is unchanged. But two rule ids become
producible, which a consumer sees as two new advisories, and a non-zero
exit becomes reachable under `--strict`. The repository grades that
shape `minor`: `.changeset/20654` is a new advisory with `Clause-②: no`
at `minor`, and the cli 17.5.0 entry grades "the new advisories and the
newly reachable non-zero exit" `minor`. `d753744` is a fix to an
existing finding, not a precedent for this. The changeset carries
`Clause-②: no` in its body.

## Who starts warning

These are this repository's example apps, run with `os lint --json` and
`os validate --json`, at base and at this head:

| example | new findings | exit codes (4 modes) |
|---|---|---|
| `app-showcase` | 2 × `liveness-dead-property`: permission
`showcase_contributor`, `rowLevelSecurity.label` and `.description` |
unchanged |
| `app-crm` | 0 | unchanged |
| `app-todo` | 0 | unchanged |
| `app-multi-package` | 0 | unchanged |

All four examples already exit 1 under `--strict`.

**The two showcase findings' printed hint.** At this head, both read:
`Remove it — it is declared in the spec but not consumed at runtime.`
That is 69 characters.

Before the hint fix, both printed the ledger note instead (939 and 270
characters of maintainer prose). The 270-character one ended "Benign,
not authorWarn'd."

The showcase's two existing `liveness-planned-property` findings
(`externalSharingModel`) are byte-identical, message and fix, between
base and this head.

## Retired keys

A `retiredKey` tombstone keeps its `dead` row.

- **Commands that parse.** `os validate`, `os build` and the runtime
gate refuse the key first, so it gets no second report. A `defineStack`
config with a retired key fails `os lint` at load as before.
- **`os lint` on a raw config.** `os lint` does not Zod-parse. A raw
config without `defineStack` that `os lint` accepts, and that carries a
retired key, now gets a `liveness-dead-property` warning with the
default "Remove it" hint. Before, it got nothing.

Ten existing pins asserted silence on such keys. That silence came from
the opt-in, not from the tombstone. They are re-judged to assert the
`dead` grade, with the parse control alongside: `ViewSchema` refuses
`list.striped`.

## Tests

All results below are at head `49fb6cfad3`, after merging `origin/main`
at `9c8b65aa23` (which carries objectstack-ai#21047).

- **Scoped file.** `pnpm --filter @objectstack/lint exec vitest run
--maxWorkers=2 src/lint-liveness-properties.test.ts`: 93 passed, against
84 at base. There are 9 new pins, and the 10 silence pins are re-judged.
- **Whole package.** `@objectstack/lint`: 118 files, 5486 tests passed.
`pnpm --filter @objectstack/lint typecheck`: exit 0.
- **CLI.** Unit tier: 240 files, 3419 tests passed. Integration tier:
all 69 files, run as 4 lock-held runs, 591 passed and 1 skipped.
`lint-per-package-authoring-parity` passes 5/5.
- **Gates.** `dispatch-gates --commands`: 61 families, all exit 0.
`--ran`: 61 derived, 61 run, 0 NOT-MEASURED.
- **eslint.** A narrowed run over the 4 changed `.ts` files: 0 errors, 0
warnings.

The new pins, end to end against the shipped ledger:

- an authorable `dead` key, `rowLevelSecurity.label` / `.description`,
produces `liveness-dead-property`;
- the live keys beside it stay silent;
- the dotted path fans out past index 0;
- the fixture parses through `PermissionSetSchema`;
- a tombstoned key is refused at parse;
- the three `manifest` pins described above.

**Hint pins, ledger-wide:**

- All 105 verdict-triggered rows show a hint that is not their note and
that carries no tracker id. Anti-vacuity: 80 of those notes do carry
one.
- **Control:** all 9 rows that warned before the ruling (`authorWarn` or
`experimental`) keep `authorHint ?? note` byte for byte. Anti-vacuity: 6
of them show their note today.
- Synthetic pins hold the precedence per verdict and opt-in.

**Ablations**, through `scripts/ablation-replace.mjs`: the anchor hit
and the blob moved each time. Every restore left the blob equal to HEAD
and `git diff HEAD` empty, under a trap.

| Mutation | Result | What it proves |
|---|---|---|
| Verdict set reduced to `{experimental}` (pre-ruling behaviour) | 12
red, including both new verdict pins | the verdict pins depend on the
flip |
| Hint selection reverted to `authorHint ?? note ?? default` | 3 red:
the three Q3 pins | the Q3 pins catch the old precedence |
| Hint selection widened to never show any note | 3 red, including the
byte-for-byte control | the control can fail |

The test subject resolves to `src` through the relative import, so no
dist build was involved in any ablation.

**Narrowed eslint** (the 4 changed `.ts` files, as in Tests above):

- Population: `eslint.config.mjs`'s `**/*.{ts,…}` and
`packages/**/*.{ts,…}` blocks select all four; the changeset is not
linted.
- Invariance: the config enables no type-aware linting (no
`parserOptions.project` or `projectService`), so this diff cannot move
any untouched file's verdict.

**Control bytes.** A self-scan of the 5 changed files finds none.
`check:nul-bytes` exits 0.

## The CLI parity fixture, re-judged

The objectstack-ai#18778 fixture in
`packages/cli/test/lint-per-package-authoring-parity.test.ts` declared
view containers as `{ name, label, object, list }`. `view.name` and
`view.label` are `dead`, so after the flip the union run raised 4
warnings, which broke the fixture's premise that the union raises
nothing. CI on `64e0275024` failed two tests (`expected 5 to be 1`, at
`:241` and `:279`). The containers now bind by `object` alone
(`list.label` is live and stays), with a comment saying why. Each test
keeps its intent: the union is clean, there is one per-package survivor,
and `--strict` fails with 1.

## Acceptance notes

- **Pre-existing note leak on opted-in rows (not touched here, as
ruled).** `object.externalSharingModel` is `planned` + `authorWarn` with
no `authorHint`, so authoring it prints its ledger note as the fix. That
note is 728 characters and cites `objectstack-ai#2696`. Measured on `app-showcase` at
this head: 2 findings (`showcase_account`, `showcase_announcement`).
Across the shipped ledgers, 6 of the 9 rows that warned before the
ruling show their note. AGENTS.md keeps tracker numbers out of anything
an author is shown.
- **A manifest walk, and the `manifest.integrity` re-grade it would need
first.** These have zero measured pull. No example or plugin config in
this repository authors `runtime` or `integrity`; `os plugin build`
writes `integrity`. The note that deferred the row's status answers 404.
No carrier.
- **`connector` is not walked.** After the flip, `connector` (a real
stack collection, `connectors`) carries a warn-worthy row, `metadata`.
By its own note it is an uninterpreted extension bag whose specification
is "no consumer", and `connector` is not in `TYPE_COLLECTIONS`.
Registering it would warn on every authored `metadata` bag. No carrier.
- **objectstack-ai#21047 landed** (`88b484e00c`) and is merged here. The field-set pin
is measured, not assumed, at `['conditionalRequired']`: `field.picklist`
is `live` with no `authorWarn` after objectstack-ai#21047, and
`field.conditionalRequired` is a `dead` tombstone.
- **Merge state.** `origin/main` was merged at `9c8b65aa23`
(`b0705c1530`). The 6 later `main` commits touch neither
`packages/lint`, `packages/spec/liveness` nor the CLI parity test.
- **`mapping.connectorSource` crash on `main`, not this PR's.** `main`
ships `mapping.connectorSource` as `live` + `authorWarn` (8368f1c),
and that crashes `os lint` and `os validate` on any stack authoring it:
`describe()` throws its integrity sentinel. This PR's hint control is
scoped to rows `describe()` answers, and its COVERAGE pin keeps the
sentinel loud. Filed as objectstack-ai#21127.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01JAhu8u8QfBvRjVZDox7CP9)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… starter barrel, and a Picklists count in the metadata summary (objectstack-ai#21167)

Part of objectstack-ai#21018
Clause-②: yes (widening)

This PR is the code half of objectstack-ai#21018. It covers scope items 1, 2, 3 and 5
of the card body. Item 4, the "seven generator barrels" line in
`skills/objectstack-platform/SKILL.md`, is on a Tier H governed path. It
follows in its own PR under a second claim, so objectstack-ai#21018 remains open for
it.

Dispatched by the `domain:cli` seat's PM under claim 5929373213. Dev
session `session_01VvcEokUG1tvVxkceYfR5XB`.

## Premise, checked on `origin/main`

The card waited on the runtime reader. That reader is on `main` as
88b484e (objectstack-ai#21047), and these are the parts this PR relies on:

- `picklists` and `picklistExtensions` are in `METADATA_ARRAY_KEYS`
(`packages/objectql/src/engine.ts`).
- The registry fold resolves a field's `picklist` into served `options`
(`picklist-resolution.ts`, `resolvePicklistFieldsOnto`).
- The write door judges a write against the resolved options.
- The boot refuses an unresolved name
(`packages/objectql/src/plugin.ts`).

The repro below measures all of this end to end on a scaffolded project.

## What changed

**Item 1: `os generate picklist NAME`**
(`packages/cli/src/commands/generate.ts`)
- This is the row written and reverted on objectstack-ai#20825's branch (`6ef78d3f29`,
reverted in `e16359a9fa`), re-read against the landed reader.
- It writes `src/picklists/NAME.picklist.ts` through `definePicklist`.
The file name comes from `metadataFileName`, with no override.
- The list is collected under `picklists` (`singularToPlural`).
`namesObject: false`, and `requires` is empty.
- The emitted header states the next rule an author meets: a field that
names the list declares no `options` of its own.
- The row's comment now names the reader: the authoring doors, the boot
refusal, the served options and the write judgement.
- Two existing comments counted the roster ("an app or a skill", "14
emission sites across all 7 generators"). Both are reworded so the new
row does not make them false.

**Item 2: scaffold wiring**
- `os init` derives its wiring from the roster
(`SCAFFOLD_WIRED_BARRELS`), so the `app` and `plugin` templates wire
`src/picklists` with no edit to `init.ts`.
- The blank starter in `create-objectstack` gains
`src/picklists/index.ts`, byte-identical to the empty barrel `os init`
writes. Its `objectstack.config.ts` gains `import * as picklists from
'./src/picklists';` and `picklists: exportsOf(picklists),`.
- `create-objectstack-wiring-parity.test.ts` holds the two scaffolders
equal.

**Item 3: docs**
- `content/docs/deployment/cli.mdx` gets the example line, the table row
(`picklist`, `src/picklists/`, `NAME.picklist.ts`, `picklists`) and the
"What it does" clause.
- `packages/cli/README.md` gets the type list and a short paragraph.

**Item 5: the metadata summary** (`packages/cli/src/utils/format.ts`)
- `MetadataStats` gains `picklists`, and `collectMetadataStats` counts
it through the existing `authoringRuleUnionStack` fold, so both ADR-0130
D4 shapes are covered.
- `printMetadataStats` renders it in the `Data:` row, the kind's own
domain (`domain: 'data'` in the registry).
- A stack with no picklists prints the row it printed before. A
`picklistExtensions` entry is not counted as a list.
- The two existing test fixtures that spell out every `MetadataStats`
member gain `picklists: 0`: `format.metadata-stats-package-fold.test.ts`
and `print-metadata-stats-zero-row.test.ts`.

**Changesets**
- `@objectstack/cli`: `minor`. A new generator kind, and
`stats.picklists` is added to the `--json` output of `os validate`, `os
build` and `os info`.
- `create-objectstack`: `minor`. The starter gains a wired barrel.
- Both carry `Clause-②: yes (widening)`. Nothing is narrowed and nothing
is renamed, so there is no ADR-0087 marker.

## Repro, before and after

Before, on `origin/main` 58a77db:

```text
os generate picklist industry      exit 1   ✗ Unknown type: picklist   (roster lists 7 types)
npm create objectstack (blank)     src/ = actions apps dashboards flows objects skills views
hand-wired picklist + select field
  os validate                      exit 0   Data: 1 Objects  3 Fields
```

After, on this branch:

```text
npm create objectstack (blank)     src/ = actions apps dashboards flows objects picklists skills views
os generate picklist industry      exit 0   ✓ Reaches the stack: objectstack.config.ts carries it in `picklists` as 'industry'
note.object.ts gains  industry: Field.select({ picklist: 'industry', label: 'Industry' })
  os validate                      exit 0   Data: 1 Objects  3 Fields  1 Picklists
  os build                         exit 0   Data: 1 Objects  3 Fields  1 Picklists   (artifact carries `picklists` and the field's `picklist`)
  os info                          exit 0   Data: 1 Objects  3 Fields  1 Picklists
  os info --json                   stats.picklists = 1
os dev --fresh -p RANDOM
  GET  /api/v1/meta/object/my_app_note   200   fields.industry = { picklist: 'industry', options: [option_a, option_b], … }
  GET  /api/v1/meta/picklist/industry    200
  POST /api/v1/data/my_app_note { industry: 'option_a' }   201
  POST /api/v1/data/my_app_note { industry: 'option_z' }   400 VALIDATION_FAILED · invalid_option · names picklist "industry"
```

On a project scaffolded before this change (its config wires no
`src/picklists`), `os g picklist region` exits 0. It reports `Not wired`
and prints the import line and the `defineStack` key to add.

## Tests

The pins are:

- `packages/cli/src/commands/generate-picklist.pin.test.ts`:
- the roster row (`src/picklists`, stack key `picklists`, `namesObject:
false`, no `requires`) and the `NAME.picklist.ts` file name;
- the scaffold loaded through the loader `os validate` uses
(`bundle-require`, `BUNDLE_REQUIRE_EXTERNALS`), parsed by
`PicklistSchema`, under the item name `os g` reports;
- that scaffold registered through `ObjectQL.registerApp` under
`picklists`, serving its options on a `Field.select({ picklist })`
field, and passing `PicklistServedFieldSchema`;
  - a control: the same field with no list serves no options.

  It constructs `ObjectQL`, so it lands in the `integration` tier.
- `packages/cli/src/utils/format.metadata-stats-picklists.test.ts`:
  - the count, top level and option-B;
  - that an extension is not a list;
  - the zero case;
  - the rendered row `Data: 1 Objects  2 Fields  1 Picklists`;
- a control: a stack with no picklists prints `Data: 1 Objects 2
Fields`.

The runs, all on HEAD 5a53515 except where noted:

| Run | Files | Tests | Result |
|:---|---:|---:|:---|
| `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2` | 241 + 2 | 3427 + 29 | All passed, 29 skipped. The 2
files first failed with `packages/cli is not built` and passed after
`pnpm --filter @objectstack/cli build`. |
| `--project integration`, the 8 integration files among the related
tests | 8 | 66 | Passed: `generate-picklist.pin`,
`create-objectstack-stack-reach`, `generate-stack-reach`,
`info-detail-package-fold` and 4 more |
| `OS_TEST_TIERS=nightly`, the related `*.e2e` files | 6 | 42 | Passed.
Includes `generate-scaffolds-reach-stack.e2e`: "`os g picklist` exits 0
and reports no wiring to add" |
| `pnpm --filter create-objectstack exec vitest run` | 16 | 249 | Passed
|
| `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit &&
check:test-typecheck`) | | | Exit 0. The test-typecheck ledger is
unchanged (3 files / 28 errors) |
| `pnpm --filter create-objectstack typecheck` | | | Exit 0 |

## Ablation

The ablations ran on the committed fix (e63b1db). Each mutation went
through `scripts/ablation-replace.mjs` with the anchor counted on disk.
Each restore was proven: the blob equals HEAD and `git diff HEAD` is
empty.

| Leg | Mutation | Red | Control (green) |
|:---|:---|:---|:---|
| generator | The `picklist` row deleted from `GENERATORS` (anchor x1 →
x0, 53 lines) | `generate-picklist.pin` 3 of 5. `wiring-parity`: imports
and stack keys (2) | `generate-scaffold-validates` 19/19; the pin's
file-name case and its no-list control |
| template | `picklists: exportsOf(picklists),` deleted from the blank
config | `wiring-parity`: "hands every wired barrel to its stack key" (1
of 22) | the other 21 |
| collect | `picklists: count(stack.picklists),` deleted |
`metadata-stats-picklists` 5 and `package-fold` 4 (its `ZEROES`
comparisons) | the pin's no-picklists row;
`print-metadata-stats-zero-row` |
| print | `['Picklists', stats.picklists],` deleted |
`metadata-stats-picklists` "Data: row", and
`print-metadata-stats-zero-row` "every metric collectMetadataStats
counts is rendered" | the other 27 |

## Gates

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`, run with no paths, derived 96 families. All 96 were run,
each with its exit code recorded before any pipe.
- Five of them first refused with `PREREQUISITE NOT MET` (exit 3)
because their build inputs were missing: `check:skill-examples`,
`check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and
`check:i18n-walk-parity`. All five passed once those inputs were built.
- `--ran` reconciliation: `✓ dispatch-gates --ran: 96 derived famil(ies)
accounted for — 96 run, 0 NOT-MEASURED`.

**`pnpm lint`, as a proven narrowing** (on HEAD 5a53515):

1. **Population, read from eslint's own config** (`isPathIgnored` /
`calculateConfigForFile`): all 8 touched `.ts` files are linted. The 4
touched `.md` / `.mdx` files are outside its population.
2. **Count, from `--format json`**: 8 files, 0 errors, 0 warnings, with
`--no-inline-config`.
3. **Invariance**: no resolved config for these files is type-aware (no
`parserOptions.project`, no `projectService`, as `eslint.config.mjs`
states). This diff touches no lint config or baseline. So it cannot move
the verdict on any file it does not touch.

## Not in this PR

- **Item 4** (Tier H). On `db48028f1a`, lines 194–195 of
`skills/objectstack-platform/SKILL.md` read:

  ```text
generic connector executors in `plugins:`; and the seven generator
barrels
(`objects`, `views`, `actions`, `flows`, `dashboards`, `apps`,
`skills`),
  ```

  The proposed text, net zero lines:

  ```text
generic connector executors in `plugins:`; and the eight generator
barrels
(`objects`, `views`, `actions`, `flows`, `dashboards`, `apps`, `skills`,
`picklists`),
  ```

- Not carried here, as the card says: the
`picklist-reference-unverified` trigger, and a dangling
`picklistExtensions[].extend` (folded into objectstack-ai#20825).

## Acceptance notes

- **The two starter README listings now name `src/picklists`** (commit
`90d55b1e13`, added on contract review `5930680827`):
- the `Layout` bullet in
`packages/create-objectstack/src/templates/blank/README.md`, which ships
into every new project;
  - the tree in `packages/create-objectstack/README.md`.

They ride this PR, not the Tier H item-4 PR, so two non-governed lines
do not wait on a human approval.
- **`Scaffold with repo dist` is red on this PR, and the PR is held
until the next release publishes.** This note was added by the seat.
- **Cause:** the job scaffolds with the repo-built `create-objectstack`,
then installs the project's framework from the npm registry. The
template's `^17.0.0` range resolves to the published `@objectstack/spec`
17.5.0, which predates the `picklists` stack key (`addbbf0`,
`.changeset/19518-picklist-kind.md`, still unreleased). So the generated
project's `defineStack` refuses the key as unrecognized at the validate
step.
- **Scope of the measurement:** the repro table's `npm create
objectstack` rows and the end-to-end chain were measured on the repo
build, not against the registry.
- **It is not this PR's code to fix.** Its gate checks the template
against the real registry, and the template now runs one release ahead
of it.
- The check is not required. It runs only on `pull_request` under its
path filter (`packages/create-objectstack/**`, `docker/**` and its own
workflow file), and it is not red on `main`.
- Landing now would leave it red on every later `create-objectstack` PR
until that release.
- **The hold:** the release that carries the key is the open Version
Packages PR objectstack-ai#20639; its `@objectstack/spec` changelog already lists
`addbbf0`. Once it merges and publishes, a re-run of this job reads the
new spec and the template is coherent with it.
- Until then, the PR stays draft and its card is `pm:blocked` on objectstack-ai#20639.
- On a project scaffolded by an earlier release, the not-wired hint
prints `picklists: Object.values(picklists),`, not the starter's
`exportsOf(...)` spelling. Both type-check once the barrel exports a
list, which is the only state the hint is printed in.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

2 participants