docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889
Conversation
…eclaration `Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`) are transition gates; a `validations[]` `script` rule is a true invariant. Both semantics are deliberate and neither moves here — what was missing is that no platform surface said so, while "required when X" reads as an invariant. Measured against the engine before writing a word (12 cases, all green): `evaluateValidationRules` refuses a `requiredWhen` write only when the merged record violates AND the pre-write record complied (the ADR-0113 non-regression branch), `validateRecord` iterates the UPDATE payload so a bound is checked on the written value only, and `checkPredicate` re-evaluates a `script` rule against the merged record with no pre-state exemption. Adds a schema-reachable pin over the contract text, and regenerates the reference pages the `.describe()` strings feed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8
|
|
CI on That is #14832 — the Generated by Claude Code |
|
Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):
⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ Generated by Claude Code |
Fixes #13879
Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so.
Field.requiredWhenand the field bounds (min/max/minLength/maxLength) are transition gates; avalidations[]scriptrule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.
The premise, re-measured against
origin/mainbefore a word was writtenThe card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over
evaluateValidationRules/validateRecord(12 cases, all green; the file was deleted before the commit — this PR adds no test topackages/objectql).requiredWhen: INSERT born inside the gate, value absentrule-validator.tsevaluateValidationRules, field-rule block; pinned byrule-validator.test.ts"rejects a missing required-when field (insert, predicate TRUE)"requiredWhen:drafttopresented, value absentrequiredWhen: clearing the cell while inside the gaterequiredWhen: in-gate movepresentedtoacceptedon a row already missing the valuepreViolatedshort-circuitif (preViolated) continue; // legacy rows rest)requiredWhen: unrelated edit on a row already missing the valuerequiredWhen: repairing writemax: write carrying an out-of-bound valuemax_value)record-validator.tsvalidateOne, number branchmax: unrelated edit while a stored out-of-bound value standsvalidateRecord's UPDATE branch iteratesObject.entries(data), the payload, not the field mapmax: repairing writescript: unrelated edit while a stored violating value standscheckPredicateoverctx.merged, no pre-state exemptionscript: repairing writescript: INSERT that violatesOne refinement over the card's wording, and it is load-bearing for the text.
requiredWhenis not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like ascriptrule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason —validateRecorditerates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.The sentences added, per site
All four sites are
.describe()text, so each ships as the JSON Schemadescriptionand as the generated reference page — the copy an author actually reads.FieldSchema.requiredWhen(packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":min/max— the bare "Minimum value" / "Maximum value" now carry:(
maxsays the same with "above" and refers tominfor the class.)minLength/maxLength— one sentence each placing them in that class:InlineGridColumnSchema.requiredWhen— the trap next door, measured rather than assumed: nothing on the write path readsinlineColumnsat all (driver-sql classifies the keypresentation, and no consumer outside the renderer exists).ScriptValidationSchema.condition(packages/spec/src/data/validation.zod.ts):Module docblock (
validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules,validateRecord). It renders intocontent/docs/references/data/validation.mdx.cross_fieldneeds nothing of its own: the file already states it "shares the exact evaluation path asscript", and the new section names it.Deliverable 3: named, not edited
The card's optional third deliverable was an implementation note in
packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text namesevaluateValidationRules(andvalidateRecord) by symbol so a reader can follow it.Proposed skills tool-choice line (for the follow-up card)
Deliverable 2 of the card is the tool-choice line in the published
skills/**. That surface isdomain:skillsand governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:The pin
packages/spec/src/data/validation-boundary-description.test.ts— 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.descriptionand siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.Reverse verification (expected direction: RED). With the fix committed first,
A TRANSITION GATE, not an invariant:was replaced byA conditional requirement:infield.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12to45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored withgit checkout HEAD --under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to294c9e12and an emptygit diff HEAD. No dist rebuild is involved in this ablation: the pin imports./field.zodas a same-package relative source path, which vitest transforms fromsrc— not through the packageexportsintodist.Regenerated artifacts
pnpm --filter @objectstack/spec check:generated --fixreported exactly one stale artifact and regenerated only it (gen:docs):content/docs/references/data/field.mdxcontent/docs/references/data/object.mdxcontent/docs/references/data/validation.mdxcontent/docs/references/system/migration.mdx(theadd_fieldmigration op embeds the field schema)Every changed line in those four files is one of the new sentences — verified by filtering the diff.
Verification record — union run at
e8cd5aa2Heavy runs went through
scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0pnpm --filter @objectstack/spec buildVERDICT command-exit 0,34/34 declared declaration file(s) presentpnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459);Tests 12294 passed, 1 skipped (12295)pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0;check:test-typecheck: OKTest Files 1 passed,Tests 18 passedTests 12 passed (12)alongsiderule-validator.test.ts+record-validator.test.ts,Tests 293 passed (293)pnpm lint(eslint . --no-inline-config, whole repo)pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)Gate family re-derived on the actual changed paths with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(8 paths, three-dot against merge base2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:node scripts/check-dev-prereqs.mjs— "The workspace is not built — 1 unmet precondition, not a list of problems."node scripts/check-test-completeness.mjs(exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."pnpm check:dual-build-cjs-loads(exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."pnpm check:type-check-debt(exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."Seven gates that read
packages/spec/distwent red on the first pass purely on mtime — the ablation's restore rewrotefield.zod.tsafter the build, sodistread as older thansrc. All seven are green after rebuilding (check:api-surface,check:browser-reachable-entries,check:dual-source-exports,check:entry-nameability,check:exported-any,check:generated,check:skill-examples; the last also needed the@objectstack/client-reactclosure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.Scope
packages/speconly, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves;packages/objectqlis not touched. Per the region split with #13894, this PR writes only therequiredWhen/ bounds describe texts infield.zod.ts— theuniqueline, the autonumber block andRUNTIME_OWNED_FIELD_TYPESare untouched, and that card rebases on this one. Apatchchangeset for@objectstack/specis included, so noskip-changesetlabel.One side finding, filed unassigned rather than fixed here: #14891 — the
script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin inrule-validator.test.ts, while therequiredWhenside's mirror case does.🤖 Generated with Claude Code
https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
Generated by Claude Code
Generated by Claude Code