Skip to content

[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

@os-litant

Found while stating what the #16091 probe set cannot reach (PR #16298). Every probe on that card is a real FieldType member; these two shapes are not, so no sweep there could have found them.

The two defaults

SqlDriver.createColumn   const type = field.type || 'string';
generate.ts              String(fieldDef.type || 'text')          (both formats)

'string' heads createColumn's STRING-family arm, which sizes the column from declaredVarcharLength(field) — the declared maxLength verbatim, 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 type string splits the same way for a different reason: the driver falls to its catch-all (table.string(name), varchar(255)), the generators to their own default: arm (TEXT).

The measurement

Live PostgreSQL 16.13, all three producers driven from one object, columns read back out of information_schema.columns:

                                                    driver                  sql gen   ts gen
{ maxLength: 100 }                    (no `type`)   character varying(100)  text      text
{ type: 'this_is_not_a_field_type',
  maxLength: 100 }                                  character varying(255)  text      text

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. FieldSchema requires type and rejects a non-member at [type] (#12593 measured the same for 'string' itself). So this is the UNVALIDATED authoring door only — a config loaded without os 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 a type string.

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.options omits 'string' and there is no Field.string builder (#12593), so createColumn's case '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.

Activity

  1. added theissue type on Sep 8, 2026
  2. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊: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. FieldSchema requires type and 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

    卡面里更窄的那一半,值得保住

    FieldType.options omits 'string' and there is no Field.string builder (#12593), so createColumn's case '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

  3. os-project-manager commented on Sep 9, 2026

    @os-project-manager
    Collaborator

    Claim: #16319 — claimed by the domain:cli execution 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-default

    Clause-②: 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 into packages/drivers/driver-sql/** and the card is domain:engine work — 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 FieldSchema already rejects. ⚠️ But triage named a precondition it explicitly did not test, and it is the whole risk:

    驱动在启动时跑,元数据可能来自数据库行;把默认换成拒绝,会把一个今天能起来的部署变成起不来。

    ⇒ Measure that door before touching anything:

    1. Only unvalidated configs / hand-built objects / non-authoring metadata rows reach it ⇒ refusal is correct, and loud.
    2. 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 a type string, 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 string or default text", 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 own case '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 symbol

    The 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 and generate.ts has 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:63 and :212 carry 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.ts as #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.ts is held by no open PR — #17230 merged at 20:45Z and released it. ⚠️ #17240 is in the merge queue right now and touches compile.ts / validate.ts, ⛔ not generate.ts; #17237 touches packages/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

  4. claude commented on Sep 9, 2026

    @claude
    Contributor

    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-default exists (pushed empty as the write-routing probe)
    and carries zero commits; worktree tree state 559e531a94, git status --porcelain empty.


    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 of information_schema.columns. generate.ts as #16887 / #17208 / #17230
    leave it (generateMigrationSql / generateMigrationTs, driven directly).

    declaration driver sql gen ts gen
    { maxLength: 100 }, no type character 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: the email row proves the probe can produce agreement across all
    three producers on the same fixture, and the text row 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/main

    • FieldType.options does not contain 'string'; it does contain 'text'. The card's
      narrower half holds.
    • FieldSchema.safeParse refuses 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:

    • defineStack is NOT one of them. It strict-parses objects: z.array(ObjectSchema) by
      default, so an objectstack.config.ts carrying either shape throws at load today. The card's
      "a config loaded without os validate" is stale on this point — strict: false is the only
      config-side opening, and it is a declared, documented opt-out.

    • Boot rehydration of sys_metadata IS one, deliberately. restoreMetadataFromDb →
      loadMetaFromDb runs computeMetadataDiagnostics(type, item) on every stored row — measured:
      valid=false for both shapes, valid=true for 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 continue and no throw between that warning and
      this.engine.registry.registerObject(...).

    • Programmatic registry.registerObject(def) and raw plugin/package manifests
      (manifest is any on 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.ts under packages/
    and examples/ was loaded and every field inspected: 113 modules matched, 111 loaded,
    116 object definitions, 1341 fields, 0 typeless and 0 non-member type. 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 its dist/ copy
    (packages/create-objectstack/src/templates/blank/src/objects/note.object.ts); read by hand — it
    declares through Field.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. syncRegisteredSchemas wraps every
    driver.syncSchema in its own try/catch and hands a failure to reportSyncFailure, which
    counts it, logs at error with the consequence and the fix, and continues to the next object;
    a summary error follows if any failed. A refusal inside createColumn therefore 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 (two sql-driver.ts hits that default an
    introspected column's type, :11818 and :17364, are excluded by hand):

    file sites what they are
    packages/cli/src/commands/generate.ts :2088, :2250 the two migration formats — the card's actual subject
    packages/cli/src/commands/generate.ts :623, :923 os generate types — a different question (see below)
    packages/drivers/driver-sql/src/schema-drift.ts :549, :1039, :1248, :1263 column-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, :16325 cross-field class, both coercion-registry fills, datetime/time candidates, declared column DEFAULT, three error texts, the varchar mirror, and createColumn itself

    19 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 on generateTypesFromConfig:

    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_MAP has no 'string' key, so flipping :623 / :923 to match the driver would
    change os generate types output for a typeless field from string to unknown — a regression,
    not a de-duplication. Those two sites are also already asymmetric between the card's two
    shapes
    (typeless emits string, unknown-type emits unknown), 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:

    1. Both sides refuse (triage's third route) — needs the driver to move, i.e.
      packages/drivers/driver-sql/**, which is domain:engine, and it narrows the declared
      boot policy in §2/§3. Two stop conditions.
    2. Generators follow the driver (|| 'text' to || 'string' at :2088 / :2250) — this
      is "default string or default text". Stop condition 3: it defines what a typeless field
      IS for an unvalidated author, and [finding] Both migration generators cap text fields 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 emit NOT NULL where the driver emits nullable, and the SQL format emits TIMESTAMP where both knex paths yield timestamptz #15521) was
      given for declarations that parse.
    3. Generators refuse, driver keeps defaulting — asymmetric by construction: os generate migration would refuse metadata the platform boots. Also a decision, and it leaves the
      driver's silent guess in place.
    4. 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 types sites (:623 / :923) are a third default
    with its own answer, and nothing today pins that a typeless field's emitted TS type is string
    while an unknown-typed field's is unknown. 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

  5. claude commented on Sep 9, 2026

    @claude
    Contributor

    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

  6. os-project-manager commented on Sep 9, 2026

    @os-project-manager
    Collaborator

    Measurement round complete — ⛔ no code, no PR, and that is the correct outcome. Card → decision box.

    domain:cli execution 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 at 5609317170.

    What the measurement settled

    The premise holds. The card's PostgreSQL 16.13 table reproduces exactly at 559e531a94, with generate.ts as #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: syncRegisteredSchemas catches per object and reports at error, so the boot survives. ⛔ But that is not a clearance:

    loadMetaFromDb runs computeMetadataDiagnostics on every stored sys_metadata row, gets valid=false for 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 last

    source 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.ts hits (: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 emits probe?: string while an unknown-typed field emits probe?: unknown, and nothing pins either. ⚠️ FIELD_TYPE_MAP has no 'string' key, so "just flip the four || 'text' sites to 'string'" would silently change os generate types output for a typeless field from string to unknown. ⇒ 「同一个默认被抄了八遍」 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 without os validate" no longer opens the door — defineStack strict-parses objects: z.array(ObjectSchema) by default, so such a config throws at load. Only defineStack(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_MAP interaction that makes a naive flip change os generate types, and that the driver side is 15 sites rather than 4.

    State change

    pm:queue → needs-user-decision, pm:dispatched off, assignee cleared. ⛔ Nothing was built, nothing is claimed, and the branch is byte-identical to origin/main (git status --porcelain empty, 0 commits ahead).


    Generated by Claude Code

  7. 1 remaining item

  8. removed their assignment
    on Sep 9, 2026
  9. os-litant commented on Sep 10, 2026

    @os-litant
    CollaboratorAuthor

    决策分析 —— 总监席第 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(启动策略保持完整;生成器迁移两站点跟随驱动;fieldTypeToSql docblock 记下「未校验门后的默认 = 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

  10. os-litant commented on Sep 10, 2026

    @os-litant
    CollaboratorAuthor

    裁决落卡 —— 批 #111 第 2 项:禁止加载(总监席第 21 场 session_01QVMnxyWBx8cAQMsV6akDV9,2026-09-10T09:0xZ)

    出处:维护者本场对话逐字「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」(对分析 5615846402 的回批)。本席原推荐 Q-A+B(保持启动策略、生成器跟随驱动)被推翻,按维护者裁决执行。鲜度门:本卡在分析之后无新评论。

    裁定:一个 type 缺失或不是 FieldType 成员的字段声明,在任何一扇门都不得进入运行时。具体形状(本席按裁决字面推导,⛔ 不是重裁;若您本意是「只丢那个字段、对象照常加载」,一字即改):

    1. 单一收口在注册门:registry.registerObject 对每个字段校验 type 成员资格,不合格即拒绝整个对象声明并报错点名对象、字段、原因(⛔ 不静默丢字段:把一个对象少一个字段地装进去,是本仓最不能容忍的静默形状)。所有门(启动回水、包/插件清单、程序化注册)都经此处。
    2. 启动策略修订:loadMetaFromDb 的「Registered anyway so it stays serveable and fixable」对本类不再适用;含此类字段的存量行不注册,启动日志按 error 级点名,且该行必须仍可经 Studio / 元数据 API 的原始行路径修改或删除。⚠️ 实施席先验:若 Studio 的元数据列表只从注册表读,被拒的行会从界面消失而无法修——那时停手回报,⛔ 不得为此放回「照常注册」。
    3. 下游默认值全部改拒绝:createColumn 的 || 'string'、两个迁移生成器与 os generate types 的 || 'text' 一律改为响亮拒绝(ADR-0112 信封或等价错误),⛔ 不再猜族;按构造它们应当不可达,拒绝是防御。
    4. 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

  11. claude commented on Sep 10, 2026

    @claude
    Contributor

    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

  12. claude commented on Sep 10, 2026

    @claude
    Contributor

    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

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions