Skip to content

Commit e0e0be0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18017-h26-counted-not-asserted
2 parents 2e6ee9e + f9e16d8 commit e0e0be0

19 files changed

Lines changed: 1312 additions & 186 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
`ObjectStackClient.environments` — the docblock that licenses the namespace's erased `any` now names where the family is enumerated, and the enumeration exists (#19383).
6+
7+
Fourteen methods on `environments.*` and the nested `environments.packages.*` return types that **contain** `any` and carry **no return annotation**. They are deliberate: the `/api/v1/cloud/*` control plane speaks snake_case, its row contracts left this repo with `@objectstack/spec/cloud`, and binding them here would typecheck and be false. Nothing mechanical held the family, though — `check:exported-any-returns` asks whether an awaited return type **IS** `any` and never whether it **CONTAINS** one (a documented scope that buys the gate zero false positives), and with no annotation on any signature line there is no text for a search to find. A 15th such method landed silently green under a paragraph that licensed it in advance.
8+
9+
- **What changed for a consumer**: one paragraph of published TSDoc on `environments`. It bounds the licence — the family is enumerated by name in the package's own `environments-any-family.pin.test.ts`, and a method that pin does not list is not covered by the paragraph. No export, signature, envelope key or runtime behaviour moves; the emitted declarations are otherwise byte-identical.
10+
- **Pinned by membership, not by a CONTAINS-any detector.** A `ts.createProgram` + `TypeChecker` census over the SDK surface shows CONTAINS-any has no canonical boundary here: the population is a function of how many hops the walk is allowed (24 at three, 43 at four, 57 at five and six), an unbounded walk does not terminate, and 144 callables are still unexplored at six hops — so such a gate's green would mean "no `any` within N hops", never "no `any`".
11+
- **And a package-wide rule would refuse the protected class.** The only other unannotated `any`-containing sites on the surface are `organizations.list` (better-auth organisation `metadata`) and `oauth.applications.list` (`Record<string, any>[]`), which is precisely the caller-shaped class the ratchet's ledger protects by name.

‎.changeset/hungry-pugs-shave.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
`DELETE /api/v1/data/sys_permission_set/{id}` stops reporting a deletion it did not perform.
7+
8+
A package-declared permission set cannot be deleted from an environment: its delete is an
9+
ADR-0005 RESET — the overlay tombstones and the record re-projects to the declared body.
10+
That behaviour is unchanged and deliberate. What was wrong is the answer: the door replied
11+
`200 {"object":…,"id":…,"success":true}`, byte-identical to a real deletion, so a caller
12+
that meant to revoke a permission set was told it was gone while it was still enforced, and
13+
a UI fired a success toast and showed the row again on refresh.
14+
15+
The write-through's delete leg now reports how many of the addressed records actually went,
16+
and `deleteData` maps that onto the already declared `success` key instead of hard-coding
17+
`true`. No key is added to `DeleteDataResponseSchema`.
18+
19+
On the wire:
20+
21+
- packaged set — `200 {"success":false}`, the record still present with the same id (was
22+
`success: true`);
23+
- environment-authored set — `200 {"success":true}`, the record really gone (unchanged);
24+
- unknown id — `404 RECORD_NOT_FOUND` (unchanged: zero-removed is deliberately not read as
25+
not-found, because the record is still there to GET).
26+
27+
The read-back that decides this is fail-closed: a read that cannot answer reports the record
28+
as NOT deleted and warns on the durability channel, because "the read failed" and "the row is
29+
gone" are opposite facts and only the second may claim a deletion.
30+
31+
Clause-②: no

