Repository navigation
Commit 85edd61
feat(spec): the stack definition admits
Fixes #22748
Clause-②: yes (widening: the stack definition admits `appName`, the
non-secret members of `email`, and `sms` carrying `retries` alone, which
it refused as unrecognized keys)
The mail and SMS readers already read `config.email`, `config.sms` and
`config.appName`, but the stack definition refused all three keys, so
only a config `defineStack` did not build could reach them. This PR
gives the stack a door for them. It follows the triage direction
(`6104830037`), claim `6105299924` and the seat's patch-round order
`6106350788`:
- `appName` and the non-secret members of `email` are admitted, typed
from the existing schemas.
- `sms` carries `retries` alone.
- The credentials and the SMS provider are refused **by name**, each
with the setting that replaces it.
**Status: draft.** The diff touches
`skills/objectstack-platform/SKILL.md`, a governed Tier H surface:
`check:skill-top-level-keys` reconciles that page's top-level key list
against the schema, so it cannot stay out of this PR. Landing therefore
needs an authorized approval (see the maintainer summary below). The
contract review at `CONTRACT_REVIEW_TIER` is owed on the head named in
the evidence section.
## Patch round 1 (seat order `6106350788`)
- **`main` was merged in** (`a2e94c2a05`) as a merge commit, through
`scripts/pm/os-regen-merge.sh`.
- `type-alias-convention.pin.test.ts` conflicted on content. Both sides'
entries were kept, and the merged pin was green at 777.
- The reference index, the one generated artifact the driver deferred,
was regenerated with `gen:schema` then `gen:docs` in its own commit
(`3a68830032`).
- **Open question 1 → A.** The `strictObject` guidance form stays as it
was.
- **Open question 2 → B.** `sms.provider` is now refused by name, with
the `OS_SMS_PROVIDER` / Settings → SMS Delivery prescription, exactly as
`sms.providerOptions` is.
- **`sms` keeps `retries` alone, and is not refused whole.** `retries`
is the SMS service's own attempt budget, and the service keeps it across
the settings namespace's transport swap: `setTransport` replaces the
transport and nothing else. So it applies to whichever transport
delivers. It has no environment or settings carrier, so this block is
its only authoring door, and refusing `sms` whole would make a live knob
unreachable. A `retries`-only block does not mislead, because a
`provider` written beside it is refused by name, with the right setting.
- **`SmsProviderSchema` leaves with its only consumer.** It is gone,
together with its isomorphic-alias pin (the pin file is back to `main`'s
bytes) and the cli parity half. It was never released. It comes back,
with the key, in the change that makes `SmsServicePlugin` honour a
constructor provider.
## Reader-key census (the admitted shape is this table)
Each key the readers read off the config, not the environment, is
classified. No reader needs a secret from the config file: every secret
has a carrier outside it. So the triage stop rule did not fire.
| Reader | Key | Secret? | Carrier outside the file | Stack definition |
|---|---|---|---|---|
| `resolveEmailCapabilityArg` | `provider` | no | `OS_EMAIL_PROVIDER` |
admitted |
| | `apiKey` | **yes** | `OS_EMAIL_API_KEY` | **refused by name** |
| | `defaultFrom` | no | `OS_EMAIL_FROM` | admitted |
| | `retries` | no | `OS_EMAIL_RETRIES` | admitted |
| | `queueDelivery` | no | `OS_EMAIL_QUEUE_ENABLED` | admitted |
| | `persist` | no | `OS_EMAIL_PERSIST_ENABLED` | admitted |
| | `appName` | no | `OS_APP_NAME` | admitted |
| | `defaultTemplateContext` | no (template render context) | none
(`OS_APP_NAME` for its `appName`) | admitted |
| | `options.host` / `.port` / `.secure` / `.user` | no |
`OS_EMAIL_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` | admitted (strict
block) |
| | `options.messageStream` | no | none | admitted |
| | `options.password` | **yes** | `OS_EMAIL_SMTP_PASSWORD` | **refused
by name** |
| | `options.apiKey` | **yes** (`makeTransport` spreads `options` over
the key for resend / postmark) | `OS_EMAIL_API_KEY` | **refused by
name** |
| `resolveCapabilityArgument` (core), `resolveDeploymentAppName` |
top-level `appName` | no | `OS_APP_NAME` | admitted |
| `resolveSmsCapabilityArg` | `retries` | no | none | admitted |
| | `provider` | no, but **not delivered from a stack** (measured,
below) | `OS_SMS_PROVIDER` / Settings → SMS Delivery | **refused by
name** |
| | `providerOptions` | **yes**: every non-`log` transport requires a
credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) |
the `sms` settings namespace overrides (`OS_SMS_TWILIO_*`,
`OS_SMS_ALIYUN_*`) | **refused by name, whole** |
### Why the SMS provider settings stop at the door
- **`sms.provider` cannot select the transport that delivers.** Measured
through `bootStack`:
- A `defineStack`-built `sms: { provider: 'twilio' }`, with
`OS_SMS_TWILIO_ACCOUNT_SID` / `_AUTH_TOKEN` / `_FROM_NUMBER` set, boots
with `sms.isConfigured() === false`. The log says "provider='twilio'
selected but transport build failed … falling back to LogSmsTransport".
- Control: the same, plus `OS_SMS_PROVIDER=twilio`, logs "transport
rebuilt from settings (provider=twilio)" and gives `isConfigured() ===
true`.
- The reason: `applySmsSettings` builds the delivering transport from
the settings namespace's own `provider`. The constructor's provider has
no credential to build with, because a stack carries none.
- **`sms.providerOptions` is refused whole, while `email.options` is
narrowed.** This too was measured.
- The mail reader layers `OS_EMAIL_SMTP_*` over `config.email.options`
key by key, so host and user from the file compose with the password
from the environment.
- The SMS reader hands `providerOptions` to the transport as written.
Non-secret members alone gave `isConfigured=false` for Twilio and for
Aliyun, with the environment credential set; the control with
`authToken` gave `true`.
## What changes
- **`packages/spec/src/system/email-config.zod.ts`**
- `StackEmailConfigSchema` is **derived** from
`EmailServiceConfigSchema`: it `.omit()`s `apiKey` and `options`, then
adds `options: StackEmailProviderOptionsSchema`. A spec pin asserts the
difference is exactly `['apiKey']`.
- `StackEmailProviderOptionsSchema` is a strict block holding `host` /
`port` / `secure` / `user` / `messageStream`.
- The layer-1 header says which members a config file may carry, and
names `resolveEmailCapabilityArg` (`@objectstack/plugin-email`) as the
reader, not `serve.ts`.
- **`packages/spec/src/system/sms-config.zod.ts` (new).**
`packages/spec` had no SMS configuration schema, so `sms` is typed from
what `resolveSmsCapabilityArg` reads. `StackSmsConfigSchema` is `{
retries }`, strict, and refuses `provider`, `providerOptions` and a flat
`authToken` / `accessKeySecret` by name.
- **`packages/spec/src/stack.zod.ts`** declares `appName`, `email` and
`sms` on the stack.
- In the compose table (`COMPOSE_KEY_DISPOSITIONS`) all three are
`'single'`: one product name, one mail configuration and one SMS
configuration per booted stack.
- Because they are not `concat` keys, they stay out of the assembled
package body.
- **The by-name refusal** is a `strictObject` `guidance` entry, the
stack's own idiom for keys at the wrong layer (seat ruling on open
question 1). The first sentence names the key, and the prescription
bullet names the setting.
Through the public door, `os validate` exits 1 on a stack carrying
`email.apiKey` and on one carrying `sms.provider`. Each refusal names
its key and its setting. The admitted shape exits 0.
```
✗ email: Unrecognized key(s) on the stack `email` block: `apiKey`.
• Not authorable here. A provider API key is a credential, and a stack definition is compiled into the published artifact — set OS_EMAIL_API_KEY in the deployment environment instead (required for provider resend / postmark). …
✗ sms: Unrecognized key(s) on the stack `sms` block: `provider`.
• Not authorable here. The SMS provider and its credentials are chosen by the sms settings namespace, which builds the delivering transport from its own settings, never from a stack definition (…). Configure them in Setup → Settings → SMS Delivery, or through that namespace's environment overrides: OS_SMS_PROVIDER plus OS_SMS_TWILIO_ACCOUNT_SID / … (Aliyun). …
```
The admitted shape, `appName` plus `email: { provider, defaultFrom,
persist }` plus `sms: { retries }`, exits 0.
`sms.retries` reaches the transport that delivers. Measured through
`bootStack` with `sms: { retries: 2 }`, plus `OS_SMS_PROVIDER=twilio`
and the Twilio credentials in the environment:
- the log reads "transport rebuilt from settings (provider=twilio)";
- `isConfigured() === true`;
- `service.options.retries === 2`, on a `TwilioSmsTransport`.
## Pins
- **`packages/spec/src/stack-email-sms.test.ts`** (16 cases):
- Accept: `email: { provider, defaultFrom }` builds through
`defineStack`. So do every non-secret mail member, `appName` and `sms: {
retries }`.
- The derivation pin.
- Refuse: `email.apiKey`, `email.options.password`,
`email.options.apiKey`, **`sms.provider`**, `sms.providerOptions` and a
flat `sms.authToken` are each refused. The pin asserts the
`STACK_SCHEMA_INVALID` / 422 envelope, the issue path and key, and the
setting in the prescription.
- Control: a stack without the keys is unchanged by the parse, and
neighbouring keys still refuse.
- Compose: an identical value passes, and a different one is refused by
key.
-
**`packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts`**
(4 cases). It drives the call `serve` makes for each provider,
`resolveCapabilityArgument` with the provider module, on a stack
`defineStack` returned:
- The `email` block reaches the email provider, with
`OS_EMAIL_SMTP_PASSWORD` layered over the file's host and user.
- `appName` reaches the template context and AuthPlugin's name.
- `sms.retries` reaches the SMS provider, beside the provider
`OS_SMS_PROVIDER` names.
- `OS_EMAIL_*` still wins per key.
- **CONTROL:** with no block, env-only configuration builds exactly what
the reader builds from no config.
## Ablation (two legs, through `scripts/ablation-replace.mjs`)
The spec pins import `./stack.zod` from `src/`, so no `dist/` sits
between the mutation and the reading.
Both legs ran on `34286f646a`. Each was restored by `ablation-replace`,
and a trap restored from `HEAD` as a fallback.
| Leg | Mutation | Reading |
|---|---|---|
| Control (unmutated) | none | `stack-email-sms.test.ts`: 16 / 16 passed
|
| `email.apiKey` refusal removed | `guidance: { apiKey:
STACK_EMAIL_API_KEY_PRESCRIPTION },` → `guidance: {},` (anchor 1 → 0,
replacement 0 → 1, blob `e9d625633b26` → `d5c0f4a5cb8b`) | 1 failed / 15
passed. Exactly the `email.apiKey` pin went red: the message became
"Unrecognized key(s) on the stack email block: apiKey. This block is
strict from birth…", with no `OS_EMAIL_API_KEY` |
| `sms.provider` refusal removed | `provider:
STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION,` → `provider_ABLATED: …`
(anchor 1 → 0, replacement 0 → 1, blob `0015c421bb5b` → `8ae310bea6c3`)
| 1 failed / 15 passed. Exactly the `sms.provider` pin went red: the
message became "Unrecognized key(s) on the stack sms block: provider.
This block is strict from birth…", with no `OS_SMS_PROVIDER` |
| Restore, each leg | `ablation-replace` restore | blob equals the
`HEAD` blob (`e9d625633b26`, `0015c421bb5b`), and `git diff HEAD` is
empty |
The direction was the expected one: red. The key is still refused, but
without its prescription.
## Evidence
Builds and tests ran through `scripts/pm/os-verify-lock.sh` on head
`34286f646a`. The closure was built first: `pnpm turbo run build
--filter=@objectstack/cli... --filter=@objectstack/example-showcase^...
…`, 68 successful.
| Command | Result |
|---|---|
| spec `--project local` over the touched and adjacent files:
`stack-email-sms`, `assembled-package-body`,
`type-alias-convention.pin`, `compose-key-dispositions-export.pin`,
`shared/alias-integrity`, `shared/strict-object`, `system/email-config`
| 7 files / 115 tests passed |
| `pnpm --filter @objectstack/spec typecheck` | exit 0; test layer 52
files / 246 errors / 135 pinned signatures held, unchanged |
| `pnpm --filter @objectstack/cli typecheck` | exit 0; test layer 3
files / 28 errors / 6 signatures, unchanged |
| cli `--project unit`: the rider (4 cases), `serve-auth-app-name`,
`serve-email-capability`, `serve-sms-capability`,
`test/option-b-reader-acceptance.pin` | 5 files / 44 tests passed |
| Consumers of the keys: core `capability-composition`, plugin-email
`capability-arg.config-parity.contract`, runtime `artifact-collections`,
verify `harness.served-composition` | 12 / 12, 8 / 8, 22 / 22, 7 / 7
passed |
| `pnpm --filter @objectstack/spec check:generated` | all 14 generated
artifacts current |
| `os validate --json` on `examples/app-crm`, `app-multi-package`,
`app-showcase` and `app-todo` | all four `valid: true`. The payloads
minus `duration` are byte-identical to round 1's pre-change reading on
the base dist: no example app changed |
Round 1's full spec suites ran on the pre-merge tree. `--project local`
gave 644 files and 19228 passed, 1 todo; `--project repo` gave 54 of 55,
the one red being the concurrent-regeneration flake, 20 / 20 when re-run
alone.
**Gates, all on head `34286f646a`** (`git rev-parse --short HEAD`,
working tree clean):
- **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derived 123 commands. Every
one ran with its exit code captured before any pipe, and **all 123
exited 0**. The changeset gates `check-changeset-no-major` and
`check-adr-0087-registration` are among them and read the new `Clause-②`
line.
- **Reconciliation.** `node scripts/pm/dispatch-gates.mjs --ran
ran.list` reports "123 derived, 123 run, 0 NOT-MEASURED, 0 UNRUN".
- **Roster gates** for the directories this diff writes in, each exit 0:
`check:meta-url-spelling`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`,
`check-changeset-fixed`, `check:published-readme-exports`,
`check:stack-collection-maps`.
- **Lint, narrowed and proven.**
- ESLint (`--no-inline-config --format json`) on the 8 touched script
files reports 8 files, 0 errors, 0 warnings.
- The narrowing excludes nothing: `eslint.config.mjs` enables no
type-aware linting, so this diff cannot move the verdict on any
untouched file.
- **Left to CI:** the repo-wide `pnpm lint` and the workspace type-check
lanes.
## The published skill: two readings, as the governed-skills rule
requires
These are unchanged by this round. The page names `sms`, never
`sms.provider`.
- `skills/objectstack-platform/SKILL.md`: **489 → 488 lines**, 23322 →
23301 bytes, **5831 → 5826 tokens** (ceiling 5833).
- Whole catalog, the sum of all ten `skills/*/SKILL.md`: **4410 → 4409
lines**, 210541 → 210520 bytes.
- The three keys are appended to the existing enumeration line. They are
paid for by deleting a restatement ("; the input type is
`ObjectStackDefinitionInput`") that the section's opening sentence
already makes. Nothing is re-wrapped.
## Deviations from the claim's file surface, each forced by a gate or a
measurement
- `skills/objectstack-platform/SKILL.md` (governed, Tier H):
`check:skill-top-level-keys`.
- `packages/cli/test/option-b-reader-acceptance.pin.test.ts` and
`packages/spec/src/assembled-package-body.test.ts`: their envelope-key
lists.
- `packages/spec/llms.txt` (`check:llms-txt`): 203 → 204 schemas; system
34 → 35.
- `content/docs/getting-started/quick-reference.mdx`
(`check:quick-reference-counts`): System, 34 → 35 reference pages.
- `content/docs/getting-started/quick-start.mdx`: its "Not in the table,
and why" list names the three keys.
## Acceptance notes (not filed here)
- **`sms.provider` comes back with the fix that honours it.** The seat
files the services-lane card from the `bootStack` measurement above.
That change re-admits the key, with its provider vocabulary.
- **The mail reader's refusal text** for a missing API key still says
"set OS_EMAIL_API_KEY (or config.email.apiKey)". A built stack refuses
the second form by name and prescribes the first. The text lives in
`@objectstack/plugin-email`.
- **`transportOptions`, `timeout` and `endpoint`** are read by the
transports but declared by no spec text, so the strict `email.options`
block does not admit them. `transportOptions` can carry credentials of
its own.
- **Two pre-existing slips in sentences this PR touched were corrected
with them.** The `llms.txt` system row named a retired schema
("Compliance"), and the quick-start list lacked `devHint` / `devLogins`.
- **Docs-drift check.** Its five hand-written pages naming `appName`
were re-read. None states that the key cannot be set in a config file,
or names `serve.ts` as the reader.
## 维护者速读(草稿)
**改了什么**:`objectstack.config.ts`(`defineStack`)现在可以写
`appName`、`email`(发件方、重试、是否落库、队列投递、模板上下文、SMTP 主机/端口/TLS/用户名等)和 `sms`(仅
`retries` 重试次数)。过去这三个键会被当成"未知键"拒绝,运行时却一直在读它们。所有密钥(邮件 API Key、SMTP
密码、短信供应商凭据)以及短信供应商 `sms.provider` 都不能写进配置文件,写了会被点名拒绝,并告诉作者改用哪个环境变量,或在
Setup → 设置 → 短信投递 中配置。已发布技能 `skills/objectstack-platform/SKILL.md`
的顶层键清单补上这三个键,所以本 PR 属于 Tier H。
**为什么改**:配置文件的这一半一直有人读,却没有入口可写。配置文件会被 `objectstack build`
编译进制品,制品会被发布,所以密钥一律不进制品。`sms.provider` 实测从配置文件选不到真正投递的通道(投递通道由设置页 /
`OS_SMS_PROVIDER` 决定),所以按"声明即兑现"原则先点名拒绝,待短信插件支持后再开放。
**风险与代价(含回滚)**:只放宽,不收紧,已有的合法配置不受影响(四个示例应用的 `os validate` 前后一致)。技能文件为凑
token 余量删去了一处重复说明。回滚:revert 本 PR 即可,无数据迁移。
**席位意见**:
**你要做的**:审阅并批准(Tier H,因触及 `skills/**`)。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj)_
---------
Co-authored-by: Claude <noreply@anthropic.com>appName and the non-secret email / sms members; their credentials are refused by name (#22778)1 parent 0984817 commit 85edd61
25 files changed
Lines changed: 858 additions & 27 deletions
File tree
- .changeset
- content/docs
- getting-started
- references
- system
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- cli
- src/commands
- test
- spec
- api-surface
- authorable-defaults
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- system
- skills/objectstack-platform
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
88 | | - | |
| 88 | + | |
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | | - | |
236 | | - | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
237 | 239 | | |
238 | 240 | | |
239 | 241 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
317 | 317 | | |
318 | 318 | | |
319 | 319 | | |
320 | | - | |
| 320 | + | |
321 | 321 | | |
322 | 322 | | |
323 | 323 | | |
| |||
333 | 333 | | |
334 | 334 | | |
335 | 335 | | |
336 | | - | |
| 336 | + | |
337 | 337 | | |
338 | 338 | | |
339 | 339 | | |
| |||
351 | 351 | | |
352 | 352 | | |
353 | 353 | | |
| 354 | + | |
354 | 355 | | |
355 | 356 | | |
356 | 357 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | | - | |
17 | | - | |
18 | | - | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
19 | 27 | | |
20 | 28 | | |
21 | 29 | | |
| |||
32 | 40 | | |
33 | 41 | | |
34 | 42 | | |
35 | | - | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
36 | 46 | | |
37 | 47 | | |
38 | 48 | | |
| |||
41 | 51 | | |
42 | 52 | | |
43 | 53 | | |
44 | | - | |
45 | | - | |
| 54 | + | |
| 55 | + | |
46 | 56 | | |
47 | 57 | | |
48 | 58 | | |
| |||
100 | 110 | | |
101 | 111 | | |
102 | 112 | | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| 40 | + | |
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
| 43 | + | |
43 | 44 | | |
44 | 45 | | |
45 | 46 | | |
0 commit comments