Repository navigation
fix(spec): serve a field's translated help on description, never on an undeclared help - #21956
Conversation
… an undeclared `help` translateObject overlaid the bundle's `objects.OBJECT.fields.FIELD.help` entry onto a `help` key FieldSchema does not declare. The entry is the translation of the field's `description` (the i18n extractor writes it from that key), so it is now served there, under ADR-0029 D9.2a: the catalog applies only while the served description equals the packaged field's (valueOverridesPackagedBase), and with no packaged base it applies. ObjectFieldLike drops its `help` member. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…tion Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1562b97cc7f3ebadae0712bb31964613a5fa3a83 && git checkout 1562b97cc7f3ebadae0712bb31964613a5fa3a83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01e0f71ad8518ed208dc95eb7bb6bf2b2fc2fa05 3ad2f22bed534424e1af81d7d96c38eab84afd0d && git checkout -B drift-repro 01e0f71ad8518ed208dc95eb7bb6bf2b2fc2fa05 && git merge --no-ff 3ad2f22bed534424e1af81d7d96c38eab84afd0d
node scripts/docs-audit/affected-docs.mjs --json 01e0f71ad8518ed208dc95eb7bb6bf2b2fc2fa05 |
…ce row no longer displaces a code-defined datasource at boot, and the metadata door refuses edits to the host default (objectstack-ai#21965) Part of objectstack-ai#21922 Fixes objectstack-ai#21944 Clause-②: no (narrowing) ## What changes A code-defined datasource (a `*.datasource.ts` the installed artifact declares, or the host's own `default`) is read-only by published contract: `DatasourceSchema.origin` says "code — authored as `*.datasource.ts`, GitOps-owned, read-only in the UI", the datasource registry entry in `metadata-plugin.zod.ts` says code-defined datasources "win on name collision", and `datasource-admin-service.ts` says "A runtime datasource never shadows a code one (code wins on collision)". The datasource-admin plugin's boot restore broke all three, and the metadata door could not see `default` at all. The fix is the one host-owned set of code datasources the two cards' triage asked for ("One set serves both, so do not build two"): - **The set** (`packages/runtime/src/code-datasource-names.ts`, new). One in-memory `Set` of the datasource names the host registers from code, on the kernel service `code-datasource-names`. `contributeCodeDatasourceNames` registers it on first use and adds to it after that, the shape `seed-summary` uses. - **Its producers, both in `init()`.** `AppPlugin.init()` adds every datasource the artifact declares: the same list its `start()` registers in the MetadataService, now memoized so the two phases read one answer. `DefaultDatasourcePlugin.init()` adds `default`. Phase 1 completes before any `start()`, so the set is whole before the restore runs, whatever order the plugins were composed in. - **The restore** (`restoreRuntimeDatasources`, `packages/services/service-datasource/src/datasource-admin-plugin.ts`). A stored row under a name in the set is not registered over the code definition. It is kept, and one boot warning names it with the repair. The warning goes to the host's `options.logger`, or to the kernel logger when the host passes none (`os serve` passes none). - **The resolver** (`isDeclaredCodeDatasource`, `packages/metadata-protocol/src/protocol.ts`, nothing else in that file). It reads the same set beside the installed packages, so the metadata door answers `default` the way it answers every code-defined datasource since PR objectstack-ai#21942. ⛔ "Code" is never read from a stored row's `origin`, the MetadataService slot's `origin`, the connection service's `ConnectResult`, or a request body's `origin`. ## Measured on a booted showcase The harness is the `@objectstack/verify` `bootStack` with the datasource-admin routes mounted the way `serve.ts` mounts them, in a temp cwd. The stored rows assert `origin: 'runtime'` and their own `config.filename` (the cards' case (b)). They were written through the metadata door's repository on the runtime-only intent, then the stack restarted. BEFORE is `76fec88b16`; AFTER is this branch at `1d840709ae`. The readings come from a throwaway probe that was never committed; the committed pins below assert the AFTER column. | Reading after the restart | BEFORE | AFTER | |---|---|---| | admin list, `showcase_external` | `origin: runtime`, label "Shadow 21922" | `origin: code`, "External Analytics (SQLite)" | | `PATCH /api/v1/datasources/showcase_external` | 200 | 400 `DATASOURCE_ADMIN_ERROR` "… is code-defined and cannot be edited at runtime." | | live pool named `default` | a second pool opened on the stored row's file; verdict `already-registered` became `connected` | none; verdict stays `already-registered` | | `showcase_ext_customer` read | 3 rows (code fixture) | 3 rows (code fixture) | | boot warning naming each stored row | none | one per row | | `PUT /api/v1/meta/datasource/default` | 200 "Saved datasource 'default'" | 403 `NOT_OVERRIDABLE` | | `DELETE /api/v1/meta/datasource/default`, no stored row | 200 | 403 `NOT_OVERRIDABLE` | | `DELETE /api/v1/meta/datasource/showcase_external` (repair), then meta `GET` in the same boot | 200, but the meta `GET` kept serving the stored edit until the next restart | 200, and the meta `GET` serves the code definition | ## The dispatch's mechanism hypotheses - **H1, start order.** In all three compositions that load `service-datasource` (`serve.ts`, `standalone-stack.ts`, the verify harness), `DefaultDatasourcePlugin` and `AppPlugin` are `use()`d before `DatasourceAdminServicePlugin`. None of the three declares an ordering edge to another, so their `start()`s run in insertion order: the code registrations did land before the restore, but by list position alone, which ADR-0116 says proves nothing. The answer is the first branch: the set is filled by a phase that precedes the restore (`init()`). It is pinned by a boot whose reader plugin is composed first, ahead of every producer. The restore pin also covers a code registration that lands after it. - **H2, the seam.** It is a kernel service read through the services registry the protocol already resolves (`getServicesRegistry()`), with no `packages/spec` change. The ObjectQL registry was rejected: the engine's datasource definitions mix both origins, and a host package record would be a fabricated provenance. - **H3, the admin refusal.** Measured, not assumed. The pins' stored rows carry `origin: 'runtime'`, and the slot the refusal reads holds AppPlugin's explicit `origin: 'code'`. Under ablation A the admin door served the stored row as `origin: runtime`, so the refusal cannot come from the admin read's `origin ?? 'code'` default. - **H4, the live pool.** For `default`: yes. The restored row reached `rehydratePools`, which opened a second pool named `default` on the row's file. Routing did not move, because the engine never routes to a driver named `default`; the default driver keeps its natural name. It is the same defect and the same decision fixes it, pinned by `getDriverByName('default')` and the connect verdict. For `showcase_external`, nothing was re-pointed at boot in this composition: AppPlugin's connect ran first, so the rehydrate answered `already-registered`. The admin `PATCH` 200 was the open door to a re-point (an update that changes connectivity rebuilds the pool), and it is now refused. ## Seam and the Clause-② limb (for the seat) - **Published exports added: none.** `code-datasource-names.ts` is not re-exported from `packages/runtime/src/index.ts`. `service-datasource` and `metadata-protocol` spell the service name privately and read the value structurally as `has(name)`, the way `'datasource-connection'` is read today. - **What the seam does add is one kernel service entry**, `code-datasource-names`, which two packages read by name. Whether that is the claim's "service contract" limb is the seat's call. The line above stays as the claim wrote it, and the changeset grades all three packages `minor`, which holds under either reading. ## Named gap: the metadata door's read while a stored row exists `GET /api/v1/meta/datasource/:name` still serves a stored row under a code-defined name for as long as the row exists. The door reads its stored overlay first (ADR-0005's read order), whatever the MetadataService holds. The AFTER boot measured it: the admin door served the code definition while the meta `GET` served the stored row, for `showcase_external` and for `default`. So triage's pins "both doors serve the code definition" and "removes it with no change to what is served" hold for the admin door. For the metadata door they hold once the repair `DELETE` has run, in the same boot. That read lives in `getMetaItem`'s overlay step, outside `isDeclaredCodeDatasource`, and `protocol.ts` is held by objectstack-ai#21934 in other regions, so it is left to the seat. `meta-door-code-datasource.dogfood.test.ts` already pins that read as it is. objectstack-ai#21922 stays open for that read: this PR is `Part of` it, and the seat routes the remaining half through triage when it merges. ## Landing beyond the claim's named files `packages/runtime/src/app-plugin.ts` is the producer of the packages' half of the set, in the declared `runtime` package. The memo also makes its residual-owner warning print once instead of once per phase. `packages/runtime/src/code-datasource-names.ts` is new in the same package. ## Patch round 1 (head `d77e150701`) Review `6011282321` on objectstack-ai#21922. The Tests, Ablations and Gates sections below are round 0's, at `80fbcfdea6`; this section carries the readings on the current head. - **The plugin-dev pin (CI red on round 0).** `AppPlugin.init()` now contributes its datasource names as a function that the host's code-datasource set resolves at its first read. The set is still contributed to only in Phase 1, before any `start()`. The resolution is deferred because the names come from the artifact's `collections`, which walk `packages[]`. `AppPlugin.init()`'s manifest registration is the one thing in `init()` allowed to touch `packages[]`, pinned by `plugin-dev`'s malformed-stack falsifier, which this PR's first head turned red. The kernel service now holds a `CodeDatasourceNames`, a set with pending contributions; readers still use `has(name)` only. A contribution that throws stays pending and rethrows to every reader. - **The `default` refusal names what defines it**: the host's database configuration (the database URL the server starts with). It names no `*.datasource.ts`, because none declares `default`. Every package-declared datasource's sentence is byte-identical, and `code`, `status` and the refused set are unchanged (`packaged-base-regime.ts`, the datasource row's `hostOwned`). - `const listOf = (` spacing restored in `app-plugin.ts`. Merged `origin/main` `80f9f7e6ba` as `49421a8fe6`. - **Ablation D:** the old sentence put back for `default` turned 6 unit cases and 1 dogfood case red, and was restored by blob. - **Tests at `d77e150701`** (each `VERDICT command-exit 0`): - `service-datasource`: 748 / 748; - `metadata-protocol`: 27978 passed, 19 skipped; - `runtime` local: 4668 passed, 19 skipped; - `plugin-dev`: 86 / 86; - the two dogfood files: 11 / 11; - downstream consumers of `runtime` (cli, client, verify, http-conformance, cloud-connection): all passed; - typechecks for the five packages: green. - **Gates at `d77e150701`:** `dispatch-gates --commands` derived 72, reconciled with `--ran` as 72 run and 0 not measured, plus `check:init-service-contract` and `check:startup-registry-verdict`. All exit 0. - **Docs:** the 18 hand-written pages the Docs Drift Check lists were read page by page. None states anything this PR makes false, so no docs are edited. ## Tests (head `80fbcfdea6`) - `pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2`: 41 files, 748 passed. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 218 files passed, 3 skipped; 27976 tests passed, 19 skipped. - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 --project local`: 331 files, 4658 passed, 19 skipped. - Dogfood, `--project isolated`: `datasource-restore-code-wins.dogfood.test.ts` (new) and `meta-door-code-datasource.dogfood.test.ts`, 2 files, 11 passed. - `typecheck` green for `service-datasource`, `metadata-protocol`, `runtime` (including its `check:test-typecheck` ledger, held) and `dogfood`. `tsc --listFiles` counts each touched test file once in its program. - Each package's run includes its new pins: 5 in `datasource-admin-plugin.test.ts` (the restore), 4 in `code-datasource-names.test.ts` (the set and its phase), and 2 resolver plus 8 door cases in `protocol.code-defined-datasource-door.test.ts` (`default`, on both kernel shapes). ## Ablations Each one was committed first and mutated with `scripts/ablation-replace.mjs` in wrap mode, under a shell trap. For subjects resolved through `dist/`, the package was rebuilt and `ablation-dist-preflight.mjs` proved the marker was present. The restore leg was rebuilt and proven `--absent`, the blob equalled HEAD, `git diff HEAD` was empty, and the tree was clean. - **A, the restore registers over a code name** (`datasource-admin-plugin.ts`; marker in 2 files of `service-datasource/dist`). Unit: 3 failed, 16 passed. The slot served the stored row, the warning was not called, and the order-independent case registered the row. Dogfood: 2 failed, 3 passed. The admin list served `showcase_external` as the stored row, and the repair case read the same. - **B, the resolver does not know the set** (`protocol.ts`; marker in 2 files of `metadata-protocol/dist`). Unit: 5 failed, 30 passed (the resolver case, and `PUT` plus no-row `DELETE` of `default` on both kernels). Dogfood: `PUT /meta/datasource/default` answered 200. The next case then failed as a cascade, because the row that `PUT` stored made the seed conflict. The repair `DELETE` and runtime controls stayed green, as expected. - **C, AppPlugin's `init()` contribution deleted** (`app-plugin.ts`; the subject resolves from source). The reader-first boot saw `['default']`, not `['app_wh', 'default']`. So the set comes from `init()`; the `start()` registration never fills it. ## Gates (head `80fbcfdea6`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 72 commands on the actual change, a superset of the 52 at dispatch. All 72 ran with exit codes captured before any pipe. `--ran` printed "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED". - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3) because eight packages outside this diff had no `dist/`. After building them it measured green. - Two families the derivation does not name were also run, both green: `check:init-service-contract` ("34 declared / 1 self-provided / 3 without a workspace provider") and `check:startup-registry-verdict` ("none recording a verdict the boot can contradict"). - `check-changeset-no-major`'s level axis needs a PR payload, so CI reads it. - Lint is a proven narrowing, not the repo-wide run. `eslint --no-inline-config --format json` on the 9 touched source and test files reported 9 files, 0 errors and 0 warnings. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules, as its own comment states), so this diff cannot move any untouched file's verdict. ## Acceptance notes - **The `default` refusal's remedy** names the host's database configuration (patch round 1). - **Cluster convergence.** `convergePool` reads a stored row directly and is unchanged. Its signals come from peer admin writes, and the admin door now refuses those for code names. - **The restore's other warnings** (a failed read, a failed register) still go only to `options.logger`, which `os serve` does not pass. They are unchanged here. - **objectstack-ai#21923 remains open.** This diff does not touch `listDatasourceRecords`, `getDatasourceRecord` or `persistDatasourceRow`. One interaction: a metadata-door-created datasource with no `origin` still restores, and is still read as `code` by the admin door's default. - **Main drift.** `origin/main` gained objectstack-ai#21956, objectstack-ai#21964 and objectstack-ai#21961 (spec and docs-qa only) after the round-1 merge. None touches a file here, and the queue's merged generation is the check. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21948
Clause-②: no
What changed
translateObjectused to put a bundle'sobjects.OBJECT.fields.FIELD.helpentry on ahelpkey thatFieldSchemadoes not declare. The served field then failedFieldSchemawithunrecognized_keys. A consumer that reads only declared keys rendered the Englishdescription, and the console logged one ingestion warning per such field.field.help ?? field.description(packages/cli/src/utils/i18n-extract.ts:1223).FieldSchemarefuseshelp, so on a spec-valid field the only source isdescription. The nine bundle source trees author 0inlineHelpTextand 0 fieldhelp. The nineenbundles carry 523helpentries.translateField(packages/spec/src/system/i18n-resolver.ts:3054) now serves the translated help ondescriptionand writes nohelpkey.inlineHelpTextis not touched.valueOverridesPackagedBasepredicate. There is no second comparison. The catalog applies only while the served field'sdescriptionequals the packaged field's./meta/objectread — and neither do extension scalar overrides #8284 established).packagedPart's convention. The new finderpackagedObjectField(:1434) reads both field-map shapes.labelis unchanged and stays a flatcatalog ?? document, as its existing pin scopes it.ObjectFieldLike(:2453) drops itshelp?: stringmember. This is safe:[key: string]: anyindex signature still accepts and types ahelpkey, so a caller that writes or reads one still compiles (probe below);packages/spec/api-surface/system.jsonrecords onlyObjectFieldLike (interface), not its members, andcheck:api-surfacestays green;service-analytics, which readsoptionsonly) still typechecks.translateObjectdocblock no longer says it translates a field'shelp. A new section states the target key and the D9.2a precedence..changeset/21948-spec-field-help-served-on-description.md:@objectstack/specpatch.Clause-② — measured
nocheck:authorable-surface,check:api-surface,check:export-origins, andcheck:generated(15 artifacts up to date).dist/system/index.d.ts, exit 0:const legacy: ObjectFieldLike = { name: 'x', help: 'legacy' }compiles, and so does readinglegacy.help;@ts-expect-erroronlabel: 42is consumed);const proof: number = ({} as ObjectFieldLike).helpcompiles, which shows the probe read the rebuilt declaration..d.tswith BASE'shelp?: stringput back gives exit 2, with exactly one error: TS2322 on theproofline. Writing and readinghelpcompile under both declarations.ObjectFieldLike['help']is now read through the index signature (any) instead ofstring | undefined. That is stated here for the at-tier review.Measured
All at HEAD
3ad2f22bedunless stated.Corpus at the resolver seam (scratch script, not committed). Every object in
packages/platform-objects/scripts/i18n-extract.config.ts(48 objects, 617 fields), with that package's real bundles, using each object as its own packaged base:helpkeyshelprefused byFieldSchemasys_userfields withhelpdescriptionequal to the bundle entrysys_user.two_factor_enabled.description=该用户是否已启用双因素认证。由 better-auth 的 \twoFactor` 插件维护。`.en, 0 descriptions change from source, because the en entries repeat the source.GET /api/v1/meta/object/sys_user). The spec change invalidates the showcase build closure: 62 of 63 turbo tasks miss the cache. The pins and the corpus run read the sametranslateMetadataDocumentdispatch the REST read calls.Pins (
packages/spec/src/system/i18n-resolver.test.ts:3552, 9 cases):help, has the translation ondescription, and parses with nounrecognized_keys;descriptionandinlineHelpTextgets the translation ondescription, andinlineHelpTextis untouched;descriptionis kept in zh-CN and en, through the type dispatch, while the undiverged sibling is translated;undefined,null, or omitted) the catalog applies;descriptionis filled by the catalog;No existing pin asserted a served field
help. I searched every test outside the bundle suites, so none had to move.Ablation, run on the committed head. Each leg used
scripts/ablation-replace.mjsin WRAP mode plus a trap. Predictions were written down before the run. Both legs were restored, with the restored blob equal to HEAD (a8eade91) andgit diff HEADempty:next.help, neverdescription)Tests 7 failed | 2 passedtrue)Tests 3 failed | 6 passedexpected 'Whether two-factor authentication is …' to be '该用户是否已启用双因素认证。…'.expected '该用户是否已启用双因素认证。…' to be 'Edited by the tenant.'.Tests
@objectstack/spec, full local project:Test Files 619 passed,Tests 18480 passed | 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0. Its test layer compiles undertsconfig.test.jsonwith the identity-pinned debt held.@objectstack/service-analytics:typecheckexit 0. The three dimension/label suites that calltranslateObject:Tests 53 passed..tsfiles: 0 errors and 0 warnings. That run is not the repo-wide lint, which is CI's.Gates.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --randerived 85 families: 84 ran with exit 0 and 1 is NOT MEASURED.pnpm check:dual-build-cjs-loads, which exits 3 withPREREQUISITE NOT METbecause 78 packages have nodist/.requireentries of@objectstack/spec'sexportsload from the rebuiltdist, andsystem.translateObjectis a function.pnpm check:lean-entry-closurefirst answered exit 3 (objectql/coreunbuilt). Afterturbo run build --filter=@objectstack/objectql...it answered exit 0, and that is the recorded code.Ships:
npm pack --dry-runof@objectstack/speclistsdist/system/index.js,.mjsand.d.ts. Both runtime files carrypackagedObjectFieldand nonext.help = translatedHelp.Base: origin/main moved to
a3bd157730(#21947, test titles underpackages/spec/src/ui/only). None of this PR's files is touched, so it is not merged.Known readers in objectui
objectui at
.objectui-sha0abd4f9f, read-only. No objectui file is edited here.packages/plugin-form/src/ObjectForm.tsx:1033andpackages/plugin-form/src/sectionFields.ts:311(field.help || field.description), andpackages/app-shell/src/utils/resolveActionParams.ts:714(param.helpText ?? field.help ?? field.description).description, so they render the same translated text, and theirhelparm is now dead. Retiring those arms is objectui's job.packages/core/src/utils/reference-keys.ts:362, reached through theno-declared-twinarm (:415) ofcanonicalizeRetiredFieldKeys. It fires for any undeclared key on a served field def, so it goes quiet once nohelpis served.Acceptance notes
None of these is addressed in this PR.
saveFieldswrite-back. NOT MEASURED; this is a read-only inference.packages/app-shell/src/services/MetadataService.ts:898to:939carries per-field server keys from the translated read back into the object PUT.helpis not in objectui'sRETIRED_FIELD_KEYS(packages/types/src/internal/retired-field-keys.ts). So a field save on a platform object with bundlehelpentries should have sent a keyFieldSchemarefuses.field.help ??arm (packages/cli/src/utils/i18n-extract.ts:1223) reads a keyFieldSchemarefuses, so it is dead on every spec-valid field. Carrier:domain:cli; none in flight.placeholderis never overlaid.FieldTranslationSchemadeclaresplaceholder, and the extractor emitsobjects.OBJECT.fields.FIELD.placeholder(:1224), buttranslateFieldnever overlays it. The nine shipped bundles carry 0 field-level placeholder entries (the 4 per locale are action params), so it is dormant. Carrier: none.inlineHelpTexthas no translation path. The extractor never reads it, so an authored one is never offered for translation. The nine bundle sources author 0 of them. Carrier: none.content/docs/protocol/kernel/i18n-standard.mdx:166lists a field'slabel/help/placeholderas display labels, butFieldSchemadeclares nohelp. Carrier: none.Generated by Claude Code