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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 5, 2026 22:42
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 69602e5 Sep 5, 2026
43 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14877-export-compose-key-dispositions branch September 5, 2026 23:50
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