Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/environments-create-declares-wire-response-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
"@objectstack/client": minor
---

feat(client): `environments.create()` declares the three response keys the control plane really sends — `warnings`, `durationMs`, and a conditional `hostnameAssignment` (#12883)

`client.environments.create()` declared its unwrap shape as the single key
`environment`, while `POST /api/v1/cloud/environments` answers `201` with three
more. `warnings` in particular is the channel a partially-degraded provision
uses to report what it could not do, and no SDK caller could reach it without
an `as any`.

This is **additive**: `environment` keeps its shape and every existing call site
keeps compiling. What changes is that three previously-erased keys are now
declared and reachable.

```ts
const res = await client.environments.create({ organization_id, display_name });

res.warnings; // string[] — partial-degradation channel, no cast needed
res.durationMs; // number
res.hostnameAssignment; // optional — present ONLY when the control plane
// renamed a colliding hostname; absence stays absence
```

Per the 2026-08-29 maintainer ruling (verbatim 「同意」, option 甲) the three keys
are typed as the **inline wire shape** and are deliberately **not** bound to
`@objectstack/spec/cloud`'s `ProvisionEnvironmentResponseSchema`: those are
camelCase row contracts, and the `/api/v1/cloud/*` control plane this namespace
calls speaks snake_case — the constraint already recorded on the namespace
docblock. Binding them would typecheck and be false.

The request side of the same method is untouched and remains a separate card.
102 changes: 102 additions & 0 deletions packages/client/src/client.environments-namespace.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,53 @@ export function createDeclaresNoDatabaseBlock(): void {
void created.database;
}

/**
* `create` declares the three keys the route sends BESIDE `environment`.
*
* ⚖️ Maintainer ruling, 2026-08-29, verbatim: 「同意」 — option 甲. `warnings`
* and `durationMs` are declared PRESENT, `hostnameAssignment` OPTIONAL (the
* producer's own "absence stays absence" contract), all three typed as the
* INLINE WIRE SHAPE and ⛔ NOT bound to `@objectstack/spec/cloud`'s
* `ProvisionEnvironmentResponseSchema` — those are camelCase row contracts for
* a control plane that speaks snake_case on `/api/v1/cloud/*` (#11925/#12036).
*
* Each read below is a separate assertion on purpose: a key that regresses on
* its own is named by the failure instead of hidden behind a sibling's.
*/
export function createDeclaresTheWireResponseKeys(): void {
const created = {} as CreateShape;

// PRESENT, not optional. Assigning into a non-optional local is the pin:
// weakening either key to `?` puts `undefined` in the type, which stops
// being assignable, and the red lands on the declaration rather than in
// some caller months later.
const warnings: string[] = created.warnings;
const durationMs: number = created.durationMs;
void warnings;
void durationMs;

// OPTIONAL by the producer's contract — forwarded only when the control
// plane renamed a colliding hostname, so `hostnameAssignment !== undefined`
// is itself the signal. The `@ts-expect-error` IS the optionality
// assertion: it holds only while `undefined` is part of the type, so
// promoting the key to required makes the error vanish and this line goes
// red as an unused expect-error.
// @ts-expect-error `hostnameAssignment` is optional; a non-optional local cannot take its `undefined`
const assignment: { requestedHostname: string; assignedHostname: string } = created.hostnameAssignment;
void assignment;
void created.hostnameAssignment?.requestedHostname;
void created.hostnameAssignment?.assignedHostname;

// ⛔ `credential` stays UNDECLARED, and this pin is what keeps it that way.
// `ProvisionEnvironmentResponseSchema` declares it REQUIRED while the
// producer body this declaration was written against does not send it —
// an unjudged divergence that is NOT settled by copying the key here.
// Declaring it would make the SDK promise a key the wire does not carry,
// which is this method's own defect class pointed the other way.
// @ts-expect-error `POST /cloud/environments` is not declared to answer a `credential`; `rotateCredential` is the method that does
void created.credential;
}

/** Helper: a client whose `fetch` answers one canned BaseResponse envelope. */
function clientAnswering(data: unknown) {
const fetchMock = vi.fn().mockResolvedValue({
Expand DownExpand Up@@ -202,6 +249,60 @@ describe('[ADR-0006 D2] client.environments — the control-plane namespace afte
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
});

it('relays `warnings` and `durationMs` beside `environment` — reachable without an `as any`', async () => {
const { client } = clientAnswering({
environment: { id: 'env_new', display_name: 'Dev' },
warnings: ['seed package skipped: registry unreachable'],
durationMs: 4210,
});

const res = await client.environments.create({
organization_id: 'org_1',
display_name: 'Dev',
});

// `warnings` is the partial-degradation channel: this read is the whole
// point of the card, and before the declaration widened it needed a cast.
expect(res.warnings).toEqual(['seed package skipped: registry unreachable']);
expect(res.durationMs).toBe(4210);
});

it('relays `hostnameAssignment` when the control plane renamed a colliding hostname', async () => {
const { client } = clientAnswering({
environment: { id: 'env_new', hostname: 'dev-a1b2' },
warnings: [],
durationMs: 900,
hostnameAssignment: { requestedHostname: 'dev', assignedHostname: 'dev-a1b2' },
});

const res = await client.environments.create({
organization_id: 'org_1',
display_name: 'Dev',
});

expect(res.hostnameAssignment?.requestedHostname).toBe('dev');
expect(res.hostnameAssignment?.assignedHostname).toBe('dev-a1b2');
});

it('absence stays absence — no `hostnameAssignment` key when the requested hostname was kept', async () => {
const { client } = clientAnswering({
environment: { id: 'env_new', hostname: 'dev' },
warnings: [],
durationMs: 880,
});

const res = await client.environments.create({
organization_id: 'org_1',
display_name: 'Dev',
});

// `in` rather than a truthiness check: the producer's contract is that
// the KEY is absent, and reading absence as "unknown" is what its own
// schema comment forbids. A relay that materialised `undefined` would
// pass a truthiness assertion while breaking that contract.
expect('hostnameAssignment' in res).toBe(false);
});

it('relays the activate envelope — `environment` beside `sessionUpdated`', async () => {
const { client } = clientAnswering({
environment: { id: 'env_1' },
Expand DownExpand Up@@ -232,5 +333,6 @@ describe('[ADR-0006 D2] client.environments — the control-plane namespace afte
expect(typeof listEnvelopeCarriesTheWireKeys).toBe('function');
expect(typeof singleRowEnvelopesCarryTheWireKey).toBe('function');
expect(typeof createDeclaresNoDatabaseBlock).toBe('function');
expect(typeof createDeclaresTheWireResponseKeys).toBe('function');
});
});
44 changes: 39 additions & 5 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2194,11 +2194,45 @@ export class ObjectStackClient {
// promised could not happen. `get` is the method that really does
// answer a `database` block.
//
// The keys the route DOES send beside `environment` (`warnings`,
// `durationMs`, and a conditional `hostnameAssignment`) are deliberately
// not declared here — adding them is new published surface and a
// separate decision, not part of this rename.
return this.unwrapResponse<{ environment: any }>(res);
// The three keys the route sends BESIDE `environment` are declared here
// as of 2026-08-29. That absence used to be deliberate and this comment
// used to say so; the decision it was waiting for has since been made, so
// the stance is recorded rather than left standing:
//
// ⚖️ Maintainer ruling, 2026-08-29, verbatim: 「同意」— option 甲.
// `warnings` and `durationMs` are declared PRESENT, `hostnameAssignment`
// OPTIONAL (the producer's own "absence stays absence" contract), and
// all three are typed as the INLINE WIRE SHAPE.
//
// ⛔ Inline, and NOT bound to `@objectstack/spec/cloud`'s
// `ProvisionEnvironmentResponseSchema`. That is the namespace docblock's
// #11925/#12036 constraint applied to the response side: those contracts
// are camelCase row types for a control plane that speaks snake_case on
// `/api/v1/cloud/*`, so binding them would typecheck and be false. The
// ruling names the inline shape for that reason.
//
// ⚠️ Two facts a later reader must not have to rediscover:
//
// - The producer shape is an INHERITED reading, not one measured from
// this repo. `objectstack-ai/cloud` is not readable from here, so the
// handler body quoted on the card (`packages/service-cloud/src/routes/
// environment-lifecycle.ts`, POST `/cloud/environments`, spreading
// `environment` / `warnings` / `durationMs` / conditional
// `hostnameAssignment`) is the card author's 2026-08-28 measurement,
// relayed. No gate in this repo can check it.
// - `ProvisionEnvironmentResponseSchema` additionally declares a REQUIRED
// `credential`, which that handler quote does not send, and marks
// `warnings` `.optional()`, which the ruling declares present. Both
// divergences are recorded and UNJUDGED; neither is settled here. The
// `credential` one is why binding the schema is not the safe default
// it looks like: it would make this SDK declare a key the wire does
// not carry — this method's own defect class, pointed the other way.
return this.unwrapResponse<{
environment: any;
warnings: string[];
durationMs: number;
hostnameAssignment?: { requestedHostname: string; assignedHostname: string };
}>(res);
},

/**
Expand Down
Loading