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
Conversation
…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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
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):
Premise re-verified on
origin/mainf7db8f4fd:COMPOSE_KEY_DISPOSITIONSwas module-private (a bareconst, noexport;packages/spec/src/index.tsalready re-exports everything from./stack.zod, so nothing else was needed to reach the surface), declaredas const satisfiesa total record fromStackDefinitionKeytoComposeDisposition— 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):COMPOSE_KEY_DISPOSITIONSObject.freezeover the existingas const satisfiesliteral — literal-typed, so indexingtypeof COMPOSE_KEY_DISPOSITIONSby a key K yields K's disposition, a subtype of the ruling's read-only record fromStackDefinitionKeytoComposeDisposition(the pin asserts that assignability at compile time)STACK_DEFINITION_KEYSStackDefinitionKey, built asObject.freeze(Object.keys(COMPOSE_KEY_DISPOSITIONS))— the derived key set, never a second literalStackDefinitionKey'manifest','packages', or a key ofSTACK_DEFINITION_COLLECTIONS_SHAPE— the existing internal alias, now publicComposeDisposition'concat','single','manifest','objects','functions'— the docblock now says what each rule does, describe-gradeWhy the existing name rather than a new
STACK_TOP_LEVEL_KEY_DISPOSITIONSview. The card's naming reference (PLURAL_TO_SINGULAR,METADATA_ALIASES) is SCREAMING_SNAKE describing the map's domain, whichCOMPOSE_KEY_DISPOSITIONSalready is. Exporting the table itself keeps one declaration under one name: the runtime warningcomposeStacksprints for an undeclared key already tells the author to "Declare what composing it means in COMPOSE_KEY_DISPOSITIONS" (andcompose-stacks-key-loss.test.tspins 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_KEYSwithAssembledPackageBodyEnvelopeKey):STACK_DEFINITION_KEYSwithStackDefinitionKey.Literal typing is kept deliberately (M4):
AssembledPackageBodyKeyis a mapped type overtypeof COMPOSE_KEY_DISPOSITIONSthat reads each key's literal disposition; a widened record annotation would collapse it tonever.Object.freezeover anas constliteral preserves those literal types — the builtdist/index.d.tsnow declaresCOMPOSE_KEY_DISPOSITIONSas aReadonlyobject whose members are the per-key literals (readonly manifest: "manifest",readonly packages: "concat", and so on).CONCAT_ARRAY_FIELDSnow derives fromSTACK_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 sevencompose-stacks-*.test.tsfiles pass).Entry placement:
stack.zod.tsis on the schema-bearing root entry, wherecomposeStacksand the schema itself already live; the consumer this serves (cloud's service seam) is server-side and already importsPLURAL_TO_SINGULARfrom 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)Object.keys(COMPOSE_KEY_DISPOSITIONS)equals sortedObject.keys(ObjectStackDefinitionSchema.shape)(each side duplicate-free, count non-vacuously above 40), plus a second test that names the drift per direction (onlyInSchemaandonlyInTableboth empty);STACK_DEFINITION_KEYSequals the same set.Object.isFrozenon both; assignment to an existing key, assignment of a new key (grantedPermissions), anddeleteall throwTypeError(ESM strict mode) and leave the table unchanged;pushon the key list throws.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).'concat'key concatenates in stack order with zero warnings (so the export's concat subset is the privateCONCAT_ARRAY_FIELDSthe composer walks — 36 keys,packagesandrequiresincluded); every'single'key passes through when identical and refuses naming the key when different;manifest/objects/functionsare each the single key of their bespoke disposition and behave per their strategy.STACK_DEFINITION_KEYSdeep-equalsObject.keys(COMPOSE_KEY_DISPOSITIONS)in order; a dynamic import of./indexhands 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.tsMETADATA_ARRAY_KEYSandcli/src/utils/stack-collections.ts, both pinned bycheck: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.tsalready derivespackageOwnedCollectionKeys()from the two schemas' shapes; its test's seven-key envelope literal is a pin expectation, left as a pin. Nothing inskills/**or.claude/**copies the table.Docs line
content/docs/getting-started/examples.mdx, section Composition Pattern (the section that documentscomposeStacks;packages/spec/README.mdhas no compose section and the glossary entry was not the place): one paragraph sayingCOMPOSE_KEY_DISPOSITIONSmaps each declared key to its rule,STACK_DEFINITION_KEYSis 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.tstoday buildsKNOWN_METADATA_CATEGORIESfromPLURAL_TO_SINGULAR,METADATA_ALIASESand a cloud-only list, plus a hand-copied five-member passthrough list (positions,requires,data,datasets,packages). The five-member literal becomes one derivation:— which is also what answers "may I concatenate this across bundles?" without the comment that today asserts it by reading
stack.zod.ts. #14865'sgrantedPermissionsarrives there the day the schema declares it.Changeset
.changeset/spec-compose-key-dispositions-export.md—@objectstack/specminor, additive (two new consts, two new exported types; nothing renamed, narrowed or removed). ADR-0087:check:adr-0087-registrationjudges only changesets that declare a breaking change (amajorbump, 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) andpackages/spec/export-origins/root.json(+4) regenerated bycheck:generated --fix/gen:api-surfaceafter a real build;declaration-map/unchanged (it covers the domain entries, not the root).gen:schemaproduced no diff (no authorable key moved).check:generatedafter 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/specbuild, typecheck (tsc, scripts, test layer) and the full spec vitest suite (479 files, 12853 tests) under the shared verify lock; the 95 derivedcheck:*commands viascripts/pm/dispatch-gates.mjs(--ranreconciliation: 95 derived, 95 run, 0 unrun), of which two are declared to CI as PREREQUISITE NOT MET here —check:dual-build-cjs-loadsandcheck:type-check-debtboth need every workspace package built (78 moredist/directories), the farm CI owns.turbo ls --affectedlists 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