Repository navigation
Commit 4bf9a2d
feat(spec): a blueprint field carries a typed
domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuHuzzmQedufwxyR2BQdPjaR)_
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.md` only points at
`solution-blueprint.zod.ts`); the reference page
`content/docs/references/ai/solution-blueprint.mdx` is regenerated from
the new describe.
## 维护者速读
- 改了什么:AI 搭建方案(蓝图)的字段可以写 `defaultValue` 了。宽松 schema 与 strict
结构化输出镜像都加了这个键,并按字段类型校验:布尔取 true/false,数字取数字,单选取本字段自己的选项值;21 种类型可写,其余 28
种写了就按类型名拒收,不再悄悄丢掉;`current_user`、`NOW()`
这类运行时令牌先按令牌拒收,理由说的是令牌,不借用类型的理由(第 1 轮)。
- 为什么改:AI 照搭建指引把「是否完成 / 已付款」写成 `defaultValue:
false`,以前解析成功但这个键被剥掉,建出的字段没有默认值,新记录是空而不是「否」,按「否」筛选会漏掉;按 cloud#3619 裁决 B
补上 spec 这一半。
- 风险与回滚:只加不收,之前能解析的蓝图照旧能解析;strict 镜像的生产者每个字段要多发一个 `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
- **`defaultValue` on `BlueprintFieldSchema` and on the strict mirror's
fields** (`SolutionBlueprintStrictSchema.objects[].fields[]`, the
`StrictField` shape). 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 after `type` and `options`.
- **The type check lives on the lenient field**, as a `superRefine`
(`refuseUnfitDefaultValue`) run with `when: () => true`. It reads one
table, `BLUEPRINT_DEFAULT_VALUE_RULES`, typed as a record keyed by every
`FieldType` member: a type added to `FieldType` does not compile until
it is placed in the table, admitted or refused with a reason. An
admitted value is judged by `checkLiteralDefaultValue` from
`data/default-value-shape.ts`, the same literal contract `FieldSchema`
runs on a built field's `defaultValue` (ADR-0104 D1,
`valueSchemaFor(def, 'stored')`). A select / radio default must be one
of THIS field's `options[].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.
- **Every refusal is one `custom` issue at the field's `defaultValue`.**
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.
- **Both describes** say the value becomes the built field's
`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_active` flag is a `boolean` field that is never
`required` and carries `defaultValue: false`. Each side uses its own
spelling of an unset `required` ("omit `required` or set it false" /
"`required` null or false").
- **Generated artifacts:**
`content/docs/references/ai/solution-blueprint.mdx` (`gen:docs`, two
rows), `packages/spec/authorable-surface/ai.json` (one key).
`packages/spec/dropped-refinements.baseline.json` gains three
hand-edited sites (`ai/BlueprintField` root, `ai/BlueprintObject`
`fields.element`, `ai/SolutionBlueprint`
`objects.element.fields.element`) and its `droppedRefinementSites` total
moves 1020 → 1023: the option-membership rule reads a sibling key, so it
has no JSON Schema form.
- **Changeset** `30584-blueprint-field-default-value.md`:
`@objectstack/spec` minor.
## Tests
A new `describe` block, "blueprint field `defaultValue`", at the end of
`packages/spec/src/ai/solution-blueprint.test.ts`, 12 cases:
- a boolean with `defaultValue: false` parses and the key survives the
parse (own-property check);
- the admitted set is exactly the declared one: every `FieldType` member
is tried, the 21 admitted take their value, each of the 28 others is
refused with one by-name issue;
- a wrong-typed value on each admitted type is refused, one issue, by
name (a string on a boolean, a number on text, `"9am"` on time, a label
`"Open"` on a select);
- a select default that is not one of its OWN option values is refused,
including a value another field of the same object declares, and a radio
with no options;
- a runtime token on an admitted type is refused;
- a runtime token on a type that takes no default (`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 ends `Remove defaultValue from this
field.`, and never the type's own reason (round 1);
- an array default on a `multiselect` is refused by name (beside the
key's own union issue);
- an absent value parses (lenient); the strict mirror takes null and
refuses the key missing, at the key's path;
- the strict mirror accepts what the lenient schema accepts for each
admitted type, and null;
- VALUE parity for a key whose rule spans `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;
- every value the blueprint admits is one `FieldSchema` admits on the
built field;
- both describes name every admitted type and state the boolean-flag
rule.
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
- **The strict mirror's shape: `z.union([z.boolean(), z.number(),
z.string()]).nullable()`.** Strict structured output states a rule per
key and cannot read `type` while it judges this one, so the mirror
cannot say which arm a type takes. The per-type check lives on the
lenient field, which `apply_blueprint` parses, 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.
- **The lenient key is the same scalar union, not `z.unknown()`** like
`FieldSchema.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's `invalid_union` aborts before an object
refinement runs by default, so the refinement runs with `when: () =>
true` (the `view.zod.ts` / `analytics-carrier-filter.ts` precedent), and
an array or object default gets the union's issue AND the by-name one. A
lenient `null` is refused by the union, as on every other lenient key.
- **The admitted types**, grounded in `data/field-value.zod.ts`'s value
classes, the ones `valueSchemaFor` answers for a built field's literal
default: `BOOLEAN_VALUE_TYPES` (boolean, toggle); `NUMERIC_VALUE_TYPES`
minus `summary` (number, currency, percent, rating, slider, progress);
`STRING_VALUE_TYPES` minus `password`, `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, `valueSchemaFor` would
take any string).
- **Refused by name, even where `FieldSchema` takes a default:** `date`
/ `datetime` (a literal day is frozen at design time; "today" is the
`NOW()` token, an instruction rather than a value); `lookup` /
`master_detail` / `tree` / `user` and 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 a `checkbox` type: `FieldType` has none. `checkboxes` is 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.
- **The boolean-flag sentence** lives in `defaultValue`'s describe on
both sides. `required`'s describe is unchanged: no existing key's shape,
default or describe moves.
- **Liveness: no row, measured.** `check:liveness` at base `14c67d44`
prints 43 governed types (33 authorable plus 10 via
`SPEC_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 a `planned` row; 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's `defaultValue`, governed by
`liveness/field.json` `props.defaultValue` (`live`,
`packages/objectql/src/engine.ts`).
- **Consumers in this repo:**
`packages/lint/src/validate-expressions.formula-one-hop.test.ts` reads
`BlueprintFieldSchema.shape` and the strict field shape; the refined
schema is still a `ZodObject`, and the file is 13/13 after a lint
closure build. `ui/packages/plugin-chatbot/src/mapMessages.ts` names
`BlueprintFieldSchema` only in a comment: it lifts each field's `name` /
`label` from 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.
- The ledger's `measured` header also carries
`refinementSitesThatDidProject: 369` and `zod: "4.4.3"`, which already
differ from what the build prints at base (714, zod 4.6.7). No test
reads them; left alone.
- **Round 1** (contract review 6670092320, PASS, one should-fix, adopted
by the seat). `refuseUnfitDefaultValue` judged 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. The
`isRuntimeDefaultToken` check 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 ends
`Remove defaultValue from this field.`; `rule.takes` is not read there.
Still refused, no token admitted. One new pin; ablation 3 below.
`origin/main` merged 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.
- Branch is the harness-assigned `claude/project-thread-7e1clu`, not
`claude/issue-30584-*`; claim 6668151062 names it.
## Verification
Readings on `objectstack-ai/objectstack`. Round 1 head `32b39007` (fix
`80d143dc`, then a merge of `origin/main` at `66a8822b`; merge base for
the change set `66a8822b`). Round 0 head `13a078c8` (base `14c67d44`);
its readings are kept below where marked.
<details><summary>Repro at base 14c67d4</summary>
`SolutionBlueprintSchema.safeParse` of a blueprint whose boolean field
carries `defaultValue: false` → `success: true`, parsed field keys
`[name, type]`, the key stripped. The strict mirror strips it too.
</details>
<details><summary>Ablation 1 (round 0, at 13a078c) — the type check
detached</summary>
`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 1663954 → 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 (1663954), `git
diff HEAD` empty. (The never-stripped cross-product pin stays green here
by design: with no check the lenient side keeps every value.)
</details>
<details><summary>Ablation 2 (round 0, at 13a078c) — the lenient key
renamed (the strip)</summary>
Same tool, anchor ` defaultValue: z.union([z.boolean(), z.number(),
z.string()]).optional()` → ` ablationStripped: …`: blob 1663954 →
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 HEAD` empty.
</details>
<details><summary>Ablation 3 (round 1) — the old type-before-token order
restored</summary>
`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'
--replacement` the round-0 `if (!('takes' in rule)) { … return; }` block
placed back ahead of the token check: anchor 1 → 0, blob b490c57 →
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 (b490c57), `git diff HEAD` empty.
</details>
<details><summary>Suites</summary>
Round 1, at `32b39007`:
- Full build after the merge, `turbo run build
--filter=!@objectstack/docs --concurrency=3` under the lock: `118
successful, 118 total`, exit 0; `git status` clean afterwards.
- `src/ai/solution-blueprint.test.ts`: 97 passed (97).
- `pnpm --filter @objectstack/spec test` under 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-typecheck` OK, 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 inside `tsc -p tsconfig.test.json`; rerun
with `NODE_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 was
`check:test-typecheck` on that out-of-memory, not a stale artifact).
- `eslint` on 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` (under `os-verify-lock.sh`) at
`13a078c8`: `Test Files 817 passed (817)`, `Tests 25750 passed | 1 todo
(25751)`, command-exit 0. The first run at `5d57ab6a` was 1 red:
`scripts/dropped-refinements.test.ts` pins the ledger's
`droppedRefinementSites` total (expected 1023, read 1020); `13a078c8`
moves the total.
- `src/ai/solution-blueprint.test.ts` alone: 96 passed (96).
- `pnpm --filter @objectstack/spec typecheck`: exit 0;
`check:test-typecheck` OK, 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 on
`check:docs` only; `--fix` regenerated the reference page, committed in
`5d57ab6a`).
- `eslint` on 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.
- Full build, `turbo run build --filter=!@objectstack/docs
--concurrency=3` under the lock: `118 successful, 118 total`, exit 0;
`git status` clean afterwards (no generated file moved).
</details>
<details><summary>Derived gates</summary>
Round 1: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `32b39007`: 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/main` on the committed head: exit 0 (no declared-breaking
changeset). Changed lines 450 (+441 / -9, 6 files) against merge base
`66a8822b`, under the 3000-line threshold.
Round 0: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `13a078c8`: 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/main` ran on the
committed head: exit 0. Changed lines 430 (+421 / -9, 6 files), under
the 3000-line threshold.
</details>
Dogfood battery: n/a — no `!` in the title and no `(narrowing)` arm.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2
---------
Co-authored-by: Claude <noreply@anthropic.com>defaultValue, so an AI-built boolean flag defaults to false (#30655)1 parent 788715e commit 4bf9a2d
6 files changed
Lines changed: 441 additions & 9 deletions
File tree
- .changeset
- content/docs/references/ai
- packages/spec
- authorable-surface
- src/ai
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
113 | 113 | | |
114 | 114 | | |
115 | 115 | | |
| 116 | + | |
116 | 117 | | |
117 | 118 | | |
118 | 119 | | |
| |||
229 | 230 | | |
230 | 231 | | |
231 | 232 | | |
| 233 | + | |
232 | 234 | | |
233 | 235 | | |
234 | 236 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
49 | 50 | | |
50 | 51 | | |
51 | 52 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
| 25 | + | |
24 | 26 | | |
25 | 27 | | |
26 | 28 | | |
| |||
46 | 48 | | |
47 | 49 | | |
48 | 50 | | |
| 51 | + | |
49 | 52 | | |
50 | 53 | | |
51 | 54 | | |
| |||
0 commit comments