Skip to content

[Decision] environmentType on POST /cloud/environments: refuse the values the wire cannot honour, honour all seven, or leave the silent default? (work card: objectstack-ai/cloud#2041) #17427

Closed

Description

@hotlong

Raised by the review of objectstack-ai/cloud#2037 (step 1 of the #16325 chain) and measured on cloud 9b85d761. The work card is objectstack-ai/cloud#2041, which carries the full measurement; it is pm:blocked on this one. Filed here because the maintainer's inbox is this repo.

⚠️ cloud#2041 was mis-graded pm:queue. Both routes it offers are wire-behaviour changes on a published control-plane route, which is the manual floor — dispatching it would have bounced. Corrected in the same pass as this filing.

What is measured

POST /api/v1/cloud/environments accepts a key called environmentType that no declaration owns, and two layers fold it silently.

  • Route (packages/service-cloud/src/routes/environment-lifecycle.ts → normalizeEnvironmentType in plan-entitlements.ts): honours five spellings — production|prod ⇒ production, development|dev|sandbox ⇒ development — and turns everything else into undefined, which then count-defaults (production if the org has none yet, else development). No error, no warning.
  • Service (packages/service-tenant/src/environment-provisioning.ts): reads environmentType off the RAW request — the request schema never declared it, so z.object strips it — honours the same five, and defaults the rest to isDefault ? production : development. Also silent.

The published vocabulary is wider than either layer honours: the framework's EnvironmentTypeSchema declares seven members — production, sandbox, development, test, staging, preview, trial.

Failure scenario, concrete. POST /api/v1/cloud/environments with environmentType: "staging" — a value the platform's own published enum calls legal — on an org that already has a production env: the route normalises it to undefined, count-defaults to development, and the service provisions a development environment with a dev- system host prefix. The caller's value is discarded and nothing reports it. Identical for test, preview, trial, and for any typo such as prodction.

The resolved type drives sys_environment.environment_type and the opaque host prefix, so this is not cosmetic: the caller gets a different kind of environment than it asked for.

Why cloud#2037 did not settle it

That PR was a relocation. Its first push declared the key as z.string().optional() on the relocated accept gate — widening the accept surface without adding a refusal — and was reworked to drop it: the relocated schema keeps upstream's key set verbatim, the service keeps the raw read, and both tests now pin that the gate STRIPS the key. Declaring it means choosing a vocabulary, and that is a wire decision, not a relocation detail.

The options

  • A — refuse at the gate. Declare the honoured subset on the route's normaliser and on ProvisionEnvironmentRequestSchema, and answer everything else with the ADR-0112 envelope (VALIDATION_ERROR, 400) instead of a silent default. ⚠️ A behaviour change for any caller sending staging today and getting an environment back.
  • B — honour the seven. Route and service accept the published enum and map each member to something the control plane can actually provision. ⚠️ A wire change for the Console and @objectstack/client, and the control plane can only really provision two kinds today, so five members would fold — declaring seven while folding to two reproduces the same defect one level up.
  • C — leave it. The silent default stays; the published enum keeps promising members the wire discards.

⚠️ Either A or B is only observable at the route: the route normalises unknown values to undefined before the service sees them, so a service-level refusal alone can never be reached with a non-canonical value.

  • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 今天这条线上有三套互不相同的词汇:已发布的枚举七个成员、路由认五个拼写、控制面实际只能开两种环境。Prime Directive Add comprehensive test suite for Zod schema validation #12 要的是「契约即那一份声明」,而这里没有任何一份声明是真的。①指向 A:让声明等于线上真正兑现的那个集合,其余响亮拒绝。⛔ 不指向 B —— B 让声明去追一个「七个成员映射到两种实现」的形状,那是把同一个 declared≠enforced 挪高一层,不是消除它。
  • ② 实际业务拉动 —— ⚠️ 这一棱是本卡最大的空洞,且方向由它决定:今天到底有没有调用方在发 staging / test / preview / trial?仓内零消费(CLI 与 Console 都只发 production/development),但这是公开路由,仓外调用方我测不到。若为零,A 的"行为变更"代价是零 —— 没人在依赖那个被丢弃的值;若非零,A 会把今天能拿到环境的调用方变成 400。⇒ ②不改变 A 的方向,但决定 A 该不该带一个过渡期。
  • ③ 防 AI 犯错 —— 最锋利,且两个选项都比现状好。今天的形状是教科书级的静默错误:调用方发一个平台自己公开声明为合法的值,拿回一个不同类型的环境,全程无提示。AI 写集成代码时读到七成员枚举、发了 staging、拿到一个 dev- 环境 —— 它没有任何办法知道自己错了。「声明即强制,绝不让 AI 声明一个运行时不兑现的能力」正是这条。A 让错误在结构上响亮;B 让它消失但代价是实现要真的兑现七种。
  • ④ 创业阶段不扩散 —— 明确反对 B。B 要求控制面新增五种环境类型的真实实现(或者假装实现),而没有任何拉动指向它。A 是收窄:把接受集缩到已经兑现的那个,零新增能力。C 是不动,但它把一个已知的静默错误留在已发布路由上,而这个错误的修复窗口只会随调用方增加而变贵。

推荐:A —— 声明已兑现的子集,其余按 ADR-0112 响亮拒绝。 ①领起(三套词汇收敛成一套真的);③让静默错误在结构上不可能;④零能力新增。②不反对,但它决定 A 的施工形状:见下。
若 ② 测出仓外确有调用方在发更宽的值,A 仍是方向,但施工应带一个明确的过渡窗口(先记录并告警、再拒绝),⛔ 这与「创业阶段不渐进」不冲突 —— 那条纪律管的是废弃别名与能力退役,不管「把一个静默丢弃改成显式拒绝」这种对调用方可见的行为收紧。
⛔ 不荐 B。 七成员映射到两种实现,是把 declared≠enforced 挪高一层保存。⛔ 不荐 C。 它把一个可复现的、用户可达的缺陷留在已发布面上。

Governing text: AGENTS.md Prime Directive #12(contract-first);ADR-0049 enforce-or-remove;ADR-0112 错误信封。


维护者速读

这是什么事 —— 我们的云 API 有个创建环境的接口,它收一个「环境类型」字段。平台自己公开声明这个字段有七种合法值(production、sandbox、development、test、staging、preview、trial),但线上实际只认其中五种拼写、并且只映射成两种环境。

发一个我们自己说合法的值(比如 staging)会怎样?它被静默丢弃,系统按"这个组织有没有生产环境"猜一个给你,然后返回成功。你拿到的环境类型和你要的不一样,而且没有任何提示。

为什么现在拿到你面前 —— 这个字段在上一步的搬迁 PR 里差点被随手声明成"任意字符串",那样等于把这个洞写进契约。我们把它拦下来了,改成单独立卡,因为要声明它就必须先决定它到底认哪些值——那是线上行为决定,不是搬家的细节。

选项 —— A:只认线上真正兑现的那几个,其余明确报错 400(我推荐)。B:让线上真的支持全部七种(代价:控制面今天只能开两种环境,要么真去实现五种,要么假装实现——那还是同一个病)。C:不动(那个静默丢弃继续留在已发布接口上)。

我的意见 —— 推荐 A。现在这条线上有三套互不相同的词汇(公开枚举七个、路由认五个、实现只有两种),没有一套是真的;A 把它们收敛成一套真的,而且零新增能力。

⚠️ 一个我测不到、但会改变 A 施工形状的事:今天有没有仓外的调用方真的在发 staging 这类值?仓内是零(我们自己的 CLI 和控制台都只发两种),但这是公开接口,外面我看不见。如果为零,A 没有任何代价;如果非零,A 会把今天能成功的调用变成报错,那就该带一个过渡期(先告警、后拒绝)。你若知道有外部调用方,说一声。

你要做的 —— 选一个:A / B / C。

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Sep 10, 2026
  2. changed the title [-][Decision] on POST /cloud/environments: refuse the values the wire cannot honour, honour all seven, or leave the silent default? (work card: objectstack-ai/cloud#2041)[/-] [+][Decision] `environmentType` on POST /cloud/environments: refuse the values the wire cannot honour, honour all seven, or leave the silent default? (work card: objectstack-ai/cloud#2041)[/+] on Sep 10, 2026
  3. added theissue type on Sep 10, 2026
  4. os-tesla commented on Sep 11, 2026

    @os-tesla
    Collaborator

    Ruling recorded — A on #17427 — environmentType declares the honoured subset; everything else is refused with the ADR-0112 envelope (director seat, summon #22, session_01QsCVSivtpwT6ZXs5Rtvqxe (GitHub os-tesla)), 2026-09-11T14:08Z

    Provenance: maintainer, director chat, 2026-09-11, decision batch #115, verbatim 「同意」 on the presented recommendations (1A·2A·3B·4A·5A). Freshness gate: no comment newer than the presentation on this card.

    Ruling A. POST /api/v1/cloud/environments declares environmentType on ProvisionEnvironmentRequestSchema and on the route's normaliser as exactly the set the control plane provisions today (production | development, with the currently honoured spellings prod / dev / sandbox mapped or refused — the implementer states which, and the answer is the same at both layers), and answers any other value — staging, test, preview, trial, a typo — with VALIDATION_ERROR / 400 instead of a silent count-default. The published seven-member EnvironmentTypeSchema is the wider vocabulary the wire cannot honour: the request schema narrows to the honoured subset and says so in its describe. ⛔ Not B (seven declared, two provisioned = the same defect one level up); ⛔ not C.

    Transition window: the in-repo callers (CLI, Console) send only the two honoured kinds. If a caller outside the repo is known to send the wider values, the implementer lands the refusal behind one release of log-and-warn (「先告警、后拒绝」); with none known, refuse directly. ⚠️ This is a behaviour tightening on a published route, ⛔ not an alias retirement, so the window is permitted but not required.

    Execution: the work card is objectstack-ai/cloud#2041 (pm:blocked on this card). This session cannot write to the cloud repo; the close below fires the unlock scan, and the cloud seat (vacant — a reachable seat or the maintainer) moves cloud#2041 to pm:queue with Clause-② measured (pulling a route back to the values it honours is a narrowing; declare per the mechanical boundary test). Whether EnvironmentTypeSchema's seven members should themselves shrink is a separate packages/spec question — file it only if a consumer of the wider enum is measured.

    State: needs-user-decision removed; closed completed (decision made; the work lives on cloud#2041).


    Generated by Claude Code

  5. removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:specDomain lane — seat card indexed by label:pm:seat

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions