Skip to content

spec: export the artifact envelope's declared top-level key set — downstream seams can derive the collection half and must hand-copy the rest #14877

Description

@baozhoutao

Found while implementing objectstack-ai/cloud#1888 (artifact assembly silently dropped metadata.packages[]). Filed, not fixed — out of that card's scope, and the change belongs in this repo.

Measured on framework 655b106 (the pin cloud main carries) and cloud main @ b16b3d3, both read-only worktrees.

The asymmetry

@objectstack/spec exports PLURAL_TO_SINGULAR and METADATA_ALIASES, so a downstream consumer can ask the contract "which keys are metadata collections?" and get an answer that stays correct as the spec evolves. cloud uses exactly that:

cloud/packages/service-cloud/src/cloud-artifact-helpers.ts
  export const KNOWN_METADATA_CATEGORIES = new Set([
      ...Object.keys(PLURAL_TO_SINGULAR),
      ...Object.keys(METADATA_ALIASES),
      ...CLOUD_ONLY_METADATA_CATEGORIES,
  ]);

That derivation was bought by an incident (cloud#897: a hand-copied category list drifted after ADR-0090 D3 renamed roles to positions, and every hosted publish silently dropped positions[]).

There is no equivalent export for the artifact envelope's NON-collection top-level keysmanifest, requires, packages, and whatever comes next. COMPOSE_KEY_DISPOSITIONS (packages/spec/src/stack.zod.ts:839) is the one place that enumerates every top-level key of ObjectStackDefinitionSchema with its composition rule, and it is module-private:

git -C objectstack grep -n "COMPOSE_KEY_DISPOSITIONS" -- packages/spec/src/index.ts   # no hits

ObjectStackDefinitionSchema itself is exported, but it is a lazySchema(() => strictObject({...}).superRefine(...)), so its key set is not reachable without digging into zod internals — not a contract a consumer should be reading.

What that costs, concretely

cloud#1888: packages (ADR-0130 D4, stack.zod.ts:1237) is declared on the artifact envelope, produced by composeStacks([...], { manifest: 'preserve' }), written by os compile, and read at load by resolveArtifactPackageOrder at the metadata door. The cloud seam's merge dropped it, because the only list that could have carried it is hand-maintained. The consequence on the hosted path was the duplicate-ownership state objectstack#14599 had already repaired at the door — every item of a multi-package artifact registering under one arbitrary member's package id — re-created downstream and visible nowhere.

The class already has five members on the cloud side (positions, requires, data, datasets, packages). The collection half of it was closed structurally in 2026; the non-collection half is still a list someone has to remember to edit.

And there is a sixth on the way: #14865 will add a top-level grantedPermissions key to the artifact envelope. With an exported key set it arrives at every downstream seam for free. Without one it is another hand edit in another repo, discovered the same way.

What would close it

Some export whose content is derived from the schema rather than transcribed. Options, cheapest first — the choice is the spec owner's, not this issue's:

  1. Export COMPOSE_KEY_DISPOSITIONS (or a Readonly view of it). It already exists, already enumerates every top-level key, and CONCAT_ARRAY_FIELDS is already derived from it inside the module, so there is a precedent for it being the source of truth. Cheapest, and carries composition semantics a consumer may also want ('concat' tells cloud that concatenating packages across bundles is correct, which today is knowledge the cloud comment has to assert from reading this file).
  2. Export a narrower derived set — e.g. STACK_DEFINITION_TOP_LEVEL_KEYS, or an array-valued subset — if exposing the disposition table is more surface than wanted. Must be derived from the same table, never a second literal, or the export becomes the drift it exists to prevent.
  3. Do nothing and accept the hand-copy, in which case the honest form is a note in stack.zod.ts saying that adding a top-level key obliges an edit at each downstream seam, and naming them. Stating the coupling is worth something even unautomated.

Whichever way it goes, the reason to decide it here rather than in cloud is that the answer is one export in one place, versus one hand-maintained list per consuming repo.

Not blocked on

cloud#1888 is repaired independently in objectstack-ai/cloud#1892 — packages is now a declared passthrough there, and that seam also gained a diagnostic that names any array-valued artifact key it drops without recognising, so the next occurrence is loud rather than silent. This issue is the upstream half that would stop the occurrence instead of reporting it.

Duplicate check

One targeted search_issues over objectstack-ai/objectstack for the exported-key-set question returned 2 issues, neither this one: #14865 (declare grantedPermissions on the artifact schema — the next instance of the problem, not the mechanism) and #5005 (closed — composeStacks dropping non-array top-level keys, the incident that produced COMPOSE_KEY_DISPOSITIONS in the first place). Non-empty result set, so the instrument reached the population. Repo-scoped REST reads work from this container but /search/issues is refused (HTTP 403, sessions bound to configured repositories), and listing every open issue in this repo would be the wide scan the dispatch rules forbid — hence the one semantic search.

Filed unassigned for triage.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions