Split off #16277 by the dispatch brief — triage judged this measurement "worth its own card" but did not re-run it, so did not file it. I re-ran it and confirmed, with a more precise characterization than the original report.
Original claim (from #16277)
Measured on the installed @objectstack/spec 17.3.0 (ObjectPermissionSchema.safeParse): only true is refused. allowRestore: false / allowPurge: false parse successfully, and the parsed output carries neither key — the retired-default residue tolerance of #12840. Consumers writing a guard against these bits therefore need === true, not a presence or truthiness check.
What I measured (origin/main @ cef399be82's base, packages/spec/src/security/permission.zod.ts, ObjectPermissionSchema.safeParse, run via pnpm --filter @objectstack/spec exec tsx against a minimal valid base object)
allowRestore=false success=true carriesKey=false (dropped — #12840 residue tolerance)
allowRestore=true success=false code=invalid_type ("expected": "never", ADR-0049 removal message)
allowRestore="true" success=false code=invalid_type (same — NOT boolean-specific)
allowRestore="false" success=false code=invalid_type (same — a string "false" is ALSO refused)
allowRestore=0 success=false code=invalid_type (same — a falsy non-boolean is ALSO refused)
allowRestore=1 success=false code=invalid_type (same)
allowRestore=null success=false code=invalid_type (same)
key entirely absent success=true carriesKey=false (never added)
allowPurge behaves identically (spot-checked true/false).
The correction to the original framing
The original report says "only true is refused" — that undersells it. The schema accepts exactly one value, the boolean literal false; every other value of any type is refused with the same ADR-0049 removal message (code: 'invalid_type', expected: 'never'), not just true. A string "false", the number 0, or null are refused exactly like true is — this is not a truthy/falsy check, it is a z.literal(false)-shaped tombstone gate (see retiredKey() in packages/spec/src/shared/retired-key.ts for the general shape).
The consumer-facing asymmetry, stated precisely
A successfully parsed ObjectPermissionSchema object can never carry allowRestore or allowPurge at all: the only value that survives parsing (false) is stripped from the output, and every other value throws before a parsed object exists. So:
- Post-parse,
permissions.allowRestore is always undefined — a presence check ('allowRestore' in permissions) or truthiness check (if (permissions.allowRestore)) against parsed/validated output is not merely imprecise, it is dead code: the condition can never be true, on any input that survived validation.
- The original report's suggested remedy ("write
=== true, not a presence/truthiness check") does not fix this for parsed output either — permissions.allowRestore === true is also always false post-parse, because a raw true never survives safeParse/parse in the first place (it throws).
- The distinction only has a live consumer on pre-parse / raw author input (e.g. a linter or migration tool reading
objectstack.json source before validation): there, false (legacy no-op) and true/other (hard ADR-0049 violation) are different facts an author-facing tool should say different things about, and a presence/truthiness check on raw input conflates them.
Ask
Whichever key's doc/prompt surface discusses this (ObjectPermissionSchema's .describe() text, the retired-key migration guidance, or downstream consumer docs) should state the parse-time behavior precisely: only false parses, and is stripped; every other value refuses at parse with the ADR-0049 message; consumers of parsed/validated data will never see either key, so no post-parse guard is meaningful — pre-parse/raw-source tooling is the only place a false-vs-other distinction is observable.
Provenance
Split off #16277 by the dispatch brief — triage judged this measurement "worth its own card" but did not re-run it, so did not file it. I re-ran it and confirmed, with a more precise characterization than the original report.
Original claim (from #16277)
What I measured (origin/main @
cef399be82's base,packages/spec/src/security/permission.zod.ts,ObjectPermissionSchema.safeParse, run viapnpm --filter @objectstack/spec exec tsxagainst a minimal valid base object)allowPurgebehaves identically (spot-checkedtrue/false).The correction to the original framing
The original report says "only
trueis refused" — that undersells it. The schema accepts exactly one value, the boolean literalfalse; every other value of any type is refused with the same ADR-0049 removal message (code: 'invalid_type',expected: 'never'), not justtrue. A string"false", the number0, ornullare refused exactly liketrueis — this is not a truthy/falsy check, it is az.literal(false)-shaped tombstone gate (seeretiredKey()inpackages/spec/src/shared/retired-key.tsfor the general shape).The consumer-facing asymmetry, stated precisely
A successfully parsed
ObjectPermissionSchemaobject can never carryallowRestoreorallowPurgeat all: the only value that survives parsing (false) is stripped from the output, and every other value throws before a parsed object exists. So:permissions.allowRestoreis alwaysundefined— a presence check ('allowRestore' in permissions) or truthiness check (if (permissions.allowRestore)) against parsed/validated output is not merely imprecise, it is dead code: the condition can never be true, on any input that survived validation.=== true, not a presence/truthiness check") does not fix this for parsed output either —permissions.allowRestore === trueis also alwaysfalsepost-parse, because a rawtruenever survivessafeParse/parsein the first place (it throws).objectstack.jsonsource before validation): there,false(legacy no-op) andtrue/other (hard ADR-0049 violation) are different facts an author-facing tool should say different things about, and a presence/truthiness check on raw input conflates them.Ask
Whichever key's doc/prompt surface discusses this (
ObjectPermissionSchema's.describe()text, the retired-key migration guidance, or downstream consumer docs) should state the parse-time behavior precisely: onlyfalseparses, and is stripped; every other value refuses at parse with the ADR-0049 message; consumers of parsed/validated data will never see either key, so no post-parse guard is meaningful — pre-parse/raw-source tooling is the only place afalse-vs-other distinction is observable.Provenance
@objectstack/metadata17.3.0's CHANGELOG says "spec 17.2.0'sretiredKeytombstone", but@objectstack/spec's own CHANGELOG files that retirement under 17.3.0 #16277 (objectstack-ai/objectstack#16277), which citesobjectstack-ai/hotcrm#1634as the original downstream report of the asymmetry.allowRestore/allowPurgepermission props (ruled 2026-08-26; M2 anchor stays open, keys return with M2) #12497 (ADR-0049 enforce-or-remove), feat(spec): retired-defaulted-key tolerance — the retired default parses as inert residue and strips; non-default values keep the loud refusal (#12497 class rule) #12840 (retired-default residue tolerance).