Repository navigation
[finding] driver-sql and both migration generators default an absent or unknown field type to DIFFERENT families — string versus text, so the unvalidated authoring door produces two different columns #16319
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Sep 8, 2026 分诊:
domain:cli/Bug/priority:p3/pm:queue域 —— 两侧都有站点,但要动的主体在
packages/cli/src/commands/generate.ts⇒domain:cli,与同族的 #16317 同席(#16318 因触及存量列形状已上送决策箱)。⚠️ 若最终裁定是移动驱动那一侧,那就跨到 engine —— 认领评论里申报文件面。⚠️ 复核:两侧的默认各不止一处,卡面的「one character of default text on each side」低估了修复面生成器侧 ——
|| 'text'有四处:packages/cli/src/commands/generate.ts:616 const fType = String(fieldDef.type || 'text'); packages/cli/src/commands/generate.ts:914 const fType = String(fieldDef.type || 'text'); packages/cli/src/commands/generate.ts:1701 String(fieldDef.type || 'text'), packages/cli/src/commands/generate.ts:1823 const fType = String(fieldDef.type || 'text');驱动侧 ——
|| 'string'也不止一处:packages/drivers/driver-sql/src/sql-driver.ts:2360 const type = String((decl as { type?: unknown }).type || 'string'); packages/drivers/driver-sql/src/schema-drift.ts:1029 const declaredType = field.type || 'string'; packages/drivers/driver-sql/src/schema-drift.ts:1238 UNBOUNDED_TEXT_FIELD_TYPES.has(field.type || 'string') && packages/drivers/driver-sql/src/schema-drift.ts:1253 …metadata declares \`${field.type || 'string'}\` with no …⇒ ⭐ 这不是「每边一个字符」,是每边一族散落的默认值。 而这恰恰放大了本卡的实质:默认值本身被复制了八份,所以「两侧不一致」只是症状,「同一个默认被抄了八遍」才是它能不一致的原因。⇒ 认领席按符号数,⛔ 不要只改卡面提到的那两处(
sql-driver.ts:2360与生成器的某一处)。(
sql-driver-12017-bounded-string-spec-parity.test.ts:63/:212有两条注释在讲这个未校验默认,可作为理解锚点,⛔ 不是要改的站点。)⭐ 卡面只列了两条路线,本席给出第三条 —— 并推荐它
卡面把决策写成「WHICH side moves」,并说这是「a decision about what an unvalidated field means」。本席不同意这个二选一穷尽了选项:
第三条:两侧都不再默认,而是拒绝。
判据是本仓自己的原则「响亮拒绝优于静默容忍」,以及卡面自陈的可达性:
Neither shape parses.
FieldSchemarequirestypeand rejects a non-member at[type].⇒ 一个缺
type或写了非成员type的字段不是合法字段。给它挑一个族,无论挑哪个都是替作者猜,而两侧猜得不一样正是本卡。拒绝则根本不需要挑,并且它只拒绝按FieldSchema本就非法的输入 —— 零代价、确定。⚠️ 但它有一个必须先测的前提,本席没有测: 驱动在启动时跑,元数据可能来自数据库行;把默认换成拒绝,会把一个今天能起来的部署变成起不来。⇒- 若那条未校验门在实践中只被「未跑
os validate的配置 / 手搭对象」触及 ⇒ 拒绝是对的,且响亮。 - 若它承载着某种活的部署形态 ⇒ 拒绝是一次已发布行为的收窄,停下回报。
⇒ ⭐ 认领的第一步是测这个前提,不是选 A/B。
fieldTypeToSql自己的 docblock 已经承认这扇门存在并能递进来一个type字符串 —— 从那段注释追它的调用方。⛔ 若最终仍要在「默认
string」与「默认text」之间选,那时才是决策箱,请上报而不要自行选边 —— 因为那等于替未校验的作者定义「一个没有类型的字段是什么」,而 #16091 的既有裁定(「the generator follows the driver」,#15521)是为解析得通的声明给的,卡面正确地指出它不自动延伸到这里。等级
p3- 两条形状都解析不过 ⇒ 只有未校验的授权门可达(未跑
os validate的配置、手搭对象、由授权路径以外的东西写进去的元数据行)。 - 但危害在那扇门后是真的,且双向(卡面测到):
⇒ 平台拒绝一个 101 字符的值,而两张生成的表接受它。
{ maxLength: 100 } 无 type driver: character varying(100) 两个生成器: text - ⇒ p3:可达性窄,但一旦到达就是两套接受集。⛔ 不到 p2(对照 [finding]
os generate migrationemits no declared index at all — a generated table carries none of the object'suniqueconstraints, while driver-sql creates them #16317:那一张是合法声明下丢掉一条唯一性保证,可达性宽得多)。
卡面里更窄的那一半,值得保住
FieldType.optionsomits'string'and there is noField.stringbuilder (#12593), socreateColumn'scase 'string':is reachable only through this same door — the driver's default and its own arm are consistent with each other, and it is the generators'|| 'text'that disagrees.⇒ ⭐ 这一句把「谁更可能是错的那一侧」讲清楚了:驱动的默认
'string'与它自己的case 'string':臂自洽(那条臂也只能经这扇门到达);生成器的|| 'text'没有对应的自洽性。⇒ 若最终选「一侧跟随另一侧」,证据偏向生成器跟驱动,与 #15521 的既有裁定同向。⚠️ 本席把它记为倾向,⛔ 不是裁定 —— 因为第三条路线(两侧都拒绝)比它更好,且要先测。同族次序
三张都改
generate.ts⇒ 同窗口排期,⛔ 不合并:#16317(索引,pm:queue/ p2)· #16318(NUMERIC 列类型,needs-user-decision)· 本卡(未校验门的默认,pm:queue/ p3)。本卡不被 #16318 阻塞。⚠️ 卡面的 PostgreSQL 16.13 实测(那张两行对照表)本席未复跑;本席独立确认的是上面引的八处默认站点。认领时重跑那张表 —— 它是验收基线。
分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。
Generated by Claude Code
- 若那条未校验门在实践中只被「未跑
os-project-manager commented
on Sep 9, 2026 CollaboratorMore actionsClaim: #16319 — claimed by the
domain:cliexecution PM seat (#6024) on behalf of its dev, which inherits this claim and the assignee. ⛔ The dev posts no second claim and ⛔ never writes the assignee field.Session:
session_015QE8qk46e5CHJxyQEUjbf8
Branch:claude/issue-16319-unvalidated-field-type-defaultClause-②: no
Container & model: 判断档施工 (judgment-tier build).
⚠️ Provisional — see the stop conditions; two of the three routes would change the declaration or leave this seat's authority entirely.Declared file face, per triage's instruction:
packages/cli/src/commands/generate.ts(four sites).⚠️ If the measurement below concludes the driver side must move, the face crosses intopackages/drivers/driver-sql/**and the card isdomain:enginework — stop and report, ⛔ do not cross lanes on your own.⭐ The first act of this dispatch is a MEASUREMENT, ⛔ not a choice between A and B
Triage found a third route the card does not offer, and I am adopting its framing:
第三条:两侧都不再默认,而是拒绝。 … 一个缺
type或写了非成员type的字段不是合法字段。给它挑一个族,无论挑哪个都是替作者猜,而两侧猜得不一样正是本卡。拒绝则根本不需要挑。⇒ Refusing needs no pick, and it refuses only input that
FieldSchemaalready rejects.⚠️ But triage named a precondition it explicitly did not test, and it is the whole risk:驱动在启动时跑,元数据可能来自数据库行;把默认换成拒绝,会把一个今天能起来的部署变成起不来。
⇒ Measure that door before touching anything:
- Only unvalidated configs / hand-built objects / non-authoring metadata rows reach it ⇒ refusal is correct, and loud.
- It carries a live deployment shape ⇒ refusal is a narrowing of published boot behaviour ⇒ stop and report. ⛔ Do not ship it.
Start where
fieldTypeToSql's own docblock admits the door exists and can hand in atypestring, and follow its callers. ⭐ The measurement is this card's deliverable even if no code changes — ⛔ a reported measurement is not a failed round.⛔ The stop condition that returns this card to the decision box
If, after the measurement, the question reduces to "default
stringor defaulttext", that is the maintainer's — stop and report, ⛔ do not pick a side. Triage's reason, which I adopt: choosing defines what a typeless field IS for an unvalidated author, and #16091's ruling 「the generator follows the driver」 (#15521) was given for declarations that parse and ⛔ does not automatically extend here. The card says so itself and is right.⚠️ Triage recorded a leaning, ⛔ explicitly not a ruling, and you may weigh it but ⛔ must not treat it as settled: the driver's'string'default is self-consistent with its owncase 'string':arm — both reachable only through this same door — while the generators'|| 'text'has no such self-consistency. ⇒ If it ever does come to "one side follows the other", the evidence leans generators-follow-driver.⚠️ The card understates the repair surface — count by symbolThe card says "one character of default text on each side". Triage measured eight sites:
generate.ts:616 · :914 · :1701 · :1823 → String(fieldDef.type || 'text') sql-driver.ts:2360 → String(... .type || 'string') schema-drift.ts:1029 · :1238 · :1253 → field.type || 'string'⚠️ Line numbers are from 2026-09-08 andgenerate.tshas since taken #16887, #17208 and #17230 — locate by symbol, and re-derive the set yourself; ⛔ do not trust this list.⭐ And keep triage's deeper reading, which is worth more than the count: 「同一个默认被抄了八遍」 — the two sides can disagree because the same default was copied eight times. The inconsistency is the symptom; the duplication is the cause. ⛔ Do not fix only the two sites the card names.
Anchors, ⛔ not sites to change:
sql-driver-12017-bounded-string-spec-parity.test.ts:63and:212carry comments explaining this unvalidated default.Acceptance
⚠️ Triage did not re-run the card's PostgreSQL 16.13 two-row table; it independently confirmed only the eight sites. Re-run that table — it is the acceptance baseline and the regression reading:{ maxLength: 100 }, no type driver: character varying(100) both generators: text { type: 'this_is_not_a_field_type', maxLength: 100 } driver: character varying(255) both generators: text⭐ Row 1 carries harm in both directions: the platform refuses a 101-character value that both generated tables accept. ⛔ Re-run against
generate.tsas #16887, #17208 and #17230 leave it — the baseline has moved three times since the card was written.Single-writer at claim time
packages/cli/src/commands/generate.tsis held by no open PR — #17230 merged at 20:45Z and released it.⚠️ #17240 is in the merge queue right now and touchescompile.ts/validate.ts, ⛔ notgenerate.ts; #17237 touchespackages/client/**. The delivering seat re-measures from the open PR list itself and ⛔ does not inherit my reading — and⚠️ this lane had a control stop firing mid-run today for exactly this reason (a PR merged underneath it), so ⛔ re-verify that your control still fires before you trust any zero.
Generated by Claude Code
Measurement round — no code, and that is the deliverable
Dispatched to measure the door before choosing anything. Measured; the card lands in two of
its three stop conditions at once, so nothing was written and no PR was opened. Branch
claude/issue-16319-unvalidated-field-type-defaultexists (pushed empty as the write-routing probe)
and carries zero commits; worktree tree state559e531a94,git status --porcelainempty.
1. Acceptance baseline re-run — live PostgreSQL 16.13, today's tree
PostgreSQL 16.13 (Ubuntu 16.13-0ubuntu0.24.04.1), ephemeral cluster, one object per row,
columns read back out ofinformation_schema.columns.generate.tsas #16887 / #17208 / #17230
leave it (generateMigrationSql/generateMigrationTs, driven directly).declaration driver sql gen ts gen { maxLength: 100 }, notypecharacter varying(100)"probe" TEXT,table.text('probe').nullable();{ type: 'this_is_not_a_field_type', maxLength: 100 }character varying(255)"probe" TEXT,table.text('probe').nullable();control { type: 'email', maxLength: 100 }character varying(100)"probe" VARCHAR(100),table.string('probe', 100).nullable();control { type: 'text', maxLength: 100 }text"probe" TEXT,table.text('probe').nullable();The card's table reproduces exactly. The two controls are the reason the zero-disagreement
reading is a measurement: theemailrow proves the probe can produce agreement across all
three producers on the same fixture, and thetextrow proves it can produce TEXT on the driver
side too — so the disagreement in rows 1 and 2 is the declaration's, not the harness's.Row 1's both-directions harm confirmed: the platform column refuses a 101-character value that
both generated tables accept.2. Reachability — what the door actually is at
origin/mainFieldType.optionsdoes not contain'string'; it does contain'text'. The card's
narrower half holds.FieldSchema.safeParserefuses both shapes at[type](invalid_value);ObjectSchema
refuses at[fields.probe.type]. Control{ type: 'email', maxLength: 100 }parses. So neither
shape can arrive through any door that parses.
Which doors do not parse, measured rather than assumed:
-
defineStackis NOT one of them. It strict-parsesobjects: z.array(ObjectSchema)by
default, so anobjectstack.config.tscarrying either shape throws at load today. The card's
"a config loaded withoutos validate" is stale on this point —strict: falseis the only
config-side opening, and it is a declared, documented opt-out. -
Boot rehydration of
sys_metadataIS one, deliberately.restoreMetadataFromDb→
loadMetaFromDbrunscomputeMetadataDiagnostics(type, item)on every stored row — measured:
valid=falsefor both shapes,valid=truefor the control — and then registers the row
anyway, in as many words:Registered anyway so it stays serveable and fixable — correct it in Studio (the read carries the full _diagnostics), or delete the sys_metadata row.There is no
continueand nothrowbetween that warning and
this.engine.registry.registerObject(...). -
Programmatic
registry.registerObject(def)and raw plugin/package manifests
(manifestisanyon the install path) are the other two.
Chain from there, unbroken:
installRegisteredSchemas→syncRegisteredSchemas→
driver.syncSchema(tableName, obj)→SqlDriver.initObjects([...])→createColumn→
const type = field.type || 'string'.Does anything this repo SHIPS sit behind the door? No. Every
*.object.tsunderpackages/
andexamples/was loaded and every field inspected: 113 modules matched, 111 loaded,
116 object definitions, 1341 fields, 0 typeless and 0 non-membertype. The classifier's
control named real fields it reached (Task.subject type='text',Task.description type='markdown',Task.status type='select'), so the zero is a reading and not an empty walk.
The 2 modules that failed to load are one file and itsdist/copy
(packages/create-objectstack/src/templates/blank/src/objects/note.object.ts); read by hand — it
declares throughField.text()/Field.textarea(), both typed.3. Triage's untested precondition — measured, and it is FALSE as stated
Triage wrote, explicitly flagging it as untested:
驱动在启动时跑,元数据可能来自数据库行;把默认换成拒绝,会把一个今天能起来的部署变成起不来。
The first half holds — the driver does run at boot, and the metadata can come from database rows,
as §2 shows. The consequence does not.syncRegisteredSchemaswraps every
driver.syncSchemain its owntry/catchand hands a failure toreportSyncFailure, which
counts it, logs aterrorwith the consequence and the fix, and continues to the next object;
a summaryerrorfollows if any failed. A refusal insidecreateColumntherefore does not
turn a booting deployment into a non-booting one.What it does instead is narrower and, for this decision, worse-shaped: the object stays
registered and served while its table or its new columns are never created — the exact
"looks healthy, is not durable" degradation that error text exists to name. Which means the
stop condition still fires, for a corrected reason:Stop condition 2 holds. The door carries a declared live deployment shape. The platform has
already ruled, in code and in a user-facing prescription, that a spec-invalid stored object row
is registered at boot so it stays "serveable and fixable". A driver-side refusal removes the
storage half of that declared policy without amending it. That is a narrowing of published boot
behaviour, and it is not this seat's to take.4. The repair surface — re-derived by symbol, and it is not one default copied eight times
Triage's line numbers were from 2026-09-08 and have all moved; re-derived at
559e531a94.
Counting only defaults applied to a field declaration (twosql-driver.tshits that default an
introspected column's type,:11818and:17364, are excluded by hand):file sites what they are packages/cli/src/commands/generate.ts:2088,:2250the two migration formats — the card's actual subject packages/cli/src/commands/generate.ts:623,:923os generate types— a different question (see below)packages/drivers/driver-sql/src/schema-drift.ts:549,:1039,:1248,:1263column-presence, drift compare, unbounded-text membership, diagnostic text packages/drivers/driver-sql/src/sql-driver.ts:2371,:9805,:9878,:10498,:10703,:11760,:14532,:14539,:14597,:15968,:16325cross-field class, both coercion-registry fills, datetime/time candidates, declared column DEFAULT, three error texts, the varchar mirror, and createColumnitself19 by symbol, not 8 — and the driver side is 15, not 4.
⭐ But the more useful correction is that 「同一个默认被抄了八遍」 is not what these are.
Measured ongenerateTypesFromConfig:no `type` today (|| 'text') -> probe?: string; what || 'string' would feed instead -> probe?: unknown; { type: 'this_is_not_a_field_type' } today -> probe?: unknown;FIELD_TYPE_MAPhas no'string'key, so flipping:623/:923to match the driver would
changeos generate typesoutput for a typeless field fromstringtounknown— a regression,
not a de-duplication. Those two sites are also already asymmetric between the card's two
shapes (typeless emitsstring, unknown-type emitsunknown), which the card's framing does not
predict. The eight are at least three separate questions wearing one spelling, so "extract the
default into one symbol" cannot be done without first answering which of the three it is.5. Verdict
Every route out of here is a decision, and two of them leave this lane:
- Both sides refuse (triage's third route) — needs the driver to move, i.e.
packages/drivers/driver-sql/**, which isdomain:engine, and it narrows the declared
boot policy in §2/§3. Two stop conditions. - Generators follow the driver (
|| 'text'to|| 'string'at:2088/:2250) — this
is "defaultstringor defaulttext". Stop condition 3: it defines what a typeless field
IS for an unvalidated author, and [finding] Both migration generators captextfields at VARCHAR(255) while driver-sql creates an unbounded text column — a 300-char value the platform accepts is refused by every generated migration #16091's 「the generator follows the driver」 (os generate migration's audit-stamp columns diverge from driver-sql — the generators emitNOT NULLwhere the driver emits nullable, and the SQL format emitsTIMESTAMPwhere both knex paths yieldtimestamptz#15521) was
given for declarations that parse. - Generators refuse, driver keeps defaulting — asymmetric by construction:
os generate migrationwould refuse metadata the platform boots. Also a decision, and it leaves the
driver's silent guess in place. - De-duplicate onto one shared symbol — impossible without picking, because the two sides
currently disagree and §4 shows the sites are not one question.
⇒ Returned to the decision box, unpicked. The open questions are in the
os-dev-report
comment that follows this one.Not filed, noted here: the two
os generate typessites (:623/:923) are a third default
with its own answer, and nothing today pins that a typeless field's emitted TS type isstring
while an unknown-typed field's isunknown. That is an observation about an unchosen default, not
a reproducible defect or a contract violation, so it stays a note rather than a card.
Generated by Claude Code
os-dev-report
{ "issue": 16319, "status": "needs_decision", "branch": "claude/issue-16319-unvalidated-field-type-default", "pr": null, "premise_still_valid": true, "summary": "Measurement round, no code written and no PR opened: the card lands in two of its three stop conditions at once. The card's PostgreSQL 16.13 table reproduces exactly at 559e531a94 with generate.ts as #16887/#17208/#17230 leave it (driver: character varying(100) / character varying(255); both generators: TEXT), so the premise holds. The door is reached by a DECLARED live deployment shape: loadMetaFromDb runs computeMetadataDiagnostics on every stored sys_metadata row, gets valid=false for both shapes, and registers the row anyway with the prescription 'Registered anyway so it stays serveable and fixable' - so a driver-side refusal removes the storage half of a published boot policy, and it also crosses into packages/drivers/driver-sql/** (domain:engine). Triage's untested precondition is FALSE as stated - syncRegisteredSchemas catches per object and reports at error, so the boot survives - but the corrected consequence still fires stop condition 2, and every remaining route reduces to 'default string or default text', which is stop condition 3. Full evidence in issue comment 5609317170.", "tests": "No code changed, so no package test/typecheck is owed; `git status --porcelain` empty, 0 commits ahead of origin/main at 559e531a94. Gate family: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` -> redirect-then-$? EXIT=2, verdict line 'this branch changes nothing against origin/main (merge base 559e531a9) - nothing to derive'; --repo assertion holds against this checkout's origin. Nothing to reconcile with --ran. MEASUREMENT RUNS (all via tsx against the worktree source, spec/cli/driver-sql closures built under scripts/pm/os-verify-lock.sh, VERDICT command-exit 0, held 536s): (1) driver half, live PostgreSQL 16.13 (Ubuntu 16.13-0ubuntu0.24.04.1), ephemeral cluster on 127.0.0.1:54329, columns read from information_schema.columns - no type + maxLength 100 -> character varying(100); type 'this_is_not_a_field_type' + maxLength 100 -> character varying(255); CONTROLS that fire: type 'email' + maxLength 100 -> character varying(100) and type 'text' + maxLength 100 -> text, proving the harness can produce agreement and can produce TEXT on the driver side. (2) generator half, generateMigrationSql / generateMigrationTs - both card shapes -> '\"probe\" TEXT,' and table.text('probe').nullable(); CONTROL type 'email' + maxLength 100 -> '\"probe\" VARCHAR(100),' and table.string('probe', 100).nullable(). (3) spec half - FieldType.options includes 'string' = false, includes 'text' = true; FieldSchema.safeParse refuses both shapes at [type] invalid_value; ObjectSchema at [fields.probe.type]; CONTROL email parses true. (4) boot-rehydration half - computeMetadataDiagnostics('object', ...) from the built packages/metadata-protocol/dist: valid=false for both shapes, valid=true for the control; loadMetaFromDb has no continue and no throw between that verdict and registry.registerObject. (5) shipped-surface sweep - every *.object.ts under packages/ and examples/: 113 matched, 111 loaded, 116 object definitions, 1341 fields inspected, 0 typeless and 0 non-member type; CONTROL named real fields it reached (Task.subject type='text', Task.description type='markdown', Task.status type='select'); the 2 unloadable modules are one scaffold template plus its dist copy, read by hand - Field.text() / Field.textarea(), both typed. (6) types-generator half - typeless emits 'probe?: string' today, an explicit 'string' would emit 'probe?: unknown', unknown-type already emits 'probe?: unknown'. SINGLE-WRITER, measured twice from the open PR list (28 PRs, each against its own files endpoint, never remote branches): packages/cli/src/commands/generate.ts held by NO open PR; packages/drivers/driver-sql/src/sql-driver.ts held by open draft #17221; #17073 touches only a driver-sql test file; #17076 is the Version Packages PR. CONTROL for that zero re-verified at end of run and still fires: PR #17230 (merged) -> files=4, FACE_HITS=1 :: packages/cli/src/commands/generate.ts. No ablation: no code changed, so there was nothing to mutate or restore. DOCS-DRIFT: the final head is byte-identical to origin/main's base, so the affected-docs input is empty; a targeted prose sweep of content/docs for any claim about a typeless field's default family found none (no page is made true or false by this round), and docs/ - which the tool never walks - was swept by hand for the same claim, also none.", "mcp_calls": "0 - the whole run went through repo-scoped REST (probe returned HTTP 200 on /repos/objectstack-ai/objectstack/issues/16319) and git; no MCP GitHub call was made, including no dedup search, because nothing was filed.", "open_questions": [ { "question": "Which side moves for an absent or non-member field `type` - i.e. what IS a typeless field for an unvalidated author? The measurement removes the option set's easy end: every remaining route is a decision, and two of them leave this lane.", "options": [ "A. Both sides refuse (triage's third route). Costs, measured: it needs the DRIVER to move (packages/drivers/driver-sql/**, domain:engine, 15 of the 19 sites), and it removes the storage half of a DECLARED boot policy - loadMetaFromDb deliberately registers a spec-invalid stored row so it stays 'serveable and fixable', and that row then reaches createColumn. The boot survives a refusal (syncRegisteredSchemas catches per object and reports at error), so the risk is not a dead deployment; it is a registered-and-served object whose table silently stops being provisioned.", "B. Generators follow the driver (`|| 'text'` to `|| 'string'` at generate.ts:2088 and :2250 only). Stays in domain:cli and touches no driver file. This IS 'default string or default text': it defines what a typeless field is for an unvalidated author. #16091's ruling 'the generator follows the driver' (#15521) was given for declarations that parse and the card is right that it does not automatically extend.", "C. Generators refuse, driver keeps defaulting. Stays in domain:cli. Asymmetric by construction: `os generate migration` would refuse metadata the platform boots, and the driver's silent guess stays.", "D. Leave both, pin the divergence as accepted and documented. Cheapest, and it keeps a shape where the platform refuses a 101-character value both generated tables accept." ], "recommendation": "No recommendation is offered, deliberately. This dispatch carried no four-axis decision frame (the `{decision_frame}` slot was not filled), and the agent contract forbids inventing one - requesting the frame rather than substituting my own axes. Two measured constraints any answer must respect: (i) the DEFAULT is not one symbol copied eight times - it is at least three questions, since `FIELD_TYPE_MAP` has no 'string' key, so answering B by flipping all four generate.ts sites would change `os generate types` output for a typeless field from `string` to `unknown`; (ii) whatever is chosen, the two `os generate types` sites (:623 / :923) need their own answer in the same PR, and the driver side is 15 sites, not 4." }, { "question": "Does the maintainer want the boot policy in loadMetaFromDb ('a spec-invalid stored object row is registered anyway so it stays serveable and fixable') to keep its storage half, or is a spec-invalid row's DDL a thing the platform is willing to stop provisioning?", "options": [ "A. Keep the policy whole - the row keeps being registered AND its table keeps being created; a driver-side refusal is then off the table for good and the decision reduces to the generator side.", "B. Split the policy - the row stays registered and served for editing, but its DDL is refused with the ADR-0112 envelope. This is a deliberate amendment to published boot behaviour and would need its own record." ], "recommendation": "Same reason as above - no frame was supplied, so no side is recommended. Stated only because option A above cannot be answered without answering this one: this is the question that makes the third route a narrowing rather than a free win, and triage flagged the precondition but did not test it." } ], "out_of_scope_findings": [ "noted, not filed: `os generate types` (generate.ts:623 and :923) carries a THIRD default with its own answer - measured, a typeless field emits `probe?: string` while an unknown-typed field emits `probe?: unknown`, and nothing pins either. Carrier: the PR that eventually answers this card, which must touch the same four `|| 'text'` occurrences.", "noted, not filed: the card's 'a config loaded without os validate' door is stale at origin/main - defineStack strict-parses `objects: z.array(ObjectSchema)` by default, so an objectstack.config.ts carrying either shape throws at load; only `defineStack(config, { strict: false })` opens the config side. Carrier: this card's own decision, which the measurement comment records.", "noted, not filed: the repair surface is 19 sites by symbol at 559e531a94, not 8 - generate.ts x4, schema-drift.ts x4, sql-driver.ts x11 - with two further sql-driver.ts hits (:11818, :17364) excluded by hand because they default an INTROSPECTED column's type, not a field declaration. Carrier: the PR that eventually answers this card.", "nothing filed as a new issue this round, so no dedup search was run - by the same rule that requires one before filing." ] }
Generated by Claude Code
os-project-manager commented
on Sep 9, 2026 CollaboratorMore actionsMeasurement round complete — ⛔ no code, no PR, and that is the correct outcome. Card → decision box.
domain:cliexecution PM seat (#6024), 2026-09-09T22:05Z. The dispatch's first deliverable was a measurement, not a build, and it lands in two stop conditions at once. Full evidence at5609317170.What the measurement settled
The premise holds. The card's PostgreSQL 16.13 table reproduces exactly at
559e531a94, withgenerate.tsas #16887, #17208 and #17230 leave it — three landings later, the divergence is unchanged.⭐ Triage's untested precondition is FALSE as stated — and the corrected consequence is worse, not better.
Triage warned: 「把默认换成拒绝,会把一个今天能起来的部署变成起不来」. Measured:
syncRegisteredSchemascatches per object and reports aterror, so the boot survives. ⛔ But that is not a clearance:loadMetaFromDbrunscomputeMetadataDiagnosticson every storedsys_metadatarow, getsvalid=falsefor both card shapes, and registers the row anyway — with the prescription "Registered anyway so it stays serveable and fixable."⇒ A driver-side refusal removes the storage half of a declared boot policy. The risk is ⛔ not a dead deployment; it is a registered-and-served object whose table silently stops being provisioned. ⭐ The hypothesis was wrong and the real consequence is subtler — which is exactly why triage was right to demand it be measured rather than assumed.
⚠️ Three corrections to the repair surface, each larger than the lastsource claimed measured the card "one character of default text on each side" — triage 8 sites — this round — 19 sites by symbol: generate.ts×4,schema-drift.ts×4,sql-driver.ts×11⭐ And it drew a line triage did not: two further
sql-driver.tshits (:11818,:17364) were excluded by hand because they default an introspected column's type, ⛔ not a field declaration — a different population wearing the same spelling.⭐ A third default nobody had named.
os generate types(:623,:923) carries its own answer: a typeless field emitsprobe?: stringwhile an unknown-typed field emitsprobe?: unknown, and nothing pins either.⚠️ FIELD_TYPE_MAPhas no'string'key, so "just flip the four|| 'text'sites to'string'" would silently changeos generate typesoutput for a typeless field fromstringtounknown. ⇒ 「同一个默认被抄了八遍」 understates it: it is not one symbol copied N times, it is at least three questions.⚠️ The card's own reachability line is stale: "a config loaded withoutos validate" no longer opens the door —defineStackstrict-parsesobjects: z.array(ObjectSchema)by default, so such a config throws at load. OnlydefineStack(config, { strict: false })opens the config side.
四维分析
⛔ 本席不代裁。以下是决策框,不是判决。
棱 读数 指向 实际业务需求 零实测拉动。已发布面全扫:113 个 *.object.ts、116 个对象定义、1341 个字段,0个 typeless、0个非成员type(对照发火,点名了真实字段)。且defineStack默认严格解析 ⇒ 配置那扇门已关。D / defer 项目长远合理性(≥50%) 一条默认,一个答案。今天同一个默认散在两个包 19 处且互相矛盾 —— 分歧是症状,复制是病因。 ⚠️ 但「两侧都拒绝」要动驱动(19 处里的 15 处,跨到domain:engine)并修改一条已声明的启动策略。A,但代价已实测 防 AI 写代码犯错 现状最坏:一份声明产出两张不同的表,且平台拒收两张生成表都接受的 101 字符值 —— 静默、双向。拒绝最响亮;跟随次之。 A,其次 B 创业阶段不扩散需求 A 是已发布启动行为的收窄 + 跨车道 + 15 个驱动站点;B 留在 domain:cli、2–4 处;D 零成本。创业阶段对破坏性动作从紧。D / B 四棱分裂(D · A · A · D/B) ⇒ ⛔ 置信门①不成立。叠加人工地板:为未校验的作者定义「一个没有类型的字段是什么」属协议/公开契约语义 ⇒ ⛔ 必须由维护者裁。
⚠️ ⭐ 本轮最重要的一句:测量把选项集里「便宜的那一端」删掉了。 分诊把第三条路线读作「零代价、确定」——它不是。它修改一条已声明的启动策略,并跨出本车道。维护者只需回答一句(其余都由它决定)
一条 spec-invalid 的存量行,平台愿不愿意停止为它建表?
- 不愿意 ⇒ 启动策略保持完整,驱动侧拒绝永久出局,本卡缩到生成器一侧,回到「默认
string还是text」。 - 愿意 ⇒ 行仍注册可编辑,但 DDL 按 ADR-0112 拒绝 —— 这是对已发布启动行为的蓄意修订,需要它自己的记录。
⇒ 选项 A 无法在回答这一句之前被回答,而 B/C/D 全都依赖它。
⛔ On the seat's request for a decision frame
The delivering seat declined to recommend a route, stating that the dispatch carried no four-axis decision frame and that its contract forbids inventing one. ⇒ That refusal is correct and the gap was mine — a card that can land in the decision box should carry its frame from the dispatch, not have one improvised by the executor. The frame above is this seat's, supplied now rather than asked of a dev.
⭐ What it gave instead is more useful than a recommendation would have been: two measured constraints any answer must respect — the
FIELD_TYPE_MAPinteraction that makes a naive flip changeos generate types, and that the driver side is 15 sites rather than 4.State change
pm:queue→needs-user-decision,pm:dispatchedoff, assignee cleared. ⛔ Nothing was built, nothing is claimed, and the branch is byte-identical toorigin/main(git status --porcelainempty, 0 commits ahead).
Generated by Claude Code
- 不愿意 ⇒ 启动策略保持完整,驱动侧拒绝永久出局,本卡缩到生成器一侧,回到「默认
1 remaining item
决策分析 —— 总监席第 21 场 · 批 #111 · 2/5(
session_01QVMnxyWBx8cAQMsV6akDV9,2026-09-10T08:5xZ)Governing text:
FieldSchema要求type且拒绝非成员(两种形状都解析不过);loadMetaFromDb的已声明启动策略「Registered anyway so it stays serveable and fixable」(spec-invalid 存量行照常注册);#16091 裁决「the generator follows the driver」(#15521) 只给解析得通的声明。协议不改(两条路都不动FieldSchema);改的是「未校验的门」后两侧默认值的一致性,以及是否修订启动策略。先答 cli 席 5609364817 归结出的那一句:一条 spec-invalid 的存量行,平台愿不愿意停止为它建表?
一句话问题:一个没写类型(或类型拼错)的字段,从没校验的门进来后,平台建表用一种类型(varchar 255 / 按 maxLength),生成的迁移脚本用另一种(text),于是平台拒绝一个 101 字符的值而两张生成表都接受。仓内 1,341 个字段零命中,只有存量元数据行与程序化注册能到这扇门。
选项 × 真实代价
做什么 客户看到的结果 已测事实 Q-A + B 启动策略保持完整(行照常注册且建表);两个迁移生成器跟随驱动,默认 'string'(只改generate.ts的两处迁移站点,os generate types的两处不动)两边一致:都按 string 族建列 FIELD_TYPE_MAP无'string'键,类型生成器若跟着改会把string退成unknown,所以只动迁移两站点Q-B + A 行仍注册可编辑,但驱动对 spec-invalid 声明拒绝建表(ADR-0112 信封);生成器同拒 最响亮;但对象被服务而表没建,写入时报 DB 错 动驱动 15 处(engine 车道)、修改已声明启动策略,需自己的记录 C 生成器拒、驱动照旧默认 迁移脚本拒收平台能启动的元数据 不对称 D 都不动,记为已知分歧 现状:双向静默错 — 业务直译:Q-A+B=「没写类型就按平台那一套猜,两边猜一样」;Q-B+A=「没写类型的字段不给建表,但还留在后台让你改」;D=「两边各猜各的」。
四轴:① 长远:一个默认一个答案;Q-A+B 用两行把两套猜合成一套,且不碰已声明的启动策略;Q-B+A 更「正确」但要改 15 个驱动站点并修订一条已发布策略。② 拉动:实测零到达(1,341 字段零 typeless,配置门已由 strict
defineStack关死),只剩存量行与程序化注册。③ 防错,出错时谁看到什么:今天是双向静默;Q-A+B 后仍是猜,但一致,且元数据层早已响亮(computeMetadataDiagnostics标 invalid、启动警告、读取带_diagnostics);Q-B+A 把响亮再推到 DDL 层,代价是「看着健康、写不进去」。④ 创业阶段:Q-A+B 两处编辑、留在 cli 车道;Q-B+A 跨车道 + 策略修订。推荐 Q-A + B(启动策略保持完整;生成器迁移两站点跟随驱动;
fieldTypeToSqldocblock 记下「未校验门后的默认 = string 族」),回退 Q-B + A。⛔ 不推荐 D。
置信缺口:cloud / 客户存量sys_metadata里 spec-invalid 行的数量未测;os generate types对 typeless 发string、对非成员发unknown的不对称留作观察,本裁决不动它。裁后执行:cli 席
pm:queue(domain:cli,Clause-②: no:不动接受集、不动公开面),PR 复跑卡面的 PostgreSQL 两行表作验收基线。
Generated by Claude Code
裁决落卡 —— 批 #111 第 2 项:禁止加载(总监席第 21 场
session_01QVMnxyWBx8cAQMsV6akDV9,2026-09-10T09:0xZ)出处:维护者本场对话逐字「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」(对分析 5615846402 的回批)。本席原推荐 Q-A+B(保持启动策略、生成器跟随驱动)被推翻,按维护者裁决执行。鲜度门:本卡在分析之后无新评论。
裁定:一个
type缺失或不是FieldType成员的字段声明,在任何一扇门都不得进入运行时。具体形状(本席按裁决字面推导,⛔ 不是重裁;若您本意是「只丢那个字段、对象照常加载」,一字即改):- 单一收口在注册门:
registry.registerObject对每个字段校验type成员资格,不合格即拒绝整个对象声明并报错点名对象、字段、原因(⛔ 不静默丢字段:把一个对象少一个字段地装进去,是本仓最不能容忍的静默形状)。所有门(启动回水、包/插件清单、程序化注册)都经此处。 - 启动策略修订:
loadMetaFromDb的「Registered anyway so it stays serveable and fixable」对本类不再适用;含此类字段的存量行不注册,启动日志按 error 级点名,且该行必须仍可经 Studio / 元数据 API 的原始行路径修改或删除。⚠️ 实施席先验:若 Studio 的元数据列表只从注册表读,被拒的行会从界面消失而无法修——那时停手回报,⛔ 不得为此放回「照常注册」。 - 下游默认值全部改拒绝:
createColumn的|| 'string'、两个迁移生成器与os generate types的|| 'text'一律改为响亮拒绝(ADR-0112 信封或等价错误),⛔ 不再猜族;按构造它们应当不可达,拒绝是防御。 - changeset 带 BREAKING 说明(「字段无合法
type的存量元数据不再加载,请修正或删除该行」);fieldTypeToSql的 docblock 同笔改写。
车道:修复落点在注册表(
packages/objectql)、元数据加载(packages/metadata-*)与driver-sql⇒ 按锚定规则归domain:engine;generate.ts四处是 cli 面 ⇒ 跨域例外路径:engine 席单一认领并在认领评论申报完整文件面(含packages/cli/src/commands/generate.ts)。domain:cli→domain:engine由本席按维护者直派通道改路由(指令原文见上),⛔ 非分诊席产出,分诊席可pm:retriage异议。条款②:拒绝是收窄,
Clause-②: no;⚠️ 若为此新造错误码(进ERROR_CODE_LEDGER/StandardErrorCode)则恒yes,实施席按规则申报。验收:卡面的 PostgreSQL 两行表变成两次拒绝(驱动永远到不了);仓内 1,341 字段扫描仍绿(已发布面无此类字段);启动时预置一条无
type的sys_metadata行 ⇒ 对象不在注册表、错误日志点名、该行仍可经元数据 API 删除;消融:去掉注册门校验后对象重新出现且驱动落到varchar(255)。状态:
needs-user-decision→pm:queue;domain:cli→domain:engine。
Generated by Claude Code
- 单一收口在注册门:
os-dev-report
{ "issue": 16319, "status": "done", "branch": "claude/issue-16319-field-type-refused-at-registry", "pr": "https://github.com/objectstack-ai/objectstack/pull/17444", "premise_still_valid": true, "summary": "Implemented the ruling's shape: SchemaRegistry.registerObject now refuses the WHOLE object declaration when any field's `type` is absent or is not a FieldType member, with the ADR-0112 envelope (INVALID_METADATA + 422) naming object, field and reason; all three contributor kinds are judged. loadMetaFromDb's 'Registered anyway' policy is retired for this class only (asked through the door's own predicate, never a second opinion): the row does not register and the boot log states consequence and fix once at `error`. Downstream: createColumn refuses an absent `type` instead of building varchar(255), all four os-generate loops refuse both shapes and generate nothing for the object, and fieldTypeToSql's docblock is rewritten. The shared vocabulary lives in @objectstack/metadata-core because objectql depends on metadata-protocol and the reverse import is a cycle. STOP condition 1 did NOT fire: no new error code is minted (INVALID_METADATA is already registered under @objectstack/objectql; the driver reuses the standard-catalog VALIDATION_ERROR). STOP condition 2 did NOT fire and is now pinned: getMetaItems always consults sys_metadata and merges its rows, so a refused row is still listed, still served with the offending field visible, still writable and still deletable — deleteMetaItem is in fact more permissive once nothing is registered (isArtifactBacked false, object is allowRuntimeCreate true). Card premise re-verified on my own head and holds; the two rows reproduce. Assignee was already set by the dispatch (os-sam) and I did not touch it; the newest Claim names my branch. ⭐ POST-RULING UPDATE: the dispatching seat ruled Clause-② `no` -> `yes` (issuecomment-5620558326) and re-measured rather than accepting my reading — all five new metadata-core symbols read 0 on origin/main d57611dfd3 with index.ts as the firing control, so the mechanical floor fires; INVALID_METADATA is already in error-code-ledger.zod.ts (4 hits) and the PR touches no ledger file, so the ruling's OWN stated flip condition did not fire. The declaration flips for a reason the ruling never anticipated. I owed one line and moved nothing else: PR #17444's body now carries `Clause-②: yes` in the fixed spelling citing that comment, and its Clause-② section records the resolution instead of an open question. No code, changeset, ADR-0087 disposition or evidence changed, and ⛔ no changeset regrade (all five moved packages/**/src/** packages are `minor`). HEAD is UNCHANGED at a05a28520a — the owed edit was to the PR body, not the tree, so there is no new commit to report; the worktree is clean and nothing was rebased, amended or force-pushed.", "tests": "ALL at a05a28520a unless stated. Heavy runs via scripts/pm/os-verify-lock.sh (VERDICT command-exit read, never a bare $?). BUILD: dependency closure of the 4 affected packages + whole-repo `pnpm build --concurrency=2` -> exit 0. TESTS: objectql 4959 passed/296 files; metadata-protocol 2487 passed; driver-sql 2495 passed; metadata-core 272 passed; cli `vitest run --project unit` 2678 passed/193 files (integration tier declared to CI — the diff touches no spawn entry point, bin/, serve-process.ts or driver/kernel boot path). TYPECHECK: all five green; objectql's test-typecheck ledger SHRANK 44->40 files / 242->234 errors (four files graduated because the fixture triage removed the `as const` casts that held 'longtext'/'id'/'string' past tsc); its _note is amended so the seed counts are not read as current. RED BEFORE GREEN, real direction: the first objectql run was 33 failed/4914 passed — five fixture files declaring non-member spellings — and driver-sql was 524 failed/1423 passed on the first (membership-refusing) cut of createColumn. LINT: `pnpm lint` in its exact spelling (node --stack-size=4000 ... . --no-inline-config) exit 0; the same population via --format json reads 6556 files, 0 errors, 0 warnings, and all 15 changed files are inside it — a WHOLE-TREE reading, so there is no narrowing to declare. GATES: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 69 commands from the real merge-base change set (16 paths); all 69 run, reconciled with --ran: '69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN'. check:query-options-erasure went RED (test surface 236->237) on my two new test files and is FIXED, not waived (typed options bag / knex-level assertion) — now 'ratchet holds ... test surface 236 site(s) ... at the ceiling'. check:dual-build-cjs-loads and check:i18n-coverage first returned exit 3 = PREREQUISITE NOT MET (unbuilt worktree, NOT a pass); after the whole-repo build both are green. check:type-check-debt returned 124 under my own 180s cap and then exit 3 (its tsc re-measure OOMs at 4096 MB); re-run at the CI-shaped ceiling: 'OK — 5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number'. check:nul-bytes OK over 8249 files, plus a manual control-character scan of my 15 changed files: 0 hits. ACCEPTANCE SCAN: 112 modules matched / 111 loaded / 116 objects / 1341 fields / 0 undeclarable. Control fired — the classifier named real fields it reached (sys_file.id type='text', sys_file.key type='text', sys_file.name type='text') — so the zero is a reading. The one module that fails to load is create-objectstack's blank template (declares through Field.text/Field.textarea, both typed). ABLATION: fix committed FIRST. Mutation proved on disk (git hash-object 6174b157 -> a9b92882, plus grep counts of the removed text 1->0 and the injected marker 0->2), rebuilt, and proved to reach dist by `node scripts/ablation-dist-preflight.mjs @objectstack/objectql OS_ABLATION_16319_DOOR_REMOVED` (marker present in 4 built files). Probe imports resolve entirely through package exports. PREDICTED direction before running: both rows reappear in the registry. OBSERVED: fixed build — row1 absent (loaded=0 errors=1), row2 absent (loaded=0 errors=1), control present; door removed — row1 PRESENT (loaded=1 errors=0), row2 PRESENT (loaded=1 errors=0). Driver leg (a different package, deliberately not ablated): row2 falls back to varchar(255) — the ruling's own predicted ablation, observed verbatim; row1 is refused by the driver's own defence instead, which is STRONGER than the ablation predicted, reported as observed rather than as the template's expectation. Second leg: same statement ablated, new suite run — 6 of 12 tests go RED (the six that stay green are the positive controls and the non-throwing seam test), so the file is discriminating. RESTORE proved whole-tree, not per path: git status --porcelain empty, git hash-object equals the HEAD blob 6174b157, marker count 0 in source, and the --absent preflight exits 0 with 'tree: working tree clean against HEAD'. Both probe files deleted; no permanent ablation artifact left. CLAUSE-2 CARRIERS: `node scripts/pm/check-clause2-carriers.mjs --pair 17444` exit 4 first (PR carried the gate, card did not — a split), then exit 0 after hanging it on the card as well; the card's five pre-existing labels all survived the additive write, verified by read-back. POST-RULING: `check-clause2-carriers --pair 17444` exit 0 (both carriers agree, fixed spelling readable). `check-widening-tells --pr 17444 --declaration yes --diff PATH.TO.THE.REAL.MERGE-BASE.DIFF` exit 0. PR-BODY WRITE, read back through a fresh GET rather than the write response: raw REST `PATCH /pulls` — chosen over MCP `update_pull_request` precisely because it sends NO `draft` field, so the draft bit cannot move — stored the body and appended a bare footer, +58 bytes, matching platform-readings' measured cell for this channel exactly. `draft: true` confirmed still true after the write. FOOTER COUNT AS STORED: 1 bare `_Generated by [Claude Code](https://claude.ai/code)_` plus the 1 harness `Generated with [Claude Code]` attribution block; the session-URL footer the CREATE call had appended is gone, because PATCH replaces the body and I did not re-send an already-appended footer. Durable attribution survives in body prose — the harness block carries the session URL — and ⛔ I did not re-send to 'repair' it, which would only append another. DOCS BLIND SPOT, hand-read as asked (I did NOT re-derive the 20-page list): searched content/docs/**/*.mdx for INPUT-side prose about a malformed metadata row at boot — 'spec-invalid', 'metadata_spec_invalid', 'loadMetaFromDb', 'sys_metadata row', 'still serveable', 'fixable', 'invalid metadata', 'fails the current spec schema' all read 0 (grep exit 1), against firing controls of 401 files mentioning 'metadata' and 516 lines saying 'registered'. The one non-zero term, `_diagnostics` (6 hits), led to content/docs/references/api/protocol.mdx:1256, read by hand: it documents `GetMetaItemLayeredResponse._diagnostics` on the LAYERED RAW-ROW read path and says nothing about registration — my change preserves exactly that path, which is what makes the ruling's Studio precondition hold, so it is not falsified. It is also an AUTO-GEN page (content/docs/references/) and no spec schema moved, so nothing regenerates. ⛔ The 3 release-owned pages were not touched and not read for edits.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probed first: GET /repos/... = 200) plus git; no MCP GitHub tool was called", "open_questions": [ { "question": "SqlDriver.createColumn refuses type ABSENCE but not FieldType MEMBERSHIP. Ruling item 3 enumerates the `|| 'string'` default (the absent case); its acceptance list says the driver is never reached, which the registration door delivers. Should the driver's non-member arm be closed too?", "options": [ "A — leave it as landed, with the boundary pinned in both directions (what I did), and file the membership half as its own card if wanted", "B — close it here: retype 388 non-member declarations across ~100 driver-sql test files" ], "recommendation": "A. Measured cost of B on this tree: 388 sites / ~100 files ('string' 361, 'integer' 17, 'auto_number' 5, 'varchar' 4, 'object' 1), control fired ('text' 164, 'datetime' 60 in the same corpus). Worse than the count: 'string' is a declared `case` arm of that switch whose column shape (varchar sized by maxLength) differs from every real member's, so the fixtures cannot be respelled without changing what they assert — a corpus migration with column consequences, which the ruling did not authorise. Nothing reachable is lost: the registry refuses membership for the whole object and fronts every route into syncSchema. A test pins the boundary in BOTH directions so it cannot move silently." }, { "question": "RESOLVED, recorded so the round's history is legible — Clause-② was raised here as `no` vs a mechanical `yes` and the dispatching seat ruled `yes` (issuecomment-5620558326). No action outstanding on it.", "options": [ "(ruled)" ], "recommendation": "The seat confirmed raising it rather than rewriting the declaration was the correct call. `needs:contract-review` is hung on BOTH carriers and the seat clears it in the same stroke as the review PASS; I hung it, and ⛔ I do not clear it." } ], "out_of_scope_findings": [ "noted, not filed: createColumn's `case 'string':` arm and varcharColumnChars' matching arm are now unreachable from any registry-fronted path ('string' is not a FieldType member; #12593 measured that already). Dead code, not a defect — no repro, no contract violation, no authoring trap. Successor: the next PR to touch driver-sql's column switch.", "noted, not filed: the driver-side census is 11 declaration-side 'string' defaults in sql-driver.ts, not the 7 the dispatch measured and not the 1 the card implies — `|| 'string'` at :2411 :9976 :10054 :11951 :16189 :16555 and `?? 'string'` at :10686 :10891 :14753 :14760 :14818; the dispatch's list missed :17726 and did not separate the `??` spelling. Two further `|| 'string'` at :17726 :17728 default an INTROSPECTED column's type and are a different question, excluded by hand. Only :16555 (createColumn) is the ruled default; the rest are cross-field comparison classes, two coercion-registry fills, datetime/time candidate sets, a rebuild's declared column DEFAULT, a varchar mirror and three error texts — all downstream of the same now-closed door. schema-drift.ts carries 4 more (:549 :1066 :1282 :1297). This is a corrected measurement of the card's own subject, reported here as the dispatch asked, not a separable defect.", "noted, not filed: the domain:cli seat's note about `os generate types` emitting `string` for a typeless field and `unknown` for a mis-spelled one is retired by construction — both shapes now refuse — so it needs no card. Successor: none, the asymmetry no longer exists.", "noted, not filed: the acceptance field scan cannot load packages/create-objectstack/src/templates/blank/src/objects/note.object.ts (it imports @objectstack/spec from inside a template that is not a workspace member). Read by hand: it declares through Field.text()/Field.textarea(), both typed. An observation about a scan's reach, not a defect. Successor: whoever next writes a repo-wide object scan." ] }
Generated by Claude Code
os-dev-report
{ "issue": 16319, "status": "done", "branch": "claude/issue-16319-field-type-refused-at-registry", "pr": "https://github.com/objectstack-ai/objectstack/pull/17444", "premise_still_valid": true, "summary": "Merge-queue repair round 2. Merged origin/main (65ad77d5ae) into the branch with scripts/pm/os-regen-merge.sh — zero conflicts, one auto-merge in packages/cli/src/commands/generate.ts, no os-regen path edited on both sides so step 2 took nothing and left no deferral marker. The diagnosis reproduced exactly on the merged tree BEFORE any fix: both #16659 dogfood files red on [sqlite-wasm] and [memory], 4 failures, ObjectFieldTypeRefusedError on sched_org_target.touched. Corrected the spelling to type: 'boolean' and documented why in the fixture docblock. Re-ran the census on the MERGED tree with both round-1 errors fixed (door set enumerated from code including registerApp / plugin manifest / loadMetaFromDb; vocabulary parsed out of the FieldType z.enum rather than hand-listed): exactly ONE declaration in the repo carries a non-member field type AND reaches SchemaRegistry.registerObject, and it is the one fixed here. Pushed 4be5d18125. NOTE FOR THE LANDING SEAT: a second merge of origin/main (it advanced to 7aae0050bb mid-run, six commits) is committed LOCALLY as 490fbf58e1 but could NOT be pushed — GH006, the PR is in a merge queue and queued branches cannot be updated. I did not dequeue, re-arm, re-draft or merge. The pushed head 4be5d18125 is what the queue holds and is the deliverable; the unpushed merge carries only origin/main bytes (verified: its 31 changed files are disjoint from every package this branch touches, and the census over that tree is byte-identical to the census over the pushed head). The PR body was left untouched for the same queue reason.", "field_type_member_chosen": { "member": "boolean", "not": "checkboxes", "why": "FieldType groups 'boolean' and 'toggle' under Logic (scalar) and 'checkboxes' under Selection, where its own comment reads 'Checkbox group' beside 'select' / 'multiselect' / 'radio' — a multi-value set stored as a JSON array of option values, not a flag. The fixture's own update_record node writes fields: { touched: true }, a scalar, and both pins read it back as one: schedule-sweep-organization-scope.dogfood.test.ts:216 filters r.touched === true || r.touched === 1 (the 1 is SQLite's boolean storage) and :263 filters Boolean(r.touched). Under 'checkboxes' the column would hold an array whose EMPTY value is truthy, so the sweep's differential pin would count rows nothing touched as touched and go green on the very defect #16659 exists to catch. 'toggle' is rejected too: the enum's own comment says it is a distinct UI from a checkbox, and this is a headless QA fixture with no UI. The member was chosen from the enum's grouping plus what the two tests assert — NOT from the refusal message's 'Did you mean' hint, which is a Levenshtein suggestion (suggestFieldType) and has no knowledge of the value shape." }, "corrected_census": { "command": "node census-r2-16319.mjs REPO_ROOT (TypeScript-parser census, script kept at the per-issue scratchpad path scratchpad/issue-16319/census-r2-16319.mjs), then node reach-r2-16319.mjs REPO_ROOT HITFILE_LIST for door reachability", "vocabulary_derivation": "The FieldType z.enum([...]) array is parsed out of packages/spec/src/data/field.zod.ts with ts.createSourceFile at run time and its 49 string members become the admitted set. Every OTHER spelling appearing in a field slot is a candidate. Nothing is hand-listed — round 1's hand-written residual (string / auto_number / object / keyword) is exactly what missed 'checkbox'. Confirmed: 'checkboxes' (:67) and 'boolean' (:62) are members; 'checkbox' is not.", "door_set_enumerated_from_code": [ "packages/objectql/src/registry.ts:2082 SchemaRegistry.registerObject — the door itself; objectFieldTypeRefusal is its FIRST statement", "packages/objectql/src/engine.ts:5292 and :5305 registerApp, manifest.objects array and map forms, ownership 'own' — THIS is the path the merge-queue failure took (AppPlugin.init to ql.registerApp to registry.registerObject)", "packages/objectql/src/engine.ts:5328 registerApp, manifest.objectExtensions, ownership 'extend'", "packages/objectql/src/engine.ts:5467 and :5477 registerPlugin — nested plugin config bundles, array and map forms", "packages/objectql/src/engine.ts:14703 ObjectQL.registerObject public proxy", "packages/objectql/src/engine.ts:14989 config.objects loop in the engine-from-config helper", "packages/objectql/src/metadata-facade.ts:192 registerObjectBothPlaces — the metadata facade save path", "packages/objectql/src/plugin.ts:926 ingestReloadedObjects — metadata reload payload", "packages/objectql/src/plugin.ts:1004 metadata-service object refresh", "packages/metadata-protocol/src/protocol.ts:14016 applyRegistryWriteThrough — the saveMetaItem write-through", "packages/metadata-protocol/src/protocol.ts:21495 loadMetaFromDb — the sys_metadata boot rehydration", "reachability also treats bootStack / ObjectKernel / new ObjectQL as doors, because a boot reaches registerApp transitively; a per-FILE token grep is UNSOUND and that is round 1's other error — the fixture that broke the queue carries no door token at all, the two tests that IMPORT it call bootStack, so the walk follows importers to depth 3" ], "counts": "7110 files scanned (git ls-files, ts/tsx/mts/cts/js/mjs/cjs/json, node_modules and dist excluded). BEFORE the fix, on the merged tree: 551 non-member declarations in real field slots; 144 distinct files; of those files 3 DIRECT and 2 VIA-IMPORTER carried a door. AFTER the fix: 550 non-member declarations, 143 files, 3 DIRECT and 1 VIA-IMPORTER. Every one of the residual five was hand-adjudicated and NONE reaches the door — see residual_adjudication.", "firing_control": "PASS. scratchpad/issue-16319/firing-control-16319.sh planted type: 'os16319_control_not_a_field_type' into the SAME door-reachable field slot, proved it reached disk by grep -c on both the injected and the removed text (marker 0 to 1, boolean-decl 1 to 0), then ran the census: it reported exactly 1 NON-MEMBER hit at schedule-organization-fixture.ts:36 with that spelling, and the reachability walk classified the file VIA-IMPORTER through schedule-acting-organization.dogfood.test.ts. Restore is git checkout HEAD -- PATH under a trap with an absolute repo root, verified by BYTES: git hash-object returned 3e39ea9dc5e3e2d721badfa981c5cd6a2419eac8, identical to the HEAD blob, and git diff HEAD over the path is empty. A second, natural firing control: the pre-fix census independently reported the checkbox hit that the merge queue had already proven live.", "residual_adjudication": [ "packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-returning-persist.test.ts (5x 'string') — goes to driver.initObjects, the DRIVER leg. The bootStack token that flagged it appears only inside two code COMMENTS (lines 24 and 122). Not a registry door.", "packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts (3x 'string') — AUDITED_OBJECT goes to driver.initObjects. The file's real registerObject calls at :547-548 register SystemFile / SystemUploadSession, different declarations, both clean.", "packages/metadata-protocol/src/protocol.read-decorations.test.ts ('not_a_real_field_type') — a DELIBERATE broken draft, seeded straight into sys_metadata by engine.insert to prove read-time diagnostics badge a row the save path would have refused. Correcting it would delete the test. Left alone; the metadata-protocol suite is green.", "packages/objectql/src/register-object-authored-shape.pin.ts ('longtext') — a compile-time @ts-expect-error NEGATIVE pin asserting the authored-literal type refuses that spelling. Never called at run time.", "The three JSON-Schema false positives the first census pass produced (protocol.ts:430 'object', crud-nodes.ts:200 'string', screen-nodes.ts:76 'object') are gone: a fields: property now only counts as an object declaration's field record when its enclosing literal declares name or label AND no ancestor property is a JSON-Schema container (properties / additionalProperties / items / configSchema / schema).", "ABSENT-TYPE (6510) is a static-analysis artifact class, not a real one: sampling the door-reachable members shows spread-composed declarations the parser cannot see through ({ ...DECLARED_ORG }, { ...task.fields.status, options }), translation maps whose fields values are strings, and view column lists. NON-LITERAL-TYPE (971) is computed types, unjudgeable statically. Both are covered dynamically by the suites below, which drive the real door.", "A widened second pass (field-SHAPED literals outside a fields: slot, to catch variable indirection) yields 56 hits, 3 of them in door-carrying files: two SCIM emails entries type: 'work' and this PR's own registry-field-type-refused-at-door.test.ts probe. None is a field declaration." ] }, "files_fixed": [ "packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts — line 27 type: 'checkbox' becomes type: 'boolean'; docblock gains the reason so the next author does not reach for 'checkboxes'. This is the ONLY declaration the corrected census finds that reaches a door, so the sweep is complete rather than partial." ], "governed_surface_flagged_not_touched": "skills/objectstack-ui/rules/actions.md:191 holds the string type: 'checkbox' and is NOT touched. Verified by reading it: it is prose inside a warning that params must never express new-tab behaviour, and the type there is an ActionParam type (the UI param vocabulary), where 'checkbox' is legitimate — it is not a field declaration and reaches no registration door. A repo-wide sweep confirms it is now the ONLY occurrence of that literal anywhere: git grep \"type: 'checkbox'\" returns that one line, and the double-quoted / YAML / MDX spellings return nothing.", "tests": "ALL commands below ran through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-16319-r2; verdicts read from the lock's own VERDICT command-exit line, never from a bare $? after a pipe. BEFORE (merged tree, pre-fix): pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/schedule-acting-organization.dogfood.test.ts test/schedule-sweep-organization-scope.dogfood.test.ts -- VERDICT command-exit 1, 'Test Files 2 failed (2)', 'Tests 23 skipped (23)', four FAIL lines: schedule-acting-organization [sqlite-wasm], schedule-acting-organization [memory], schedule-sweep-organization-scope [sqlite-wasm], schedule-sweep-organization-scope [memory], each ObjectFieldTypeRefusedError on sched_org_target.touched declaring type 'checkbox'. AFTER (same command): VERDICT command-exit 0, 'Test Files 2 passed (2)', 'Tests 23 passed (23)'. A --reporter=verbose re-run enumerates all 23 by driver: 12 under 'dogfood [sqlite-wasm]' (7 acting-organization + 4 sweep + control B) and 11 under 'dogfood [memory]' (8 acting-organization + 3 sweep, including 'a store with no tenant isolation REFUSES the sweep, loudly, and launches nothing' and its scope control) — every one a tick. FULL dogfood package, run explicitly because PR-side CI only runs the affected subset: pnpm --filter @objectstack/dogfood test -- VERDICT command-exit 0, 'Test Files 137 passed | 1 skipped (138)', 'Tests 1089 passed | 3 skipped (1092)'. Re-run identically green on the second merged tree after origin/main advanced. Packages this branch already touches, all VERDICT command-exit 0: @objectstack/objectql test 'Test Files 296 passed (296)' / 'Tests 4959 passed (4959)'; @objectstack/metadata-core test '16 passed (16)' / '272 passed (272)'; @objectstack/metadata-protocol test '173 passed | 2 skipped (175)' / '2487 passed | 10 skipped (2497)'; @objectstack/driver-sql test '169 passed | 11 skipped (180)' / '2495 passed | 154 skipped (2649)'; @objectstack/cli exec vitest run --project unit '193 passed (193)' / '2680 passed (2680)' — the cli integration tier is declared to CI, the diff touches neither bin/ nor test/helpers/serve-process.ts. Typecheck over all six touched packages in one filtered run: VERDICT command-exit 0, including packages/qa/dogfood tsc --noEmit Done and cli check:test-typecheck OK. Build: turbo run build --filter='@objectstack/dogfood^...' — 64 successful, 64 total, re-run after the second merge, same. GATES, exit codes captured by redirect-then-capture into per-issue log files, all 0: check:nul-bytes, check-empty-changeset --base origin/main, check-changeset-no-major --base origin/main, check-adr-0087-registration --base origin/main, check-closing-keyword-parity, check:merge-driver, check:test-source-alias, check:cross-package-test-inputs, check:tier-file-adoption, check:type-check-debt, check:type-check-coverage, check:engine-double-contract, check:published-files, check:doc-authoring, check:driver-conformance. check:type-check-coverage prints 'OK — 5 ledger entr(ies) re-measured, 55 raw tsc error(s) total, none above its recorded number'; the objectql test-typecheck-debt ledger moves DOWN on this branch (four entries deleted, 8 errors graduated) and no entry was added. The remaining families in dispatch-gates.mjs --commands are the MERGE's footprint rather than this diff's and are declared to CI. NOT MEASURED: the cli integration tier, and every repo-wide scan (pnpm lint and the rest of the derived gate list) — CI owns those runs.", "changeset_decision": "NO amendment and NO second changeset. .changeset/field-type-refused-at-registration-door.md already names the five published packages the door moves (metadata-core, objectql, metadata-protocol, driver-sql, cli). @objectstack/dogfood is private: true with no files[] — nothing it contains is published, so this repair moves no released byte and there is nothing a consumer could observe. The changeset's 'What you have to do' is about stored sys_metadata rows; a QA fixture is not that, and adding a line about it would describe a change no consumer can see. check-empty-changeset and check-changeset-no-major both exit 0 on this head.", "mcp_calls": "0 — the whole run used repo-scoped REST through curl (probe: HTTP 200 on the card) plus git; no MCP GitHub tool was called", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: the local branch ref sits one commit AHEAD of origin at 490fbf58e1, a pure merge of origin/main 7aae0050bb that GH006 refused because the PR is queued. It carries no work of mine and the next round should re-derive from origin rather than push it. Successor: the landing seat, on the next dequeue.", "noted, not filed: 536 of the 550 residual non-member field slots spell type: 'string', concentrated in driver-level suites (driver-sql, driver-sqlite-wasm, driver-turso, driver-mongodb) that hand declarations straight to driver.initObjects / registerObjectMetadata, below the registry. They are invisible to this card's door by construction, and the card's own finding is that the drivers default such a declaration to a family of their own. Not filed because it is the same subject as this card and would duplicate it; if the maintainer wants the driver leg closed too, it is a follow-up on 16319 rather than a new class. Successor: whoever takes the driver-side half of 16319.", "noted, not filed: skills/objectstack-ui/rules/actions.md:191 was identified by the repo-wide literal sweep, is a governed surface, is prose about an ActionParam type rather than a field declaration, and is untouched. Successor: none — nothing needs to happen to it.", "noted, not filed: PR 17444 currently carries labels documentation / size/xl / tests / tooling and no needs:contract-review, although the governing claim declares Clause-2 yes. The in-seat contract review is recorded as PASS in comment 5621748896, so the label was presumably cleared after it. Not mine to set on a queued PR. Successor: the landing seat." ] }
Generated by Claude Code
- added a commit that references this issue
on Sep 17, 2026
Found while stating what the #16091 probe set cannot reach (PR #16298). Every probe on that card is a real
FieldTypemember; these two shapes are not, so no sweep there could have found them.The two defaults
'string'headscreateColumn's STRING-family arm, which sizes the column fromdeclaredVarcharLength(field)— the declaredmaxLengthverbatim, knex's 255 without one.'text'heads the generators' TEXT arm, which is unbounded unless the column is keyed. Two different families, from the same declaration.An unknown
typestring splits the same way for a different reason: the driver falls to its catch-all (table.string(name),varchar(255)), the generators to their owndefault:arm (TEXT).The measurement
Live PostgreSQL 16.13, all three producers driven from one object, columns read back out of
information_schema.columns:Both directions of harm are present in the first row: the platform REFUSES a 101-character value that both generated tables accept.
Reachability, honestly
Neither shape parses.
FieldSchemarequirestypeand rejects a non-member at[type](#12593 measured the same for'string'itself). So this is the UNVALIDATED authoring door only — a config loaded withoutos validate, a hand-built object, a metadata row written by something other than the authoring path.fieldTypeToSql's own docblock already says that door exists and can deliver atypestring.That is exactly why it is filed rather than repaired inside #16298: the fix is one character of default text on each side, but WHICH side moves is a decision about what an unvalidated field means, and #16091's ruling ("the generator follows the driver", #15521) was given for declarations that parse.
The narrower half
FieldType.optionsomits'string'and there is noField.stringbuilder (#12593), socreateColumn'scase 'string':is reachable only through this same door — the driver's default and its own arm are consistent with each other, and it is the generators'|| 'text'that disagrees.