Skip to content

feat(spec): export COMPOSE_KEY_DISPOSITIONS and STACK_DEFINITION_KEYS — the artifact envelope's top-level key set and each key's composition rule, derivable instead of hand-copied - #16051

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-14877-export-compose-key-dispositions
Sep 5, 2026
Merged

feat(spec): export COMPOSE_KEY_DISPOSITIONS and STACK_DEFINITION_KEYS — the artifact envelope's top-level key set and each key's composition rule, derivable instead of hand-copied#16051
os-project-manager merged 3 commits into
mainfrom
claude/issue-14877-export-compose-key-dispositions

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #14877

Ruling executed

Director seat, comment 5542628978 on #14877 (2026-09-04T15:20Z; maintainer verbatim 「同意」 to decision batch #38 item 3), quoted (the one generic in it is spelled with HTML entities because GitHub's body sanitizer removes angle-bracket tokens from inline code):

Ruled: 1. @objectstack/spec exports a read-only view of the disposition table (Readonly<Record<TopLevelKey, Disposition>>, frozen), plus the derived key set if convenient — ⛔ derived from the same table, never a second literal. Not taken: 2 (a narrower key-set-only export — the fallback if the maintainer had judged the full table too wide; the 'concat' disposition is exactly the knowledge cloud asserts today by reading this file), 3 (prose only — does not change the outcome).

Premise re-verified on origin/main f7db8f4fd: COMPOSE_KEY_DISPOSITIONS was module-private (a bare const, no export; packages/spec/src/index.ts already re-exports everything from ./stack.zod, so nothing else was needed to reach the surface), declared as const satisfies a total record from StackDefinitionKey to ComposeDisposition — total over the schema's key set by construction — and the new pin measures the same thing at runtime off the schema's actual shape: 44 keys, equal in both directions, no key missing on either side (the stop condition did not trigger).

The export — name and shape

packages/spec/src/stack.zod.ts, root entry (@objectstack/spec):

symbol kind shape
COMPOSE_KEY_DISPOSITIONS const Object.freeze over the existing as const satisfies literal — literal-typed, so indexing typeof COMPOSE_KEY_DISPOSITIONS by a key K yields K's disposition, a subtype of the ruling's read-only record from StackDefinitionKey to ComposeDisposition (the pin asserts that assignability at compile time)
STACK_DEFINITION_KEYS const a frozen readonly array of StackDefinitionKey, built as Object.freeze(Object.keys(COMPOSE_KEY_DISPOSITIONS)) — the derived key set, never a second literal
StackDefinitionKey type 'manifest', 'packages', or a key of STACK_DEFINITION_COLLECTIONS_SHAPE — the existing internal alias, now public
ComposeDisposition type 'concat', 'single', 'manifest', 'objects', 'functions' — the docblock now says what each rule does, describe-grade

Why the existing name rather than a new STACK_TOP_LEVEL_KEY_DISPOSITIONS view. The card's naming reference (PLURAL_TO_SINGULAR, METADATA_ALIASES) is SCREAMING_SNAKE describing the map's domain, which COMPOSE_KEY_DISPOSITIONS already is. Exporting the table itself keeps one declaration under one name: the runtime warning composeStacks prints for an undeclared key already tells the author to "Declare what composing it means in COMPOSE_KEY_DISPOSITIONS" (and compose-stacks-key-loss.test.ts pins that wording), every link-tag to it inside the module keeps resolving, and the issue, the ruling and cloud's seam comment all name it. A second view name would put two names on one object and leave the private one in the user-facing message. The derived key set pairs with its type the way this file already does it (ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS with AssembledPackageBodyEnvelopeKey): STACK_DEFINITION_KEYS with StackDefinitionKey.

Literal typing is kept deliberately (M4): AssembledPackageBodyKey is a mapped type over typeof COMPOSE_KEY_DISPOSITIONS that reads each key's literal disposition; a widened record annotation would collapse it to never. Object.freeze over an as const literal preserves those literal types — the built dist/index.d.ts now declares COMPOSE_KEY_DISPOSITIONS as a Readonly object whose members are the per-key literals (readonly manifest: "manifest", readonly packages: "concat", and so on).

CONCAT_ARRAY_FIELDS now derives from STACK_DEFINITION_KEYS (one hop further along the same chain); the readers at the concat pass and the remaining-keys loop are unchanged reads (M3 — the seven compose-stacks-*.test.ts files pass).

Entry placement: stack.zod.ts is on the schema-bearing root entry, where composeStacks and the schema itself already live; the consumer this serves (cloud's service seam) is server-side and already imports PLURAL_TO_SINGULAR from the root. No schema-free entry is added, per the README's standing principle for browser-reachable surfaces — nothing browser-side needs this table.

Pins — packages/spec/src/compose-key-dispositions-export.pin.test.ts (15 tests)

  1. Key-set parity, both directions: sorted Object.keys(COMPOSE_KEY_DISPOSITIONS) equals sorted Object.keys(ObjectStackDefinitionSchema.shape) (each side duplicate-free, count non-vacuously above 40), plus a second test that names the drift per direction (onlyInSchema and onlyInTable both empty); STACK_DEFINITION_KEYS equals the same set.
  2. Frozen: Object.isFrozen on both; assignment to an existing key, assignment of a new key (grantedPermissions), and delete all throw TypeError (ESM strict mode) and leave the table unchanged; push on the key list throws.
  3. Every value is a declared disposition: a total record over ComposeDisposition (tsc reds it if the union moves) is the runtime vocabulary; every disposition is used by at least one key; the table is assignable to the ruling's read-only record shape (compile-time).
  4. The dispositions are the composer's rules: every 'concat' key concatenates in stack order with zero warnings (so the export's concat subset is the private CONCAT_ARRAY_FIELDS the composer walks — 36 keys, packages and requires included); every 'single' key passes through when identical and refuses naming the key when different; manifest / objects / functions are each the single key of their bespoke disposition and behave per their strategy.
  5. Derived and reaching the surface: STACK_DEFINITION_KEYS deep-equals Object.keys(COMPOSE_KEY_DISPOSITIONS) in order; a dynamic import of ./index hands back the same objects (toBe), not copies.

No second literal in-repo. Grepped packages/**, scripts/**, skills/** and .claude/** for the table's keys as literal lists: the hits are per-purpose collection subsets with their own owners and gates (objectql/src/engine.ts METADATA_ARRAY_KEYS and cli/src/utils/stack-collections.ts, both pinned by check:stack-collection-maps; core/src/namespace-resolver.ts; runtime/src/app-plugin.ts), none a copy of the disposition table. packages/runtime/src/artifact-collections.ts already derives packageOwnedCollectionKeys() from the two schemas' shapes; its test's seven-key envelope literal is a pin expectation, left as a pin. Nothing in skills/** or .claude/** copies the table.

Docs line

content/docs/getting-started/examples.mdx, section Composition Pattern (the section that documents composeStacks; packages/spec/README.md has no compose section and the glossary entry was not the place): one paragraph saying COMPOSE_KEY_DISPOSITIONS maps each declared key to its rule, STACK_DEFINITION_KEYS is the derived key set, and a downstream seam derives both from the exports instead of copying a list.

Consumer sketch (cloud's seam — not an edit to cloud)

cloud/packages/service-cloud/src/cloud-artifact-helpers.ts today builds KNOWN_METADATA_CATEGORIES from PLURAL_TO_SINGULAR, METADATA_ALIASES and a cloud-only list, plus a hand-copied five-member passthrough list (positions, requires, data, datasets, packages). The five-member literal becomes one derivation:

const ARTIFACT_CONCAT_KEYS = STACK_DEFINITION_KEYS.filter((key) => COMPOSE_KEY_DISPOSITIONS[key] === 'concat');

— which is also what answers "may I concatenate this across bundles?" without the comment that today asserts it by reading stack.zod.ts. #14865's grantedPermissions arrives there the day the schema declares it.

Changeset

.changeset/spec-compose-key-dispositions-export.md@objectstack/spec minor, additive (two new consts, two new exported types; nothing renamed, narrowed or removed). ADR-0087: check:adr-0087-registration judges only changesets that declare a breaking change (a major bump, a bold BREAKING marker, or a ! summary); this one declares none, so the gate wants no disposition marker and none is written. Cross-links: objectstack-ai/cloud#897, objectstack-ai/cloud#1888, #14865, #14599.

Generated artifacts

packages/spec/api-surface/root.json (+4 entries) and packages/spec/export-origins/root.json (+4) regenerated by check:generated --fix / gen:api-surface after a real build; declaration-map/ unchanged (it covers the domain entries, not the root). gen:schema produced no diff (no authorable key moved). check:generated after regeneration: all 15 artifacts up to date.

Verification (tree f8d52d540)

The report comment on #14877 carries the command list with verdict lines. Local scope, declared: @objectstack/spec build, typecheck (tsc, scripts, test layer) and the full spec vitest suite (479 files, 12853 tests) under the shared verify lock; the 95 derived check:* commands via scripts/pm/dispatch-gates.mjs (--ran reconciliation: 95 derived, 95 run, 0 unrun), of which two are declared to CI as PREREQUISITE NOT MET here — check:dual-build-cjs-loads and check:type-check-debt both need every workspace package built (78 more dist/ directories), the farm CI owns. turbo ls --affected lists 75 packages (everything depends on spec); consumer sweep direction: none — the change is additive (no export removed or narrowed, so no downstream compile can move) and the freeze touches only in-module readers, which the compose test files cover.

Out of scope here — #14512 remains open, #14599 remains open, #14865 remains open.

Generated by Claude Code


Generated by Claude Code

…in, docs line, changeset

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…gen export-origins

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/examples.mdx (via COMPOSE_KEY_DISPOSITIONS (symbol, a top-level const object), STACK_DEFINITION_KEYS (symbol, a top-level const object))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/root.json, packages/spec/export-origins/root.json) — pages documenting those are invisible to this run
  • 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 — 129 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 53cbad9f75572600ef43bb2a18071633fd6c0f68packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 53cbad9f75572600ef43bb2a18071633fd6c0f68

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

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants