Skip to content

feat(spec): the stack definition admits appName and the non-secret email / sms members; their credentials are refused by name - #22778

Merged
os-zhuang merged 7 commits into
mainfrom
claude/issue-22748-stack-email-sms-door
Oct 11, 2026
Merged

os-zhuang merged 7 commits into
mainfrom
claude/issue-22748-stack-email-sms-door

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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

…ack-email-sms-door

# Conflicts:
#	packages/spec/src/type-alias-convention.pin.test.ts
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:system tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 15 documentable anchor(s). ⚠️ 8 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/authorable-defaults/system.json, packages/spec/authorable-surface/system.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/environment-variables.mdx (via appName (symbol, a field of const object COMPOSE_KEY_DISPOSITIONS; a field of const object STACK_DEFINITION_COLLECTIONS_SHAPE))
  • content/docs/getting-started/examples.mdx (via COMPOSE_KEY_DISPOSITIONS (symbol, a top-level const object))
  • content/docs/getting-started/quick-start.mdx (via appName (symbol, a field of const object COMPOSE_KEY_DISPOSITIONS; a field of const object STACK_DEFINITION_COLLECTIONS_SHAPE))
  • content/docs/permissions/authentication.mdx (via appName (symbol, a field of const object COMPOSE_KEY_DISPOSITIONS; a field of const object STACK_DEFINITION_COLLECTIONS_SHAPE))
  • content/docs/ui/setup-app.mdx (via appName (symbol, a field of const object COMPOSE_KEY_DISPOSITIONS; a field of const object STACK_DEFINITION_COLLECTIONS_SHAPE))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via COMPOSE_KEY_DISPOSITIONS (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 8 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/authorable-defaults/system.json, packages/spec/authorable-surface/system.json, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 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 a2e94c2a053ab004b5cb9fea27824bb614e5759c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ac63919b0af569f8ddb2eec979c629ffd1079857 — the merge of head 34286f646a982afcdc8ecdc962ad1afeaa93d043 into base a2e94c2a053ab004b5cb9fea27824bb614e5759c, 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 ac63919b0af569f8ddb2eec979c629ffd1079857 && git checkout ac63919b0af569f8ddb2eec979c629ffd1079857
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a2e94c2a053ab004b5cb9fea27824bb614e5759c 34286f646a982afcdc8ecdc962ad1afeaa93d043 && git checkout -B drift-repro a2e94c2a053ab004b5cb9fea27824bb614e5759c && git merge --no-ff 34286f646a982afcdc8ecdc962ad1afeaa93d043

node scripts/docs-audit/affected-docs.mjs --json a2e94c2a053ab004b5cb9fea27824bb614e5759c

⚠️ 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 a2e94c2a053ab004b5cb9fea27824bb614e5759c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 34286f646a982afcdc8ecdc962ad1afeaa93d043
Local-runs: none

Inputs: card #22748 (body; comments 6104830037 triage, 6105299924 claim, 6106330513 and 6106819772 dev reports, 6106350788 seat order, 6106836535 ACCEPT, the last three read as claims), PR #22778 (body, 25 files, net diff against main at merge-base a2e94c2a05), the 42 check-runs on the head. Every sentence below was verified with git show on the head or the merge-base; the seat's conclusions were not adopted.

① Derived judgments

Security — can a secret reach a built artifact through the stack definition? No. The three readers were enumerated at the head and every key each one reads off config was classified:

  • resolveEmailCapabilityArg (packages/plugins/plugin-email/src/capability-arg.ts:152–:226) reads provider, apiKey, defaultFrom, retries, queueDelivery, persist, appName, defaultTemplateContext, options (spread under OS_EMAIL_SMTP_*, so options.host / port / secure / user / password reach the SMTP transport; makeTransport spreads options over apiKey for resend / postmark, transports/index.ts:150–:152, and reads options.messageStream for postmark).
  • resolveDeploymentAppName (same file :79–:86) reads email.appName, email.defaultTemplateContext.appName, top-level appName.
  • resolveSmsCapabilityArg (packages/services/service-sms/src/capability-arg.ts:73–:89) reads provider, providerOptions, retries.
  • resolveCapabilityArgument (packages/core/src/capability-composition.ts:210–:226) reads stack.email, stack.appName, stack.sms and hands them to the two readers.

Admitted on the stack (StackEmailConfigSchema, StackEmailProviderOptionsSchema, StackSmsConfigSchema, top-level appName): email.provider / defaultFrom / retries / persist / queueDelivery / appName / defaultTemplateContext, email.options.host / port / secure / user / messageStream, sms.retries, appName. None is a credential. Refused by name through strictObject guidance: email.apiKey, email.options.apiKey, email.options.password, sms.provider, sms.providerOptions, sms.authToken, sms.accessKeySecret. Right.

  • All three blocks are strictObject (closed, terminal unknown-key refusal; shared/strict-object.ts), so email.options has no passthrough; the control pin refuses options.transportOptions (the SMTP escape hatch that can carry credentials) and sms.bogus. Right.
  • email.defaultTemplateContext is a free Record inherited from EmailServiceConfigSchema. Judged: no reader treats any member of it as a credential (the reader spreads it into the template render context, capability-arg.ts:203; email-service.ts:1342 spreads it into render data). It is template data, not a secret-bearing member; the residual is author misuse, and the triage asked for the non-secret members of the existing schema, which this is. Right, with that note.
  • The derivation pin asserts EmailServiceConfigSchema.shape minus StackEmailConfigSchema.shape is exactly ['apiKey'], so a future secret added to the operator contract has to be omitted on purpose. Right.

Honoured at runtime — every admitted key reaches a reader from a defineStack-built stack. serve calls resolveCapabilityArgument(cap, { stack: config, providerModule: mod, env: process.env }) (serve.ts:4641) and builds AuthPlugin's name from resolveDeploymentAppName(config.email, process.env, config.appName) (serve.ts:3941); bootStack calls the same function with stack: opts.config (packages/verify/src/required-providers.ts:153, env defaulting to process.env). Each admitted mail key maps to a line of the reader above; top-level appName is the reader's bottom configured rung and AuthPlugin's name.

  • sms.retries alone: the dev's measurement is true by the code. resolveSmsCapabilityArg passes retries (:87); SmsServicePlugin.init constructs SmsService({ transport, configured, retries: this.options.retries, … }) (sms-plugin.ts:206–:213); applySmsSettings swaps the transport through setTransport, which writes only options.transport and options.configured (sms-service.ts:104–:107); send reads this.options.retries (:155). So retries applies to whichever transport the sms settings namespace selects. It has no other carrier (the reader reads only cfgSms.retries; sms.manifest.ts has no retries key), so refusing sms whole would strand a live knob. Right.
  • sms.provider refused: resolveInitialTransport builds from the constructor's provider + providerOptions and falls back to LogSmsTransport when the build throws (sms-plugin.ts:185–:196), and the delivering transport is rebuilt by applySmsSettings from the settings namespace's own provider (:318–:353), never from the constructor tag. With providerOptions refused a stack can carry no credential, so a stack's provider can never select a delivering transport. A new declaration the runtime does not honour is refused rather than admitted (seat order B). Right. Re-admission belongs to the fix in @objectstack/service-sms (service-sms: a configured sms.provider never selects a delivering transport unless OS_SMS_PROVIDER is also set — applySmsSettings decides from the settings namespace and no env credential reaches providerOptions #22789 per ACCEPT).
  • sms.providerOptions refused whole, not narrowed: the SMS reader hands it to the transport as written and nothing layers an env credential into it (contrast the mail reader, which spreads OS_EMAIL_SMTP_* over options per key); every non-log transport requires a credential inside it (sms.manifest.ts:95, :99). A narrowed block would be a declared key no deployment can deliver through. Right.
  • No admitted key is unread. Right.

By-name refusals — message, env var, and where they fire.

  • Each guidance entry produces "Unrecognized key(s) on the stack email block: apiKey." plus the prescription bullet naming the setting; the pins assert path, key, STACK_SCHEMA_INVALID / 422 and the env var in the message. The env var names all exist in the readers: OS_EMAIL_API_KEY (capability-arg.ts:153), OS_EMAIL_SMTP_PASSWORD (:225), OS_SMS_PROVIDER (sms capability-arg.ts:73). The six provider-credential names (OS_SMS_TWILIO_ACCOUNT_SID / _AUTH_TOKEN / _FROM_NUMBER, OS_SMS_ALIYUN_ACCESS_KEY_ID / _ACCESS_KEY_SECRET / _SIGN_NAME) are the settings namespace's derived overrides: envKeyOf(namespace, key) is OS_ + upper(sms_ + key) (service-settings/src/settings-service.types.ts:403–:406) over the sms.manifest.ts keys twilio_account_sid, twilio_auth_token, twilio_from_number, aliyun_access_key_id, aliyun_access_key_secret, aliyun_sign_name. All seven real. Right.
  • The refusal is the ObjectStackDefinitionSchema parse, so it fires at defineStack, and therefore at os validate / os build (which refuse a config defineStack did not build). Right.
  • Nothing that parsed before fails now: on the merge-base stack.zod.ts declares no email, sms or appName and carries no guidance for them (they were bare unrecognized keys), so every previously accepted stack lacks all three and is unchanged; the control pins hold that, and the four example apps validate byte-identically (dev measurement, consistent with the schema). Right.
  • aliases: { from: 'defaultFrom' }: the triage spelled the pin email: { provider, from }, but the reader reads defaultFrom; admitting from would be a declared key no runtime reads, so pointing from at defaultFrom is the correct reading of "from-address". Right.
  • Form of the refusal (guidance idiom, issue code stays unrecognized_keys): the stack's own idiom for wrong-layer keys (storage, server.port), ruled A by the seat; keeps secret names out of the JSON schema, the docs and the authorable surface. Right.

Merge semantics. appName, email, sms are 'single' in COMPOSE_KEY_DISPOSITIONS; composeSingleValue passes deepEqualAuthored values through and throws StackComposeKeyConflictError naming the key and both stacks on a difference (stack.zod.ts:4280–:4300). That is the right rule for how serve and bootStack read them: one EmailServicePlugin, one SmsServicePlugin and one AuthPlugin are constructed from the one composed config, so last-wins or deep-merge would silently drop a declaration such as persist: false. Not concat, so the keys stay out of the assembled package body; the two envelope-key pins (assembled-package-body.test.ts, option-b-reader-acceptance.pin.test.ts) were extended accordingly. Right.

Generated artifacts and docs match their sources.

  • api-surface/system.json +9 and export-origins/system.json +9: the 3 new schema consts and 6 types exported from email-config.zod.ts / sms-config.zod.ts (barrel line added in system/index.ts). declaration-map/system.json +6: schema + input-type names, Parsed types excluded as for the neighbouring StackServerConfig. json-schema.manifest/system.json +3. authorable-surface/system.json +14: 8 StackEmailConfig members, 5 StackEmailProviderOptions, 1 StackSmsConfig. authorable-defaults/system.json +1: StackEmailConfig:provider = "log", the inherited default. All match.
  • llms.txt 203 to 204 modules, system 34 to 35 (one new .zod.ts); the row's "Compliance" (a schema retired under ADR-0056, per system/index.ts) replaced by "Email / SMS Config". quick-reference.mdx 34 to 35. references/index.mdx 1517 to 1520 schemas, 195 to 196 pages, system 35 pages / 278 schemas. email-config.mdx tables match the derived shape and describes; sms-config.mdx matches; system/index.mdx and meta.json add the page. Strictness ledger counts 355 to 358: three new strictObject sites. All match. CI runs check:export-origins, check:authorable-surface (manifest / surface / defaults), check:docs, check:api-surface, check:llms-txt, check:quick-reference-counts, check:skill-top-level-keys and the skills token ratchet in Lint & Repo Gates, which is green.
  • SmsProviderSchema removed cleanly: absent from origin/main, the merge-base and the head (git grep on all three), never released; type-alias-convention.pin.test.ts is not in the PR's file list, so it equals main. Right.

Skill edit (skills/objectstack-platform/SKILL.md, Tier H). Forced: scripts/check-skill-top-level-keys.mjs reads COMPOSE_KEY_DISPOSITIONS, holds out viewItems / runtimeModule, and requires the page's enumeration to list every other key. The diff appends the three keys to the existing enumeration line and deletes "; the input type is ObjectStackDefinitionInput", which the section's opening sentence ("defineStack() accepts an ObjectStackDefinitionInput whose top-level keys are …") already states. True and minimal.

② Semver level

.changeset/22748-stack-email-sms-door.md: "@objectstack/spec": minor. The diff widens the accept set (three top-level keys admitted that were refused) and adds public exports; AGENTS.md:1076 says Clause-②: yes takes at least minor and (widening) is not breaking. Right. No other published package changes: packages/cli gains a test file only, packages/core is untouched. The Clause-②: line is byte-identical across the changeset (line 7), PR body line 2 and claim 6105299924 (sha256 538f7cd7… for all three): Clause-②: yes (widening: the stack definition admits appName, the non-secret members of email, and smscarryingretries alone, which it refused as unrecognized keys). check-changeset-no-major and check-adr-0087-registration run in the Check Changeset job, green on the head.

③ Boundary flags

Report 6106330513 (round 0):

  • Tier H surface forced by check:skill-top-level-keys: answered, verified above; token-neutral edit under the ratchet, which is green.
  • File-surface additions forced by gates (option-b-reader-acceptance.pin, assembled-package-body.test, type-alias-convention.pin, llms.txt, quick-reference.mdx, quick-start.mdx): answered, each verified; the type-alias-convention.pin change was reverted with SmsProviderSchema in round 1.
  • sms.providerOptions refused whole: answered, right by the reader's code (no env credential layered in).
  • SMS provider-vocabulary parity pin placed in the cli rider: moot, removed with SmsProviderSchema.
  • Guidance idiom rather than a declared z.never key: answered by seat order A; verified the message names the key and the setting.
  • The -- launch trap: process note, no reading taken from it; nothing to judge.
  • Open question 1 (A) and 2 (B): answered by 6106350788 and implemented as ordered (verified).
  • Out-of-scope: sms.provider not honoured by SmsServicePlugin: escalated as service-sms: a configured sms.provider never selects a delivering transport unless OS_SMS_PROVIDER is also set — applySmsSettings decides from the settings namespace and no env credential reaches providerOptions #22789 (domain:services) per ACCEPT. resolveEmailCapabilityArg's missing-key text still offers "or config.email.apiKey": noted, not blocking; the claim bars reader changes and the text stays true for a raw serve config, but a built stack now refuses that form, so the services lane should drop the clause when it next touches the reader. SMTP timeout / transportOptions and resend / postmark endpoint are read but undeclared and so refused by the strict block: right posture, transportOptions can carry credentials. Local concurrency flake: not this diff.

Report 6106819772 (patch round 1):

  • sms keeps retries alone: answered, true by SmsService (above).
  • SmsProviderSchema removed with its only consumer: answered, verified absent on main, never released.
  • Baselines restored from main's side and regenerated, diff additions only: answered, json-schema.manifest +3/-0 and authorable-surface +14/-0 confirm it; no retired-def entry owed.
  • Clause-② line changed with the shape: answered, byte-identical in all three places.
  • Merge conflict resolution: answered, the pin file equals main.
  • Docs-drift re-read: answered; a git grep of hand-written content/docs for appName finds only the updated quick-start.mdx list and an unrelated setup-app.mdx API reference.
  • open_questions: none.

Residuals carried forward, none blocking: email.defaultTemplateContext stays a free record by inheritance (no reader treats a member as a credential); the ACCEPT's CI tally (33 / 2) predates later runs and is now 38 success / 4 skipped.

Check-runs on the head: 42 completed: 38 success, 4 skipped (Auto Label and Check PR Size duplicates on a path filter, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, 0 in progress.

Implemented-by: claude/issue-22748-stack-email-sms-door
Reviewed-by: session_01S3aAf11JjbW1mSGL1EhfFj

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 · PR #22778(#22748:stack 定义开放 appName 与 email / sms 的非密钥配置,密钥只走环境变量)

domain:spec seat 1(#6017)· os-project-manager · session session_01S3aAf11JjbW1mSGL1EhfFj · 2026-10-11T08:00Z · 终稿。本 PR 动了已发布技能目录 skills/objectstack-platform/SKILL.md 的一行(受管面 Tier H),需要你的手合并,或授权账号的 APPROVED。

改了什么

  • defineStack 的 stack 定义现在接受 appName、email 的非密钥成员,以及只含 retries 的 sms:
    • email 接受:provider、defaultFrom、retries、queueDelivery、persist、appName、defaultTemplateContext,以及严格的 options(host / port / secure / user / messageStream)。
    • 以前这三个键都被当成「未知键」拒掉,所以 EmailServiceConfigSchema 文档里写的 config.email 根本写不进任何能构建的 stack。
  • 密钥类成员按名拒绝,报错直接给出替代的环境变量,例如 email.apiKey → OS_EMAIL_API_KEY、email.options.password → OS_EMAIL_SMTP_PASSWORD、sms.providerOptions 及其中的 token → 设置页「SMS Delivery」或 OS_SMS_*。
  • sms.provider 暂时也按名拒绝,指向 OS_SMS_PROVIDER / 设置页。原因:实测短信插件不认构造参数里的 provider,写在 stack 里永远选不到真正能发信的通道。修复见 service-sms: a configured sms.provider never selects a delivering transport unless OS_SMS_PROVIDER is also set — applySmsSettings decides from the settings namespace and no env credential reaches providerOptions #22789(services 车道),修好后再开放。

为什么改

风险与代价(含回滚)

  • 受管面: skills/objectstack-platform/SKILL.md 改一行(+2/−3),token 数 5831 → 5826,由 check:skill-top-level-keys 门禁强制(技能页列出的顶层键必须与 stack 一致)。删掉的是一句重复的话。
  • 残余: email.defaultTemplateContext 是自由记录,只作邮件模板渲染数据,读取方不把其中任何成员当凭据。技术上作者能往里写任何字符串,但没有读取路径会把它当密钥用。
  • 行为变化只有放宽: 以前能解析的配置现在全都照旧能解析;四个示例应用 os validate 输出逐字节不变。
  • 回滚: 单个 squash 提交,revert 即可。

席位意见

  • 契约复审档复核 PASS:PR 评论 6106904303。它逐个核对了三个读取方读取的每个配置键,没有一个开放的键是凭据;每个开放的键都有运行时读取;每条按名拒绝给出的环境变量名都真实存在。
  • CI 在 head 34286f646a 上全绿:38 通过,4 个跳过均为预期。
  • 推荐批准。

你要做的(一个动作)

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 protocol:system size/l tests tooling

Projects

None yet

3 participants