Skip to content

Commit 34286f6

Browse files
committed
fix(spec): refuse sms.provider by name; sms carries retries alone
The stack cannot select the delivering SMS transport: the sms settings namespace builds it from its own provider setting, so a stack-declared provider is refused with the OS_SMS_PROVIDER / Settings prescription, as providerOptions is. SmsProviderSchema leaves with its only consumer. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a68830 commit 34286f6

13 files changed

Lines changed: 145 additions & 181 deletions

File tree

‎.changeset/22748-stack-email-sms-door.md‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,18 @@
22
"@objectstack/spec": minor
33
---
44

5-
feat(spec): the stack definition admits `appName` and the non-secret members of `email` and `sms`; their credentials are refused by name
5+
feat(spec): the stack definition admits `appName`, the non-secret members of `email`, and `sms.retries`; credentials and the SMS provider are refused by name
66

7-
Clause-②: yes (widening: the stack definition admits `appName` and the non-secret members of `email` and `sms`, which it refused as unrecognized keys)
7+
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)
88

9-
- **What you can write now.** `defineStack()` accepts three top-level keys it used to refuse as unrecognized. The mail and SMS readers already read all three, and `objectstack serve` and `bootStack` construct the email and SMS providers through those readers:
9+
- **What you can write now.** `defineStack()` accepts three top-level keys it used to refuse as unrecognized. The mail and SMS readers already read them, and `objectstack serve` and `bootStack` construct the email and SMS providers through those readers:
1010
- `appName`: the deployment's product name. It is the last configured rung of the name chain (`OS_APP_NAME` > `email.appName` > `email.defaultTemplateContext.appName` > `appName` > `'ObjectStack'`), and it reaches the email templates and AuthPlugin.
11-
- `email` (`StackEmailConfigSchema`): every member of `EmailServiceConfigSchema` except `apiKey`. That is `provider`, `defaultFrom`, `retries`, `persist`, `queueDelivery`, `appName` and `defaultTemplateContext`, plus `options` narrowed to `host` / `port` / `secure` / `user` / `messageStream`.
12-
- `sms` (`StackSmsConfigSchema`): `provider` (`SmsProviderSchema`: `log` / `aliyun` / `twilio`) and `retries`.
13-
- `OS_EMAIL_*` and `OS_SMS_PROVIDER` still override the file per key.
14-
- **What stays in the environment, and is refused by name.** A stack definition is compiled into the published artifact, so it does not carry credentials. Each refusal names the key and the variable that replaces it:
15-
- `email.apiKey` and `email.options.apiKey`: set `OS_EMAIL_API_KEY`.
16-
- `email.options.password`: set `OS_EMAIL_SMTP_PASSWORD`. The reader layers it over the host / port / secure / user the file carries.
17-
- `sms.providerOptions`: configure the provider in Setup → Settings → SMS Delivery, or through that namespace's overrides (`OS_SMS_PROVIDER`, `OS_SMS_TWILIO_*`, `OS_SMS_ALIYUN_*`). It is refused whole, because every non-`log` SMS transport needs a credential inside it and nothing merges an environment credential into it.
11+
- `email` (`StackEmailConfigSchema`): every member of `EmailServiceConfigSchema` except `apiKey`. That is `provider`, `defaultFrom`, `retries`, `persist`, `queueDelivery`, `appName` and `defaultTemplateContext`, plus `options` narrowed to `host` / `port` / `secure` / `user` / `messageStream`. `OS_EMAIL_*` still overrides it per key.
12+
- `sms` (`StackSmsConfigSchema`): `retries`, the SMS service's attempt budget. It applies to whichever transport the `sms` settings namespace selects.
13+
- **What stays elsewhere, and is refused by name.** Each refusal names the key and the setting that replaces it:
14+
- `email.apiKey` and `email.options.apiKey`: a credential, and a stack definition is compiled into the published artifact. Set `OS_EMAIL_API_KEY`.
15+
- `email.options.password`: a credential. Set `OS_EMAIL_SMTP_PASSWORD`; the reader layers it over the host / port / secure / user the file carries.
16+
- `sms.provider`, `sms.providerOptions` (and a flat `authToken` / `accessKeySecret`): the delivering SMS transport is built by the `sms` settings namespace from its own provider and credentials, never from a stack definition. Configure them in Setup → Settings → SMS Delivery, or through that namespace's overrides (`OS_SMS_PROVIDER`, `OS_SMS_TWILIO_*`, `OS_SMS_ALIYUN_*`).
1817
- **Composition.** All three keys compose as single values. `composeStacks` passes an identical value through and refuses two different ones by key.
19-
- **Nothing to migrate.** No stack that was accepted before is refused now. A configuration that set these keys only in an object `defineStack` did not build (the one `objectstack serve` boots) reads the same as before. To build it, move any credential it carries into the environment.
20-
- New exports from `@objectstack/spec/system`: `StackEmailConfigSchema`, `StackEmailProviderOptionsSchema`, `StackSmsConfigSchema`, `SmsProviderSchema`, and their `z.input` / `z.infer` types.
18+
- **Nothing to migrate.** No stack that was accepted before is refused now. A configuration that set these keys only in an object `defineStack` did not build (the one `objectstack serve` boots) reads the same as before. To build it, move any credential, and the SMS provider, into the environment or Settings.
19+
- New exports from `@objectstack/spec/system`: `StackEmailConfigSchema`, `StackEmailProviderOptionsSchema`, `StackSmsConfigSchema`, and their `z.input` / `z.infer` types.

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1520 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with
3232
| [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. |
3333
| [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
35-
| [System Protocol](/docs/references/system) | 35 | 279 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
35+
| [System Protocol](/docs/references/system) | 35 | 278 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **196** | **1521** | 14 protocol modules |
37+
| **Total** | **196** | **1520** | 14 protocol modules |
3838

3939
---
4040

@@ -317,7 +317,7 @@ Studio designer metadata — the authoring surfaces for the protocols above.
317317

318318
## System Protocol
319319

320-
**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **35 pages, 279 schemas**
320+
**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **35 pages, 278 schemas**
321321

322322
The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance.
323323

@@ -351,7 +351,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
351351
| [`security-context.zod.ts`](/docs/references/system/security-context) | `ComplianceAuditRequirement`, `ComplianceEncryptionRequirement`, `ComplianceFramework`, `DataClassification`, `DataClassificationPolicy`, `MaskingVisibilityRule`, `SecurityContextConfig`, `SecurityEventCorrelation` |
352352
| [`settings-client.zod.ts`](/docs/references/system/settings-client) | `SettingsChangeEvent` |
353353
| [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType`, `SpecifierValueDomain` |
354-
| [`sms-config.zod.ts`](/docs/references/system/sms-config) | `SmsProvider`, `StackSmsConfig` |
354+
| [`sms-config.zod.ts`](/docs/references/system/sms-config) | `StackSmsConfig` |
355355
| [`stack-server.zod.ts`](/docs/references/system/stack-server) | `ServerRateLimitConfig`, `StackServerConfig`, `StackServerSecurity` |
356356
| [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` |
357357
| [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` |

‎content/docs/references/system/sms-config.mdx‎

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,47 @@ There was no SMS configuration schema in the spec before this file. The
1212
shape below is typed from what its one reader reads:
1313
`resolveSmsCapabilityArg` in `@objectstack/service-sms`, which
1414
`objectstack serve` and `bootStack` both construct `SmsServicePlugin`
15-
through. It reads three keys off `config.sms`:
16-
17-
| key | secret? | here |
18-
|---|---|---|
19-
| `provider` | no — a transport tag | admitted (`SmsProviderSchema`); `OS_SMS_PROVIDER` wins |
20-
| `retries` | no | admitted |
21-
| `providerOptions` | YES — every non-`log` transport requires a credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) | refused by name |
22-
23-
`providerOptions` is refused WHOLE rather than narrowed to its non-secret
15+
through. It reads three keys off `config.sms`, and only one of them can do
16+
from a stack definition what it says:
17+
18+
| key | secret? | delivered from a stack? | here |
19+
|---|---|---|---|
20+
| `retries` | no | yes | admitted |
21+
| `provider` | no — a transport tag | NO, measured | refused by name |
22+
| `providerOptions` | YES — every non-`log` transport requires a credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) | — | refused by name |
23+
24+
**`retries`** is the SMS service's own attempt budget. The service keeps it
25+
when the `sms` settings namespace swaps its transport at `kernel:ready`
26+
(`setTransport` replaces the transport and nothing else), so it applies to
27+
whichever transport ends up delivering. It has no environment or settings
28+
carrier, so this block is its only authoring door.
29+
30+
**`provider`** cannot select a delivering transport from here. A stack is
31+
not allowed credentials (below), so the transport the plugin constructs from
32+
this tag falls back to its log transport, and the transport that DOES
33+
deliver is the one the settings namespace builds at `kernel:ready` from its
34+
own `provider` setting — it never reads this one. Measured through
35+
`bootStack`: `sms: { provider: 'twilio' }` with the Twilio credentials in the
36+
environment boots with `isConfigured() === false`; adding `OS_SMS_PROVIDER`
37+
makes it `true`. A declared key the runtime does not honour is refused rather
38+
than admitted, with the setting that does work as its prescription. It comes
39+
back, with a provider vocabulary, in the change that makes `SmsServicePlugin`
40+
honour it.
41+
42+
**`providerOptions`** is refused WHOLE rather than narrowed to its non-secret
2443
members, and the difference from mail's `options` is measured, not a
2544
preference. The mail reader layers `OS_EMAIL_SMTP_*` over `config.email.options`
2645
per key, so a file carrying host and user composes with an environment
2746
carrying the password. The SMS reader hands `providerOptions` to the
2847
transport as written, and nothing merges an environment credential into it:
2948
a Twilio or Aliyun transport built from the non-secret members alone cannot
30-
be constructed, so the plugin falls back to its log transport. A narrowed
31-
`providerOptions` would therefore be a declared key no deployment could ever
32-
deliver through. SMS provider settings — credentials included — live in the
33-
`sms` settings namespace (Setup → Settings → SMS Delivery), whose every key
34-
accepts its `OS_SMS_*` environment override, and the refusal says so.
49+
be constructed, so the plugin falls back to its log transport.
3550

3651
Why the credentials stop at this door at all: the stack definition is what
3752
`objectstack build` compiles into the artifact, and artifacts are published.
53+
SMS provider settings — the provider and its credentials — live in the `sms`
54+
settings namespace (Setup → Settings → SMS Delivery), whose every key
55+
accepts its `OS_SMS_*` environment override; every refusal here says so.
3856

3957
<Callout type="info">
4058
**Source:** `packages/spec/src/system/sms-config.zod.ts`
@@ -43,24 +61,13 @@ Why the credentials stop at this door at all: the stack definition is what
4361
## TypeScript Usage
4462

4563
```typescript
46-
import { SmsProviderSchema, StackSmsConfigSchema } from '@objectstack/spec/system';
47-
import type { SmsProvider, StackSmsConfig } from '@objectstack/spec/system';
64+
import { StackSmsConfigSchema } from '@objectstack/spec/system';
65+
import type { StackSmsConfig } from '@objectstack/spec/system';
4866

4967
// Validate data
50-
const result = SmsProviderSchema.parse(data);
68+
const result = StackSmsConfigSchema.parse(data);
5169
```
5270

53-
---
54-
55-
## SmsProvider
56-
57-
### Allowed Values
58-
59-
* `log`
60-
* `aliyun`
61-
* `twilio`
62-
63-
6471
---
6572

6673
## StackSmsConfig
@@ -69,8 +76,7 @@ const result = SmsProviderSchema.parse(data);
6976

7077
| Property | Type | Required | Description |
7178
| :--- | :--- | :--- | :--- |
72-
| **provider** | `Enum<'log' \| 'aliyun' \| 'twilio'>` | optional | SMS transport tag (OS_SMS_PROVIDER env wins). Default log. Credentials never come from the stack: the delivering aliyun / twilio transport is built at kernel:ready from the sms settings namespace (Setup → Settings → SMS Delivery, or its OS_SMS_* env overrides), whose own provider setting decides |
73-
| **retries** | `integer` | optional | Retry attempts on transport throw (default 0) |
79+
| **retries** | `integer` | optional | Retry attempts on transport throw (default 0); applies to whichever transport the sms settings namespace selects. The provider and its credentials are not authorable here: Setup → Settings → SMS Delivery, or OS_SMS_PROVIDER and the other OS_SMS_* overrides |
7480

7581

7682
---

‎packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts‎

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// A `defineStack`-built `email` / `sms` / `appName` reaches the providers
4-
// `objectstack serve` constructs — and the SMS provider vocabulary the stack
5-
// declares is the one the transports can build.
4+
// `objectstack serve` constructs.
65
//
76
// The stack definition used to refuse all three keys while the mail and SMS
87
// readers read them, so the configuration arm of both readers was reachable
@@ -17,23 +16,16 @@
1716
// pinned by the source scan in `serve-auth-app-name.contract.test.ts`; the two
1817
// files together are the reading from authored stack to constructed provider.
1918
//
20-
// The second half is a CROSS-PACKAGE vocabulary assertion, for the reason
21-
// `@objectstack/plugin-email`'s `spec-provider-parity.contract.test.ts` gives
22-
// for mail: `SmsProviderSchema` (the authoring contract) and
23-
// `SMS_TRANSPORT_PROVIDERS` (the array `makeSmsTransport` switches on) are two
24-
// literals describing one vocabulary, and two literals left alone drift. It
25-
// lives here because this package depends on both `@objectstack/spec` and
26-
// `@objectstack/service-sms`; the SMS package's own tests read no spec value.
27-
// This package's `tsconfig.json` includes `src`, tests and all, so the
28-
// compile-time half below is real.
19+
// `sms` carries `retries` alone: the stack refuses `sms.provider` by name,
20+
// because the delivering SMS transport is built by the `sms` settings
21+
// namespace from its own provider setting (`OS_SMS_PROVIDER`), so the provider
22+
// below always comes from the environment.
2923

3024
import { describe, it, expect } from 'vitest';
3125
import { resolveCapabilityArgument } from '@objectstack/core';
3226
import { defineStack } from '@objectstack/spec';
33-
import { SmsProviderSchema, type SmsProvider } from '@objectstack/spec/system';
3427
import * as pluginEmail from '@objectstack/plugin-email';
3528
import * as serviceSms from '@objectstack/service-sms';
36-
import { SMS_TRANSPORT_PROVIDERS, type SmsProviderTag } from '@objectstack/service-sms';
3729

3830
const manifest = {
3931
id: 'com.example.mailer',
@@ -79,17 +71,17 @@ describe('a defineStack-built email / sms / appName reaches the providers serve
7971
expect(pluginEmail.resolveDeploymentAppName(stack.email ?? {}, {}, stack.appName)).toBe('Acme');
8072
});
8173

82-
it('sms { provider, retries } reaches the SMS provider', () => {
83-
const stack = defineStack({ manifest, sms: { provider: 'twilio', retries: 3 } });
84-
expect(argumentFor('sms', stack, {})).toEqual({ provider: 'twilio', retries: 3 });
74+
it('sms { retries } reaches the SMS provider, beside the provider OS_SMS_PROVIDER names', () => {
75+
const stack = defineStack({ manifest, sms: { retries: 3 } });
76+
expect(argumentFor('sms', stack, {})).toEqual({ provider: 'log', retries: 3 });
77+
expect(argumentFor('sms', stack, { OS_SMS_PROVIDER: 'twilio' })).toEqual({ provider: 'twilio', retries: 3 });
8578
});
8679

87-
it('OS_EMAIL_* / OS_SMS_PROVIDER still override the stack per key', () => {
88-
const stack = defineStack({ manifest, email: { provider: 'log', retries: 1 }, sms: { provider: 'log' } });
80+
it('OS_EMAIL_* still override the stack per key', () => {
81+
const stack = defineStack({ manifest, email: { provider: 'log', retries: 1 } });
8982
const email = argumentFor('email', stack, { OS_EMAIL_PROVIDER: 'smtp', OS_EMAIL_SMTP_HOST: 'relay.example', OS_EMAIL_RETRIES: '4' });
9083
expect(email.provider).toBe('smtp');
9184
expect(email.retries).toBe(4);
92-
expect(argumentFor('sms', stack, { OS_SMS_PROVIDER: 'aliyun' }).provider).toBe('aliyun');
9385
});
9486

9587
it('CONTROL — env-only configuration is unchanged: a stack with no block builds what the reader builds from no config', () => {
@@ -110,24 +102,3 @@ describe('a defineStack-built email / sms / appName reaches the providers serve
110102
expect(argumentFor('sms', stack, env)).toEqual({ provider: 'aliyun' });
111103
});
112104
});
113-
114-
/**
115-
* Compile-time halves of the vocabulary invariant: each union must be
116-
* assignable to the other, so a member added on one side only makes `true`
117-
* unassignable — a type error in this package's `typecheck`.
118-
*/
119-
type SpecAssignableToTransport = SmsProvider extends SmsProviderTag ? true : never;
120-
type TransportAssignableToSpec = SmsProviderTag extends SmsProvider ? true : never;
121-
const MUTUALLY_ASSIGNABLE: [SpecAssignableToTransport, TransportAssignableToSpec] = [true, true];
122-
123-
describe('SmsProviderSchema ↔ SMS_TRANSPORT_PROVIDERS', () => {
124-
it('declares exactly the providers the SMS transports can build, both directions', () => {
125-
expect(MUTUALLY_ASSIGNABLE).toEqual([true, true]);
126-
const declared = [...SmsProviderSchema.options].sort();
127-
const buildable = [...SMS_TRANSPORT_PROVIDERS].sort();
128-
expect(declared.filter((p) => !buildable.includes(p as SmsProviderTag))).toEqual([]);
129-
expect(buildable.filter((p) => !declared.includes(p))).toEqual([]);
130-
// Anti-vacuity: two empty lists would agree.
131-
expect(buildable.length).toBeGreaterThan(1);
132-
});
133-
});

‎packages/spec/api-surface/system.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -615,8 +615,6 @@
615615
"SettingsUnsubscribe (type)",
616616
"Sha256Digest (type)",
617617
"Sha256DigestSchema (const)",
618-
"SmsProvider (type)",
619-
"SmsProviderSchema (const)",
620618
"SocialProviderConfig (type)",
621619
"SocialProviderConfigParsed (type)",
622620
"SocialProviderConfigSchema (const)",

‎packages/spec/authorable-surface/system.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1125,7 +1125,6 @@
11251125
"system/StackServerConfig:security",
11261126
"system/StackServerConfig:trustProxy",
11271127
"system/StackServerSecurity:rateLimit",
1128-
"system/StackSmsConfig:provider",
11291128
"system/StackSmsConfig:retries",
11301129
"system/StorageConnection:accessKeyId",
11311130
"system/StorageConnection:accountKey",

‎packages/spec/declaration-map/system.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,6 @@
398398
"SettingsNamespacePayloadSchema": "system/SettingsNamespacePayload",
399399
"Sha256Digest": "system/Sha256Digest",
400400
"Sha256DigestSchema": "system/Sha256Digest",
401-
"SmsProvider": "system/SmsProvider",
402-
"SmsProviderSchema": "system/SmsProvider",
403401
"SocialProviderConfig": "system/SocialProviderConfig",
404402
"SocialProviderConfigSchema": "system/SocialProviderConfig",
405403
"Span": "system/Span",

0 commit comments

Comments
 (0)