Skip to content

Commit 5c8be5a

Browse files
committed
feat(client): declare the three response keys environments.create() really receives (#12883)
`client.environments.create()` declared its unwrap shape as the single key `environment`, while `POST /api/v1/cloud/environments` answers 201 with `warnings`, `durationMs` and a conditional `hostnameAssignment` as well. `warnings` is the partial-degradation channel, so no SDK caller could learn what a provision failed to do without an `as any`. Per the 2026-08-29 maintainer ruling (verbatim 「同意」, option 甲) the three keys are declared as the INLINE WIRE SHAPE and are NOT bound to `@objectstack/spec/cloud`'s `ProvisionEnvironmentResponseSchema`, honouring the namespace docblock's recorded snake_case constraint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
1 parent 3ec8646 commit 5c8be5a

3 files changed

Lines changed: 174 additions & 5 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
feat(client): `environments.create()` declares the three response keys the control plane really sends — `warnings`, `durationMs`, and a conditional `hostnameAssignment` (#12883)
6+
7+
`client.environments.create()` declared its unwrap shape as the single key
8+
`environment`, while `POST /api/v1/cloud/environments` answers `201` with three
9+
more. `warnings` in particular is the channel a partially-degraded provision
10+
uses to report what it could not do, and no SDK caller could reach it without
11+
an `as any`.
12+
13+
This is **additive**: `environment` keeps its shape and every existing call site
14+
keeps compiling. What changes is that three previously-erased keys are now
15+
declared and reachable.
16+
17+
```ts
18+
const res = await client.environments.create({ organization_id, display_name });
19+
20+
res.warnings; // string[] — partial-degradation channel, no cast needed
21+
res.durationMs; // number
22+
res.hostnameAssignment; // optional — present ONLY when the control plane
23+
// renamed a colliding hostname; absence stays absence
24+
```
25+
26+
Per the 2026-08-29 maintainer ruling (verbatim 「同意」, option 甲) the three keys
27+
are typed as the **inline wire shape** and are deliberately **not** bound to
28+
`@objectstack/spec/cloud`'s `ProvisionEnvironmentResponseSchema`: those are
29+
camelCase row contracts, and the `/api/v1/cloud/*` control plane this namespace
30+
calls speaks snake_case — the constraint already recorded on the namespace
31+
docblock. Binding them would typecheck and be false.
32+
33+
The request side of the same method is untouched and remains a separate card.

packages/client/src/client.environments-namespace.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,53 @@ export function createDeclaresNoDatabaseBlock(): void {
121121
void created.database;
122122
}
123123

124+
/**
125+
* `create` declares the three keys the route sends BESIDE `environment`.
126+
*
127+
* ⚖️ Maintainer ruling, 2026-08-29, verbatim: 「同意」 — option 甲. `warnings`
128+
* and `durationMs` are declared PRESENT, `hostnameAssignment` OPTIONAL (the
129+
* producer's own "absence stays absence" contract), all three typed as the
130+
* INLINE WIRE SHAPE and ⛔ NOT bound to `@objectstack/spec/cloud`'s
131+
* `ProvisionEnvironmentResponseSchema` — those are camelCase row contracts for
132+
* a control plane that speaks snake_case on `/api/v1/cloud/*` (#11925/#12036).
133+
*
134+
* Each read below is a separate assertion on purpose: a key that regresses on
135+
* its own is named by the failure instead of hidden behind a sibling's.
136+
*/
137+
export function createDeclaresTheWireResponseKeys(): void {
138+
const created = {} as CreateShape;
139+
140+
// PRESENT, not optional. Assigning into a non-optional local is the pin:
141+
// weakening either key to `?` puts `undefined` in the type, which stops
142+
// being assignable, and the red lands on the declaration rather than in
143+
// some caller months later.
144+
const warnings: string[] = created.warnings;
145+
const durationMs: number = created.durationMs;
146+
void warnings;
147+
void durationMs;
148+
149+
// OPTIONAL by the producer's contract — forwarded only when the control
150+
// plane renamed a colliding hostname, so `hostnameAssignment !== undefined`
151+
// is itself the signal. The `@ts-expect-error` IS the optionality
152+
// assertion: it holds only while `undefined` is part of the type, so
153+
// promoting the key to required makes the error vanish and this line goes
154+
// red as an unused expect-error.
155+
// @ts-expect-error `hostnameAssignment` is optional; a non-optional local cannot take its `undefined`
156+
const assignment: { requestedHostname: string; assignedHostname: string } = created.hostnameAssignment;
157+
void assignment;
158+
void created.hostnameAssignment?.requestedHostname;
159+
void created.hostnameAssignment?.assignedHostname;
160+
161+
// ⛔ `credential` stays UNDECLARED, and this pin is what keeps it that way.
162+
// `ProvisionEnvironmentResponseSchema` declares it REQUIRED while the
163+
// producer body this declaration was written against does not send it —
164+
// an unjudged divergence that is NOT settled by copying the key here.
165+
// Declaring it would make the SDK promise a key the wire does not carry,
166+
// which is this method's own defect class pointed the other way.
167+
// @ts-expect-error `POST /cloud/environments` is not declared to answer a `credential`; `rotateCredential` is the method that does
168+
void created.credential;
169+
}
170+
124171
/** Helper: a client whose `fetch` answers one canned BaseResponse envelope. */
125172
function clientAnswering(data: unknown) {
126173
const fetchMock = vi.fn().mockResolvedValue({
@@ -202,6 +249,60 @@ describe('[ADR-0006 D2] client.environments — the control-plane namespace afte
202249
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
203250
});
204251

252+
it('relays `warnings` and `durationMs` beside `environment` — reachable without an `as any`', async () => {
253+
const { client } = clientAnswering({
254+
environment: { id: 'env_new', display_name: 'Dev' },
255+
warnings: ['seed package skipped: registry unreachable'],
256+
durationMs: 4210,
257+
});
258+
259+
const res = await client.environments.create({
260+
organization_id: 'org_1',
261+
display_name: 'Dev',
262+
});
263+
264+
// `warnings` is the partial-degradation channel: this read is the whole
265+
// point of the card, and before the declaration widened it needed a cast.
266+
expect(res.warnings).toEqual(['seed package skipped: registry unreachable']);
267+
expect(res.durationMs).toBe(4210);
268+
});
269+
270+
it('relays `hostnameAssignment` when the control plane renamed a colliding hostname', async () => {
271+
const { client } = clientAnswering({
272+
environment: { id: 'env_new', hostname: 'dev-a1b2' },
273+
warnings: [],
274+
durationMs: 900,
275+
hostnameAssignment: { requestedHostname: 'dev', assignedHostname: 'dev-a1b2' },
276+
});
277+
278+
const res = await client.environments.create({
279+
organization_id: 'org_1',
280+
display_name: 'Dev',
281+
});
282+
283+
expect(res.hostnameAssignment?.requestedHostname).toBe('dev');
284+
expect(res.hostnameAssignment?.assignedHostname).toBe('dev-a1b2');
285+
});
286+
287+
it('absence stays absence — no `hostnameAssignment` key when the requested hostname was kept', async () => {
288+
const { client } = clientAnswering({
289+
environment: { id: 'env_new', hostname: 'dev' },
290+
warnings: [],
291+
durationMs: 880,
292+
});
293+
294+
const res = await client.environments.create({
295+
organization_id: 'org_1',
296+
display_name: 'Dev',
297+
});
298+
299+
// `in` rather than a truthiness check: the producer's contract is that
300+
// the KEY is absent, and reading absence as "unknown" is what its own
301+
// schema comment forbids. A relay that materialised `undefined` would
302+
// pass a truthiness assertion while breaking that contract.
303+
expect('hostnameAssignment' in res).toBe(false);
304+
});
305+
205306
it('relays the activate envelope — `environment` beside `sessionUpdated`', async () => {
206307
const { client } = clientAnswering({
207308
environment: { id: 'env_1' },
@@ -232,5 +333,6 @@ describe('[ADR-0006 D2] client.environments — the control-plane namespace afte
232333
expect(typeof listEnvelopeCarriesTheWireKeys).toBe('function');
233334
expect(typeof singleRowEnvelopesCarryTheWireKey).toBe('function');
234335
expect(typeof createDeclaresNoDatabaseBlock).toBe('function');
336+
expect(typeof createDeclaresTheWireResponseKeys).toBe('function');
235337
});
236338
});

packages/client/src/index.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2194,11 +2194,45 @@ export class ObjectStackClient {
21942194
// promised could not happen. `get` is the method that really does
21952195
// answer a `database` block.
21962196
//
2197-
// The keys the route DOES send beside `environment` (`warnings`,
2198-
// `durationMs`, and a conditional `hostnameAssignment`) are deliberately
2199-
// not declared here — adding them is new published surface and a
2200-
// separate decision, not part of this rename.
2201-
return this.unwrapResponse<{ environment: any }>(res);
2197+
// The three keys the route sends BESIDE `environment` are declared here
2198+
// as of 2026-08-29. That absence used to be deliberate and this comment
2199+
// used to say so; the decision it was waiting for has since been made, so
2200+
// the stance is recorded rather than left standing:
2201+
//
2202+
// ⚖️ Maintainer ruling, 2026-08-29, verbatim: 「同意」— option 甲.
2203+
// `warnings` and `durationMs` are declared PRESENT, `hostnameAssignment`
2204+
// OPTIONAL (the producer's own "absence stays absence" contract), and
2205+
// all three are typed as the INLINE WIRE SHAPE.
2206+
//
2207+
// ⛔ Inline, and NOT bound to `@objectstack/spec/cloud`'s
2208+
// `ProvisionEnvironmentResponseSchema`. That is the namespace docblock's
2209+
// #11925/#12036 constraint applied to the response side: those contracts
2210+
// are camelCase row types for a control plane that speaks snake_case on
2211+
// `/api/v1/cloud/*`, so binding them would typecheck and be false. The
2212+
// ruling names the inline shape for that reason.
2213+
//
2214+
// ⚠️ Two facts a later reader must not have to rediscover:
2215+
//
2216+
// - The producer shape is an INHERITED reading, not one measured from
2217+
// this repo. `objectstack-ai/cloud` is not readable from here, so the
2218+
// handler body quoted on the card (`packages/service-cloud/src/routes/
2219+
// environment-lifecycle.ts`, POST `/cloud/environments`, spreading
2220+
// `environment` / `warnings` / `durationMs` / conditional
2221+
// `hostnameAssignment`) is the card author's 2026-08-28 measurement,
2222+
// relayed. No gate in this repo can check it.
2223+
// - `ProvisionEnvironmentResponseSchema` additionally declares a REQUIRED
2224+
// `credential`, which that handler quote does not send, and marks
2225+
// `warnings` `.optional()`, which the ruling declares present. Both
2226+
// divergences are recorded and UNJUDGED; neither is settled here. The
2227+
// `credential` one is why binding the schema is not the safe default
2228+
// it looks like: it would make this SDK declare a key the wire does
2229+
// not carry — this method's own defect class, pointed the other way.
2230+
return this.unwrapResponse<{
2231+
environment: any;
2232+
warnings: string[];
2233+
durationMs: number;
2234+
hostnameAssignment?: { requestedHostname: string; assignedHostname: string };
2235+
}>(res);
22022236
},
22032237

22042238
/**

0 commit comments

Comments
 (0)