fix(spec): discriminate FieldSchema.defaultValue (literal / runtime token / CEL envelope) and validate each shape on its own terms - #7403
Conversation
…me-token / CEL-envelope (#7127) Commit 1 of the maintainer's sequenced ruling (2026-08-10): land the discriminator first, shared by both defaultValue authoring gates, then narrow FieldSchema.defaultValue on top of it. New module src/data/default-value-shape.ts: - discriminateDefaultValueShape / isExpressionEnvelopeDefault — the ENGINE's structural predicate (ObjectQL.applyFieldDefaults), verbatim, so the authoring gates and the resolver cannot drift apart; - checkLiteralDefaultValue — the shared literal-vs-stored-contract core (valueSchemaFor(def, 'stored'), ADR-0104 D1) both consumers run. ActionParamSchema's #6970 defaultValue refinement is refactored onto the shared core with ZERO behavior change — its pins (action-param-default-value.test.ts) stay green unedited. Action params deliberately do NOT discriminate: a param default is a pure literal (the dialog seeds it verbatim; serializeParamValues resolves nothing), and that stance is now recorded in the shared module's note. api-surface/data.json + export-origins/data.json regenerated after a real spec build (dual-snapshot rule). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…hape on its own terms (#7127) Commit 2 of the sequenced ruling: narrow FieldSchema.defaultValue on the commit-1 discriminator, in the engine's own order — absent (null/undefined; '' is a REAL default, engine presence semantics) → CEL envelope (structural acceptance only) → runtime token (per-token × per-type) → literal (valueSchemaFor(def, 'stored'), the #6970 mechanics one layer down). Token table as adjudicated on the issue: NOW() legal on datetime/date/time (both resolvers + types.mdx already support all three); current_user legal on user + lookup with reference 'sys_user' only (#4560); no token on a multi-value field (both resolve to one scalar). Near-miss spellings ('now', '{current_user}') are suggested inside refusals, never accepted as tokens. The field-value.zod <-> field.zod ESM cycle (dossier §2d) is discharged structurally: AddressSchema moves to field-value.zod (it IS the enforced address value contract) and field.zod re-exports it unchanged — the back-edge becomes type-only (FieldType), erased at runtime. Verified with a real build plus a dist-eval smoke test, not assumption. Census re-run against the IMPLEMENTED gate: objectstack 244 + cloud 127 = 371 shipped field-level defaults, 0 refusals (matches the decision dossier exactly). The two known docs-sample defects (schema.mdx bare CEL source string, currency object literal) are refusals of this gate and are fixed. Regenerated after a real spec build: api-surface + export-origins (dual snapshot — AddressSchema origin move + 4 new exports), docs references, skill refs, strictness ledger (AddressSchema's strip-site row moved files). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…ault-value-discriminator # Conflicts: # content/docs/protocol/objectql/schema.mdx
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Closes #7127.
Implements the maintainer's ruling on the issue (comment 5236142227, 2026-08-10), verbatim and untranslated in its directive part:
One PR, two commits in ruling order (the spec seat's recorded slicing decision, claim comment 5237312268), plus a merge of
main:packages/spec/src/data/default-value-shape.ts): the engine's envelope predicate verbatim (ObjectQL.applyFieldDefaults— truthydialect+ stringsource), the spec token predicates, discrimination in the engine's own order (envelope → token → literal), andcheckLiteralDefaultValue— the shared literal-vs-stored-contract core (ADR-0104 D1valueSchemaFor(def, 'stored')). The An action param'sdefaultValueis typedz.unknown(), so a default that can never satisfy the param's own value contract is accepted at authoring time and only 400s at submit #6970 action-param refinement inui/action.zod.tsis refactored onto the shared core with zero behavior change — its pins (action-param-default-value.test.ts) stayed green unedited through the whole PR. Presence deliberately stays with each consumer (engine:''is a real default; action params: blank is absent) — recorded in the module note.FieldSchema's existing superRefine (the ADR-0113 block) is extended — no second refinement — with: absent (dv == null) skip → envelope structural acceptance → token per-token-per-type table → literal through the shared core. Plus tests, changeset, regenerated artifacts.origin/main(base moved while the PR opened): one content conflict incontent/docs/protocol/objectql/schema.mdx, resolved by taking main's side — see the docs note below.ui/action.zod.tsauto-merged (main's fix(spec): prescribe per action type when object-formparamsis refused —bodyExtraforapi,targetinterpolation +openInforurl(#6828) #7375/refactor(spec)!: retireglobal_navfromACTION_LOCATIONS— a location the product never rendered, and the designer previewed anyway (#6888) #7322 changes + this PR's refactor coexist; pins re-run green on the merged tree). Generated artifacts regenerated from the merged tree after a fresh spec build (the pre-commit os-regen debt hook enforced exactly this),check:generated11/11.Premise re-verification (issue rule 6)
Re-probed on the pre-merge
origin/main(f188ed6) before any edit: all 11 dossier acceptance rows reproduce — every one ACCEPTED by the pre-change schema, includingnumber+"abc",text+NOW(),user+current_user, a CEL envelope, an arbitrary object, and a function. (One probe row initially rejected onoptions.0.value— my probe's own invalid option value, corrected and re-measured as ACCEPT.) Premise holds.Gating table as implemented (dossier §3, with the recorded Q1–Q5 adjudications)
dv == null(engineapplyFieldDefaults;''is PRESENT)dialect+ stringsource— the engine's predicate verbatimNOW()isNowDefaultToken(case-insensitive, trim)datetime,date,time(Q1 in — both resolvers andtypes.mdxsupport all three); refused elsewhere incl.text(Q2 — the silent instant-into-text interception "nobody chose" is refused, prescription names the temporal types); refused on any multi-value field (Q3 — both tokens resolve to one scalar)current_useruser, andlookupwithreference: 'sys_user'only (Q4 strict, matching the #4560 failure mode); refused elsewhere and on multi-valuevalueSchemaFor(def, 'stored')— the #6970 mechanics one layer down; near-miss token spellings ('now','{current_user}', …) are SUGGESTED inside refusals, never accepted as tokens (Q5;default-value-tokens.ts's recorded refusal to widen)Refusals follow the self-prescribing house style (field name, type, offending value verbatim, why, legal alternatives), pinned by path + code + message content in
field-default-value.test.ts(37 cases), mirroring the #6970 pin table.The §2d ESM cycle — discharged structurally, verified by real build
field-value.zod.tsdereferencedAddressSchema(imported fromfield.zod.ts) at module-eval time, so afield.zod → field-value.zodruntime edge would have been an evaluation cycle with an order-dependent TDZ crash. Resolution:AddressSchemaMOVED intofield-value.zod.ts(it IS the enforced address value contract there) and is re-exported fromfield.zod.tsunchanged — the back-edge becomes the type-onlyFieldTypeimport, erased at runtime. This is the dossier's sanctioned escape, at minimum width: no import site outside the two files changes, the public surface is identical, only the export ORIGIN moves (both snapshots regenerated). Discharged by: fullpnpm --filter @objectstack/spec buildincluding the DTS pass (exit 0) + a dist-eval smoke test (dist/data/index.mjsand rootdist/index.mjsboth evaluate; gate live;AddressSchema/AddressValueSchemaparse correctly from dist).Census — migration surface (hard constraint: 0 shipped refusals)
Re-ran the dossier's census approach by executing the implemented gate's branches (imported from this branch's source) over every object module. Cloud WAS locally available (
/home/user/cloud@485cbd3— the dossier's exact ref), censused via a dedicated read-only worktree with a throwaway spec symlink, removed afterwards.datetime)user)datetime)Matches the decision dossier's table exactly, after deduping barrel re-exports by object identity. 2 failed imports, both
create-objectstackscaffold templates with no resolvable deps — hand-checked: they declare nodefaultValueat all.The two docs-sample defects — fixed on
mainfirst, no longer in this diffThe claim comment sanctioned fixing
schema.mdx's two defective samples (bare CEL source string; currency object literal) in this PR, and commit 2 originally did. While this PR was opening, an independent docs-only change landed the same two fixes onmain; the merge conflict was exactly that collision and was resolved by taking main's text (semantically identical to mine). This PR's effective diff therefore no longer touchescontent/docs/protocol/objectql/schema.mdx— both samples are already correct on the base, and the gate this PR adds is what refuses their old forms.Reverse verification — predicted BEFORE running, then measured
Prediction (recorded in writing first): reverting ONLY the field-gate block leaves 21 tests red — the 18 rejection-class table rows + 3 standalone pins — with all acceptance rows, the discriminator suite, and the action-param suite green; direction plain red, explicitly NOT the #5009 inversion (there was no pre-existing rule to over-reach; removal can only widen acceptance).
Measured:
field-default-value.test.ts→ 37 tests | 21 failed, exactly the predicted 21;default-value-shape.test.ts+action-param-default-value.test.tsfully green. Predicted-vs-measured 21/21, no surprises. Gate restored viagit checkout; no stash used anywhere.Gates and evidence
pnpm --filter @objectstack/spec build(incl. DTS).build-input-hashstampedstrictness-ledger.test.tspinsfield-value.zod.ts's site count, which theAddressSchemamove changed 2 → 3 (comment updated in the pin, same style as itschart.zod.tssibling)pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/platform-objects test(largest defaultValue consumer)check:generatednode scripts/check-adr-0087-registration.mjs --base origin/maindocs/adr/**path touchednode scripts/check-nul-bytes.mjsChangeset level — minor, judged from the sibling precedent (a recorded deviation from the dispatch's "expect major")
The dispatch expected breaking-class = major. Landed as minor, for three reasons: (1) the direct sibling #6970 / PR #7126 — the same acceptance-narrowing class on the same key one layer up — shipped as
minortwo days ago with the same loud-at-authoring / non-fatal-at-rest stock story; (2) the measured migration surface is zero across both repos, and stored metadata keeps loading (read path runs no Zod validation; write-path validation surfaces as the advisory_diagnosticsenvelope); (3) the ADR-0087 registration gate, named by the dispatch as the arbiter, demands nothing for this changeset — and amajorhere would ask changesets to leave the 17.0.0-rc line entirely. If the spec seat or maintainer prefers major-with-registration, saying so reverses this at one-line cost.Generated by Claude Code