fix(spec): the blueprint mirror the model generates against carries the applier's SNAKE_CASE constraint (cloud#1967) - #15798
Conversation
…he applier's SNAKE_CASE constraint The strict structured-output mirror and the lenient authoring schema are two declarations of one shape. An existing test pinned their KEYS; nothing pinned the constraints on those keys, and 20 identifier leaves had drifted — every one of them `.regex(SNAKE_CASE)` on the lenient side and unconstrained on the mirror. A design model could therefore emit `1_49` (from a 「1-49人」 label) and `apply_blueprint`, which validates against the lenient schema, refused the whole blueprint on the turn the user approved it. Every identifier leaf in the mirror now reuses the same regex, so the pattern rides into the JSON Schema the model is given and an out-of-pattern identifier is refused at generation rather than after approval. Option `value` states the leading-digit rule explicitly (「1-49人」 → `size_1_49`); the `label` is untouched, so only the stored value becomes an identifier. A VALUE-parity test walks both schemas leaf by leaf, the twin of the key-parity gate that already guards this pair. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 129 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 a78b6e8d8d23de7f4dea8aa1bb8f9d213b335500 && git checkout a78b6e8d8d23de7f4dea8aa1bb8f9d213b335500
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fa85759963fcf25a55cd9a159c8e9c44bb9dde13 065ee300a249373a6e9544b0637ff4ed0dbab8b5 && git checkout -B drift-repro fa85759963fcf25a55cd9a159c8e9c44bb9dde13 && git merge --no-ff 065ee300a249373a6e9544b0637ff4ed0dbab8b5
node scripts/docs-audit/affected-docs.mjs --json fa85759963fcf25a55cd9a159c8e9c44bb9dde13
|
Part of objectstack-ai/cloud#1967 (cloud epic objectstack-ai/cloud#1955). The confirm-path half is a companion cloud PR; this is the producer-side half and reaches cloud only through a
.objectstack-shabump.The two declarations disagreed about values
packages/spec/src/ai/solution-blueprint.zod.tsdeclares one shape twice:SolutionBlueprintSchema— the authoring schema. Cloud'sapply_blueprintvalidates the approved blueprint against it. Every identifier leaf carries.regex(/^[a-z_][a-z0-9_]*$/).SolutionBlueprintStrictSchema— the OpenAI-strict structured-output contract the design model generates against. Not one identifier leaf carried it.The
strict mirror ↔ lenient schema — key paritytest in this file already pins which keys each side carries, and its own comment records why: a key present on one side and missing on the other silently drops authored config. Nothing pinned the constraints on those keys, and 20 leaves had drifted.What it cost, measured
A CRM build on the cloud golden-journey gate (run
mtmgrsfbkusx, prompt 「帮我搭建一个 CRM:客户、联系人、商机…」): the design model wrote acompany_sizeselect whose option values came off their labels —1_49for 「1-49人」. Generating that was legal. On the turn the user clicked 「确认,开始搭建」, the deterministic confirm replay handed that exact blueprint toapply_blueprint, which refused it wholesale:Nothing was staged. The app existed at the end only because the model noticed the error card and re-applied a different, repaired plan the user had never seen. 1 of 4 measured builds — latent, model-word-choice dependent, not constant.
The change
Every identifier leaf in the mirror now reuses the same
SNAKE_CASEregex through two shared builders (strictIdent/strictIdentOrNull), so the two declarations cannot drift on this axis by construction: object / field / view / dashboard / widget / app / navnames,reference,nameField,columns,groupBy,measure, roll-upobject/field/relationshipField, conditionfield, and select optionvalue. The regex is emitted into the JSON Schema the model is handed (pattern, on all 21 emitted string leaves — measured withz.toJSONSchema), so an out-of-pattern identifier is refused at generation rather than after the user has approved it.Option
valueadditionally states the rule that produced the incident: it may never start with a digit, so 「1-49人」 is authored assize_1_49. Thelabelbeside it is untouched and explicitly documented as free text in the user's own language — the constraint is on the stored identifier alone, never on what a person reads.Why the pattern is safe under strict structured outputs. The mirror's own header comment enumerates exactly two strict-mode limits — every property present in
required, and no open-endedadditionalProperties— and neither is touched here. Two live structured-output schemas on the same cloud adapter and gateway already ship constraint keywords of this class (QueryPlanSchema's.min(1)/.int().min(1).max(200),JudgeOutputSchema's.min(0).max(100)), and cloud's sibling authoring tools already publishpattern: '^[a-z_][a-z0-9_]*$'on option values in their model-facing parameter schemas.Tests
New
strict mirror ↔ lenient schema — VALUE parity (cloud#1967)block, the value-side twin of the key-parity gate:optional/nullable/default/lazy— are transparent, so the lenient.optional()and the strict.nullable()spelling of one key land on the same path) and asserts they enforce the same pattern wherever both carry the key. It fails on any future divergence, in either direction.1_49on a 「1-49人」 label is rejected by the applier's schema and by the mirror.Both fail on the parent commit. Measured before the fix — the walk printed all 20 drifted leaves:
Verification
Run at
065ee300a:pnpm --filter @objectstack/spec exec vitest run src/ai/ src/type-alias-convention.pin.test.ts— 9 files / 203 tests passed (2 of them failing on the parent commit, as above)pnpm --filter @objectstack/spec typecheck— clean, includingcheck:scripts-typecheckandcheck:test-typecheckpnpm --filter @objectstack/spec build, then the build-reading gates:check:api-surface·check:browser-reachable-entries·check:dual-source-exports·check:entry-nameability·check:exported-any— all exit 0 (they refuse to answer against an unbuiltdist, so they were re-run after the build, not before)check:authorable-surface·check:docs·check:empty-state·check:export-origins·check:liveness·check:llms-txt·check:objectui-pin-citations·check:skill-refs·check:strictness-ledger·check:variant-docs·check:yaml-examples— all exit 0pnpm check:nul-bytes·pnpm check:changeset-gate-self-tests·node scripts/check-adr-0087-registration.mjs --base origin/main— all exit 0 (the last re-run after committing the changeset:1 non-breaking changeset(s) seen)scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderives 65 families for this surface. The set above is the spec-scoped subset plus the changeset and byte gates — a declared narrowing; the remaining families (self-tests of unrelated scripts, repo-wide censuses, sharded CI attestations, and the six the tool marks not runnable locally) are CI's run. The shared verify lock printedUNLOCKED (declared) — no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized, so the wall-clock figures it reported are shared-box readings.🤖 Generated with Claude Code