Found while fixing #4854 (the resolver read fields the served document does not have). That fix makes objects.<object>._views.<bare key> resolve for listViews / formViews entries. This issue is the residual: for one common authoring shape the key the extractor writes and the key the runtime identity yields are still different strings, so the translation can never be found no matter which side you author against.
Filed unassigned — recording the finding, not claiming it.
The divergence, measured
Three surfaces derive a _views key, and they do not agree:
| surface |
file |
key it produces for a container's default list |
| view-document composer (runtime identity) |
packages/spec/src/ui/view.zod.ts expandViewContainerWithDiagnostics |
<object>.default → bare key default |
| i18n extractor (writes the bundle skeleton) |
packages/cli/src/utils/i18n-extract.ts:453 |
list |
| translation-reference lint (validates keys) |
packages/lint/src/validate-translation-references.ts collectViewRecord |
accepts both the map key and the inner name |
Composer:
const key = typeof defaultList.name === 'string' && defaultList.name ? defaultList.name : 'default';
Extractor:
pushViewEntries(out, viewObjectName(view.list) ?? containerObject, view.list.name ?? 'list', view.list);
Measured against the real composer:
container { list: { label: 'All Tasks', type: 'grid', data: { provider: 'object', object: 'task' }, columns: ['title'] } }
composer -> ["task.default"] // bare key `default`
extractor -> objects.task._views.list.label
So an app whose object declares only a default list — no listViews — gets a skeleton keyed list, a runtime view keyed default, and an English label on screen forever. It is invisible in examples/app-crm only by luck: its default list is structurally identical to listViews.all, so the composer's signature dedupe collapses the two and the live key becomes all. The _views.list entry in examples/app-crm/src/translations/crm.translation.ts is dead weight that looks correct.
Second, related spelling question
collectViewRecord deliberately accepts a listViews.<key> entry's inner name as a legal _views key ("authors write either"), but the runtime identity for such an entry is derived from the map key only — expandViewContainer never reads the inner name except for the default list/form. So a bundle keyed by the inner name passes lint and resolves to nothing. No view in this repo's own apps carries an inner name, so this is currently theoretical; it is the same contract question one layer over.
Why this needs a decision rather than a patch
Per Prime Directive #12 the cure belongs at the producer, not in resolveViewLabel — teaching the resolver to try default, then list, then config.name would fossilize three dialects for one key and is exactly the consumer-side tolerance the directive bans. The decision is which spelling is canonical:
- A. Canonical = the runtime identity's bare key (
default). Extractor changes ?? 'list' → ?? 'default'; lint narrows to the map key / default key; existing bundles with _views.list need a conversion entry or a release note. Pro: one key, derived from the identity the registry actually assigned, and the lint can then enforce it — an AI-authored bundle either matches or is rejected loudly. Con: touches shipped bundles.
- B. Canonical =
list. Composer names the default list <object>.list instead of <object>.default. Pro: no bundle churn. Con: default is the registry identity that appears in URLs, overlay rows (sys_metadata), and normalizeViewMetadata baselines — renaming it is a far wider blast radius than renaming a translation key.
Recommendation: A, and tighten collectViewRecord to the same single spelling in the same change, so "declared = enforced" holds for view translation keys.
Repro
pnpm --filter @objectstack/spec build
# then, against src:
node -e "0" # see the measurement snippet above; expandViewContainer('task', { list: {...} }) -> ['task.default']
Found while fixing #4854 (the resolver read fields the served document does not have). That fix makes
objects.<object>._views.<bare key>resolve forlistViews/formViewsentries. This issue is the residual: for one common authoring shape the key the extractor writes and the key the runtime identity yields are still different strings, so the translation can never be found no matter which side you author against.Filed unassigned — recording the finding, not claiming it.
The divergence, measured
Three surfaces derive a
_viewskey, and they do not agree:listpackages/spec/src/ui/view.zod.tsexpandViewContainerWithDiagnostics<object>.default→ bare keydefaultpackages/cli/src/utils/i18n-extract.ts:453listpackages/lint/src/validate-translation-references.tscollectViewRecordnameComposer:
Extractor:
Measured against the real composer:
So an app whose object declares only a default
list— nolistViews— gets a skeleton keyedlist, a runtime view keyeddefault, and an English label on screen forever. It is invisible inexamples/app-crmonly by luck: its defaultlistis structurally identical tolistViews.all, so the composer's signature dedupe collapses the two and the live key becomesall. The_views.listentry inexamples/app-crm/src/translations/crm.translation.tsis dead weight that looks correct.Second, related spelling question
collectViewRecorddeliberately accepts alistViews.<key>entry's innernameas a legal_viewskey ("authors write either"), but the runtime identity for such an entry is derived from the map key only —expandViewContainernever reads the innernameexcept for the defaultlist/form. So a bundle keyed by the inner name passes lint and resolves to nothing. No view in this repo's own apps carries an innername, so this is currently theoretical; it is the same contract question one layer over.Why this needs a decision rather than a patch
Per Prime Directive #12 the cure belongs at the producer, not in
resolveViewLabel— teaching the resolver to trydefault, thenlist, thenconfig.namewould fossilize three dialects for one key and is exactly the consumer-side tolerance the directive bans. The decision is which spelling is canonical:default). Extractor changes?? 'list'→?? 'default'; lint narrows to the map key / default key; existing bundles with_views.listneed a conversion entry or a release note. Pro: one key, derived from the identity the registry actually assigned, and the lint can then enforce it — an AI-authored bundle either matches or is rejected loudly. Con: touches shipped bundles.list. Composer names the default list<object>.listinstead of<object>.default. Pro: no bundle churn. Con:defaultis the registry identity that appears in URLs, overlay rows (sys_metadata), andnormalizeViewMetadatabaselines — renaming it is a far wider blast radius than renaming a translation key.Recommendation: A, and tighten
collectViewRecordto the same single spelling in the same change, so "declared = enforced" holds for view translation keys.Repro