Repository navigation
feat(spec): a blueprint field carries a typed defaultValue, so an AI-built boolean flag defaults to false - #30655
Conversation
…I-built boolean flag defaults to false `BlueprintFieldSchema` and the strict structured-output mirror `StrictField` gain `defaultValue`: a boolean / number / string union, optional on the lenient side and required-but-nullable on the mirror. The lenient field checks the value against its `type` with the field schema's own literal contract (`checkLiteralDefaultValue`): 21 types take a default, the other 28 are refused by name at the key, and a select / radio default must be one of the field's own option values. Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude <[email protected]>
…ield's dropped refinement The `defaultValue` type check is a lenient-side refinement with no JSON Schema form (a select default must be one of the field's own option values), so the three published schemas that reach the blueprint field record its site in `dropped-refinements.baseline.json`. Adds the `@objectstack/spec` minor changeset. Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude <[email protected]>
…ew sites Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude <[email protected]>
📓 Docs Drift Check8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 170 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 8252a3b8090b3b67c727bbe6beaab68197de4b83 && git checkout 8252a3b8090b3b67c727bbe6beaab68197de4b83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b77c160a02c2bb69ad6c986461b2633fa8d28c18 32b39007ee649da8520bdcc92e212a81871da576 && git checkout -B drift-repro b77c160a02c2bb69ad6c986461b2633fa8d28c18 && git merge --no-ff 32b39007ee649da8520bdcc92e212a81871da576
node scripts/docs-audit/affected-docs.mjs --json b77c160a02c2bb69ad6c986461b2633fa8d28c18 |
|
domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa Contract-review-claim: session_01L6bhgTqqeHSwZYJmApbDCa · head 13a078c · 2026-11-05T21:09Z Full review at Generated by Claude Code |
|
domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa Contract reviewServed-tier: Inputs read: card #30584 (body; comments 6666474709, 6668086354, 6668151062, 6669582413), PR #30655 (body, file list: 6 files, +421 / -9, net diff against merge-base Check-runs on the head: 65 runs, every one completed before this record was written (first read 2026-11-05T21:11Z, polled at 3-minute spacing, last run completed 2026-11-05T21:31Z); 57 ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentpackages/spec/src/ai/solution-blueprint.zod.ts, against ruling B as the card quotes it,
Findings: one should-fix (token-before-type ordering of the refusal reason, solution-blueprint.zod.ts:247-258). No must-fix. Implemented-by: VERDICT: PASS Generated by Claude Code |
… before its type is judged A `user` field with `defaultValue: 'current_user'` or a `date` field with `defaultValue: 'NOW()'` was refused with that type's own reason (no record exists yet / a literal day is frozen), which is false of a token. The token check now runs first for every type, and on a type that takes no default the refusal says it is a runtime token, that the type takes no defaultValue in a blueprint, and to remove it. Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude <[email protected]>
|
domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa Contract reviewServed-tier: Inputs read: the card thread's round-1 report 6670803741; PR #30655 as refreshed at 2026-11-05T22:09Z (body, file list: 6 files, +441 / -9, net diff against merge-base Check-runs on the head: 69 runs, every one completed before this record was written (first read 2026-11-05T22:10Z, polled at 3-minute spacing, last run completed 2026-11-05T22:16Z); 59 ① Derived judgments
② Semver levelUnchanged and still right: ③ Boundary flags
④ Design judgmentpackages/spec/src/ai/solution-blueprint.zod.ts at
Findings: none. The round-0 should-fix is closed by Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa 维护者速读
席位意见:契约复审两轮都 PASS。第一轮唯一的 should-fix:运行时令牌写在不收默认值的类型上(如 user + 席位核对 — ACCEPT · domain:spec#3 · head
|
domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa
Requested by Sales · project thread
Implemented by the card thread of claim 6668151062 (session_01L6bhgUCEUicLCQ4tkXayK2); round 1 adopted from contract review 6670092320.
Fixes #30584
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — no file under
skills/**restates a blueprint field key (skills/objectstack-ai/references/_index.mdonly points atsolution-blueprint.zod.ts); the reference pagecontent/docs/references/ai/solution-blueprint.mdxis regenerated from the new describe.维护者速读
defaultValue了。宽松 schema 与 strict 结构化输出镜像都加了这个键,并按字段类型校验:布尔取 true/false,数字取数字,单选取本字段自己的选项值;21 种类型可写,其余 28 种写了就按类型名拒收,不再悄悄丢掉;current_user、NOW()这类运行时令牌先按令牌拒收,理由说的是令牌,不借用类型的理由(第 1 轮)。defaultValue: false,以前解析成功但这个键被剥掉,建出的字段没有默认值,新记录是空而不是「否」,按「否」筛选会漏掉;按 cloud#3619 裁决 B 补上 spec 这一半。defaultValue(无默认填 null)。回滚即还原solution-blueprint.zod.ts、它的测试和台账三处站点,无数据迁移。packages/spec/src/ai/solution-blueprint.zod.ts(准入表BLUEPRINT_DEFAULT_VALUE_RULES与refuseUnfitDefaultValue)、packages/spec/src/ai/solution-blueprint.test.ts(末尾blueprint field defaultValue一节)、packages/spec/dropped-refinements.baseline.json(新增三处站点)。pnpm --filter @objectstack/spec exec vitest run src/ai/solution-blueprint.test.ts,预期 97 个用例全绿。What changes
defaultValueonBlueprintFieldSchemaand on the strict mirror's fields (SolutionBlueprintStrictSchema.objects[].fields[], theStrictFieldshape). Both sides declare one scalar union,boolean | number | string. Lenient:.optional(). Strict:.nullable()and present on every field, as with every other key in that mirror; the blueprint tools strip the null. The key is appended last on both sides, so the model generates it aftertypeandoptions.superRefine(refuseUnfitDefaultValue) run withwhen: () => true. It reads one table,BLUEPRINT_DEFAULT_VALUE_RULES, typed as a record keyed by everyFieldTypemember: a type added toFieldTypedoes not compile until it is placed in the table, admitted or refused with a reason. An admitted value is judged bycheckLiteralDefaultValuefromdata/default-value-shape.ts, the same literal contractFieldSchemaruns on a built field'sdefaultValue(ADR-0104 D1,valueSchemaFor(def, 'stored')). A select / radio default must be one of THIS field'soptions[].value; with no options declared it is refused. A runtime token (NOW(),current_user) is refused on every type, and it is judged FIRST, before the type's own rule: the refusal gives the token's reason (a blueprint default is a literal), never a refused type's reason, which is false of a token.customissue at the field'sdefaultValue. It names the field and its type (Field "is_done" (boolean): …), says why, and prescribes the fix. A non-admitted type's refusal lists the types that take a default.defaultValue, give the admitted list (built from the table, so describe and refusal read the same list), and state the boolean-flag rule: a 是否完成 / 已付款 /is_activeflag is abooleanfield that is neverrequiredand carriesdefaultValue: false. Each side uses its own spelling of an unsetrequired("omitrequiredor set it false" / "requirednull or false").content/docs/references/ai/solution-blueprint.mdx(gen:docs, two rows),packages/spec/authorable-surface/ai.json(one key).packages/spec/dropped-refinements.baseline.jsongains three hand-edited sites (ai/BlueprintFieldroot,ai/BlueprintObjectfields.element,ai/SolutionBlueprintobjects.element.fields.element) and itsdroppedRefinementSitestotal moves 1020 → 1023: the option-membership rule reads a sibling key, so it has no JSON Schema form.30584-blueprint-field-default-value.md:@objectstack/specminor.Tests
A new
describeblock, "blueprint fielddefaultValue", at the end ofpackages/spec/src/ai/solution-blueprint.test.ts, 12 cases:defaultValue: falseparses and the key survives the parse (own-property check);FieldTypemember is tried, the 21 admitted take their value, each of the 28 others is refused with one by-name issue;"9am"on time, a label"Open"on a select);user/lookup+current_user,date+NOW(),datetime+now()) is refused with one issue that gives the token's reason, says the type takes no defaultValue in a blueprint and endsRemove defaultValue from this field., and never the type's own reason (round 1);multiselectis refused by name (beside the key's own union issue);type: 49 types × 9 values the strict union accepts; the lenient schema either keeps the value byte-equal or refuses it with exactly one issue that names the type at this key, never strips it;FieldSchemaadmits on the built field;Seven existing strict fixtures gained
defaultValue: null(strict mode needs every key), including the leading-digit pin, whose "the only defect left is the leading digit" claim needs it.Acceptance notes
z.union([z.boolean(), z.number(), z.string()]).nullable(). Strict structured output states a rule per key and cannot readtypewhile it judges this one, so the mirror cannot say which arm a type takes. The per-type check lives on the lenient field, whichapply_blueprintparses, following the precedent this file already sets for the view filter rule's operator ↔ value refinement. The mirror can therefore generate a value the lenient schema refuses (a string on a boolean), and the cross-product pin holds every such refusal to one by-name issue at the key. The existing identifier VALUE-parity walker does not walk union leaves and is unchanged and green.z.unknown()likeFieldSchema.defaultValue. The published JSON Schema then types the key as the scalars every admitted type draws from, rather than as anything. The price: the union'sinvalid_unionaborts before an object refinement runs by default, so the refinement runs withwhen: () => true(theview.zod.ts/analytics-carrier-filter.tsprecedent), and an array or object default gets the union's issue AND the by-name one. A lenientnullis refused by the union, as on every other lenient key.data/field-value.zod.ts's value classes, the onesvalueSchemaForanswers for a built field's literal default:BOOLEAN_VALUE_TYPES(boolean, toggle);NUMERIC_VALUE_TYPESminussummary(number, currency, percent, rating, slider, progress);STRING_VALUE_TYPESminuspassword,secret,signature,qrcode(text, textarea, email, url, phone, markdown, html, richtext, code, color);CLOCK_TIME_TYPES(time,HH:MM[:SS], a wall-clock literal that does not age);SINGLE_OPTION_TYPES(select, radio), narrowed to the field's own option values (with no options,valueSchemaForwould take any string).FieldSchematakes a default:date/datetime(a literal day is frozen at design time; "today" is theNOW()token, an instruction rather than a value);lookup/master_detail/tree/userand the five file types (the value is the id of a record or file that does not exist while the blueprint is designed; seed data is reported, never applied);multiselect/checkboxes/tags(an array, and this key carries one scalar);formula/summary/autonumber(computed, never read);password/secret(one credential on every record);signature/qrcode(made per record); the seven structured JSON types. The brief's example names acheckboxtype:FieldTypehas none.checkboxesis the multi-option type and is refused with the other list types. Widening any of these later is additive; narrowing would be breaking, so the first cut is the narrow one.defaultValue's describe on both sides.required's describe is unchanged: no existing key's shape, default or describe moves.check:livenessat base14c67d44prints 43 governed types (33 authorable plus 10 viaSPEC_ONLY_SCHEMAS); no blueprint shape is among them, because a blueprint is never persisted and is not a metadata type. The card's Acceptance asks for aplannedrow; that is a deviation with this reading. Carrier for "planned until the cloud half lands": cloud#3619 / cloud#3633 (the build applier that carries the value into the built field and admits the key in its undeclared-key refusal). Once carried, the value is the built field'sdefaultValue, governed byliveness/field.jsonprops.defaultValue(live,packages/objectql/src/engine.ts).packages/lint/src/validate-expressions.formula-one-hop.test.tsreadsBlueprintFieldSchema.shapeand the strict field shape; the refined schema is still aZodObject, and the file is 13/13 after a lint closure build.ui/packages/plugin-chatbot/src/mapMessages.tsnamesBlueprintFieldSchemaonly in a comment: it lifts each field'sname/labelfrom the plan envelope and imports no blueprint schema, so it neither reads nor drops the new key.packages/spec/src/type-alias-convention.pin.test.ts(input = output for both schemas) is green in the spec suite. The producers and readers that move on the new key (cloud's blueprint tools and build applier) are in objectstack-ai/cloud, outside this repo.measuredheader also carriesrefinementSitesThatDidProject: 369andzod: "4.4.3", which already differ from what the build prints at base (714, zod 4.6.7). No test reads them; left alone.refuseUnfitDefaultValuejudged the type before the token, so{ type: 'user', defaultValue: 'current_user' }read "no record exists while the blueprint is designed" and{ type: 'date', defaultValue: 'NOW()' }read "a literal date is fixed when the blueprint is written", both false of a token. TheisRuntimeDefaultTokencheck now runs first for every type in the table. An admitted type keeps its message (… default is ${rule.takes}; write one, or remove defaultValue.); a refused type says the value is a runtime token, that a blueprint default is a literal, that the type takes no defaultValue in a blueprint (with the admitted list), and endsRemove defaultValue from this field.;rule.takesis not read there. Still refused, no token admitted. One new pin; ablation 3 below.origin/mainmerged in (a merge commit, not a rebase). The describe's "carried into the object field" sentence stays as written: it waits on cloud#3633 by the ruled sequencing.claude/project-thread-7e1clu, notclaude/issue-30584-*; claim 6668151062 names it.Verification
Readings on
objectstack-ai/objectstack. Round 1 head32b39007(fix80d143dc, then a merge oforigin/mainat66a8822b; merge base for the change set66a8822b). Round 0 head13a078c8(base14c67d44); its readings are kept below where marked.Repro at base 14c67d4
SolutionBlueprintSchema.safeParseof a blueprint whose boolean field carriesdefaultValue: false→success: true, parsed field keys[name, type], the key stripped. The strict mirror strips it too.Ablation 1 (round 0, at 13a078c) — the type check detached
node scripts/ablation-replace.mjs --file packages/spec/src/ai/solution-blueprint.zod.ts --anchor '}).superRefine(refuseUnfitDefaultValue, { when: () => true }));' --replacement '})); // ABLATION: type check detached': anchor 1 → 0, blob 16639542 → cdf6df65.src/ai/solution-blueprint.test.ts→Tests 5 failed | 91 passed (96): the admitted-set, wrong-type, own-option, token and multiselect pins red. Restored: blob == HEAD (16639542),git diff HEADempty. (The never-stripped cross-product pin stays green here by design: with no check the lenient side keeps every value.)Ablation 2 (round 0, at 13a078c) — the lenient key renamed (the strip)
Same tool, anchor
defaultValue: z.union([z.boolean(), z.number(), z.string()]).optional()→ablationStripped: …: blob 16639542 → f7c41b8f. →Tests 9 failed | 87 passed (96): the field key-parity pin, the key-survives pin, the never-stripped cross-product pin and the describe pin red with the refusal pins. Restored: blob == HEAD,git diff HEADempty.Ablation 3 (round 1) — the old type-before-token order restored
node scripts/ablation-replace.mjs --file packages/spec/src/ai/solution-blueprint.zod.ts --anchor ' // A token is judged before the type. On a type that takes no default, that' --replacementthe round-0if (!('takes' in rule)) { … return; }block placed back ahead of the token check: anchor 1 → 0, blob b490c570 → 234c4bbe.src/ai/solution-blueprint.test.ts→Tests 1 failed | 96 passed (97): the new pin "refuses a runtime token on a type that takes no default with the token's reason, never the type's" red. Restored: blob == HEAD (b490c570),git diff HEADempty.Suites
Round 1, at
32b39007:turbo run build --filter=!@objectstack/docs --concurrency=3under the lock:118 successful, 118 total, exit 0;git statusclean afterwards.src/ai/solution-blueprint.test.ts: 97 passed (97).pnpm --filter @objectstack/spec testunder the lock:Test Files 817 passed (817),Tests 25786 passed | 1 todo (25787), command-exit 0.pnpm --filter @objectstack/spec typecheck: exit 0;check:test-typecheckOK, 52 file(s) / 240 error(s) / 131 pinned signature(s), unchanged. (A first run without a raised heap died on a JavaScript heap out-of-memory insidetsc -p tsconfig.test.json; rerun withNODE_OPTIONS=--max-old-space-size=6144, green.)pnpm --filter @objectstack/spec run check:generated: exit 0, every artifact current (same heap note: the first run's only red wascheck:test-typecheckon that out-of-memory, not a stale artifact).eslinton the two changed source files: exit 0, no output.node scripts/check-nul-bytes.mjs: OK.Round 0, at
13a078c8:pnpm --filter @objectstack/spec test(underos-verify-lock.sh) at13a078c8:Test Files 817 passed (817),Tests 25750 passed | 1 todo (25751), command-exit 0. The first run at5d57ab6awas 1 red:scripts/dropped-refinements.test.tspins the ledger'sdroppedRefinementSitestotal (expected 1023, read 1020);13a078c8moves the total.src/ai/solution-blueprint.test.tsalone: 96 passed (96).pnpm --filter @objectstack/spec typecheck: exit 0;check:test-typecheckOK, 52 file(s) / 240 error(s) / 131 pinned signature(s), unchanged.pnpm --filter @objectstack/spec run check:generated: exit 0, every entry current (the first run before the regen commit was red oncheck:docsonly;--fixregenerated the reference page, committed in5d57ab6a).eslinton the two changed source files: exit 0, no output.packages/lint/src/validate-expressions.formula-one-hop.test.ts(reads both blueprint field shapes): 13 passed (13), after a lint closure build.turbo run build --filter=!@objectstack/docs --concurrency=3under the lock:118 successful, 118 total, exit 0;git statusclean afterwards (no generated file moved).Derived gates
Round 1:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat32b39007: 102 derived families, the same list as round 0. Each was run on that head after the post-merge build, with its exit code written to disk before any pipe (cmd :: exit N), then reconciled:node scripts/pm/dispatch-gates.mjs --ran r1-ran.list --repo objectstack-ai/objectstack→Run reconciliation — 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. All 102 exited 0 on the first run; no prerequisite refusal.check:adr-0087-registration --base origin/mainon the committed head: exit 0 (no declared-breaking changeset). Changed lines 450 (+441 / -9, 6 files) against merge base66a8822b, under the 3000-line threshold.Round 0:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat13a078c8: 102 derived families. Each was run on that head with its exit code written to disk before any pipe (cmd :: exit N), then reconciled:node scripts/pm/dispatch-gates.mjs --ran ran-final.list --repo objectstack-ai/objectstack→Run reconciliation — 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. Every one of the 102 exited 0.Six of them first exited 3 (PREREQUISITE NOT MET, built output missing):
check:doc-formula-expressions,check:doc-security-posture(lint dist),check:skill-examples(client-react / client declarations),check:docs-transcript-drift(lint dist),check:dual-build-cjs-loads(several dists),check:lean-entry-closure(objectql dist). After the full build above, all six reran with exit 0; those are the readings counted.check:adr-0087-registration --base origin/mainran on the committed head: exit 0. Changed lines 430 (+421 / -9, 6 files), under the 3000-line threshold.Dogfood battery: n/a — no
!in the title and no(narrowing)arm.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2