Skip to content

i18n: give bulk-action defs, custom validation messages and dataset labels a bundle key - #14381

Merged
os-warren merged 7 commits into
mainfrom
claude/group-company-task-tracking-astg44
Sep 2, 2026
Merged

os-warren merged 7 commits into
mainfrom
claude/group-company-task-tracking-astg44

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #14253

Note: key placeholders below are written OBJECT / VIEW / RULE / NAME / DIM / MEASURE / PARAM
rather than in angle brackets — GitHub's body sanitizer strips angle-bracket-shaped fragments, which is what
ate them on the first revision of the issue this closes.

Three authored display surfaces carried user-facing text that no key in TranslationDataSchema could reach — not a drifted key, no key. Each rendered in the source locale inside an otherwise fully translated screen, which is the bad failure mode: it reads as a styling quirk rather than as a missing translation.

Purely additive. Three new translation groups, one new dispatch-table entry, one new lookup on the write path, one localization step at the analytics door. No existing key changes shape, no resolution order changes, and every surface still falls back to the authored literal when the bundle carries nothing.

Five commits, one per surface plus two the work turned up.


1 · bulkActionDefs on a list view

objects.OBJECT._views.VIEW.bulkActions.NAME.{label,confirmText,confirmLabel,params.PARAM.{label,help,placeholder}}, resolved in translateView against config.bulkActionDefs.

A bulk-action def is part of the view document, not an action document, so it never reached translateAction. The selection bar read 已选择 1 项 · Complete · Skip · 清除.

config is the one address a served def has — both ViewItemSchema and expandViewContainer nest the whole ListView under config — so the fixture is derived from the composer rather than transcribed, the #4854 lesson one level in.

The def's label deliberately stays a plain z.string() on the authoring side: ui/bulk-action.zod.ts's module header measured that the bar renders it as a React child, so an inline locale map would be a blank cell rather than a parse error. Overlaying at the metadata boundary keeps the wire value a plain string and changes only its language. The documented alternative (bulkActions: ['NAME'], promoting a declared action) is not equivalent: it is N elevated per-record dispatches instead of one data-plane updateMany.

2 · A custom validation rule's message

objects.OBJECT._validations.RULE.message, spelled by the new objectValidationMessageKey and read on the write path by a new authoredRuleMessage seat in the rule evaluator.

⚠️ This adds a key shape, not a channel. The lookup runs on the existing ValidationMessageContext.translate hook — the engine's i18nService, bridged by ObjectQLPlugin — that resolveFieldLabel and renderValidationMessage have used since #3957. Before it, a deployment read platform-generated refusals in the caller's language and author-written refusals in the source language inside one 400 VALIDATION_FAILED envelope.

All five authored-message emitters route through the one seat (script/cross_field, state_machine, format, and both json_schema arms). A nested conditional branch is addressed by the branch's own name; the wrapping conditional's message never reaches a caller. A PLATFORM-generated rejection (an unevaluable predicate) is deliberately left alone — the caller needs to be told the rule is broken, not told the rule's verdict.