‎.changeset/olive-moons-tickle.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
Translate the object field-editor panel leaves that shipped their English source in every locale
6+
7+
Fourteen metadata-form keys under `object.fields['fields.*']` — the field editor on the object form (`placeholder`, `valueDomain`, `rows`, `lookupFilters`, `deleteBehavior`, `expression`, the four `summaryOperations` entries, `autonumberFormat`, `visibleWhen`, `readonlyWhen`, `requiredWhen`) — carried both their `label` and their `helpText` byte-identical to the `en` source in `zh-CN`, `ja-JP` and `es-ES`, so an author working in a translated locale read English on the most trafficked authoring panel in Studio while everything around them was translated. All 28 leaves are now translated in each locale; each was judged individually, and the 84 verdicts with their reasons are pinned in `object-field-editor-panel-echo-decisions.test.ts`, which also derives the panel's population so a re-fill or a newly added field is red on the day it lands. No key was added, removed or renamed — the bundles' shape is unchanged.
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19383] The `environments.*` any-CONTAINING family, pinned by MEMBERSHIP.
5+
*
6+
* ## What was holding this family before this file
7+
*
8+
* A prose docblock, and nothing else. `index.ts` says, above `environments`,
9+
* that *every* unannotated method in this namespace and in the nested
10+
* `packages` block keeps its erased `any` deliberately (#12036, hardened when
11+
* ruling B on #16325 moved the control-plane contracts out of this repo —
12+
* `packages/spec/src/cloud` is absent here today). That sentence is a blanket
13+
* licence over an UNBOUNDED future population: a 15th such method inherits it
14+
* on arrival, with nothing for a reviewer to point at.
15+
*
16+
* Two mechanisms that look like they would hold it, and cannot:
17+
*
18+
* - `check:exported-any-returns` (#11927) asks whether an awaited return type
19+
* **IS** `any`, never whether it **CONTAINS** one. That scope is deliberate,
20+
* documented in its ledger `$comment`, and is what buys the gate its
21+
* zero-false-positive property. All 14 of these sites are `any`-CONTAINING,
22+
* so the gate is silent about them BY DESIGN and correctly so.
23+
* - any text search. These 21 callables carry NO return annotation at all, so
24+
* `any`, `Promise` and `unwrapResponse` need never appear on a signature
25+
* line. That is #11925's own thesis, and it is why the census behind this
26+
* file used `ts.createProgram` + `TypeChecker` rather than a grep.
27+
*
28+
* ## The reading this file pins
29+
*
30+
* Census over `packages/client/src/index.ts` in `objectstack-ai/objectstack`
31+
* at `8ddefbc977da`, asking the two halves separately — (a) does the SOURCE
32+
* declaration node write an explicit return type (an AST property, invisible in
33+
* a built `.d.ts` because tsup always emits one: 318/318 annotated there against
34+
* 292/331 in source), and (b) does `checker.getAwaitedType` CONTAIN `any` (a
35+
* type property, invisible to text):
36+
*
37+
* ObjectStackClient.environments.* 21 callables, 0 annotated
38+
* 14 CONTAINS-any, 7 clean
39+
* package-wide, unannotated 39 callables
40+
* 2 IS-any (both already ledgered)
41+
* 16 CONTAINS-any
42+
*
43+
* The 2 unannotated any-CONTAINING sites outside this namespace are
44+
* `organizations.list` (better-auth organisation `metadata`) and
45+
* `oauth.applications.list` (`Record<string, any>[]`, the opaque OAuth client
46+
* row) — i.e. exactly the caller-shaped class the ratchet's ledger protects by
47+
* name. That is why this pin is scoped to the NAMESPACE and does not become a
48+
* package-wide CONTAINS-any rule: measured against the same census, a
49+
* package-wide rule flags 43 sites at a 4-hop bound and 57 at 6, and all but
50+
* these 14 are caller-shaped, lib-shaped (`Response.json()`, `AsyncIterable`'s
51+
* `TReturn`) or the `FilterCondition` operator bag.
52+
*
53+
* ## Why MEMBERSHIP and not a CONTAINS-any detector
54+
*
55+
* "Contains `any`" has no canonical boundary over this surface: the population
56+
* is a function of how many hops the walk is allowed (24 at 3, 43 at 4, 57 at
57+
* 5 and 6), an unbounded walk does not terminate in practice, and 144 callables
58+
* are still unexplored at 6 hops — so a CONTAINS-any gate's green would mean
59+
* "no `any` within N hops", never "no `any`". This file asks a bounded question
60+
* instead: WHICH KEYS are on the namespace, and which of their envelopes carry
61+
* `any` in their own top two levels. Both are stable across every bound
62+
* measured (3, 4, 5, 6).
63+
*
64+
* ## What goes red, and what it costs
65+
*
66+
* A 15th method on `environments` or `environments.packages` fails BOTH the
67+
* runtime key pin and — if its envelope carries `any` — the type pin. Cost to
68+
* land one: add its name to the union below, in a diff someone reads. Binding
69+
* one of the 14 to a real contract also goes red, in the shrink-only direction:
70+
* remove the name. Neither is a refusal; both are a sentence someone has to
71+
* write.
72+
*/
73+
74+
import { describe, it, expect } from 'vitest';
75+
import { ObjectStackClient } from './index';
76+
77+
type EnvironmentsNamespace = ObjectStackClient['environments'];
78+
type EnvironmentPackagesNamespace = EnvironmentsNamespace['packages'];
79+
80+
// ── The predicate ───────────────────────────────────────────────────────────
81+
82+
/**
83+
* `any` absorbs every intersection, so `1 & T` is `any` exactly when `T` is,
84+
* and only `any` makes `0 extends …` true. A caller-supplied `<T = any>` is
85+
* NOT `any` here for the same reason the #11927 ratchet gives: the default is
86+
* what an absent type ARGUMENT resolves to at a call site, and no call site is
87+
* read.
88+
*/
89+
type IsAny<T> = 0 extends 1 & T ? true : false;
90+
91+
/** The element of an array type; anything else unchanged. */
92+
type Unwrap<T> = T extends readonly (infer E)[] ? E : T;
93+
94+
/** Assertion carrier: a `false` here is a compile error, which is the point. */
95+
type Assert<T extends true> = T;
96+
97+
/** Both directions, so a wider OR narrower union is equally red. */
98+
type Exact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
99+
100+
/**
101+
* Does this envelope carry `any` in its own top two levels — the envelope
102+
* itself, or one of its members, or the element of a member array?
103+
*
104+
* The 2-hop bound is DECLARED, not accidental: the control battery below pins
105+
* that a 3-hop `any` reads `false`. Every one of the 14 sites carries its `any`
106+
* at hop 1 or 2, written directly into the method's own `unwrapResponse<…>`
107+
* type argument, which is what makes the bound safe here and is exactly the
108+
* property that separates them from the deep caller-shaped hits.
109+
*/
110+
type EnvelopeCarriesAny<T> = IsAny<T> extends true
111+
? true
112+
: T extends object
113+
? true extends { [K in keyof T]-?: IsAny<Unwrap<NonNullable<T[K]>>> }[keyof T]
114+
? true
115+
: false
116+
: false;
117+
118+
/** The keys of a namespace whose awaited envelope carries `any`. */
119+
type AnyCarryingKeys<N> = {
120+
[K in keyof N]-?: N[K] extends (...args: never[]) => unknown
121+
? EnvelopeCarriesAny<Awaited<ReturnType<N[K]>>> extends true
122+
? K
123+
: never
124+
: never;
125+
}[keyof N];
126+
127+
// ── CONTROL ─────────────────────────────────────────────────────────────────
128+
//
129+
// A pin that only inspects its own hits cannot find its own false negatives,
130+
// and a predicate that collapsed to a constant would hold every assertion below
131+
// it green forever. These are compiled by `tsconfig.test.json` (which `package
132+
// .json`'s `typecheck` script names), so they are real checks rather than the
133+
// phantom class AGENTS.md warns about. Both verdicts are exercised.
134+
135+
/** TRUE side — including the mapped-type shape a `typeArguments`-only walk misses. */
136+
export type ControlDirectAny = Assert<Exact<EnvelopeCarriesAny<any>, true>>;
137+
export type ControlMemberAny = Assert<Exact<EnvelopeCarriesAny<{ environment: any }>, true>>;
138+
export type ControlMemberAnyArray = Assert<Exact<EnvelopeCarriesAny<{ environments: any[]; total: number }>, true>>;
139+
export type ControlOptionalMemberAny = Assert<Exact<EnvelopeCarriesAny<{ a: string; b?: any }>, true>>;
140+
export type ControlIndexSignatureAny = Assert<Exact<EnvelopeCarriesAny<Record<string, any>>, true>>;
141+
142+
/** FALSE side — a predicate stuck on `true` dies here. */
143+
export type ControlConcrete = Assert<Exact<EnvelopeCarriesAny<{ id: string; total: number }>, false>>;
144+
export type ControlConcreteArray = Assert<Exact<EnvelopeCarriesAny<{ items: { id: string }[] }>, false>>;
145+
export type ControlUnknown = Assert<Exact<EnvelopeCarriesAny<{ payload: unknown }>, false>>;
146+
export type ControlGenericParam = Assert<Exact<EnvelopeCarriesAny<{ rows: unknown[] }>, false>>;
147+
/** The declared 2-hop bound: `any` three levels down reads FALSE, on purpose. */
148+
export type ControlBeyondTheBound = Assert<Exact<EnvelopeCarriesAny<{ a: { b: any } }>, false>>;
149+
150+
// ── The pins ────────────────────────────────────────────────────────────────
151+
152+
/**
153+
* Every key on `client.environments`. `packages` is the nested namespace
154+
* object, not a method; the other 14 are the callables.
155+
*/
156+
export type EnvironmentsKeysArePinned = Assert<
157+
Exact<
158+
keyof EnvironmentsNamespace,
159+
| 'list'
160+
| 'get'
161+
| 'create'
162+
| 'update'
163+
| 'delete'
164+
| 'activate'
165+
| 'rotateCredential'
166+
| 'updateHostname'
167+
| 'listRevisions'
168+
| 'listBranches'
169+
| 'renameBranch'
170+
| 'deleteBranch'
171+
| 'retryProvisioning'
172+
| 'listDrivers'
173+
| 'packages'
174+
>
175+
>;
176+
177+
/** Every key on the environment-scoped `client.environments.packages`. */
178+
export type EnvironmentPackagesKeysArePinned = Assert<
179+
Exact<
180+
keyof EnvironmentPackagesNamespace,
181+
'list' | 'install' | 'get' | 'enable' | 'disable' | 'uninstall' | 'upgrade'
182+
>
183+
>;
184+
185+
/**
186+
* The 8 `environments.*` methods whose envelope carries `any`. The 6 absentees
187+
* — `delete`, `listRevisions`, `listBranches`, `renameBranch`, `deleteBranch`,
188+
* `listDrivers` — are unannotated too, and are concrete anyway: they are what
189+
* proves this pin is not simply "the whole namespace".
190+
*/
191+
export type EnvironmentsAnyFamilyIsPinned = Assert<
192+
Exact<
193+
AnyCarryingKeys<EnvironmentsNamespace>,
194+
| 'list'
195+
| 'get'
196+
| 'create'
197+
| 'update'
198+
| 'activate'
199+
| 'rotateCredential'
200+
| 'updateHostname'
201+
| 'retryProvisioning'
202+
>
203+
>;
204+
205+
/**
206+
* The 6 `environments.packages.*` methods whose envelope carries `any`.
207+
* `uninstall` is the absentee: it answers `{ id, success }`.
208+
*/
209+
export type EnvironmentPackagesAnyFamilyIsPinned = Assert<
210+
Exact<
211+
AnyCarryingKeys<EnvironmentPackagesNamespace>,
212+
'list' | 'install' | 'get' | 'enable' | 'disable' | 'upgrade'
213+
>
214+
>;
215+
216+
describe('[#19383] the environments.* any-CONTAINING family is pinned by membership', () => {
217+
/**
218+
* The runtime half. It catches what the type half deliberately does not: a
219+
* 15th method that is fully bound to a concrete contract still changes this
220+
* namespace, and this repo's reason for reading the namespace as one family
221+
* is #12036's blanket licence, which such a method would also inherit.
222+
*
223+
* `environments` is a class property holding an object literal, so
224+
* `Object.keys` on an instance is exactly the literal's own keys — no
225+
* prototype walk, no inherited members.
226+
*/
227+
it('exposes exactly the 15 environments keys and the 7 packages keys', () => {
228+
const client = new ObjectStackClient({ baseUrl: 'http://pin.invalid' });
229+
230+
expect(Object.keys(client.environments).sort()).toEqual(
231+
[
232+
'activate',
233+
'create',
234+
'delete',
235+
'deleteBranch',
236+
'get',
237+
'list',
238+
'listBranches',
239+
'listDrivers',
240+
'listRevisions',
241+
'packages',
242+
'renameBranch',
243+
'retryProvisioning',
244+
'rotateCredential',
245+
'update',
246+
'updateHostname',
247+
],
248+
);
249+
expect(Object.keys(client.environments.packages).sort()).toEqual(
250+
['disable', 'enable', 'get', 'install', 'list', 'uninstall', 'upgrade'],
251+
);
252+
});
253+
254+
/**
255+
* Anti-vacuity for the half tsc owns: name the assertion aliases so a
256+
* reader can see the file really carries them, and state the count this
257+
* round measured. A `describe` block with no reference to them would leave
258+
* the type pins looking like commentary.
259+
*/
260+
it('carries 14 pinned any-carrying sites across the two namespaces', () => {
261+
const pinned: Record<string, readonly string[]> = {
262+
environments: [
263+
'list',
264+
'get',
265+
'create',
266+
'update',
267+
'activate',
268+
'rotateCredential',
269+
'updateHostname',
270+
'retryProvisioning',
271+
],
272+
'environments.packages': ['list', 'install', 'get', 'enable', 'disable', 'upgrade'],
273+
};
274+
expect(pinned.environments.length + pinned['environments.packages'].length).toBe(14);
275+
276+
// Every pinned name is a real callable on the namespace it names — so a
277+
// rename cannot leave the prose list above pointing at nothing.
278+
const client = new ObjectStackClient({ baseUrl: 'http://pin.invalid' });
279+
for (const key of pinned.environments) {
280+
expect(typeof (client.environments as Record<string, unknown>)[key]).toBe('function');
281+
}
282+
for (const key of pinned['environments.packages']) {
283+
expect(typeof (client.environments.packages as Record<string, unknown>)[key]).toBe('function');
284+
}
285+
});
286+
});

‎packages/client/src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2917,6 +2917,15 @@ export class ObjectStackClient {
29172917
* that it is wrong when it is right — the `SearchResult` near-miss class
29182918
* #8140 recorded, at family scale. The control-plane implementation is not
29192919
* in this repo, so the casing cannot be settled from here.
2920+
*
2921+
* ⚠️ [#19383] That licence is BOUNDED, and this paragraph is no longer the
2922+
* only thing holding it. `src/environments-any-family.pin.test.ts` enumerates
2923+
* the family by name — 21 callables here, 14 of them `any`-carrying, derived
2924+
* with a `ts.createProgram` + `TypeChecker` census rather than a text search,
2925+
* because these methods carry no return annotation for text to read and
2926+
* `check:exported-any-returns` asks IS-`any`, never CONTAINS-`any`. ⛔ Do not
2927+
* read this paragraph as covering a method that pin does not list: a 15th is
2928+
* a diff whose author adds its name, not a silent addition absorbed here.
29202929
*/
29212930
environments = {
29222931
/**

0 commit comments

Comments
 (0)