You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit ba5927f
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec,cli)!: one stack authoring shape — os validate / os build refuse a default export defineStack did not build (#20460)
Fixes#20367
Clause-②: yes
Implements ruling B on #20367 (`Ruling-ref: 5869334748`): **one
authoring shape**. `os validate` and `os build` now refuse a default
export that no stack producer built, and `composeStacks` refuses an
input no producer built. The six `STACK_*` cross-field refusals
(capability, cross-reference, namespace prefix, single app, hierarchy
scope, trigger capability) therefore reach both doors by construction,
not only when the author happened to call `defineStack()`.
## What changed
**spec (`@objectstack/spec`)**
- `src/stack-provenance.ts` (new): a non-enumerable, non-writable
`Symbol.for('objectstack.stack.provenance')` mark. The precedent is
`data/filter-subtree-provenance.ts`. Stamping is internal to the two
producers. The one published predicate is `hasStackProvenance(value)`,
re-exported from `stack.zod.ts`.
- `stack.zod.ts`: `defineStack` stamps its return value in both strict
and `strict: false` mode. `composeStacks` stamps its artifact at every
arity. Its step 0 refuses unbuilt inputs with `STACK_PROVENANCE_MISSING`
(422), naming each refused input. This runs first, ahead of the
single-input early return. The cross-field validators are unchanged and
stay inside the producer.
- `api/error-code-ledger.zod.ts`: registers `STACK_PROVENANCE_MISSING`
under `@objectstack/spec` (emitted by `composeStacks`) and under
`@objectstack/cli` (emitted by the doors). One condition, two emitters,
following the `ENVIRONMENT_NOT_FOUND` precedent. The `:1323-1328`
comment is left as written. The family is now raised by both doors
through provenance.
- ADR-0087 semantic migration entry
`stack-config-default-export-unbuilt-refused`, plus the regenerated
`registry.ts`, `api-surface/root.json`, `export-origins/root.json` and
two reference docs pages.
**cli (`@objectstack/cli`)**
- `utils/config.ts`: `loadConfig` reads the mark off the default export
before the named-export merge, which is a spread and drops the mark. It
exposes the result as `LoadedConfig.stackProvenance`.
- `utils/stack-provenance-refusal.ts` (new): `refuseUnbuiltStack` throws
a `STACK_PROVENANCE_MISSING` / 422 error with the prescription to wrap
the export in `defineStack(...)`.
- `commands/validate.ts` and `commands/compile.ts`: step 1a, directly
after load and ahead of every other judgement. The throw lands in each
command's existing catch-all, so the `--json` envelope keeps its shape:
`valid`/`success`, `error`, `code`, `warnings`, `conversions`. This is
the same envelope a `defineStack` refusal raised at load already
reaches. PR #20391's step 2c is on `main`, and this step sits above it
in the same `try`, feeding the same catch-all envelope.
- `test/validate-build-gate-parity.test.ts`: the roster gains a row for
`refuseUnbuiltStack` in `SHARED_NON_REGISTRY_GATES`.
- Retired the plain-object door in three places: the plugin README
template in `create.ts`, the `generate.ts` field-type comment, and the
`environment-routing.mdx` sentence about spread configs.
## Premise test (ruled to be run first)
The premise was that host-shaped exports (a `plugins` list of
instantiated plugin objects, i.e. the `os serve` / `os migrate` host
configs) do not go through these two doors. Measured on this tree, it
holds for those host configs:
- The only plain-object host configs in the repo are CLI test fixtures
for `os serve`, `os migrate` and `os doctor`. None of them runs `os
validate`, `os build` or `os dev`, and every one of them passes
unchanged in the unit, integration and nightly tiers.
- The one host-shaped config that does reach both doors is
`examples/app-showcase`, which is authored through `defineStack`. It
carries the mark and passes both doors.
- A plain host-shaped export can mechanically reach the doors. The
ablation leg below shows it passed `os build` at exit 0 before this
change. It is now refused like any other unbuilt export, and the
prescribed fix works: `defineStack({ manifest, plugins: [instance] })`
is accepted by both doors. That row is pinned.
## Pins (`test/stack-provenance-door.test.ts`, integration tier, both
doors, `code` + exit)
| default export | answer at `os validate --json` and `os build --json`
|
|:--|:--|
| defective stack (`requires: ['no-such-capability']`) as `defineStack({
… })` | `STACK_CAPABILITY_UNKNOWN`, exit 1 |
| the SAME stack as a plain object | `STACK_PROVENANCE_MISSING`, exit 1;
the prescription's first sentence is asserted; `os build` writes no
artifact |
| host-shaped plain object | `STACK_PROVENANCE_MISSING`, exit 1 |
| host-shaped `defineStack({ … })` | accepted, exit 0 |
| spread copy `{ ...defineStack(…), api: {} }` |
`STACK_PROVENANCE_MISSING`, exit 1 |
| `defineStack` + named export `onEnable` | accepted, exit 0 (the mark
is read before the merge) |
`packages/spec/src/stack-provenance.test.ts` pins the rest:
- both producers stamp, in every mode and at every arity;
- the mark is not visible through `Object.keys`, `JSON.stringify` or the
strict schema;
- `false` for a literal, a spread copy, an assign copy, a JSON copy or a
structured clone;
- `composeStacks` refuses first, names every input and refuses a lone
input;
- both ledger rows exist.
**Examples: the echo from route D does not reproduce.** Measured with
the built CLI at the merged head:
| example | `os validate` | `os build` |
|:--|:--|:--|
| `examples/app-crm` | exit 0, `valid: true` | exit 0, `success: true` |
| `examples/app-todo` | exit 0, `valid: true` | exit 0, `success: true`
|
| `examples/app-multi-package` | exit 0, `valid: true` | exit 0,
`success: true` |
| `examples/app-showcase` | exit 0, `valid: true` | exit 0, `success:
true` |
The build door is also held in CI: each example's `build` script is
`objectstack build`, and the root `turbo run build` ran all four green
(`Tasks: 73 successful, 73 total`).
## Verification record
- **Ablation.** Committed first, then mutated through
`scripts/ablation-replace.mjs`: `if (loaded.stackProvenance) return;`
became `return;`, landing confirmed by anchor 1 → 0 and blob `5ce32a82`
→ `d78f0948`. The door test's plain-object rows went red (4 failed;
under the mutation the plain defective stack answered `code: undefined`
at exit 0 and the plain host shape built at exit 0, which is the
original defect). The file was restored byte-identical: its blob equals
the `HEAD` blob and `git diff HEAD` is empty. The green leg is the full
integration run at `HEAD`.
- **`@objectstack/spec`:** `vitest run`, 602 files, 17349 passed.
- **`@objectstack/cli` unit:** 233 files, 3336 passed.
- **`@objectstack/cli` integration:** 62 files, 533 passed, 1 skipped.
- **`@objectstack/cli` nightly e2e tier** (`OS_TEST_TIERS=nightly`): the
18 files this change reddened or touched are green, 96 + 57 + 6 tests
across three runs. The first full nightly run (17 red files) is what
named them.
- **Other consumers:** the `composeStacks` test inputs in
`@objectstack/metadata`, `@objectstack/runtime`,
`@objectstack/plugin-dev` and `@objectstack/plugin-security` are green
(1 + 3 + 1 + 1 files). Filter direction: `composeStacks` / `os validate`
/ `os build` callers found by `git grep` over `packages/**`. No non-test
caller of `composeStacks` exists outside `stack.zod.ts`; every other hit
is a comment.
- **Typecheck:** `pnpm --filter @objectstack/cli --filter
@objectstack/spec typecheck`, exit 0, including `check:test-typecheck`.
- **Generated artifacts:** `pnpm --filter @objectstack/spec
check:generated --fix` rewrote only the three it proved stale
(api-surface, export-origins, docs). All 15 were green on re-check.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 123 commands at head
`0360658`. 122 exited 0. One is NOT MEASURED: `node
scripts/check-plugin-teardown-shape.mjs --self-test` exited 3 on the
shallow clone, because its pinned fixture commit is unreachable; it is
checker-health only. `--ran` reconciliation: 123 accounted, 122 run, 1
NOT-MEASURED. Four gates first exited 3 for lack of a full build
(`check:skill-examples`, `check:dual-build-cjs-loads`,
`check:i18n-coverage`, `check:type-check-debt`). They were re-run after
the full build and exited 0.
## Fixture census
The dispatch estimated "63 CLI test files". The instrument used here is
the suite's own reds after the change, across all three tiers, plus `git
grep` for `composeStacks(` inputs:
- CLI: 3 unit, 11 integration and 17 nightly e2e files red, plus the
re-judged `requires-retired-capability.e2e.test.ts`. Each fixture was
rewritten to `defineStack(…)` with spec linked into the temp dir through
the new `test/helpers/define-stack-fixture.ts`.
- spec: 4 test files red. Their hand-built inputs are now a built stack
mutated after `defineStack` returned: the mark survives in-place
mutation, so this is the reachable route to the composer guards.
- Other packages: 3 test files with plain `composeStacks` inputs.
Fixtures for `os serve`, `os migrate`, `os doctor` and `os lint` were
not rewritten. Those doors are outside the ruled surface and do not
refuse; this is the host-config premise above.
## Acceptance notes
- **Pin re-judged: `requires-retired-capability.e2e.test.ts`.** An
unknown `requires` token is now the producer's
`STACK_CAPABILITY_UNKNOWN`, exit 1, at both doors. The retired token's
spec-owned prescription is asserted verbatim in the refusal message, and
the typo hint is asserted to appear exactly once, for the misspelled
token.
- **Pins re-judged: the conversions and `--strict` pins.** These are
`validate-json-failure-conversions.e2e`,
`build-json-failure-conversions.e2e` and the #11301 cell of
`validate-json-strict-exit.e2e`. `defineStack` applies every ADR-0087 D2
conversion itself at load, in either mode, and reports it on stderr.
That leaves the doors' own step-2 sink nothing to convert on any
accepted config, so `conversions` is `[]` on every exit and `--strict`
does not fail on a retiring conversion. This was already true for every
`defineStack` config before this PR. The pins now assert `[]` plus the
live notice on the producer's stderr line, matched by conversion id and
path. The loss is raised as an open question in the report, not fixed
here.
- **`os dev`** compiles through `os build`, so it refuses an unbuilt
export when it compiles. `os serve`, `os migrate`, `os lint` and `os
generate` still load unmarked configs, because the ruling scopes the
refusal to `os validate` and `os build`. The `generate.ts` comment now
says so rather than claiming the door is gone everywhere.
- **Fixture spelling.** Fixtures whose content is the door's to judge
use `defineStack(…, { strict: false })`: schema errors the door must
report, rule findings, the conversion fixtures. Valid fixtures use
strict `defineStack`.
- **Landing site vs the declared surface.** Three additions go beyond
the claim's file surface: the ADR-0087 semantic entry and its generated
`registry.ts` region (the `registered` disposition the breaking
changeset needs), test inputs in three other packages, and the stale
comment in `capability-preflight.test.ts`. Each is required by the
change itself.
- **Serial constraint.** PR #20391 is on `main`. `main` was merged into
this branch at `6e3e546` before this PR opened.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
**BREAKING — one authoring shape for a stack config.**`objectstack validate` and `objectstack build` now refuse a config whose default export was not built by `defineStack(...)` (either mode) or `composeStacks(...)`, with `STACK_PROVENANCE_MISSING` and exit 1, right after the config loads and before any other check. `composeStacks` refuses an input no producer built the same way.
7
+
8
+
Why: the stack family's cross-field refusals (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, `STACK_NAMESPACE_PREFIX_INVALID`, `STACK_SINGLE_APP_VIOLATION`, `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`, `STACK_TRIGGER_CAPABILITY_REQUIRED`) run inside `defineStack` only. The same defective stack exported as a plain object passed both commands at exit 0, and `objectstack build` shipped it. Re-running those refusals on whatever the config exports cannot fix that: a built stack carries each bound action twice, so the re-run refuses every correct project that has one. So the commands check who BUILT the export instead.
9
+
10
+
-`@objectstack/spec`: `defineStack` and `composeStacks` stamp a non-enumerable `Symbol.for` provenance mark on what they return. The mark is invisible to the schema, to `Object.keys` and to `JSON.stringify`, so no compiled artifact changes. New export: `hasStackProvenance(value)` — `true` only for a value one of the two producers returned. New registered error code: `STACK_PROVENANCE_MISSING` (422), raised by `composeStacks` for an unbuilt input.
11
+
-`@objectstack/cli`: `loadConfig` reads the mark off the default export before merging named exports into it (the merge is a spread, which drops the mark), and exposes it as `LoadedConfig.stackProvenance`. `objectstack validate` / `objectstack build` refuse on `false` through their existing error path: under `--json`, `error` + `code: 'STACK_PROVENANCE_MISSING'`. The envelope has no new fields. `objectstack dev` compiles through `objectstack build`, so it refuses the same way when it compiles. `objectstack serve`, `objectstack migrate`, `objectstack lint` and `objectstack generate` load configs exactly as before.
12
+
13
+
**Migration** — FROM a plain-object (or copied) default export TO the value `defineStack` returns:
// every stack key inside the call — `api`, `plugins`, `requires`, …
30
+
});
31
+
```
32
+
33
+
One-line fix: wrap the export in `defineStack(...)`, and move any key spread onto a copy into the call. For compositions, wrap each input: `composeStacks([defineStack({ … }), …])`. Once wrapped, a config that used to pass can now fail with one of the family's own codes. Those findings were always there; the plain export hid them. Fix each one as its message says. Host-style configs whose `plugins` hold plugin instances are covered by the same rule, and the same wrap fixes them (`defineStack` accepts plugin instances). A project already exporting `defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected.
Copy file name to clipboardExpand all lines: content/docs/references/api/contract.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
28
28
29
29
| Property | Type | Required | Description |
30
30
| :--- | :--- | :--- | :--- |
31
-
|**code**|`Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>`| ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31
+
|**code**|`Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>`| ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
32
32
|**declaredCode**|`string`| optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
|**userMessage**|`string`| optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -335,6 +335,7 @@ const result = ApiErrorSchema.parse(data);
0 commit comments