This is not validationMessages coming back. That group (retired 17.0.0, #4667, ADR-0049) was keyed by rule name at the bundle's top level, so it could not tell two objects' rules apart, and — the reason it was retired — nothing read it. Its ADR-0087 conversion still strips it from stored bundles, untouched. The replacement is object-scoped, sits beside _views/_actions/_tabs, and ships its reader in the same change. Its tombstone and the errors tombstone now point at it instead of asserting no route exists.

messages['validation.field.*'] is unchanged and still overrides the built-in field catalog only.

3 · Dataset labels — and the door the dashboard actually draws through

datasets.NAME.{label,description,dimensions.DIM.label,measures.MEASURE.label} plus translateDataset in METADATA_DOCUMENT_TRANSLATORS. Registering the translator is the whole wiring: TRANSLATABLE_METADATA_TYPES is derived from that table and @objectstack/rest reads the derived set (#3786). No second hand-maintained list exists — the derivation is intact; the one historical hand-copy in rest-server.ts was already retired.

Top-level rather than nested under dashboards because a dataset is the ONE definition every presentation binds to by reference (ADR-0021 D1): the same measure is drawn by N widgets across M dashboards, and a dataset no dashboard references would otherwise be unaddressable.

The fifth commit exists because translateDataset alone would not have fixed the screenshot. AnalyticsResult.fields[].label is documented as the display label "for legends/KPIs", and AnalyticsService fills it by copying dataset.measures[].label off the definition. That definition arrives through POST /analytics/dataset/query, which resolves a saved dataset via getMetaItems and never passes through translateMetaItem. So the metadata read was the door a dashboard does not draw through — covered at one door, open at the other. The saved definition now goes through the existing translateMetaItem before it is parsed and compiled, and the existing enrichment carries the translated label to the wire untouched: nothing downstream learns about bundles, the analytics service stays free of i18n, and there is no second resolution path. ⛔ The inline branch is deliberately not translated — a Studio preview posts the draft the designer is editing, which carries no saved name to address a bundle entry with. Pinned both ways.


Key faces are measured, not mirrored

Nothing here parses clean and translates nothing. Every exclusion carries guidance naming the right home:

excluded why, measured
bulk param options BulkActionParamSchema.options[].value is string | number | boolean, so a value-keyed map cannot address true and "true" apart — the same reason FLOW_SCREEN_FIELD_NO_OPTIONS gives
bulk def successMessage a def declares none; the run reports a per-record outcome summary the console words itself
validation label / description the admin rule-listing entry and the administrative note — neither reaches a rejected caller
dataset dimension/measure description dataset.zod.ts already tells authors "its author-facing text is label. description is declared on the DATASET itself"

And a bulk param's hint is help, not the action-param helpText — the face follows the authored key and aliases the neighbouring spelling onto it, so one string keeps one address.


Verification

Union re-run after the final commit, at e865543fe (git rev-parse --short HEAD), which includes the merge of origin/main @ 72adb7fcb.

Red-first, per surface — each proved by committing the fix, restoring the source from origin/main/HEAD~1 (tree only), confirming the reversion on disk with an anchored grep -c, and re-running:

surface reverted result
1 · bulkActionDefs i18n-resolver.ts + translation.zod.ts 10 failed / 190 passed
2 · validation messages rule-validator.ts only 3 failed / 180 passed
3 · datasets (metadata door) i18n-resolver.ts + translation.zod.ts 10 failed / 210 passed
3b · datasets (analytics door) rest-server.ts only 1 failed / 12 passed

Each reversion was confirmed by grep before the run (authoredRuleMessage: 0, message: rule.message: 5, translateDataset: 0, translateMetaItem(req, 'dataset': 0) and each restoration confirmed the same way afterwards, with git status --porcelain empty.

Suites — pnpm --filter PKG test / typecheck, all at the merged head: @objectstack/spec 12 103 passed / 450 files · @objectstack/objectql 4 398 passed / 254 files · @objectstack/rest 2 805 passed / 167 files. Typechecks green for all three.

Gates — node scripts/pm/dispatch-gates.mjs --commands derives 78 families for this diff (74 by path + 7 by kind, 3 shared). All 78 run at e865543fe: 76 green, 2 NOT MEASURED — check-test-completeness and check:pm-half-states, both exit 3 ("nothing was measured", distinct from a finding's 1): the first needs a saved turbo run test log, the second needs the GitHub PM board. Neither is a finding and neither is evidence about this tree.

pnpm lint (eslint . --no-inline-config, whole repo) green — no narrowing to declare. check:nul-bytes green, plus a direct control-byte scan over every file in the diff.

Two gates were red on first run and are worth recording rather than hiding: check:doc-authoring caught two #NNNN citations this branch had written into customer-facing tombstone text (printed verbatim at an author whose reader has no tracker — maintainer ruling 2026-08-12), and five spec gates refused on a stale dist. The first is commit 4, which also adds the negative pin the gate's own remedy asks for; the second was a rebuild.

External acceptance — measured from outside the platform

objectstack-ai/duly records all three surfaces as declared, reasoned exemptions in src/translations/authored-text.ts, and its i18n gate anchors them on the platform's own answer. Run at dd3c619 with node_modules/@objectstack/spec repointed at this branch's build (an untracked symlink, restored immediately and verified):

baseline (published 17.2.0)   29 passed
against this branch            1 failed | 28 passed

  × pins the metadata types the PLATFORM says are translatable
    the platform's translatable metadata types changed — re-derive the
    `untranslatable` verdicts in src/translations/authored-text.ts
    expected [ 'action', 'app', 'dashboard', …(4) ]
      to deeply equal [ 'action', 'app', 'dashboard', …(3) ]

TRANSLATABLE_METADATA_TYPES now reads ["action","app","dashboard","dataset","object","page","view"].

Stated plainly, because it is one of two mechanisms and not both: duly's gate has a platform-derived pin (that set) and an app-side pin (an exact list of exemption paths, plus a staleness check). Only the platform-derived pin goes red here, which is the correct behaviour — the exemption-staleness check cannot fire until duly's own walk starts emitting the new keys, since its authored strings are unchanged. So this is end-to-end proof that the platform's answer moved, not proof that duly's three exemptions have been retired; retiring them is duly's follow-up.


Out of scope, filed

Notes for the reviewer

🤖 Generated with Claude Code

https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p


Generated by Claude Code

os-warren and others added 6 commits September 2, 2026 00:55
A `bulkActionDefs` entry is part of the VIEW document, not an action
document, so it never reaches `translateAction` and no bundle group
addressed it. Measured against a fully translated app the selection bar
read `已选择 1 项 · Complete · Skip · 清除` — two English words between
two Chinese ones, which reads as a styling quirk rather than a missing
translation. Not a drifted key: no key.

Adds `objects.<object>._views.<view>.bulkActions.<def_name>` carrying
`label` / `confirmText` / `confirmLabel` and per-param `label` / `help` /
`placeholder`, resolved in `translateView` against `config.bulkActionDefs`
— the one address a served def has (`ViewItemSchema` and
`expandViewContainer` both nest the whole ListView under `config`).

The def's `label` stays `z.string()` on the authoring side: the bar
renders it as a React child, so an inline locale map would be a blank
cell rather than a parse error (`ui/bulk-action.zod.ts` module header).
Overlaying at the metadata boundary keeps the wire value a plain string
and changes only its language.

Key face measured against `BulkActionDefSchema`, not mirrored from the
report. Two exclusions carry `guidance`: `successMessage` (a def declares
none) and per-param `options` (`options[].value` is unconstrained, so a
value-keyed map cannot address `true` and `"true"` apart — the same
measured reason `FLOW_SCREEN_FIELD_NO_OPTIONS` gives). `help`, not
`helpText`: the face follows the authored key and aliases the
neighbouring action-param spelling onto it.

Part of #14253

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
… key

`object.validations[].message` is the sentence a rejected write returns,
and the evaluator emitted it verbatim. A deployment with a complete
`zh-CN` bundle therefore read platform-generated refusals in Chinese and
author-written refusals in English *inside one 400 VALIDATION_FAILED
envelope* — the built-in field catalog has resolved through the engine's
i18n service since #3957, and only the authored half had nowhere to look.

Adds `objects.<object>._validations.<rule_name>.message`, spelled by
`objectValidationMessageKey` (the third member of the
`objectFieldLabelKey` / `objectLabelKey` family) and read on the write
path by a new `authoredRuleMessage` seat in the rule evaluator.

⚠️ No second i18n path into objectql. The lookup runs on the SAME
`ValidationMessageContext.translate` hook — the engine's `i18nService`,
bridged by `ObjectQLPlugin` — that `resolveFieldLabel` and
`renderValidationMessage` already use. What was missing was a key shape,
not a channel. All five authored-message emitters route through the one
seat (`script`/`cross_field`, `state_machine`, `format`, and both
`json_schema` arms); a nested `conditional` branch is addressed by the
BRANCH's own name, and a PLATFORM-generated rejection (an unevaluable
predicate) is deliberately left alone.

This is not `validationMessages` (#4667, ADR-0049) coming back. That group
was keyed by rule name at the bundle's TOP level — it could not tell two
objects' rules apart — and, the reason it was retired, nothing read it.
This address is object-scoped, sits beside `_views`/`_actions`/`_tabs`,
and ships its reader in the same change. Its retired-key guidance is
updated to point here instead of asserting that no route exists, and the
`errors` tombstone with it.

Key face is one key, measured: a rule also declares `label` (the admin
listing entry) and `description` (administrative notes), neither of which
reaches a rejected caller — declaring them would parse clean and translate
nothing, so both carry `guidance`.

Also corrects the `validation.message` liveness row, whose overrides
clause named `validationMessages` — a route removed a major version ago.

Part of #14253

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
…s group

A dataset reads like a back-office definition, but a measure label is
drawn ON THE DASHBOARD — under every metric tile and on every chart axis.
`dataset` was neither in `TRANSLATABLE_METADATA_TYPES` nor addressed by
any bundle group, so a translated dashboard rendered Chinese tile titles
(those are `dashboards.<name>.widgets.<id>.*`) with `Untouched > 14 days`
directly beneath them. Not a drifted key: no key.

Adds `datasets.<name>.{label,description,dimensions.<d>.label,
measures.<m>.label}` and `translateDataset`, registered in
`METADATA_DOCUMENT_TRANSLATORS`. That registration is the whole wiring:
`TRANSLATABLE_METADATA_TYPES` is DERIVED from the table and
`@objectstack/rest` reads the derived set, so the REST boundary follows
with nothing else to remember (#3786). No second hand-maintained list was
found — the derivation is intact.

Top-level rather than nested under `dashboards` because a dataset is the
ONE definition every presentation binds to by reference (ADR-0021 D1):
the same measure is drawn by N widgets across M dashboards, and a dataset
no dashboard references would otherwise be unaddressable.

The four keys are `I18nLabelSchema` at the authoring site, so a dataset's
copy may already be an inline `{ en, 'zh-CN' }` map (#5728).
`translateDataset` writes ONLY where the bundle answers — the same rule
`translatePage` follows — so an uncovered inline map is left intact
rather than flattened to one language, and the member arrays keep their
identity when nothing matched.

Key face measured against `DatasetSchema`: a dimension and a measure each
declare `label` and nothing else display-shaped, which `dataset.zod.ts`
states at the authoring site too ("its author-facing text is `label`.
`description` is declared on the DATASET itself"). `description` therefore
lives on the dataset and carries `guidance` below it.

Liveness: the `datasets` group is seeded LIVE and DRILLED (label /
description / dimensions / measures) with its reader in the same change.
The ledger's own header sentence is corrected with it — it still called
`validationMessages` "the one dead group" a major version after #4667
removed it.

Part of #14253

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`check:doc-authoring` caught two `#NNNN` citations in the tombstone text
this branch rewrote. That text is printed AT the author, verbatim, the
moment their bundle is refused — by `os validate`, by a publish gate, by
a parse — and none of those readers has a tracker, so the token resolves
to nothing in the one place the sentence most needs to be actionable.
Maintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成
经验,保留 issue id没有意义」.

The customer-resolvable references stay: the protocol version, ADR-0049,
the migration command and the replacement key path.

Adds the negative pin the gate's own remedy asks for, beside the twin
that pins the wording: the tombstone must name the live replacement
group AND must not carry an issue id.

Part of #14253

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Surface 3's second door, found by measuring where a measure label
actually reaches a dashboard rather than assuming it rides the metadata
read.

`AnalyticsResult.fields[].label` is documented as the display label "for
legends/KPIs", and `AnalyticsService` fills it by copying
`dataset.measures[].label` off the definition. That definition arrives
through `POST /analytics/dataset/query`, which resolves a saved dataset
via `getMetaItems` and never passes through `translateMetaItem`. So
`translateDataset` alone closes `/meta/datasets` — the door a dashboard
does NOT draw through — and leaves the one in the issue's screenshot
open: covered at one door, open at the other.

Fixed at the metadata boundary, where every other document is localized:
the resolved saved definition goes through the existing
`translateMetaItem` before it is parsed and compiled, and the existing
field enrichment carries the translated label to the wire untouched.
Nothing downstream learns about bundles — the analytics service stays
free of i18n and there is no second resolution path.

⛔ The INLINE branch is deliberately not translated: a Studio preview
posts the draft the designer is editing, which carries no saved name to
address a bundle entry with, and overwriting its copy would misreport
what is about to be saved. Pinned by test, both ways.

Part of #14253

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/lint, @objectstack/objectql, @objectstack/rest, @objectstack/spec, touching 50 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/authorable-surface/system.json, packages/spec/export-origins/system.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

36 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 5c9e40ad91028b57b0748e3ea0347189bac72ce9.

⛔ 4 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/api-surface/system.json, packages/spec/authorable-surface/system.json, packages/spec/export-origins/system.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 130 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 5c9e40ad91028b57b0748e3ea0347189bac72ce9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f4fc60591e522fdb0b8c368bb7eb2d4610723f84 — the merge of head 7b1c387bb3769c44f03b6c8b79757d62d98960f2 into base 5c9e40ad91028b57b0748e3ea0347189bac72ce9, 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 f4fc60591e522fdb0b8c368bb7eb2d4610723f84 && git checkout f4fc60591e522fdb0b8c368bb7eb2d4610723f84
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5c9e40ad91028b57b0748e3ea0347189bac72ce9 7b1c387bb3769c44f03b6c8b79757d62d98960f2 && git checkout -B drift-repro 5c9e40ad91028b57b0748e3ea0347189bac72ce9 && git merge --no-ff 7b1c387bb3769c44f03b6c8b79757d62d98960f2

node scripts/docs-audit/affected-docs.mjs --json 5c9e40ad91028b57b0748e3ea0347189bac72ce9

⚠️ 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 5c9e40ad91028b57b0748e3ea0347189bac72ce9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

PM review — the open question is settled as A, and one claim in this PR is deliberately not being made

The ADR question: land as-is, with the paper trail as a follow-up

Surface 2 gives author-written validation-rule messages a translation route that 17.0.0 closed, and Prime Directive #13 says reversing a recorded decision is itself a decision. The judgement:

This is ADR-0049 being enforced, not reversed. The policy is enforce-or-remove; validationMessages was removed because nothing read it. Shipping a key with its reader in the same commit is the other half of that same policy. Three facts make it not-the-same-key rather than a comeback: the old group was top-level-keyed and could not tell two objects' rules apart, the new one is object-scoped and sits beside _views/_actions/_tabs, and the ADR-0087 conversion that strips the old key from stored bundles is untouched.

Raising it instead of quietly landing it was the right call — a decision recorded in a tombstone rather than an ADR is exactly the shape #13 warns is easy to miss, and the tombstone text does become false with this change.

So: A now, B as a follow-up card, not "later if someone remembers". The whole reason this issue existed is that a governance record living only in prose drifts out of true. I would not accept the same fate for the record of this change. Please file the status-line/ADR card before this merges, blocked-by this PR, so the trail is queued rather than intended.

C is correctly rejected, and for the reason the card gives: it leaves a deployment reading platform refusals in Chinese and author-written refusals in English inside one 400 VALIDATION_FAILED envelope.

The correction to my own scoping is the most valuable thing here

I scoped surface 3 as "add translateDataset, register it in the table, done" and said the derivation would carry it to the REST boundary for free. That would have shipped a fix that did not fix the screenshot. AnalyticsResult.fields[].label is filled by copying dataset.measures[].label off the definition, and that definition arrives via POST /analytics/dataset/query → getMetaItems, which never passes through translateMetaItem. Covered at one door, open at the other — and the door I named is not the one a dashboard draws through. Finding that instead of implementing my prescription is the difference between this PR working and it looking like it worked.

What is NOT proven yet, and I want that on the record before review

The external-acceptance section is scrupulous about this and I am restating it so no reviewer reads past it: the red test in duly proves the platform's answer moved, not that the screenshot is fixed. Only the platform-derived pin fires. The measure labels on that dashboard will still render English after this merges, because duly's zh-CN bundle carries no datasets.* keys yet — its authored strings are unchanged.

The visual proof needs both halves. I have filed objectstack-ai/duly#106 for the application side (retire the three exemptions, author the keys, re-take the two screenshots in this issue), blocked on this PR shipping in a release. Until that lands, this PR's claim is "the route exists and is read", which is exactly what its tests show.

Marking ready for review. Not merging: needs:contract-review + protocol:system, and a system-protocol change is not mine to land.


Generated by Claude Code

…anslation-references

#14253 added `objects.<obj>._validations.<rule>.message` to
`ObjectTranslationDataSchema`; the lint rule's per-group coverage pin
(`classifies every key the schema declares, and no key it does not`)
correctly went red because the rule had no leg for it — the exact
"second hand-maintained list drifted" class the PR exists to close.

The walker now registers `objects[].validations[].name` per object and
reports `translation-target-unknown` for a `_validations` key naming a
rule the object does not declare, with the same guidance shape as
`_tabs`. The coverage pin and the all-real-names control both carry the
new group; the fixture declares one rule so the control stays non-vacuous.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

Copy link
Copy Markdown
Collaborator Author

CI red root-caused and fixed — 7b1c387bb

Test Core (3/6) failed on one pin in @objectstack/lint:

validate-translation-references.test.ts:1514
  classifies every key `ObjectTranslationDataSchema` declares, and no key it does not
  expected [ …8 ] to deeply equal [ …9 ]      ← "_validations"

That pin reads the schema's .shape and demands the lint rule classify every group with a working leg. This PR added the _validations group to the schema and no leg to the rule — exactly the "a second hand-maintained enumeration drifted" defect class this PR exists to close, caught by the guard built for it. Right failure, right place.

The fix is what the pin asks for, in the rule's own idiom: the walker now registers objects[].validations[].name per object, and reports translation-target-unknown for a _validations key naming a rule the object does not declare, with the same guidance shape as _tabs. The coverage pin and the all-real-names control both carry the group, and the fixture declares one rule so the control is non-vacuous. @objectstack/lint added to the changeset as a patch — a new finding is a user-visible behaviour of a published package.

Verified locally before pushing, on the PR head with spec, formula and sdui-parser built (the fresh worktree's first run showed 33 file failures that were all Failed to resolve entry for package "@objectstack/formula" — unbuilt siblings, not the change; recorded so nobody chases it):

@objectstack/lint test      Test Files 93 passed (93) · Tests 2715 passed | 5 skipped
@objectstack/lint typecheck 0 errors

Nothing else in the PR changed. Subscribed to this PR's CI; I will act on the next red rather than wait to be asked.


Generated by Claude Code

os-bill pushed a commit that referenced this pull request Sep 11, 2026
…es migration text

The ADR-0087 conversion `translation-validation-messages-removed` told an
author whose retired `validationMessages` key was rejected to author the
message on the rule and stopped there. Since 17.3.0 (#14381, #14253) there is
a translation route for that message —
`objects.<object_name>._validations.<rule_name>.message`, resolved on the write
path — and the sibling prescription in the same package
(`TRANSLATION_KEY_GUIDANCE.validationMessages`) already names it.

Everything the old text said is true; the defect is silence. It is read by
exactly the population that authored the retired key — the authors who wanted
translated rule messages — and it steered them to a plain authored literal
without telling them the bundle key now exists.

Both texts in the file carry the narrow prescription, so both are completed:
the conversion `summary` and the docblock above it, which asserted "not
translated through a group" directly above the corrected summary. The literal
advice is kept in both — it is still correct.

`docs/protocol-upgrade-guide.md` is regenerated with `gen:upgrade-guide`, never
hand-edited.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…es migration text (objectstack-ai#17604)

* docs(spec): name the object-scoped bundle key in the validationMessages migration text

The ADR-0087 conversion `translation-validation-messages-removed` told an
author whose retired `validationMessages` key was rejected to author the
message on the rule and stopped there. Since 17.3.0 (objectstack-ai#14381, objectstack-ai#14253) there is
a translation route for that message —
`objects.<object_name>._validations.<rule_name>.message`, resolved on the write
path — and the sibling prescription in the same package
(`TRANSLATION_KEY_GUIDANCE.validationMessages`) already names it.

Everything the old text said is true; the defect is silence. It is read by
exactly the population that authored the retired key — the authors who wanted
translated rule messages — and it steered them to a plain authored literal
without telling them the bundle key now exists.

Both texts in the file carry the narrow prescription, so both are completed:
the conversion `summary` and the docblock above it, which asserted "not
translated through a group" directly above the corrected summary. The literal
advice is kept in both — it is still correct.

`docs/protocol-upgrade-guide.md` is regenerated with `gen:upgrade-guide`, never
hand-edited.

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

* chore(spec): regenerate spec-changes.json and declare the changeset

`check:generated` caught `spec-changes.json` stale: the ADR-0087 conversion
registry feeds two generated artifacts, not one, and the completed summary
renders into both. Regenerated with `gen:spec-changes`, never hand-edited.

The changeset is measured, not assumed. `registry.ts` is not a `.zod.ts` so it
does not ship as source, but two paths in `packages/spec`'s `files[]` move: the
new sentence is emitted into six files under `dist`, and `spec-changes.json` is
itself a `files[]` entry.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ptions state each decision in words instead of a tracker number (stage 4) (objectstack-ai#21555)

Part of objectstack-ai#20749
Clause-②: no

Stage 4 of the `domain:spec` lane's share of the runtime-string
burn-down (ruling `5902360492`, form D): class (b) of the stage-3
census, the text `packages/spec/src` shows authors and administrators.
Every rewritten string now states in words what the cited decision was,
or drops a citation its sentence already explained. Text only.

## What changed

- **56 ADR-0087 conversion summaries** in
`packages/spec/src/conversions/registry.ts` (68 tracker ids): every
protocol 16 → 17 summary that carried an id. A summary is the "Change"
column of `docs/protocol-upgrade-guide.md`, the `to` text of
`spec-changes.json`'s `converted[]` records and what `os migrate meta
--json` reports under `specChanges`, so it is read by an author
upgrading metadata.
- **Three descriptions** (3 ids): `FieldSchema.autonumberFormat`'s
`.meta()` description (`data/field.zod.ts`), and the route descriptions
of `GET /:type/:name/layers` and `POST /:type/:name/publish`
(`api/plugin-rest-api.zod.ts`).
- **Generated, by `check:generated --fix`** (exactly the three artifacts
it proved stale): `docs/protocol-upgrade-guide.md` (56 rows),
`packages/spec/spec-changes.json` (56 summaries, twice each: the
per-major record and the aggregate), and the `autonumberFormat` rows of
`content/docs/references/data/field.mdx`, `data/object.mdx` and
`system/migration.mdx`.
- One `@objectstack/spec` **patch** changeset, `Clause-②: no`.

## Size: the split (A2)

At this base the class (b) population is unchanged from the stage-3
census: 91 conversion summaries with 108 ids (81 distinct cards) plus 3
descriptions with 3 ids. That is over the ~60-card bar, so this PR
delivers up to a protocol-step boundary, lowest step first:

| protocol step | summaries with ids | ids | distinct cards |
|---|--:|--:|--:|
| toMajor 11 / 13 / 14 / 15 | 0 | 0 | 0 |
| **toMajor 17 (delivered)** | **56** | **68** | **48** |
| toMajor 18 (next stage) | 35 | 40 | 34 (33 not cited in 17) |

The three descriptions ride this part (3 more cards, no overlap): 59
messages, 71 ids, 51 distinct cards delivered. The next stage's exact
list is the 35 toMajor-18 summaries at the end of this body.

## Delivered: each site, the decision read, the new words

Every cited card was read through REST with all comments; the record
column names the comment (or commit) the decision was read from. Where a
summary already said why, the citation is dropped and the sentence kept.
Placeholders `OBJECT_NAME` / `RULE_NAME` below stand for the
angle-bracket spelling in the source.

| conversion (head line) | cited | decision as read (record) | summary
now reads |
|---|---|---|---|
| `action-execute-to-target` (:727) | objectstack-ai#3713 | `execute` is the
deprecated alias of `target`; spec and objectui resolved the pair in
opposite directions; align on the spec rule and drop the alias so the
divergence is unrepresentable (body (closed completed, no comments)) |
action key 'execute' → 'target' (the deprecated handler alias; the spec
and the renderer had resolved the pair in opposite directions, so one
key now names the handler) |
| `field-conditionalRequired-to-requiredWhen` (:767) | objectstack-ai#3754 | same
fold-and-drop as objectstack-ai#3713: `requiredWhen` canonical, alias folded and
dropped from parsed output (body (closed completed, no comments)) |
field key 'conditionalRequired' → 'requiredWhen' (the deprecated
predicate alias, folded into the canonical key so no reader picks its
own precedence) |
| `agent-tools-to-skills` (:822) | objectstack-ai#3894 | ADR-0109 accepted;
`agent.tools[]` removed because it resolved names against the full
registry with no surface check, breaking ADR-0064 (tool set = union of
skills' tools) (PR body (merged)) | agent key 'tools' removed — declare
capability in a skill (ADR-0064: an agent's tools are exactly its
skills' tools, and this inline slot resolved names against the whole
registry with no surface check) |
| `sharing-rule-access-level-full-to-edit` (:881) | objectstack-ai#3865 | route B is
the end state: sharing grants read/edit only; delete, transfer and
re-share come from object permissions, ownership and admin scope; `full`
→ `edit` is lossless (5105498900) | sharing-rule accessLevel 'full' →
'edit' (`full` never granted more than `edit`; a sharing rule grants
read or edit, while delete and transfer come from object permissions and
ownership) |
| `flow-node-crud-object-alias` (:954) | objectstack-ai#3796 | the seven aliases (six
open-coded `??` + the shim's last `object`) graduate straight into the
D2 layer; the `readAliasedConfig` shim is deleted (5125152179) | CRUD
flow-node config key 'object' → 'objectName' (the last alias in the
executors' `readAliasedConfig` shim graduates into this layer, and the
shim is deleted) |
| `flow-node-notify-config-aliases` (:1077) | objectstack-ai#3796, objectstack-ai#4045 | objectstack-ai#3796: `??`
fallbacks graduate, `actionUrl` canonical (downstream chain uses it).
objectstack-ai#4045: `notify.source` was a read-but-undeclared shape → conversion
layer, not configSchema (5125152179; 5127636357, 5138487536) | notify
flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' →
'message', 'url' → 'actionUrl' (executor `??` fallbacks graduated into
this layer; `actionUrl` is canonical because the notification chain
downstream already uses it), and nested 'source: {object, id}' →
'sourceObject' / 'sourceId' (a shape the executor read that no config
schema declared) |
| `flow-node-wait-event-config-lift` (:1288) | objectstack-ai#4045 |
`node.config.eventType` etc. were an undeclared second contract beside
the declared `waitEventConfig`; graduate them (objectstack-ai#4161) (5130277099,
5138487536) | wait flow-node loose config keys → the declared
`waitEventConfig` block: 'eventType', 'timerDuration'/'duration' →
'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (the
executor also read these keys from the loose config, a second contract
beside the declared block) |
| `flow-node-map-flow-alias` (:1364) | objectstack-ai#4045 | reconciliation found the
`map.flow` alias (undeclared executor fallback); graduated (objectstack-ai#4228)
(5138487536) | map flow-node config key 'flow' → 'flowName' (an
undeclared spelling the executor accepted through a bare fallback; it
graduates into this layer) |
| `flow-node-subflow-flow-alias` (:1421) | objectstack-ai#4278 | reconcile the
schemaless nodes' forms with their executors and check `subflow`; its
bare `flowName ?? flow` fallback graduates (body (closed completed, no
comments) + conversion docblock) | subflow flow-node config key 'flow' →
'flowName' (an undeclared spelling the executor accepted through a bare
fallback, found when the schemaless nodes were reconciled with their
executors; it graduates into this layer) |
| `flow-node-connector-config-lift` (:1529) | objectstack-ai#4045 | executor reads
only `connectorConfig`; the descriptor schema rooted the triple at
`config`, so the Studio form wrote unread keys; descriptor stops
publishing, stored loose keys lift (5132926517, 5138487536) |
connector_action flow-node loose config keys 'connectorId' / 'actionId'
/ 'input' → the declared `connectorConfig` block (the executor reads
only that block; the published designer form had been writing these keys
where nothing read them) |
| `flow-node-script-config-aliases` (:1641) | objectstack-ai#3796 | as above:
open-coded `??` fallbacks graduate into the D2 layer (5125152179) |
script flow-node config keys 'functionName' → 'function', 'input' →
'inputs' (executor `??` fallbacks, graduated into this layer) |
| `app-dead-authoring-keys-removed` (:1742) | objectstack-ai#4001, objectstack-ai#4509, objectstack-ai#4667, objectstack-ai#4709
| liveness audits: keys unread or wrongly encoded are removed; objectstack-ai#4709:
the "no shell read homePageId" premise was false, ruling B keeps the
retirement (an ID cross-reference that dangles is the wrong encoding)
(5158421901 (objectstack-ai#4509), 5159774792 (objectstack-ai#4667), 5164227920 (objectstack-ai#4709 ruling B)) |
app keys
'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId'
plus contextSelectors 'includeAll'/'placement' and areas 'order' removed
(liveness audits found each one unread or wrongly encoded; sharing/embed
declared a public surface no route enforced, mobileNavigation was fully
unimplemented, includeAll was deliberately disobeyed because an 'All'
row would clear a mandatory scope, homePageId WAS read by objectui's
console before v17 but encoded the landing page as an ID cross-reference
that silently fell back when it dangled — the landing page is the first
nav item (the first retirement record said nothing read it, a premise
since corrected; the retirement stands), and no renderer ever sorted
areas) |
| `app-area-fail-open-gates-removed` (:1853) | objectstack-ai#4651 | ruling B: remove
both keys; removing a fail-open gate is strictly safer than keeping it;
prescription names the two enforced layers (5160072589) |
navigation-area keys 'visible'/'requiredPermissions' removed (ADR-0049 —
FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or
permission-gated area was served and rendered to every user, while the
identically named keys on a navigation ITEM and on the APP are enforced;
gate the items inside the area, or gate the app) |
| `permission-rls-priority-removed` (:1955) | objectstack-ai#3896 | the objectstack-ai#3896
security-audit line: rls.priority promised conflict resolution that
cannot exist (policies OR-combine) and had no reader; removed (card body
+ commits d6bfb3d (objectstack-ai#3990), eb95d97 (objectstack-ai#3998)) | RLS-policy key
'priority' removed (a security audit found no reader: policies
OR-combine, so the promised conflict-resolution semantics cannot exist;
dropping it changes no outcome) |
| `tool-inert-authoring-keys-removed` (:2024) | objectstack-ai#3896 | the audit
close-out removes the four inert tool keys (permissions gated nothing,
active:false withdrew nothing) (commit eb95d97 (objectstack-ai#3998)) | tool keys
'category'/'permissions'/'active'/'builtIn' removed (authorable and
inert, so removed under ADR-0049 enforce-or-remove; permissions gated
nothing, active:false withdrew nothing) |
| `action-inert-keys-removed` (:2137) | objectstack-ai#3896 | close-out sweep:
enforce-or-remove worklist, fourteen inert authoring keys leave the
surface (commit 12a19a8 (objectstack-ai#4054)) | action keys
'shortcut'/'bulkEnabled' removed (inert, removed under ADR-0049
enforce-or-remove: no keydown path dispatches shortcuts; the
multi-select toolbar reads the view's bulkActions) |
| `flow-inert-keys-removed` (:2162) | objectstack-ai#3896 | as above (commit
12a19a8 (objectstack-ai#4054)) | flow keys 'active'/'template', node 'outputSchema'
and errorHandling 'fallbackNodeId' removed (inert, removed under
ADR-0049 enforce-or-remove: active:false never stopped a flow; status is
the enforced lifecycle) |
| `view-inert-keys-removed` (:2221) | objectstack-ai#3896 | as above (commit
12a19a8 (objectstack-ai#4054)) | view keys removed as inert (ADR-0049
enforce-or-remove): list 'responsive'/'performance', form
'defaultSort'/'aria' — no renderer read them (list aria/data and form
data stay live) |
| `view-list-passthrough-keys-removed` (:2267) | objectstack-ai#7176 | maintainer
ruling: retire under ADR-0049; pass-through-only reads are dead in
effect (5236139723) | view list keys removed:
'striped'/'bordered'/'virtualScroll' — every measured reader copied the
key forward and none applied it (a key that is only passed through is
dead in effect; ADR-0049 enforce-or-remove) |
| `view-export-options-pdf-removed` (:2335) | objectstack-ai#8010, objectstack-ai#1301 | option A;
`pdf` leaves the enum (honest narrowing, not a runtime console.warn);
PDF export declined (5270998514; objectstack-ai#1301 is not planned) | list-view
export format 'pdf' removed (PDF export was declined as not planned, and
ObjectGrid dropped the declared format from the menu with only a runtime
console.warn; an honest enum replaces that warning) |
| `dashboard-inert-keys-removed` (:2404) | objectstack-ai#3896 | close-out sweep (as
above) (commit 12a19a8 (objectstack-ai#4054)) | dashboard keys 'aria'/'performance'
and widget 'performance' removed (inert, removed under ADR-0049
enforce-or-remove: no renderer applied any of them) |
| `dashboard-widget-responsive-removed` (:2474) | objectstack-ai#4876, objectstack-ai#11027 | objectstack-ai#4876
ruling A: retire widget `responsive` (no reader); objectstack-ai#11027 ruling B:
retire `page.components[].responsive`, equally unread (5169512655;
5380752244) | dashboard widget key 'responsive' removed (no renderer
ever applied per-widget breakpoint overrides; the
page.components[].responsive key this entry once deferred to was
measured equally unread and retired at protocol 18) |
| `dashboard-widget-action-aria-removed` (:2555) | objectstack-ai#5010 | retire the
four dead widget keys; colorVariant kept (body ruling + 5179556002) |
dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria'
removed (no renderer ever drew a per-widget action button, and widget
ARIA attributes never reached the DOM; use header.actions[] and the
widget title/description) |
| `dashboard-widget-compareto-converged` (:2652) | objectstack-ai#5011 | converge
`compareTo` on the implemented executor contract `{ kind, dimension? }`;
`1y` rewrites, other offsets delegated (5173559485) | dashboard widget
'compareTo' converged on the executor's { kind, dimension? } contract
(the shape the dataset executor implements; the bare strings and {
offset: '1y' } rewrite mechanically; other { offset } durations have no
faithful target and are reported, not guessed) |
| `agent-knowledge-removed` (:2726) | objectstack-ai#3896 | close-out sweep (as above)
(commit 12a19a8 (objectstack-ai#4054)) | agent key 'knowledge' removed (inert,
removed under ADR-0049 enforce-or-remove: declaring sources/indexes
never scoped retrieval; restrict at the knowledge-service level) |
| `skill-trigger-phrases-removed` (:2744) | objectstack-ai#3896 | close-out sweep (as
above) (commit 12a19a8 (objectstack-ai#4054)) | skill key 'triggerPhrases' removed
(inert, removed under ADR-0049 enforce-or-remove: activation is
triggerConditions + the agent's skills[] allowlist; phrases were a
dead-end projection) |
| `stack-api-require-auth-removed` (:2782) | objectstack-ai#3963 | delete the
`api.requireAuth` opt-out; anonymous always denied; public surfaces
derive authorization from a declaration (form, share link, book
audience) (body (decision recorded in body)) | stack key
'api.requireAuth' removed — anonymous access is always denied; publish
public surfaces by declaration (a public form, a share link or
`book.audience: 'public'`), which replaced the deployment-wide opt-out |
| `flow-node-wait-timeout-keys-removed` (:2864) | objectstack-ai#4158 | wait never had
a timeout: withdraw the contract (route B), `timeoutMs` moves to
`timerDuration` (body (closed completed) + conversion docblock) |
waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its
only reader used it as the duration) and 'onTimeout' (removed — zero
readers, so no timeout ever fired): wait never had a timeout, so its
timeout contract is withdrawn rather than built |
| `datasource-inert-blocks-removed` (:2944) | objectstack-ai#4583 | all 20 dead
datasource keys removed; each job already has a different live mechanism
(5157934690) | datasource keys 'retryPolicy'/'healthCheck' and external
'label'/'requirePermission' removed (nothing retried, nothing probed on
a schedule, and the federation label/permission were read by nobody;
each of those jobs already has a live mechanism) |
| `mapping-inert-keys-removed` (:3032) | objectstack-ai#4509 | the three mapping keys
retire (schema defaults made authorWarn impossible; removal is the only
signal) (5158421901, 5158744185) | mapping keys
'extractQuery'/'errorPolicy'/'batchSize' removed (no exporter reads a
mapping, error handling belongs to the import request, and the write
path sizes its own batches) |
| `book-translations-removed` (:3087) | objectstack-ai#4667 | six dead authorWarn keys
retired (5159774792) | book keys 'translations' (book-level and
group-level) removed (no resolver read them; the tree endpoint and
portal render labels verbatim, so a localized book served its authoring
locale to everyone). Localize the docs instead: `doc.translations` is
live |
| `job-id-removed` (:3149) | objectstack-ai#4667 | as above (5159774792) | job key
'id' removed (nothing read it; `name` is the job's identity everywhere,
so two jobs differing only in `id` were the same job, and the key's own
description advertised an override that did not exist) |
| `translation-validation-messages-removed` (:3206) | objectstack-ai#4667, objectstack-ai#3778,
objectstack-ai#14381 | objectstack-ai#4667 retire; objectstack-ai#3778 legacy-key table had pointed `errors` at
it; objectstack-ai#14381 an object-scoped key ships with its reader (ADR-0049
enforced) (5159774792; objectstack-ai#3778 body; 5503980929) | translation key
'validationMessages' removed (no resolver read it, so a translated rule
message was stored and never shown; the legacy-key table of the
translation-bundle migration had been steering retired `errors:` authors
into it). Author the message on the rule itself
(`object.validations[].message`), and translate it under the
object-scoped group
`objects.OBJECT_NAME._validations.RULE_NAME.message`, which the write
path resolves (17.3.0, a translation key shipped together with its
reader) |
| `datasource-capabilities-removed` (:3264) | objectstack-ai#4583 | as above
(5157934690) | datasource key 'capabilities' removed (eleven flags no
code read; pushdown comes from the driver's own supports.*, and
`readOnly` never made anything read-only) |
| `datasource-read-replicas-removed` (:3319) | objectstack-ai#4468 | remove:
read-replica routing is an unbuilt feature (5150771330) | datasource key
'readReplicas' removed (no driver opened a replica connection and no
query path splits reads from writes; front replicas behind one endpoint
and point `config` at it) |
| `datasource-config-driver-key-aliases` (:3419) | objectstack-ai#4456 | the factory's
undeclared `??` fallbacks graduate to a D2 entry and are deleted from
the reader (5157922838) | datasource config keys → canonical per driver:
sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString'
→ 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' →
'username' (undeclared driver-factory `??` fallbacks, graduated into
this layer and deleted from the reader) |
| `flow-node-script-branch-keys-removed` (:3665) | objectstack-ai#4343 | operator
ruling: `script` converges to a pure function-call node; the five branch
keys retire (5151704360) | script flow-node config keys 'actionType' (→
'function' when it was shorthand for one; otherwise removed —
'email'/'slack' were logger-backed stubs that delivered nothing), plus
'template' / 'recipients' / 'variables' (fed those stubs) and 'script'
(inline JS the runtime never executed); script is now a pure
function-call node, the only path that ran real logic |
| `object-managed-by-system-to-system-data` (:3762) | objectstack-ai#3355 | retire
`system`, new value `system-data` (not `platform-data`) (5157022965) |
object managedBy 'system' → 'system-data' (ADR-0103's residual bucket
named the engine-owned half v16 had already moved out to `engine-owned`;
the rename leaves the name describing what the bucket actually holds:
admin/user-writable platform data) |
| `object-enable-trash-mru-removed` (:3829) | objectstack-ai#3207, objectstack-ai#2377 | remove
`enable.trash` / `enable.mru`; soft delete stays parked; last slice of
the dead-property removals (5156966571, 5161298967; 5051634768) | object
capability flags 'enable.trash'/'enable.mru' removed (the last slice of
the dead author-facing property removals: no recycle bin and no MRU
tracking ever ran; both default-true flags gated nothing) |
| `object-index-type-partial-removed` (:3912) | objectstack-ai#5248, objectstack-ai#4943 | remove
both index keys; no DDL consumer; return enforce-first on real demand
(5199336983; 5194762679 (duplicate)) | object index keys
'indexes[].type'/'indexes[].partial' removed (no driver ever read
either: the index method is the dialect's choice and a partial index is
built by a database-layer migration, not declared) |
| `retry-policy-converged` (:4070) | objectstack-ai#4661, objectstack-ai#4964 | one RetryPolicy
declaration, `backoffMs`, merged default 0 / 1 with pre-17 job defaults
written out; flow.errorHandling joins, default 0 (silent retry can
double-write) (5158710540 (analysis); 5173148383) | retry policy unified
across job.retryPolicy, try_catch retry and flow.errorHandling: base
delay 'retryDelayMs' → 'backoffMs', and the pre-17 job defaults
(maxRetries 3, backoffMultiplier 2) written out explicitly now that the
merged default is 0 / 1: two declarations that differed only by accident
became one, and retry is opt-in because a retry replays whatever the
attempt already did |
| `hook-body-crypto-hash-removed` (:4249) | objectstack-ai#4391 | remove the
capability token and its build-time inference (5156969500) | script-body
capability token 'crypto.hash' removed (the sandbox never installed
ctx.crypto.hash, so the token granted a call that always threw; the CLI
inferred it too) |
| `dataset-measure-array-string-agg-removed` (:4419) | objectstack-ai#6188 | retire
`array_agg` / `string_agg`; keep and enforce `count_distinct`
(5219849918) | dataset measure aggregates 'array_agg' / 'string_agg'
removed (no SQL backend compiled them and the v1 dataset runtime refused
them by name, so a measure declaring one never produced a value; the
measure is dropped, and with it any derived measure left referencing it)
|
| `connector-rate-limit-config-removed` (:4544) | objectstack-ai#4911 | outbound
rate-limit vocabulary removed: no engine exists (implementation-first)
(body ruling + 5169405647) | connector key 'rateLimitConfig' removed (no
outbound rate-limiting engine exists; the runtime's only token bucket
limits INBOUND requests, so every knob here was inert while reading like
a configured cap. The whole ConnectorRateLimitConfig shape went with it)
|
| `field-mapping-transform-removed` (:4669) | objectstack-ai#5552, objectstack-ai#3278 |
enforce-or-remove: all five members dead, the union retires; `js`
dialect was retired as redundant with the L2 script body (5199338349;
objectstack-ai#3278 body) | field-mapping key 'transform' removed (the whole
five-member FieldMappingTransform union went with it: no runtime ever
executed constant/cast/lookup/javascript/map, and the javascript member
advertised dialect="js", a dialect already retired because JavaScript
belongs in a script body. The enforced transform pipeline is the import
mapping's string-enum `mapping.fieldMapping[].transform`, which is
unaffected) |
| `theme-inert-token-scales-removed` (:4786) | objectstack-ai#5021 | retire all nine
token groups; re-declare under `customVars` (5175091297) | theme keys
'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing',
'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed
(ADR-0049 — the engine emitted --font-size-*, --font-weight-*,
--line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*,
--font-heading and --font-mono faithfully, and no first-party component
or stylesheet has ever read one. Re-declare any variable you actually
consume under customVars, which emits it verbatim) |
| `page-header-subtitle-alias` (:4940) | objectstack-ai#3226 | route B: a D2
conversion rewrites `description` → `subtitle`; the consumer's bare `??`
retires (5160118898, 5194297145) | page-header component prop
'description' → 'subtitle' (the off-spec spelling a renderer tolerated
through a bare `subtitle ?? description` fallback; `subtitle` is the
declared key, and the fallback retires) |
| `record-picker-display-field-to-label-field` (:5165) | objectstack-ai#5775 |
direction A: `labelField` (the delivered spelling) becomes canonical;
`displayField` retires via conversion (5202137085) | record-picker
component prop 'displayField' → 'labelField' (the required key no
renderer read; `labelField ?? 'name'` is what renders the row, so the
delivered spelling became the declared one) |
| `record-picker-inert-keys-removed` (:5287) | objectstack-ai#5775 | `searchFields` /
`multiple` retire (zero readers) (5202137085) | record-picker component
props 'searchFields'/'multiple' removed (the control is a plain
single-select with no search box; neither key had a reader) |
| `page-card-body-to-children` (:5414) | objectstack-ai#5775 | `children` is the one
composition key (5202137085) | page:card component prop 'body' →
'children' (one composition key across every container; the card
renderer already reads both) |
| `inline-action-api-params-to-body-extra` (:5582) | objectstack-ai#5777 | direction
A: the static payload gets its own key (`bodyExtra`); `params` keeps one
meaning (5202138112, 5228796853) | inline type:'api' action prop
'params' (object form) → 'bodyExtra' (a static payload and a parameter
definition are two things, so the payload gets its own key; `params`
stays the ActionParam[] definition array) |
| `page-tabs-type-to-tab-style` (:5847) | objectstack-ai#6776 | Route A: rename to the
spelling the renderer reads (5229120747, 5229693342) | page:tabs
component prop 'type' → 'tabStyle' (a props key named `type` collides
with the node's dispatch key and is unauthorable in flat/JSX carriers;
`tabStyle` is the spelling the renderer reads in all of them) |
| `page-structure-inert-keys-removed` (:6035) | objectstack-ai#6946 | retire three
zero-reader UI keys (body (maintainer ruling) + 5232767409) |
page:header prop 'icon' and page:card prop 'actions' removed (neither
has a renderer read point in objectui; the header resolves icons per
action and the card renders title/children/footer only) |
| `record-details-layout-removed` (:6201) | objectstack-ai#6946 | as above (as above)
| record:details component prop 'layout' removed (the declared
auto\|custom modes were never implemented; the renderer branches only on
inline\|compact, values the schema never permitted, so both legal values
selected nothing) |
| `app-hidden-to-unpublished` (:6343) | objectstack-ai#4829 | A1: a machine-managed
key carries the publish gate; `hidden` back to navigation presentation
only; ADR-0045 amended (5173161521) | stored app publish gate 'hidden' →
'_unpublished' (ADR-0045 amended — `hidden` carried BOTH the publish
gate and 'keep out of the App Switcher', so the built-in Account app was
withheld from every non-builder; the gate is now the machine-managed
`_unpublished`, and `hidden` is navigation presentation only, never an
access gate. Stored rows only — an authored `hidden: true` is left
untouched) |
| `action-global-nav-location-removed` (:6456) | objectstack-ai#6888 | direction 2:
retire `global_nav` (no demand; the designer previewed a surface the
product lacks) (5229990375) | action location 'global_nav' removed (no
running-app surface rendered it; the ⌘K palette reads no action
metadata, while the Studio designer previewed a command-palette frame
for it. The value is stripped and the key kept, so an action left with
no location becomes the documented headless shape `locations: []`) |

Descriptions:

| site | cited | decision as read (record) | change |
|---|---|---|---|
| `data/field.zod.ts` `autonumberFormat` | objectstack-ai#6555 | route 3: `{0000}` is
a declared contract default, and both hand-written fallbacks read it
(5225535766, family done 5240072006) | "⇒ the contract default `{0000}`,
which every driver and the engine fallback read, so one field numbers
alike on every backend." |
| `api/plugin-rest-api.zod.ts` `GET /:type/:name/layers` | objectstack-ai#5882 |
ruling B: its own `/layers` path and schema, one route one shape
(recorded 5216370790) | "…hence its own path and its own response
schema, since one route answers one shape." |
| `api/plugin-rest-api.zod.ts` `POST /:type/:name/publish` | objectstack-ai#7294 |
declare the served publish route's response, the save door's discipline
(5237410722) | "The route was served for a long time with no declaration
behind it — this entry is what makes its response contract nameable, the
same declared-equals-returned rule the save door follows." |

## Text-only proof (A4)

Stage 3's AST-skeleton + string-text tool (`skeleton.cjs`, one line
changed: the TypeScript 6.0.3 load path), BASE `1ac7308d7a` against this
branch: **3 of 3 SAME** on both legs, exit 0 each — `registry.ts` 62346
tokens, 4920 string groups, 56 changed; `field.zod.ts` 8682 / 581 / 1;
`plugin-rest-api.zod.ts` 4912 / 475 / 2 (one literal re-split into three
`+` pieces, which the skeleton reads as one string); parse diagnostics 0
/ 0. Every changed group carried an id before and carries none after;
every other string is byte-identical. Controls on scratch copies of the
head `registry.ts`, each mutation counted on disk first: an identifier
rename → DIFF exit 1; `!==` flipped to `===` → DIFF exit 1; one summary
re-split into two `+` operands → SAME exit 0; a `surface` string (never
carried an id) changed → text leg VIOLATION exit 1. No repo file was
mutated for the controls.

Census after the edit (stage 3's instrument, unchanged): non-test 219 →
160 messages, 429 → 358 ids; class (b) conversion summaries 91 / 108 →
35 / 40 (all toMajor 18); descriptions 3 / 3 → 0.

## Pins and quotes (A6)

- **Tests:** no test asserts a changed summary or description phrase.
The id-bearing fragments and the distinctive phrases of all 59 messages
were searched across every `*.test.*` / `*.spec.*`; the hits are other
files' own prose with their own citations (test titles and comments such
as `(objectstack-ai#3896 close-out)` in `view.test.ts`), not quotes of a summary.
- **`content/docs/**`:** the only quotes are the three generated
`references/**` pages, regenerated.
`content/docs/releases/v17/17-0.mdx:1052` repeats one action-key phrase
with its own `(objectstack-ai#3896 close-out)`; it is release-owned and untouched.
- **`skills/**`:** no quote of any changed text.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at `db81973cf6` (9 paths, 493 changed lines): 105 commands,
each run from the worktree with its exit code written before any pipe;
`--ran` → "✓ dispatch-gates --ran: 105 derived famil(ies) accounted for
— 105 run, 0 NOT-MEASURED". Three first exited 3 (prerequisite: unbuilt
lint / client packages) and were re-run green after the full package
build (71 tasks). Plus `@objectstack/spec` build, `check:generated`
("All 15 generated artifacts are up to date"), the package `test`
project (603 files, 17845 passed), 48 of the 51 `test:repo` files, and
`typecheck`. Details are in the report on the card.

## Acceptance notes

- **The `objectstack-ai#3896` citations.** Eight summaries cited `objectstack-ai#3896` as an "audit"
or a "close-out". The card's own body is the sharing-rule REST finding
that opened that security-audit line; the decisions the summaries cited
(remove `rls.priority`, the four inert tool keys, then the fourteen-key
enforce-or-remove sweep) are recorded in the landed commits
`d6bfb3d0ab`, `eb95d97c02` and `12a19a88a5`. Each summary already said
why its key went, so the new words name the rule (ADR-0049
enforce-or-remove) rather than the card.
- Comments in `conversions/registry.ts` still carry tracker ids; they
belong to objectstack-ai#20234's comment stages and are untouched. So is
`migrations/registry.ts`.
- `docs/protocol-upgrade-guide.md` is not a governed surface
(`check-governed-merges`' register).
- **Hot file:** open PR objectstack-ai#21547 (objectstack-ai#21459) also edits
`conversions/registry.ts`: it adds one toMajor-18 entry and its ordering
row, in a region this PR does not touch. A local `git merge-tree` of the
two heads is clean (the file is hand-written, so no merge driver is
involved). Neither PR's spec-changes / upgrade-guide output includes the
other's entries, so whichever lands second merges `main` and regenerates
them.

## Next stage: the 35 toMajor-18 summaries (file:line at this head · ids
· conversion)

- `registry.ts:6550` objectstack-ai#8321 `field-malformed-scale-precision-removed`
- `registry.ts:6658` objectstack-ai#8762 `record-chatter-position-vocabulary`
- `registry.ts:6777` objectstack-ai#9198 `element-input-target-variable-removed`
- `registry.ts:7024` objectstack-ai#9220 `element-filter-removed`
- `registry.ts:7177` objectstack-ai#9249 `element-form-removed`
- `registry.ts:7355` objectstack-ai#15178,objectstack-ai#19620
`translation-per-app-settings-removed`
- `registry.ts:7609` objectstack-ai#9249 `translation-component-submit-label-removed`
- `registry.ts:7782` objectstack-ai#3951,objectstack-ai#9227 `field-column-lists-canonicalized`
- `registry.ts:7909` objectstack-ai#10414 `metric-filters-removed`
- `registry.ts:8104` objectstack-ai#17296 `cube-sub-day-granularities-removed`
- `registry.ts:8237` objectstack-ai#18612 `cube-join-sql-and-relationship-removed`
- `registry.ts:8502` objectstack-ai#10054 `record-highlights-field-icon-removed`
- `registry.ts:8759` objectstack-ai#11027 `page-component-responsive-removed`
- `registry.ts:8860` objectstack-ai#11805 `object-grid-default-sort-removed`
- `registry.ts:9047` objectstack-ai#21445 `object-grid-resizable-columns-removed`
- `registry.ts:9250` objectstack-ai#17260 `object-kanban-quick-add-removed`
- `registry.ts:9563` objectstack-ai#12497,objectstack-ai#1883
`permission-allow-restore-purge-removed`
- `registry.ts:9881` objectstack-ai#6837 `field-reference-to-alias`
- `registry.ts:10301` objectstack-ai#14478 `hook-timeout-to-timeout-ms`
- `registry.ts:10342` objectstack-ai#14478 `job-timeout-to-timeout-ms`
- `registry.ts:10946` objectstack-ai#14478
`api-endpoint-cache-ttl-to-cache-ttl-seconds`
- `registry.ts:11015` objectstack-ai#14478
`dashboard-refresh-interval-to-refresh-interval-seconds`
- `registry.ts:11376` objectstack-ai#14478
`memory-persistence-auto-save-interval-to-ms`
- `registry.ts:11600` objectstack-ai#14478 `turso-config-timeout-to-timeout-ms`
- `registry.ts:11690` objectstack-ai#17063 `view-page-mount-removed`
- `registry.ts:11795` objectstack-ai#17053,objectstack-ai#8221
`list-view-sort-string-clause-to-array`
- `registry.ts:12295` objectstack-ai#19054 `object-tenancy-organization-field-removed`
- `registry.ts:12405` objectstack-ai#20085 `view-item-owner-hidden-removed`
- `registry.ts:12549` objectstack-ai#20230 `view-overlay-owner-hidden-removed`
- `registry.ts:13137` objectstack-ai#6206,objectstack-ai#17321
`page-component-filter-record-to-rule-array`
- `registry.ts:13392` objectstack-ai#20161 `report-joined-chart-removed`
- `registry.ts:13657` objectstack-ai#20221 `form-layout-inline-grid-to-vertical`
- `registry.ts:13813` objectstack-ai#19992 `currency-config-precision-removed`
- `registry.ts:13917` objectstack-ai#20321 `permission-rls-tags-removed`
- `registry.ts:14056` objectstack-ai#15429 `flow-decision-mode-inclusive-explicit`

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

---------

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels)

1 participant