diff --git a/.changeset/tenant-plan-opaque-widening.md b/.changeset/tenant-plan-opaque-widening.md new file mode 100644 index 0000000000..d3d3af611a --- /dev/null +++ b/.changeset/tenant-plan-opaque-widening.md @@ -0,0 +1,18 @@ +--- +"@objectstack/spec": minor +--- + +Widen `TenantPlanSchema` / `TenantPlan` (`packages/spec/src/cloud/tenant.zod.ts`) from a +closed 5-value enum (`free`/`starter`/`pro`/`enterprise`/`custom`) to an opaque plan +identifier (any string). The vocabulary — which plan strings exist and what each one +unlocks — is control-plane config owned by the cloud distribution, not protocol; the +schema's `.describe()` states this ownership and the shared empty/unknown-⇒-free-tier +convention. + +This is a pure widening: every value the old enum accepted is still accepted, and nothing +in the framework, console, or spec package parses this field against the enum at runtime +(measured in cloud#1216 / objectstack#7513 — the only branching readers are the cloud +distribution's own entitlement modules). No migration is required; `sys_environment.plan` +and the other embedding schemas (`TenantContext`, `TenantDatabase`, +`ProvisionTenantRequest`, `ProvisionEnvironmentRequest`, `ProvisionOrganizationRequest`) +keep their field-level shape and defaults unchanged. diff --git a/content/docs/references/cloud/environment.mdx b/content/docs/references/cloud/environment.mdx index 663d2c76d8..22e3a728c0 100644 --- a/content/docs/references/cloud/environment.mdx +++ b/content/docs/references/cloud/environment.mdx @@ -57,7 +57,7 @@ const result = EnvironmentSchema.parse(data); | **displayName** | `string` | ✅ | Display name shown in Studio and APIs | | **isDefault** | `boolean` | ✅ | Whether this is the default environment for the organization | | **isSystem** | `boolean` | ✅ | Whether this is a system environment (platform infrastructure, not user data) | -| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | ✅ | Plan tier for this environment | +| **plan** | `string` | ✅ | Plan tier for this environment | | **status** | `Enum<'provisioning' \| 'active' \| 'suspended' \| 'archived' \| 'failed' \| 'migrating'>` | ✅ | Environment lifecycle status | | **createdBy** | `string` | ✅ | User ID that created the environment | | **createdAt** | `string` | ✅ | Creation timestamp (ISO-8601) | @@ -197,7 +197,7 @@ Public exposure of this environment artifacts (private | unlisted | public). | **organizationId** | `string` | ✅ | Organization that will own the new environment | | **displayName** | `string` | ✅ | Display name shown in Studio and APIs | | **driver** | `string` | optional | Driver key (defaults to provisioning service config) | -| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | optional | Plan tier | +| **plan** | `string` | optional | Plan tier | | **storageLimitMb** | `integer` | optional | Storage quota in megabytes | | **isDefault** | `boolean` | optional | Mark as the organization default environment | | **createdBy** | `string` | ✅ | User ID that initiated the provisioning | @@ -233,7 +233,7 @@ Public exposure of this environment artifacts (private | unlisted | public). | **organizationId** | `string` | ✅ | Organization being bootstrapped | | **defaultEnvironmentDisplayName** | `string` | ✅ | Display name for the default environment | | **driver** | `string` | optional | Driver key | -| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | optional | Plan tier | +| **plan** | `string` | optional | Plan tier | | **storageLimitMb** | `integer` | optional | Storage quota in megabytes | | **createdBy** | `string` | ✅ | User ID that initiated provisioning | | **metadata** | `Record` | optional | Free-form metadata | diff --git a/content/docs/references/cloud/tenant.mdx b/content/docs/references/cloud/tenant.mdx index c3d4769962..e11dbd70e5 100644 --- a/content/docs/references/cloud/tenant.mdx +++ b/content/docs/references/cloud/tenant.mdx @@ -72,7 +72,7 @@ const result = PackageInstallationSchema.parse(data); | :--- | :--- | :--- | :--- | | **organizationId** | `string` | ✅ | Organization ID | | **region** | `string` | optional | Deployment region preference | -| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | ✅ | Tenant plan tier | +| **plan** | `string` | ✅ | Tenant plan tier | | **storageLimitMb** | `integer` | optional | Storage limit in megabytes | | **metadata** | `Record` | optional | Custom tenant metadata | @@ -102,7 +102,7 @@ const result = PackageInstallationSchema.parse(data); | **organizationId** | `string` | ✅ | Current organization ID | | **organizationSlug** | `string` | optional | Organization slug | | **databaseUrl** | `string` | ✅ | Tenant database URL | -| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | ✅ | Tenant plan tier | +| **plan** | `string` | ✅ | Tenant plan tier | | **metadata** | `Record` | optional | Custom tenant metadata | @@ -121,7 +121,7 @@ const result = PackageInstallationSchema.parse(data); | **authToken** | `string` | ✅ | Encrypted tenant-specific auth token | | **status** | `Enum<'provisioning' \| 'active' \| 'suspended' \| 'archived' \| 'failed'>` | ✅ | Database status | | **region** | `string` | ✅ | Deployment region | -| **plan** | `Enum<'free' \| 'starter' \| 'pro' \| 'enterprise' \| 'custom'>` | ✅ | Tenant plan tier | +| **plan** | `string` | ✅ | Tenant plan tier | | **storageLimitMb** | `integer` | ✅ | Storage limit in megabytes | | **createdAt** | `string` | ✅ | Database creation timestamp | | **updatedAt** | `string` | ✅ | Last update timestamp | @@ -158,16 +158,6 @@ const result = PackageInstallationSchema.parse(data); --- -## TenantPlan - -### Allowed Values - -* `free` -* `starter` -* `pro` -* `enterprise` -* `custom` - --- diff --git a/packages/spec/src/cloud/tenant.test.ts b/packages/spec/src/cloud/tenant.test.ts index 6a43f8cac4..8742134be8 100644 --- a/packages/spec/src/cloud/tenant.test.ts +++ b/packages/spec/src/cloud/tenant.test.ts @@ -119,9 +119,9 @@ describe('[#4739] `TenantPlan(Schema)` resolves to the ./cloud declaration every expect('ProvisioningStepSchema' in system).toBe(false); }); - it('what the name now unambiguously means: the 5-value cloud plan vocabulary', () => { - // The full surviving vocabulary — an enum def has no authorable keys, so - // the vocabulary IS the surface this pin protects. + it('what the name now unambiguously means: the cloud declaration, opaque (objectstack#7513)', () => { + // The former 5-value enum is a strict subset of "any string" — still + // accepted, but no longer the boundary of what's valid. for (const plan of ['free', 'starter', 'pro', 'enterprise', 'custom']) { expect(() => TenantPlanSchema.parse(plan)).not.toThrow(); } @@ -129,8 +129,51 @@ describe('[#4739] `TenantPlan(Schema)` resolves to the ./cloud declaration every // rejected: their acceptance proves the survivor is the cloud declaration, // not the provisioning one. expect(TenantPlanSchema.parse('starter')).toBe('starter'); - // And it is still a closed enum, not an open string. - expect(() => TenantPlanSchema.parse('solo')).toThrow(); - expect(() => TenantPlanSchema.parse('')).toThrow(); + }); + + // ─── [objectstack#7513] opaque widening — cloud#1216's measured "no reader + // outside the cloud distribution branches on plan values" ──────────────── + // + // Flips (does not delete) the pre-#7513 pin that asserted the enum's closed + // set. That pin protected the CLOSED-SET boundary; this one protects the + // NEW contract's substance: any string is accepted (the cloud distribution + // owns the vocabulary, not this schema), and the schema's own `.describe()` + // still states that ownership so a reader who only sees the generated + // reference docs (not this source file) gets the same fact. + describe('[objectstack#7513] TenantPlanSchema is an opaque string, not a closed enum', () => { + it('accepts values the retired enum would have rejected — the widening is real', () => { + // `solo` is one of the CLOUD distribution's own vocabulary values + // (`packages/objectos-runtime/src/plan-entitlements.ts` in the cloud + // repo) — exactly the kind of value this widening exists to accept + // without a spec-side edit. + expect(() => TenantPlanSchema.parse('solo')).not.toThrow(); + expect(TenantPlanSchema.parse('solo')).toBe('solo'); + // Arbitrary/unknown strings — the vocabulary is cloud config, not a + // spec-enumerated set, so nothing here is "invalid" on shape grounds. + expect(() => TenantPlanSchema.parse('anything-the-cloud-distribution-declares')).not.toThrow(); + // The empty string is accepted too — free-tier fallback is a cloud-side + // normalization convention, not a spec-level rejection or default. + expect(() => TenantPlanSchema.parse('')).not.toThrow(); + }); + + it('still rejects non-string shapes — opaque means "any string", not "any value"', () => { + expect(() => TenantPlanSchema.parse(42)).toThrow(); + expect(() => TenantPlanSchema.parse(null)).toThrow(); + expect(() => TenantPlanSchema.parse(undefined)).toThrow(); + expect(() => TenantPlanSchema.parse({ plan: 'free' })).toThrow(); + }); + + it('the describe() states the ownership + convention, for readers who only see generated docs', () => { + const description = TenantPlanSchema.description; + expect(description, 'TenantPlanSchema must carry a .describe()').toBeDefined(); + // Ownership statement (ask #1): vocabulary is control-plane config, + // owned by the cloud distribution — not protocol. + expect(description).toMatch(/control-plane config/); + expect(description).toMatch(/cloud distribution/); + expect(description).toMatch(/not protocol/); + // Empty/unknown ⇒ free tier convention (ask #2) — one sentence worth + // keeping, not enforced by this schema. + expect(description).toMatch(/free tier/); + }); }); }); diff --git a/packages/spec/src/cloud/tenant.zod.ts b/packages/spec/src/cloud/tenant.zod.ts index adff8d6e34..5c6e0e7758 100644 --- a/packages/spec/src/cloud/tenant.zod.ts +++ b/packages/spec/src/cloud/tenant.zod.ts @@ -30,15 +30,31 @@ export const TenantDatabaseStatusSchema = lazySchema(() => z.enum([ export type TenantDatabaseStatus = z.input; /** - * Tenant Plan Tier + * Tenant Plan Identifier + * + * An **opaque** plan/tier identifier (ruling: cloud#1216, executed on the + * spec side by objectstack#7513). The vocabulary — which strings exist, what + * each one unlocks — is control-plane **config owned by the cloud + * distribution**, not protocol: this schema does not enumerate it and never + * has runtime knowledge of it. Widened from a closed 5-value enum + * (`free`/`starter`/`pro`/`enterprise`/`custom`) after measuring that no + * reader outside the cloud distribution branches on the value — framework + * and console treat it as a pass-through string; only the cloud + * distribution's own entitlement modules (e.g. `isFreePlan`, + * `planAllowsAiStudio`) interpret specific values, and they own that + * interpretation independently of this schema. + * + * Convention (not enforced here): an empty or unrecognized value is treated + * as the free tier by cloud-side readers. Spec accepts any string, including + * the empty one — the free-tier fallback is the cloud distribution's + * normalization, not a spec-level default. */ -export const TenantPlanSchema = lazySchema(() => z.enum([ - 'free', - 'starter', - 'pro', - 'enterprise', - 'custom', -])); +export const TenantPlanSchema = lazySchema(() => z.string().describe( + 'Opaque plan/tier identifier. The vocabulary is control-plane config owned by the ' + + 'cloud distribution, not protocol — this schema accepts any string and does not ' + + 'enumerate valid values. Convention: an empty or unrecognized value is treated as ' + + 'the free tier by cloud-side readers (not enforced by this schema).', +)); export type TenantPlan = z.input;