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
18 changes: 18 additions & 0 deletions .changeset/tenant-plan-opaque-widening.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/references/cloud/environment.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand DownExpand Up@@ -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 |
Expand DownExpand Up@@ -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<string, any>` | optional | Free-form metadata |
Expand Down
16 changes: 3 additions & 13 deletions content/docs/references/cloud/tenant.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any>` | optional | Custom tenant metadata |

Expand DownExpand Up@@ -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<string, any>` | optional | Custom tenant metadata |


Expand All@@ -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 |
Expand DownExpand Up@@ -158,16 +158,6 @@ const result = PackageInstallationSchema.parse(data);

---

## TenantPlan

### Allowed Values

* `free`
* `starter`
* `pro`
* `enterprise`
* `custom`


---

Expand Down
55 changes: 49 additions & 6 deletions packages/spec/src/cloud/tenant.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,18 +119,61 @@ 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();
}
// `starter` / `custom` are the values the deleted 3-value system subset
// 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/);
});
});
});
32 changes: 24 additions & 8 deletions packages/spec/src/cloud/tenant.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,15 +30,31 @@ export const TenantDatabaseStatusSchema = lazySchema(() => z.enum([
export type TenantDatabaseStatus = z.input<typeof TenantDatabaseStatusSchema>;

/**
* 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<typeof TenantPlanSchema>;

Expand Down
Loading