Skip to content

The shared identifier schemas declare no maximum length, so every cap on an identifier is a storage accident rather than a contract #12144

Description

@os-trump

Found while landing the four sourced bounds for #12059 (PR #12143). Filed unassigned.

What

packages/spec/src/shared/identifiers.zod.ts declares the platform's identifier
schemas with a .min() and a regex, and no .max():

exportconstSnakeCaseIdentifierSchema=lazySchema(()=>z.string().min(2,{message: 'Identifier must be at least 2 characters'}).regex(/^[a-z][a-z0-9_]*$/,{}));

SystemIdentifierSchema and EventNameSchema in the same file have the same
shape — .min() + regex, no upper bound. 20 non-test modules under
packages/spec/src/ reference the first two, including PermissionSetSchema .name, PositionSchema.name, AppSchema.name, object/field name schemas via
branded-types.zod.ts, and the metadata-type base in shared/metadata-types.zod.ts.

So the spec — the contract layer — accepts an identifier of any length, while
every column that stores one is bounded:

ColumnBound
sys_permission_set.name100
sys_position.name100
sys_capability.name100
sys_metadata.name255

Why it matters (declared ≠ enforced, pointed the other way)

The usual shape of this class is a declaration the runtime ignores. This is the
inverse: the runtime enforces a bound the declaration never made. Measured on a
real ObjectQL engine over a real SqlDriver, inserting a permission set whose
name is one character over the column's width:

len 101 → ValidationError: API Name must be ≤ 100 characters (got 101)
at validateRecord (packages/objectql/src/validation/record-validator.ts:1058)
len 120 / 255 / 256 / 300 → the same refusal

That refusal is correct behaviour. What is missing is that nothing in the spec
says it will happen
: a package author reading PermissionSetSchema sees a
snake_case identifier with a floor of 2 and no ceiling, and learns the real
ceiling only when a write fails at boot — or, for a metadata declaration that is
never materialized, does not learn it at all.

Two consequences observed while sourcing #12059's bounds:

  1. Every identifier bound in the platform is derived by transitivity from a
    storage column rather than from the contract.
    That is what PR fix(plugin-audit,plugin-security): declare sourced bounds on the four keyed text columns that break MySQL schema-sync #12143 had to
    do for sys_audience_binding_suggestion.permission_set_name (100, inherited
    from sys_permission_set.name) and what sys_user_position.position already
    did (100, inherited from sys_position.name). Each such bound is only as
    stable as the column it was read off, and nothing links the two.
  2. The bounds disagree with each other — 100 for the three config-object
    names, 255 for sys_metadata.name — so an identifier's real ceiling depends
    on which table it lands in. A permission set name of 150 characters is a legal
    metadata row and an illegal permission set.

Direction (for triage — not prescribing a fix)

⛔ Not claimed: that any real declaration is affected today. The longest built-in
permission set name is well under 40 characters and the longest reverse-domain
package id in this repo is 57, so the measured corpus is entirely inside every
bound above. This is a contract-accuracy defect, not a live breakage — which is
why it is filed as a finding rather than a bug.

Related: #12059 (the four keyed text bounds), #11374 (route A).

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions