Skip to content

fix(cli)!: os validate and os build refuse a seed record value the seed load would refuse, in the load's words - #27600

Draft
objectstack-fleet[bot] wants to merge 5 commits into
mainfrom
claude/project-thread-3t6xrk
Draft

objectstack-fleet[bot] wants to merge 5 commits into
mainfrom
claude/project-thread-3t6xrk

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP

Requested by Yibo Wang · project thread

Fixes #27259

Claim: 6496966334 (amended 6499269644)
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none edited. No skill teaches that os validate passes a seed value the load refuses, and none teaches a { value, currency } seed. Governed hits that list what os validate checks stay true as non-exhaustive lists: skills/objectstack-platform/references/operations.md:13, skills/objectstack-data/SKILL.md:453, skills/objectstack-ui/SKILL.md:284. The seed teaching (skills/objectstack-data/references/seeds.md, skills/objectstack-formula/SKILL.md:270 on cel seed values) stays true: an expression value is left to the load. Named, not edited.

ADR-0087: not-required (no-migration-prescription), in the changeset. No key of any schema moves; the refusal moves earlier, from the seed load to the authoring commands, in the load's own words, and every row it names was already dropped at load.

Metadata-protocol: narrowed, per the claim amendment. A seed value os validate and os build newly refuse is a value refused at the authoring door, so the kind is narrowed and both packages the diff moves take minor. The measurement beside it: what the runtime stores does not move. The record validator refused these values at seed load before this PR and refuses the same values after it; the refusal only moves earlier. The changeset carries the BREAKING banner.

维护者速读

  • 改了什么: os validate 和 os build 现在用种子加载时同一个记录校验器检查每条种子记录的字段值;加载会拒收的值(比如货币字段写成 { value: 10, currency: 'USD' })在校验时就报错,错误码和文案与加载时一字不差,并定位到 data[0].records[4].budget。
  • 为什么改: 以前作者自己的检查说"通过"(exit 0),这条种子要等到启动加载时才被丢弃,只在启动日志里留一行错误。
  • 风险与回滚: 收窄 os validate/os build 的接受集;运行时接受与拒收的集合不变(这些行加载时本来就被拒)。仓内四个示例应用 873 个种子值全部照常通过。回滚 = revert 本 PR。
  • 先看: packages/cli/src/utils/seed-record-values.ts(哪些值交给判定、哪些留给加载)、packages/objectql/src/validation/record-validator.ts 的 validateInsertValues(插入路径的同一套归一化与校验调用)、packages/cli/test/seed-record-values-load-parity.test.ts(与真实种子加载逐行对拍)。
  • 两分钟验证: pnpm --filter @objectstack/cli exec vitest run test/seed-record-values-load-parity.test.ts → 1 个用例绿:校验时拒收的行号 = 真实加载拒收的行号(0、3、4、5、6),且加载日志里的句子以校验给出的句子结尾。
  • 需要你做的: 无,席位落地。

What changes

Before, measured on origin/main ad3a640e. A scratch copy of examples/app-todo with budget: Field.currency({ label: 'Budget' }) on todo_task and one seed row budget: { value: 10, currency: 'USD' }:

$ npx objectstack validate          → "✓ Validation passed (277ms)", exit 0
$ npx objectstack dev --fresh       → ERROR [SeedLoader] Failed to write todo_task record #4 (subject=Fix Server bug):
                                      Budget is a currency amount: write it as a bare number (ADR-0104), such as 10,
                                      not an object such as { value, currency }. A currency field declares its currency
                                      in currencyConfig: the fixed mode's defaultCurrency, or under the dynamic mode
                                      (the default) the tenant default currency
                                      "Seeds: com.example.todo 7 ok / 1 error"

After, same copy, head d914d366 and later.

$ npx objectstack validate          → exit 1
  ✗ Seed record values the seed load refuses (1 issue)
  ✗ data[0].records[4].budget: Budget is a currency amount: write it as a bare number (ADR-0104), such as 10,
    not an object such as { value, currency }. … the tenant default currency (invalid_number)
$ npx objectstack build             → exit 1, the same two lines
budget: 10      → "✓ Validation passed", exit 0
budget: '10'    → "✓ Validation passed", exit 0   (the load writes the number 10)
budget: -5      → "✓ Validation passed", exit 0
budget: 'abc'   → exit 1, "data[0].records[4].budget: Budget must be a number (invalid_number)"
  • @objectstack/objectql: validateInsertValues(objectSchema, row, options?), new export. The insert write's verdict on the values a row supplies: the insert door's two rewrites (normalizeBlankTypedValues, normalizeNumericStringValues), the multi-value wrap (normalizeMultiValueFields, scope 'include'), then the same validateRecordInScope(…, 'insert', 'include', …) call ObjectQL.insert() makes. It judges only the declared fields the row supplies with a non-missing value, so required (which a default or a hook may still satisfy) and undeclared names are left to the load. ⛔ One judge: nothing in it re-states a value rule; it composes the functions the insert path already calls, in the insert path's order.
  • @objectstack/cli: judgeSeedRecordValues(stack) (utils/seed-record-values.ts). Owns only the WALK. The field map is the runtime registry's: a driverless ObjectQL (the shape authoring-filter-judge.ts measured) holding the stack's objects, picklists and picklist extensions, folded across packages (authoringRuleUnionStack). Datasets are read where they sit (datasetsOf, now exported from bundle-seed-files.ts): top level, manifest.data, each packages[i].manifest.data. Each issue is { location, object, field, code, message }, with the validator's code and sentence.
  • What the walk does not hand to the judge, each a value the load rewrites before the write or one a deployment decides: a value holding an expression envelope (the load evaluates it) or a seedFile(path) (the load stores the file and writes its id; collectSeedFiles judges the path); a reference, file or structured-JSON field (the load resolves a reference's natural key to an id, and the ADR-0104 shape check refuses only on a deployment whose migration evidence says so); a field bound to a picklist the stack does not declare (judgePicklistReferences reports it); a dataset whose object the stack does not declare or the registry refuses.
  • Wiring. os validate step 3d-bis, right after the seed-file gate; os build step 4a-0, right before it. Both exit 1 with the issues; --json carries them as errors (validate) and issues (build), the shapes each command's seed-file exit already uses. test/validate-build-gate-parity.test.ts holds the call in SHARED_NON_REGISTRY_GATES.
  • Two exports made visible inside packages/cli: objectsOf / registrationCopy (authoring-filter-judge.ts) and datasetsOf / DatasetLike (bundle-seed-files.ts), so the registry build and the dataset walk are each written once. No published CLI surface changes.

Shipped-seed reading (the narrowing)

Every seed this repository ships, run through the new check on head 98d28ec0 (os validate in each app, plus a read-only probe counting the values the walk hands the judge):

App Datasets Records Values Judged Left to load: expression or seed file Left: reference, file, structured Object not in stack Newly refused
examples/app-todo 1 8 39 32 7 0 0 0
examples/app-crm 5 28 185 137 17 31 0 0
examples/app-showcase 19 132 969 704 101 135 29 (sys_business_unit) 0
examples/app-multi-package 0 0 0 0 0 0 0 0

All four apps answer ✓ Validation passed, exit 0, as before. The showcase's field-zoo dataset covers every field type and passes. No platform object, plugin or service in packages/** ships seed datasets (git grep for defineSeed( and records: [ outside tests: none). Dogfood boots these examples and is green on 603ecf33. HotCRM fixtures are not in this repository and were not read.

Tests

  • packages/objectql/src/validation/record-validator.test.ts, a new [#27259] block, 7 cases: a currency object is refused with exactly the fields validateRecord(…, 'insert') gives (invalid_number, the lesson, the label); a bare number passes; a numeric string, a blank and a scalar on a multi-value field pass; an omitted or null required value is not judged (CONTROL: validateRecord on insert refuses the omission); an undeclared name gets no answer (CONTROL); a readonly value's shape is judged, as on the insert path; the row is never mutated.
  • packages/cli/src/utils/seed-record-values.test.ts, 11 cases: the currency object refused at data[0].records[1].budget with the lesson; a bare number passes; rewritten values pass; an unreadable number, an unknown option, an unreadable date, a value below min and a bad multi-value element are refused with their codes; a picklist-bound field is judged against the stack's picklist; an expression, a seed file, a reference, a file and a field on a picklist declared elsewhere are left to the load; required is not judged; CONTROL: an undeclared field and a dataset for an object outside the stack get no answer; a package body's dataset is located at packages[1].manifest.data[0].records[0].budget; the stack is not mutated.
  • packages/cli/test/seed-record-values-load-parity.test.ts (integration tier by its new ObjectQL( signal), 1 case: seven rows through the check and through a real SeedLoaderService load on an ObjectQL engine over SQLite :memory:. The rows the check refuses are exactly the rows the load refuses (0, 3, 4, 5, 6), each load message ends with the check's sentence, and the two rows it passes are written.
  • packages/cli/test/validate-build-gate-parity.test.ts: judgeSeedRecordValues joins SHARED_NON_REGISTRY_GATES. packages/cli/test/normalized-call-sites.test.ts: a resolved row for the walk's folded read.

Acceptance notes

  • The CONTROL with an unknown field: on a defineSeed dataset, defineSeed itself already refuses an undeclared field when the config loads (✗ defineSeed('todo_task'): unknown field(s) in records — bogus_field., exit 1). That answer is byte-identical before and after (diffed, timing lines aside). On a plain dataset the new check gives no answer for the name.
  • A seed dataset for a platform object (sys_business_unit in the showcase) is not judged: the stack does not declare the object, so os validate has no field map for it. The runtime judges it at load as before.
  • A readonly field's value is judged for shape on the insert path; the check judges it the same way. The required check on a missing value, validation rules, reference resolution and permissions stay the load's. A supplied empty array on a required multi-value field is not missing: the check refuses it required, as the load's insert path does.
  • Known limit, hooks: a beforeInsert hook runs on a seed write before the record validator, and the authoring check runs no hooks. A stack whose hook rewrites a seed value into an accepted shape loads clean but is refused by os validate. The walk's header names this.
  • Known limit, coverage: a dataset whose object is declared only under manifest.objects, outside packages[], has no field map in the folded stack and is not judged (no false refusal). The walk's header names this.

Verification

Package suites
  • pnpm --filter @objectstack/objectql test on 98d28ec0 (after it, objectql source moved only in a docblock, a44c5261) exit 0: Test Files 485 passed (485), Tests 9124 passed (9124). pnpm --filter @objectstack/objectql typecheck exit 0.
  • pnpm --filter @objectstack/cli exec vitest run --project unit on 603ecf33: VERDICT command-exit 0, Test Files 350 passed (350), Tests 5066 passed (5066). (On 98d28ec0 it caught normalized-call-sites.test.ts: the walk's folded read had no row; added in 603ecf33.) pnpm --filter @objectstack/cli typecheck exit 0. The parity file (integration tier): 1 passed on 603ecf33.
  • The integration tier beyond the new parity file is declared to CI.
Ablation (each through scripts/ablation-replace.mjs, restored blob == HEAD, git diff HEAD empty)
  • packages/cli/src/utils/seed-record-values.ts: the validateInsertValues(...) call replaced by void row; (anchor 1 → 0, blob 1e847578 → 1748ccd5): seed-record-values.test.ts 4 failed, 7 passed. Restored.
  • packages/objectql/src/validation/record-validator.ts: the normalizeMultiValueFields(objectSchema, row, 'include'); line deleted (anchor 1 → 0, blob dc438b68 → 16c92c87): the [#27259] block 2 failed, 5 passed (the rewrite case and the no-mutation case). Restored.
Dogfood (Clause-② narrowing arm), head 603ecf3

pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test under the lock: VERDICT command-exit 0, Test Files 350 passed | 3 skipped (353), Tests 2817 passed | 20 skipped (2837).

Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 65 derived families, the same list on 603ecf33 and on 8e7f125e. All 65 run serially as CMD :: exit N on 603ecf33; the 9 changeset and closing-keyword families re-run on 8e7f125e after the changeset's kind changed (all exit 0). --ran: 65 accounted, 64 run, 1 NOT-MEASURED, 0 UNRUN.

  • 64 exit 0.
  • pnpm check:dual-build-cjs-loads exit 3, PREREQUISITE NOT MET (needs a full build: no dist for embedder-openai, knowledge-ragflow, service-cluster-redis); CI runs it.
  • GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event with this body: exit 0 (Clause-②: no (narrowing), Metadata-protocol: narrowed, minor).
  • A first run on 98d28ec0 caught check:driver-memory-census exit 1: the parity test bound the frozen @objectstack/driver-memory. It now loads through @objectstack/driver-sql on SQLite :memory:, and the gate is green on 603ecf33.
  • PR check-runs: in progress at the time of writing.

Generated by Claude Code

…s with the insert write's value verdict

Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS
Co-authored-by: Claude <noreply@anthropic.com>
… seed load refuses the same rows in the same words

Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS
Co-authored-by: Claude <noreply@anthropic.com>
…he seed walk's folded read is classified

Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS
Co-authored-by: Claude <noreply@anthropic.com>
…d; both packages minor

Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/objectql, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 144702ca5dd1bb74bd6c0151068d75c73c00c3c5.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 80 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 47 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 144702ca5dd1bb74bd6c0151068d75c73c00c3c5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b77e083217046689ec967460bbe6612b48de059 — the merge of head a44c5261869245bd52c58fb4cdc408909d310eb3 into base 144702ca5dd1bb74bd6c0151068d75c73c00c3c5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b77e083217046689ec967460bbe6612b48de059 && git checkout 5b77e083217046689ec967460bbe6612b48de059
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 144702ca5dd1bb74bd6c0151068d75c73c00c3c5 a44c5261869245bd52c58fb4cdc408909d310eb3 && git checkout -B drift-repro 144702ca5dd1bb74bd6c0151068d75c73c00c3c5 && git merge --no-ff a44c5261869245bd52c58fb4cdc408909d310eb3

node scripts/docs-audit/affected-docs.mjs --json 144702ca5dd1bb74bd6c0151068d75c73c00c3c5

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 144702ca5dd1bb74bd6c0151068d75c73c00c3c5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 8e7f125 · 2026-10-29T15:20Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8e7f125e0360963beb8d7dab11cda6ca538532e3
Local-runs: none
Scope: packages/cli/src/commands/compile.ts, packages/cli/src/commands/validate.ts, packages/cli/src/utils/authoring-filter-judge.ts, packages/cli/src/utils/bundle-seed-files.ts, packages/cli/src/utils/seed-record-values.test.ts, packages/cli/src/utils/seed-record-values.ts, packages/cli/test/normalized-call-sites.test.ts, packages/cli/test/seed-record-values-load-parity.test.ts, packages/cli/test/validate-build-gate-parity.test.ts, packages/objectql/src/index.ts, packages/objectql/src/validation/record-validator.test.ts, packages/objectql/src/validation/record-validator.ts + packages/objectql/src/engine.ts (insert path 14469-15432, update path 15836-17477, validationMessageContext), packages/objectql/src/registry.ts (registerPicklistExtension, resolvePicklistOptions, picklistContributions), packages/metadata-protocol/src/seed-loader.ts (resolveSeedRecord call, seedValuesInStoredForm, SEED_OPTIONS, buildWriteError, engine.update sites), packages/formula/src/seed-eval.ts, packages/spec/src/data/seed.zod.ts (SeedRecordValueSchema, SeedFieldValue, defineSeed), packages/spec/src/data/field-value.zod.ts (REFERENCE_VALUE_TYPES, FILE_REFERENCE_TYPES, STRUCTURED_JSON_TYPES, COMPUTED_VALUE_TYPES), packages/spec/src/data/seed-file.zod.ts, packages/spec/src/automation/flow-node-expression-paths.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/core/src/utils/required-emptiness.ts, packages/core/src/stack-collections.ts, packages/cli/src/utils/stack-collections.ts, packages/cli/src/index.ts, packages/cli/package.json, .changeset/27259-validate-seed-record-values.md (read for ②, not reviewed), skills/objectstack-platform/references/operations.md:13, skills/objectstack-data/SKILL.md:453, skills/objectstack-ui/SKILL.md:284, skills/objectstack-formula/SKILL.md:270, content/docs/data-modeling/seed-data.mdx, content/docs/deployment/validating-metadata.mdx, AGENTS.md §3 (Clause-② / Metadata-protocol)

Check-runs on the head: 53 total, 35 success, 18 skipped (opt-in and UI-only lanes), 0 failure. Check Changeset, Governed Surface Queue Guard, PR-body guards, Lint, Type Check, Test Core 1-8, Dogfood Regression Gate all green. These are the gate verdicts this record relies on.

① Derived judgments

  1. os validate accept set NARROWS — right. A stack whose seed record carries, on a field the object declares, a non-missing value that is not rewritten at load (no expression envelope, no seedFile), on a field outside the ADR-0104 classes (reference, file, structured JSON) and whose picklist the stack resolves, and that validateInsertValues refuses, now exits 1 at step 3d-bis. Before it exited 0. Nothing is widened.
  2. os build accept set NARROWS identically — right. Step 4a-0 runs the same judgeSeedRecordValues(finalBundle) before the seed-file bundling; validate-build-gate-parity.test.ts pins the call in SHARED_NON_REGISTRY_GATES.
  3. --json of both commands: a new refusal exit whose errors (validate) / issues (build) carry { location, object, field, code, message } — additive, the shape the seed-file exit at 3d / 4a already uses. Right.
  4. @objectstack/objectql main entry gains validateInsertValues — right, a public-surface addition (minor). It composes, in the insert path's own order, normalizeBlankTypedValues then normalizeNumericStringValues (engine.ts 14495-14496), normalizeMultiValueFields(…, 'include') then validateRecordInScope(…, 'insert', 'include', options) (engine.ts 15425-15427). No value rule is restated; the only new logic is the projection of fields onto the names the row supplies with a non-missing value, which drops the insert walk's required check on omitted or empty values and the undeclared-name question. Right: one judge.
  5. validateRecord, validateRecordInScope, the engine's insert/update verdicts: unchanged. What the runtime stores and refuses does not move — right, this is what makes the change a narrowing of the authoring door only.
  6. CLI module-internal exports objectsOf, registrationCopy (authoring-filter-judge.ts) and datasetsOf, DatasetLike (bundle-seed-files.ts): packages/cli/src/index.ts re-exports commands only and package.json exports ., ./console, ./hook-body; none of these utils reaches a published entry. No published CLI surface change — right.
  7. Exclusion classes, each read against the load:
    • expression envelope anywhere in the value: resolveSeedRecord (formula/seed-eval.ts) evaluates it before the write — skipping is right (judging the envelope would refuse what the load accepts);
    • seedFile(path): the load stores the file and writes its id; collectSeedFiles already judges the path at 3d — right;
    • reference types: the load resolves the natural key to an id (seed-loader.ts reference pass) — right; file and structured-JSON types: the ADR-0104 shape arm refuses only under valueShapeStrict / mediaValueShapeStrict, which the engine reads from sys_migration per deployment — a fact the authoring door cannot know, so leaving them is right;
    • a picklist the stack does not declare: validateOne refuses an unresolved list with invalid_option_picklist_unresolved, while the runtime registry resolves it from the owning package — skipping is right, and judgePicklistReferences already reports the reference;
    • a dataset whose object the stack's registry does not hold: no field map, unjudged — right (the runtime judges it at load as before).
  8. Values the load does NOT rewrite and the judge sees as written — each checked for parity: a Date instance (load converts via seedValuesInStoredForm; the validator's date/datetime/time arms read a Date directly, record-validator.ts 1398 and 1500) passes both; a readonly field's value (seed write is isSystem, strip-exempt, judged shape-only under 'include'; the judge uses 'include' too) — same verdict; a value on a formula / summary / autonumber field (COMPUTED_VALUE_TYPES, excluded from the number arm, no arm applies) passes the judge and is stripped at load — fail-open; owner_id and other system non-readonly columns are unreached in every scope on both sides.
  9. Upsert / update-mode seeds: the load's update path runs the same validateOne arms with skipRequired plus the 'only' readonly pass; codes agree. One sentence differs: an explicit [] on a required multi-value field is required with the required_cleared sentence on update and the plain required sentence from the judge — same code, a sentence nit only.
  10. The header's "required is left to the load" is slightly over-stated: isEmptyForRequired counts an explicit [] on a required multi-value field as empty, so that one supplied value IS refused required by the judge — and by the load's insert path too (same verdict, no false refusal). Wording nit, not a judgment error.
  11. Message locale: the load renders through validationMessageContext(object, context) with the seed context's locale, which SEED_WRITE_EXECUTION_CONTEXT does not set, so negotiatedMessageLocale(undefined) yields the en default the judge also uses. The "same sentence" claim holds; the parity test on driver-sql :memory: pins it end to end.

② Semver level

.changeset/27259-validate-seed-record-values.md: @objectstack/cli minor, @objectstack/objectql minor. Both packages the diff moves are named; no other released package moves. The body carries Clause-②: no (narrowing) and Metadata-protocol: narrowed line-initial, the BREAKING banner, exactly one ADR-0087 marker (not-required (no-migration-prescription)) and the remedy (write the value in the shape the message names). Under AGENTS.md §3 a narrowed kind takes at least minor on each package the diff moves, (narrowing) is BREAKING and keeps its banner and disposition — matched. Metadata-protocol: narrowed is the right kind: a value os validate / os build newly refuse is "a value refused" at the authoring door (the seat's 14:22 amendment, followed). Clause-②: line judged right — the PR body and the changeset agree with the diff: an accept-set narrowing of the two authoring commands, no runtime contract moved, no key added, removed or re-shaped (hence no tombstone, no migration mapping). The Check Changeset run on the head is green.

③ Boundary flags

  • open_questions: [] in the os-dev-report — nothing to answer.
  • Deviation 1 (PR created with assignees: [os-support-ai], against os-dev.md): process, not contract; the dispatching seat decides, no bearing on the verdict.
  • Deviation 2 (pr_request_reviewers beyond the three-write budget): process, same.
  • Governed hits: skills/** and docs/adr/** not edited; ADR-0104 classes cited as the exclusion basis only — verified, the three constants are read from @objectstack/spec/data, not restated.
  • Skills-docs claim checked on main: operations.md:13, objectstack-data SKILL.md:453 and objectstack-ui SKILL.md:284 list what os validate checks as open lists ("Zod schema + CEL predicates + bindings", "Same gates as os build") — they stay true, not falsified. objectstack-formula SKILL.md:270 teaches cel over new Date() for a dynamic seed value — stays true, and a Date literal still passes the judge (①.8). seeds.md says references and dynamic values resolve at load — both left to the load here. Nothing in skills teaches that os validate passes a value the load refuses.
  • Published docs on main: content/docs/data-modeling/seed-data.mdx promises defineSeed() checks record KEYS at objectstack validate / build / boot — unchanged and still true; validating-metadata.mdx lists the gates as a growing table. No page states the opposite of this narrowing. The docs-drift bot flags packages/objectql/src/index.ts as unanchored: the new export is documented nowhere in content/docs — advisory, within the lane seat's remit, not a contract fault.
  • pnpm check:dual-build-cjs-loads NOT MEASURED locally by the dev (no full build) — the head's Build Core and Lint · CI, release & engine gates runs are green, which is the gate's answer.
  • The claim's Metadata-protocol: none was amended to narrowed before the PR opened; the PR body, changeset and os-dev-report all carry the amended kind.

④ Design judgment

The narrowing, read against the published spec on main. SeedRecordValueSchema (seed.zod.ts:84) is z.unknown() with one refinement (a well-formed seedFile), and SeedFieldValue types every non-reference field unknown: the spec declares no accept-set for seed VALUES at the authoring door; it declares the record validator's verdict at the write. ADR-0104 (via #27236's lesson) declares a currency as a bare number. So the capability the spec declares is "a seed value the record validator accepts is stored", and nothing declares "a value the record validator refuses still loads". This PR takes no declared capability away at the consumer: every value it refuses is one the spec's own write door refuses in the same words, and the refusal is now real two steps earlier, where the author asked. The one-judge rule from triage holds in the tree, not only in the PR body: the CLI file owns the walk and the field map; the verdict function composes the engine's own calls.

Tried to break it:

  • Hook rewrite (should-fix, documentation). beforeInsert hooks run on a seed write (engine.ts triggerHooks('beforeInsert', …); SEED_WRITE_EXECUTION_CONTEXT.skipTriggers suppresses automation triggers, not object hooks) and run BEFORE validateRecordInScope. A stack whose hook rewrites a seed value into an accepted shape loads clean and is now refused by os validate. The authoring door has no hook runtime by design (lowered bodies run in the sandbox), so this is an accepted limit, not a defect — but the header's "what is not handed to the judge" list and the changeset's "left to the load" list do not name it. Name it in the walk's header so the next reader of a false refusal knows where it comes from. Not a must-fix: no spec text on main promises that a seed value may rely on a hook to become storable, and the realistic authoring mistake (the card's own case) is caught.
  • manifest.data datasets (should-fix, documentation or fold). datasetsOf reads manifest.data, but authoringRuleUnionStack folds packages[] bodies only (core stackPackageBodies), so an object declared under manifest.objects alone is not in the judge's registry and its datasets are left unjudged — fail-open, consistent with the header's "object the stack does not declare" clause, and no false refusal. Worth a line in the header, or a fold of manifest, so the coverage statement is exact.
  • Top-level collection shadowing a package's: the fold leaves a key the top level carries, so a package-declared picklist or object behind a top-level picklists / objects is unreachable → judgedField returns false or getObject is undefined → unjudged, fail-open. Same posture as every other authoring rule on this fold.
  • Registry refusals in stackEngine (duplicate picklist value, refused object) are swallowed and the affected fields or datasets go unjudged; the runtime refuses the same stack at boot. Fail-open, the authoring-filter-judge.ts pattern; acceptable.
  • Rethrow of a non-ValidationError from validateInsertValues would crash the command instead of refusing; nothing in the composed functions throws anything else on a value, so unreachable today.
  • Record-mode currency (assertRecordCurrencyCodes, ADR-0144 D8) is not mirrored — left to the load, fail-open, correctly outside a VALUE judge.
  • env-scoped datasets are judged regardless of env: right, each loads in some environment.
  • The parity test (seed-record-values-load-parity.test.ts) runs a real SeedLoaderService on driver-sql :memory: and pins refused-row set equality and sentence suffix equality — the strongest available check of "one judge, two moments", and it is in the integration tier CI runs.

Findings: 0 must-fix; 2 should-fix (name the hook-rewrite limit and the manifest.data coverage limit in seed-record-values.ts's header); 1 wording nit (①.10, "required is left to the load" vs an explicit [] on a required multi-value field).

Implemented-by: claude/project-thread-3t6xrk
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: PASS


Generated by Claude Code

…rage limits (#27259)

Contract review should-fixes, comment and changeset text only: the walk's
header names that a beforeInsert hook is not run at authoring time and that
a dataset whose object is declared only under manifest.objects is unjudged;
validateInsertValues' docblock no longer over-states that `required` is left
to the load (an explicit [] on a required multi-value field is refused here,
as at load).

Claude-Session: https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head a44c526 · 2026-10-29T16:26Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a44c5261869245bd52c58fb4cdc408909d310eb3
Local-runs: none
Scope: packages/cli/src/commands/compile.ts, packages/cli/src/commands/validate.ts, packages/cli/src/utils/authoring-filter-judge.ts, packages/cli/src/utils/bundle-seed-files.ts, packages/cli/src/utils/seed-record-values.test.ts, packages/cli/src/utils/seed-record-values.ts, packages/cli/test/normalized-call-sites.test.ts, packages/cli/test/seed-record-values-load-parity.test.ts, packages/cli/test/validate-build-gate-parity.test.ts, packages/objectql/src/index.ts, packages/objectql/src/validation/record-validator.test.ts, packages/objectql/src/validation/record-validator.ts + packages/objectql/src/engine.ts (insert path 14469-15836: triggerHooks('beforeInsert') at 14808, normalizeMultiValueFields / validateRecordInScope at 15425-15427; triggerHooks at 5360-5390 and its skipAutomations rule), packages/spec/src/kernel/execution-context.zod.ts (skipTriggers, skipAutomations, seedReplay, SEED_WRITE_EXECUTION_CONTEXT), packages/metadata-protocol/src/seed-loader.ts (SEED_OPTIONS), packages/core/src/utils/required-emptiness.ts (isMissing, isEmptyForRequired), packages/core/src/stack-collections.ts (stackPackageBodies, collectFromPackageBodies), packages/cli/src/utils/stack-collections.ts (authoringRuleUnionStack, packageOwnedCollectionKeys), .changeset/27259-validate-seed-record-values.md (read for ②, not reviewed), AGENTS.md §3 (Clause-② / Metadata-protocol), and every file record 6501312345 names in its Scope line, which this record stands on

Delta record. The previous record on this PR is comment 6501312345 (PASS) on head 8e7f125e0360963beb8d7dab11cda6ca538532e3; it carried two should-fixes for the header of packages/cli/src/utils/seed-record-values.ts (name the beforeInsert hook-rewrite limit; name or close the manifest.data coverage gap) and one wording nit (①.10: "required is left to the load" over-stated against an explicit [] on a required multi-value field). What moved since: git log 8e7f125e..a44c5261 is exactly ONE commit, a44c5261 with parent 8e7f125e (no merge from main, nothing carried over), "docs(cli): name the seed-value judge's hook-rewrite and manifest coverage limits (#27259)". git diff 8e7f125e a44c5261: 3 files, 19 insertions, 2 deletions, every changed line inside a /** … */ docblock or the changeset's Markdown body. The PR's file list against main is the same 13 paths the previous record reviewed. No code, test, export or frontmatter line moves. Each item the move touches is judged afresh below; every section the move does not touch stands as judged in record 6501312345.

Check-runs on the head a44c5261: 50 total, 25 success, 16 skipped (opt-in and UI-only lanes), 0 failure, 9 in progress at the time of writing. Completed green: Check Changeset (two runs), Governed Surface Queue Guard, PR-body guards (two runs), Lint & Repo Gates, Lint · CI release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, the three Lint · scoped lanes, Type Check · source gates, Type Check · debt ledger, Type Check · consumer gates, Build Core, Dogfood Regression Gate, Plan Test Core shards, Test UI, Flag docs affected, Check PR Size, Auto Label, filter. PENDING, not green: Test Core (1/8) through (8/8) and Type Check · workspace. Their conclusions are the gate verdicts for those families and this record does not pre-empt them; the seat reads them before merge. The move is prose-only (two docblocks and a changeset body), so the test and type-check inputs are the ones that passed green on 8e7f125e plus comment text; nothing in the move can turn them. The verdict below rests on the completed gates and the read of the tree, and names the nine pending runs as the one open merge condition.

① Derived judgments

The accept-set and public-surface judgments 1-9 and 11 of record 6501312345 stand as judged there: the move changes no code, so os validate / os build narrow exactly as before, validateInsertValues is the same export composing the same four calls in the same order, validateRecord / validateRecordInScope / the engine's verdicts are unchanged, the CLI module-internal exports reach no published entry, the exclusion classes are the same, and message locale parity holds.

①.10 (the wording nit) — judged afresh, CLOSED. The validateInsertValues docblock (record-validator.ts 1977-1981) now reads: a missing value (undefined, null, blank) is left to the load, and "a SUPPLIED empty array on a required multi-value field is not missing: it is refused required here, as the insert path refuses it". Verified against the code at the head: the projection keeps every own-property name whose value is not isMissing (undefined, null, whitespace-only string — core required-emptiness.ts), so [] stays in supplied; validateRecordInScope(…, 'insert', 'include') then runs validateOne, whose arm at 1037 is def.required && isEmptyForRequired(def, value), and isEmptyForRequired counts an empty list on a multi-value field as empty. The engine's insert path reaches the same arm at 15427 with the same scope. The sentence is now exact, and "(undefined, null, blank)" is the isMissing set to the letter. The changeset's matching bullet says the same ("an explicit [] on a required multi-value field is refused required here, as at load") — right; the load's insert path refuses it with the plain required sentence, and its update path (upsert) with the required_cleared sentence under the same code, as ①.9 of the previous record already notes.

② Semver level

Stands as judged in record 6501312345, re-read on the head: the changeset frontmatter is unchanged (@objectstack/cli minor, @objectstack/objectql minor), the line-initial Clause-②: no (narrowing) and Metadata-protocol: narrowed, the BREAKING banner, the single ADR-0087 marker (not-required (no-migration-prescription)) and the remedy are all byte-identical to 8e7f125e. The move edits one bullet ("Left to the load", the [] qualification) and adds one ("Known limits": the hook rewrite and the manifest.objects coverage). Neither names a new package, moves a level, or changes what the diff publishes: a known-limit disclosure of an authoring-time check is a note on the narrowing, not a further narrowing (the accept set is the same set the previous record judged). Both Check Changeset runs on a44c5261 are green, which is the gate's answer. Clause-②: line judged right, unchanged.

③ Boundary flags

Stands as judged in record 6501312345: open_questions: [], the two process deviations are the seat's, governed paths (skills/**, docs/adr/**) are untouched by the move too (the file list is the same 13 paths), the skills and docs claims on main are unchanged by a prose edit inside packages/. The docs-drift bot's note on packages/objectql/src/index.ts (no anchor) is advisory and unchanged. The one new fact for this section: the PR body's two "Known limit" acceptance notes now say "The walk's header names this" — judged against the tree, not the body: the header at the head does name both (seed-record-values.ts 45-57). The pending Test Core and Type Check · workspace runs are the open gate condition named above.

④ Design judgment

The narrowing itself stands as judged in record 6501312345: the spec declares no accept set for seed VALUES at the authoring door (SeedRecordValueSchema is z.unknown() with the seed-file refinement), the capability it declares is "a value the record validator accepts is stored", and every value the PR refuses is one the write door refuses in the same words. No declared capability is taken away at the consumer; the spec's own refusal is made real two steps earlier. The move changes no code, so that judgment is unmoved. The two should-fixes are judged afresh against the tree:

  • Hook rewrite (was should-fix) — CLOSED, and the new text is accurate. The header's "Known limits" says a beforeInsert hook is not run here, the seed write runs object hooks ("the seed context skips automation triggers, not hooks"), and they run BEFORE the record validator. Read in the live tree: SEED_WRITE_EXECUTION_CONTEXT (execution-context.zod.ts 630-634) is { isSystem, skipTriggers, seedReplay }; skipTriggers suppresses record-change flow dispatch, and the only flag that makes triggerHooks skip anything is skipAutomations (engine.ts 5376-5386, metadata-bound entries only), which the seed context does not set. ObjectQL.insert() dispatches triggerHooks('beforeInsert', …) at 14808 and reaches normalizeMultiValueFields / validateRecordInScope(…, 'insert', 'include') at 15425-15427, inside the same method (14469-15836). So on a seed write every hook runs, ahead of the validator, exactly as the header states. The header also says where a refusal of a hook-fixed value comes from, which is what the should-fix asked for. The converse (a hook that rewrites an ACCEPTED value into one the validator refuses: the judge passes, the load refuses) is fail-open and not a false refusal, so it belongs to no "known limits" list of false refusals; noted here, no finding.
  • manifest.data coverage (was should-fix) — CLOSED, documented rather than folded, and the text is exact. authoringRuleUnionStack (cli stack-collections.ts) returns the stack unchanged when stackPackageBodies(stack) is empty, and otherwise fills only a package-owned key the top level lacks, from packages[] bodies (collectFromPackageBodies); it never reads stack.manifest. stackEngine registers objectsOf(stack.objects) of the folded stack and nothing else, while datasetsOf does read manifest.data. So a dataset under manifest.data whose object sits only under manifest.objects meets getObject(name) undefined and continue: unjudged, fail-open, by the header's own dataset clause, as the text says. Documenting rather than folding is a legitimate choice: the fold is the same one every authoring rule on compile.ts step 3b uses, and widening it is a separate change with its own readers.
  • Tried to break the new prose rather than the code: the hook paragraph's "object hooks" versus the engine's two kinds (metadata-bound and code-registered) — both run under the seed context, so the broader word is right; the [] paragraph's "as the insert path refuses it" — the same validateOne arm, same code, same sentence on insert; the changeset's "as at load" — true for insert, and for the upsert update path under the same code with the required_cleared sentence (a sentence difference the previous record already named as a nit on the load's side, not the judge's). The record-validator docblock is the only change to @objectstack/objectql source and it sits entirely inside a comment (lines 1977-1981), so the published behaviour of validateInsertValues is unmoved; Lint · ESLint and Type Check · source gates are green on the head.
  • Adds nothing outside the review's findings: the three hunks are the two should-fixes and the nit, one each, and the changeset mirrors them. Breaks nothing the previous record judged: no test, export, call site or frontmatter line moves.

Findings: 0 must-fix; 0 should-fix (both of record 6501312345's should-fixes land in the tree, accurately); 0 nits (①.10 closed). Open merge condition, not a finding: Test Core (1/8)-(8/8) and Type Check · workspace on a44c5261 were in progress at the time of writing and must conclude green; their inputs are the code that passed on 8e7f125e plus comment text.

Implemented-by: claude/project-thread-3t6xrk
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: PASS


Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants