Skip to content

[P3] Retire the three deprecated aliases — via the ADR-0087 D2 conversion layer, not by deleting the keys #3855

Description

@os-zhuang

Filed per Prime Directive #10 rather than expanding #3838 / #3854.

Revised. The first version of this issue proposed deleting the keys from the Zod objects and adding UNKNOWN_KEY_GUIDANCE tombstones. That is wrong on both halves — see "The trap" below. The corrected plan is the conversion layer.

Where the three aliases stand

The spec carries three fold-and-drop aliases. Each is accepted on input, lowered into its canonical key, and dropped from the parsed output:

Alias Canonical Transform Since
action.execute action.target ui/action.zod.ts:683 #3713 / #3742
field.conditionalRequired field.requiredWhen data/field.zod.ts:622 #3754 / #3764
agent.knowledge.topics agent.knowledge.sources ai/agent.zod.ts:43 #1878 / #1891

Two things have landed for all three: the conflict is unrepresentable on the parsed output (#3742 and siblings), and the discard is no longer silent (#3838, #3854). What is missing is a retirement date — all three say "will be removed in a future version" with no version named.

The trap: deleting the key is a silent strip, not an error

None of the three schemas is .strict().

So "delete the key from the Zod object" does not produce an error — it produces a silent strip. An author still writing conditionalRequired after the removal gets: parses clean, key vanishes, field never required. That is precisely the failure mode #3713#3743#3838#3854 exists to eliminate, reintroduced one layer down.

And UNKNOWN_KEY_GUIDANCE does not cover it. That map lives on ObjectSchema.create() (data/object.zod.ts:1243, rejection at :1365) and only governs object top-level keys. There is no equivalent for field-level, action-level, or agent-knowledge-level keys. The AGENTS.md checklist item "add a tombstone entry in the relevant UNKNOWN_KEY_GUIDANCE map" has no relevant map for two of the three — building the unknown-key rejection would be a prerequisite, not a step.

The right mechanism: the ADR-0087 D2 conversion layer

The conversion layer exists for exactly this, and its contract is lossless / declared / loud / expiring:

  • toMajor: N → the loader accepts the N−1 shape and rewrites it to canonical at load; retiresIn = toMajor + 1 (conversions/apply.ts:71).
  • Every application emits a structured ConversionNotice.
  • The registry already carries entries for majors 11 / 13 / 14 / 15, five of them marked retiredFromLoadPath — the cycle demonstrably completes.
  • spec-changes.json (D4) is derived from this registry plus the api-surface diff (migrations/spec-changes.ts:22), so the generated upgrade guide and the spec_changes MCP tool pick the entry up with no extra authoring.

This is the same graduation Prime Directive #12 describes for filtersfilter: rewritten to the canonical key at load, so no consumer-side fallback survives.

Current version is @objectstack/spec 16.1.0 / PROTOCOL_VERSION 16.0.0, so the entries are toMajor: 17.

Two things that must be handled, not assumed

1. renameKey goes silent on the conflict case. conversions/walk.ts:113 returns null when the canonical key is already present ("canonical already wins — nothing to do"), leaving the alias key in place — where the non-strict schema then strips it silently. A plain renameKey conversion therefore re-creates the exact bug #3838/#3854 fixed. Each conversion needs a custom apply() covering three cases: alias only → rewrite + notice; both with different values → drop the alias + notice naming both (this is where the #3743 warning relocates); both with the same value → drop quietly.

2. lintDeprecatedAliases goes blind, and then has no subject left. Conversions run inside normalizeStackInput, and the pass reads normalized — i.e. after conversions. The moment an alias graduates, its rule stops firing. Retire each rule together with its alias, in the same change; a rule that silently stops firing is the #3782 dead-gate class. Since all three aliases graduate together, lintDeprecatedAliases retires entirely — its wiring (warnDeprecatedAliases in defineStack, the CLI pre-parse pass) is repurposed as the conversion-notice sink rather than deleted.

3. os build currently drops conversion notices. compile.ts:96 calls normalizeStackInput(config) with no options while validate.ts:85 passes onConversionNotice. defineStack passes none either. Today that is cosmetic; once the aliases live in the conversion layer it is the only warning channel, and two of the three surfaces are deaf. Same #3782 parity class — must be closed in the same change.

Blast radius

Zero authored usage of any of the three aliases in this repo (verified by grep across examples/ and packages/*/src; the apparent hits are an unrelated automation-engine interface and the GitHub-connector / message-queue topics schemas). The frozen downstream-contract fixture does not exercise them either.

The part that needs a release owner, not a patch

Removing the keys narrows z.input for three schemas. Per ADR-0059 §4 that is by definition a breaking change — "restore it, or bump @objectstack/spec to a new major and document the migration … in the same PR". Two notes on the gates:

  • The api-surface snapshot is name+kind of exports, not schema keys, so removing an object key does not trip it. ADR-0059 §5 acknowledges the snapshot "catches add/remove/rename but not every narrowing", and the frozen fixture does not exercise these keys — so neither automated witness will stop this change. That makes the migration text the only real protection, not less important but more.
  • Retiring lintDeprecatedAliases does remove exports (lintDeprecatedAliases, formatDeprecatedAliasFinding, and the three rule-id consts), which trips the freeze contract mechanically and requires the snapshot regenerated in the same PR.
  • PROTOCOL_VERSION is asserted in lockstep with package.json major (protocol-version.test.ts), so it is bumped by the release, not by hand in a feature PR. The major changeset is what drives it.

ADR-0085 set the precedent for who decides: "Release owner decides major vs. exception at version time."

Concrete plan for 17

  1. Delete the three keys and their transform branches (action.zod.ts:683, field.zod.ts:622, agent.zod.ts:43).
  2. Add three toMajor: 17 conversions with custom apply() handling the conflict case (see Add metamodel interfaces for ObjectQL/ObjectUI contract #1 above).
  3. Retire lintDeprecatedAliases; repurpose its defineStack and CLI wiring as the conversion-notice sink.
  4. Close the notice-channel gap in compile.ts and defineStack.
  5. Regenerate api-surface.json (removed exports) and add a major changeset carrying FROM → TO and the one-line fix per alias — this ships as CHANGELOG.md in the npm package and is what an upgrading agent greps.
  6. Update the three docs surfaces that describe the aliases (protocol/objectui/actions.mdx, data-modeling/fields.mdx, ai/agents.mdx).

Note that the original suggestion "promote the lint rules from warning to error one major before removal" is dropped: the conversion notice is an equivalent signal that also expires on its own, so the manual promote-then-delete cycle buys nothing.

Refs #3713, #3742, #3743, #3754, #3764, #1878, #1891, #3838, #3854, ADR-0059 §4/§5, ADR-0087 D2/D4, ADR-0104.

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:p3Low: nice-to-have, backlog

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions