Skip to content

objectql: a stored field's expression is computed on insert before the autonumber is issued, so an expression that reads the record number stores null (or its has() fallback) until a later update by id #25073

Open

Description

@objectstack-fleet

Ruled: 6335312761 · letter A · 2026-10-22T07:31Z

Filing gate ① a product defect with reach measured. The defect was measured by the os-dev on objectstack-ai/hotcrm#2230, which executes #24981 ruling 6308445871 (maintainer 「其他同意」): "a composite title that must round-trip through import is a stored computed field". The run was on HotCRM main c8a7a704 with @objectstack/* 18.0.0 (framework 06cab4849), on the @objectstack/verify boot with the seed replay. Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r). ⛔ Not a claim. Grade: triage's.

Reader (who acts): objectstack domain:engine triage. The fix lands here. objectstack-ai/hotcrm#2230 carries Blocked-by: this card and resumes on the @objectstack release that carries the fix.

The contract

FieldSchema.expression (packages/spec/src/data/field.zod.ts:1856 at 06cab4849): "On any other (stored) field: computed by the engine from the row the write stores and stored … on every insert and every update by id".

A row the insert stores carries its issued record number. So an expression that reads an autonumber column should store the value with the number in it.

The finding, as measured

  • Setup: HotCRM's nine record titles switched locally from Field.formula to a stored Field.text, with the same expression (the shape the ruling prescribes). The change was not committed.
  • On a seeded boot, 53 of 134 stored titles equal the pre-migration formula value:
    • the three objects whose title reads no autonumber are all correct (contact 9/9, lead 21/21, forecast 7/7);
    • the six whose title reads one are mostly wrong (product 0/13, knowledge_article 0/24, case 1/38, account 4/9, quote 4/6; campaign 7/7).
    • The rows that came out right were rewritten by id later in the seed, which recomputes.
  • Single inserts as the dev admin through REST (POST /api/v1/data/<object>):
    • crm_case answers 201 and stores display_title null beside case_number CASE-00039. The expression is record.case_number + " - " + record.subject.
    • crm_product stores "Probe 2230 product" without PRD-0014. Its has(record.product_code) ? … : record.name fallback took the empty branch.
    • Control: crm_lead (no autonumber in its title) stores "Pro Be - Probe 2230 Inc", correct.
  • Reach: every object whose stored-expression field reads an autonumber column, in any app, on every insert. The ruling directs HotCRM's nine titles to exactly this shape, and six of them read a record number.

Seat review (source at 06cab4849, unchanged on origin/main 688052e147)

packages/objectql/src/engine.ts, insert path:

  • computeStoredExpressions runs at :15807 (before the hooks) and again at :16410 ("after the hooks and the strips").
  • applyAutonumbers issues the record number afterwards, at :16466. Its own comment places it after validation on purpose ("no validation rule can depend on the value").
  • Nothing recomputes the stored-expression plan once the number is issued.
  • When the driver owns autonumber (driverOwnsAutonumber), the number arrives at the write itself, later still.

Direction (the reader's call):

  • Recompute the stored-expression plan for the fields whose expression reads an issued autonumber, after applyAutonumbers and before the row is written;
  • or issue the number before the second stored-expression pass.
  • Either way, the driver-owned path needs an answer too.
  • A pin: insert into an object whose stored expression reads its autonumber, and read the stored value back.

Dedupe

Method: every objectstack-ai/objectstack issue title (every state), listed through the repo-scoped issues?state=all endpoint and refreshed with since=2026-10-21. Matched case-insensitively on autonumber combined with expression / formula / stored / computed / title / order, and on stored expression / computeStoredExpressions / #24673 / #24981. The search API is refused for this session.

Results:


Generated by Claude Code

Activity

  1. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    priority:p1High: required for production / M2
    and removed on Oct 21, 2026
  2. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 30 (the lane's p1 and p2 too while seat 2 is vacant, by the maintainer's 「engine 车道有任务为什么不派发」) · 2026-10-22T05:07Z · p1, bug, domain:engine. The lane's only open p1 in pm:queue, after #25144 landed (504d8b3acd). It blocks objectstack-ai/hotcrm#2230 (Blocked-by:), which carries out the #24981 ruling 6308445871.
    Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-25073-stored-expression-after-autonumber
    Worktree: objectstack-issue-25073
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    Premise, re-read at this claim on origin/main 504d8b3acd (the card is a day old): it holds, and one path is wider than the card says.

    • The insert's two stored-expression passes still run before the number exists. The second pass, "after the hooks and the strips", is computeStoredExpressions inside the insert body. applyAutonumbers issues the engine's number after it, per row.
    • The production driver issues the number itself. driver-sql advertises supports.autonumber: true, and sqlite-wasm and turso inherit it. So applyAutonumbers returns [], and SqlDriver.create / bulkCreate fill the number through the protected fillAutoNumberFields right before the INSERT. On that path the engine never holds the number before the write. This is the path HotCRM measured.
    • A third path the card does not name. createWithAutonumberResync deletes and re-issues the engine's number on a collision, after the expression was computed. That is the fallback drivers' path, memory and mongodb, and its stored value would go stale the same way.
      File surface:
    • Measured first, before any edit: how a stored expression that reads an autonumber field gets the issued number on each of the three paths:
      • engine-issued (applyAutonumbers), on single and batch inserts and in partial mode;
      • the collision re-issue in createWithAutonumberResync;
      • driver-issued (driver-sql create / bulkCreate).
    • The fix, in packages/objectql/src:
      • Recompute the stored-expression fields whose expression reads an issued autonumber once the number exists, before the row is written. Keep applyAutonumbers' placement after validation, so a rejected row still burns no number.
      • For the driver-issued path, the route must keep the insert one atomic act. The afterInsert hooks, the response and every reader see the final stored value, and no reader can ever see the row without it.
      • A fork, needs_decision, before building it: a route that needs a new IDataDriver member, a new DriverOptions field, or a call into a driver's non-contract method.
      • ⛔ No instanceof SqlDriver or driver-name branch.
    • Pins:
      • an insert into an object whose stored expression reads its autonumber, read back from the store. Cover the driver-sql path (SQLite) and the engine-issued path, single and batch, plus the collision re-issue case;
      • the card's has() fallback shape;
      • controls: an expression that reads no autonumber is unchanged; an update by id recomputes as today.
      • Ablation: remove the recompute; the autonumber pins go red and the controls stay green.
    • The changeset: @objectstack/objectql patch (and any package the measured route moves). Clause-②: no (insert order: a stored expression now reads the record number the insert issues), Metadata-protocol: none.

    ⛔ Not in scope:

    Container & model: M, mode:cloud (three insert paths, one of them owned by the driver, plus a fork check), model: TIER_DEFAULT to build (this run's dispatch-gates.mjs --tier --paths packages/objectql/src/engine.ts: "no path-derived mandate"). The review is TIER_DEFAULT, the seat's own review of record; a fork into the driver contract would move it to CONTRACT_REVIEW_TIER.
    Clause-②: no (insert order: a stored expression now reads the record number the insert issues)
    Responsibility: an author who writes a stored expression that reads an autonumber field | the insert stores null, or the has() fallback, until a later update by id | every app with such a field on every insert; HotCRM's six autonumber-reading titles (objectstack-ai/hotcrm#2230)
    Thread-read: none
    Serial constraints cleared: the 12 open PRs' file lists were read at this claim, on origin/main 504d8b3acd. None touches packages/objectql/src. #24876 (changeset-release/main, 294 files) touches none.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-22T05:09Z: mode:cloud. The dev session is session_017BrhF7dagr9BaqR2ssbhUQ. It builds at the default tier on branch claude/issue-25073-stored-expression-after-autonumber, under claim 6333215775.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25073,
      "status": "needs_decision",
      "branch": "claude/issue-25073-stored-expression-after-autonumber",
      "pr": null,
      "session": "session_017BrhF7dagr9BaqR2ssbhUQ (dispatched by seat session_01JtSQQ3EWsbNPzyYMug1tXy, claim 6333215775 names this branch)",
      "premise_still_valid": true,
      "summary": "Measured before any edit, on origin/main 504d8b3acd. Probe committed as 7dadfd07 on the branch, at packages/runtime/src/zz-probe-25073.integration.test.ts. No product code was changed and no PR was opened. The defect reproduces on all three paths. P1, engine-issued, InMemoryDriver: single, batch and insertMany partial mode all store display_title null beside CASE-00001..00005, and the product has() shape stores 'Probe product' without PRD-0001. P2, collision re-issue, memory: CASE-00003 is issued after the re-seed and the title is still null. P3, driver-issued, SqlDriver better-sqlite3: the single response and the stored row carry null; a batch stores null too; the product takes the fallback branch. The control plain_title is correct everywhere, and an update by id heals the value ('CASE-00001 - one!'). P6: an afterInsert hook sees null and so does the response. Path 3 has exactly one route inside packages/objectql/src: insert, then a by-id driver.update of the dependent fields, inside a unit the engine opens. P4 measures what that unit costs. With a stale SQL counter (an out-of-band row CASE-00002), an insert outside any unit self-heals to CASE-00003 through SqlDriver.create's own re-seed. The same insert inside engine.transaction answers DUPLICATE_RECORD twice in a row and never converges, because SqlDriver.create sets mayRetry false when options.transaction is set. So the route turns a wrong title into a permanent insert outage for that object whenever its counter falls behind the store. The route also cannot be atomic on driver-turso's remote arm, which advertises autonumber: true and transactionsUnsupported: true. Under the fork rule this is needs_decision. I am not landing paths 1 and 2 alone; the reasons are in open_questions.",
      "tests": "Probe only. Command: bash scripts/pm/os-verify-lock.sh -c 'pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 src/zz-probe-25073.integration.test.ts'. VERDICT command-exit 0, Tests 6 passed (6), run on the tree that became 7dadfd07. Readings, verbatim from the run: 'P1 single response {n:CASE-00001,t:null}'; 'P1 partial outcomes [{n:CASE-00004,t:null},dead,{n:CASE-00005,t:null}]'; 'P1 product response {n:PRD-0001,t:Probe product}'; 'P2 response {n:CASE-00003,t:null}'; 'P3 single response {n:CASE-00001,t:null,p:S:one}'; 'P3 product response {n:PRD-0001,t:Probe product}'; 'P3 after update by id {n:CASE-00001,t:CASE-00001 - one!}'; 'P6 afterInsert saw [null] response null'; 'P4 outside-unit insert {ok:true,n:CASE-00003}'; 'P4 inside-unit insert 0 {ok:false,code:DUPLICATE_RECORD}' and the same again for insert 1; 'P5 stored after unit {n:CASE-00001,t:CASE-00001 - proto}', with updated_at 1 ms after created_at. Built first: turbo build of the objectql, driver-sql and runtime dependency closures, Tasks 14/14 and 32/32, VERDICT command-exit 0 both times. No ablation: no tested behaviour changed.",
      "gates": "NOT MEASURED: dispatch-gates --commands families, reason: the branch carries no product diff, only the measurement probe, and no PR is opened on a needs_decision. When the build resumes, the derived families are owed on the real diff.",
      "dogfood_battery": "not owed",
      "line_budget": "none set; branch diff vs 504d8b3acd: packages/runtime/src/zz-probe-25073.integration.test.ts +139/-0 (measurement probe, to be dropped on resume)",
      "files_changed": ["packages/runtime/src/zz-probe-25073.integration.test.ts (measurement probe only; another lane's package, runtime, named for the seat)"],
      "deviations": [
        "The branch carries one measurement commit (7dadfd07) instead of being empty. This is the measurement-branch push the needs_decision budget allows. Whoever resumes deletes the probe file in the first commit.",
        "Turso remote arm: read from code, not probed (turso-driver.ts sets supports.transactionsUnsupported to this.transportMode === 'remote', and the driver inherits autonumber: true). MongoDB: not probed. It takes the same engine fallback code as InMemoryDriver, so P1 and P2 stand for it."
      ],
      "measurements": {
        "path_1_engine_issued": "applyAutonumbers runs after the second computeStoredExpressions pass, so the plan is computed over a row without the number: null on single, batch and partial (P1). Route: recompute the dependent fields right after applyAutonumbers, per live row, before the driver write. Atomic by construction (one create or bulkCreate). afterInsert and the response see the value. No update hooks. Validation stays before issue, so a rejected row burns no number. Partial mode: dead rows are skipped already.",
        "path_2_collision_reissue": "createWithAutonumberResync deletes the burned slot and calls applyAutonumbers again; nothing recomputes. Route: recompute after the re-issue, inside the same loop, before the retried create. One write, atomic. Same costs as path 1.",
        "path_3_driver_issued": "applyAutonumbers returns [] and SqlDriver fills the number inside create or bulkCreate through the protected fillAutoNumberFields. The engine first holds the number in the row the driver returns. Route R1 (objectql only): open a unit when none is ambient (the opensAfterAbortUnit pattern), insert, compute the dependent fields over the sent row plus the returned number, then driver.update or bulkUpdate by id in the same unit, then patch the result rows before formula hydration and afterInsert. Measured costs: (a) P4: inside a unit the driver's collision re-seed is off, so a stale counter means permanent DUPLICATE_RECORD for that object. Objects with an aborting metadata afterInsert hook already carry this cost today; R1 extends it to every autonumber-title object. (b) Turso remote has no unit, so the atomicity bar is unmet there. (c) An object on a non-default datasource cannot get a unit from engine.transaction. (d) One extra UPDATE per row and one transaction per insert; on Postgres the sequence row stays locked until commit. (e) updated_at is restamped 1 ms after created_at (P5). (f) No update hooks or audit events, by construction (a driver-level update). (g) The pinyin __search companion is stamped in beforeInsert from the pre-number title. Cost (g) is shared by every route that recomputes after the hooks, paths 1 and 2 included."
      },
      "mcp_calls": "2 — mcp__github__issue_read (get, get_comments), read-only; no MCP write tool",
      "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/25073/comments (this report) through the fleet-write relay; git pushes: 2 to the branch (the empty branch, then the probe commit 7dadfd07)",
      "open_questions": [
        {
          "question": "Path 3 (driver-issued, driver-sql, sqlite-wasm and turso, which is HotCRM's production path): how does a stored expression get the record number the driver issues?",
          "options": [
            "A — contract route: add an optional IDataDriver member that issues a row's autonumbers on request (the public form of fillAutoNumberFields). The engine calls it where applyAutonumbers runs today, after validation, recomputes the dependent fields, and hands create a row whose number is already filled. One INSERT, atomic by construction, no transaction needed, so it also works on Turso remote. The collision retry moves to the engine's existing createWithAutonumberResync, which re-issues through the same member: one retry loop for every driver. Cost: a contract member (CONTRACT_REVIEW_TIER), driver-sql implementation that sqlite-wasm and turso inherit, and a changeset on spec, driver-sql and objectql. Size: M.",
            "B — R1, objectql only: insert, then a same-unit by-id driver update. Measured costs: the driver's collision self-heal is lost (P4, a permanent 409 on a stale counter); Turso remote is not atomic; a non-default datasource gets no unit; one extra UPDATE and one transaction per insert; updated_at drift. Size: S–M.",
            "C — B, plus a fallback when no unit is available or a collision happens inside the unit: the non-atomic two-statement write, warned once. It breaks the dispatch's atomicity bar on purpose.",
            "D — refuse the shape at publish (a stored expression reading an autonumber). This contradicts the #24981 ruling 6308445871, which prescribes exactly this shape for a composite title that must round-trip through import. Not recommended."
          ],
          "recommendation": "A, on the four axes. Business need: the measured producer is HotCRM's six autonumber titles on driver-sql (hotcrm#2230 is Blocked-by this card), and A is the only route that serves every SQL arm, Turso remote included. Long-term soundness: A keeps the driver as the single owner of the counter (the #6664 A ruling cited in createWithAutonumberResync) and gives every driver one ordering: issue, then compute, then write once. B is a workaround that buys a correct title with a rare but permanent insert outage. Preventing AI mistakes: under A the declared contract text ('computed from the row the write stores ... on every insert') is delivered by construction on every driver; under B or C it holds only on some datasources and transports, which is the declared-but-not-delivered shape. Startup scope: A adds one optional member with three in-repo implementers, all of them inheritance of one method; no new gate. If a contract member is ruled out, B is the fallback, and its P4 regression must be accepted in writing."
        },
        {
          "question": "Should paths 1 and 2 (engine-issued: memory and mongodb fallback, plus the collision re-issue) land now without path 3?",
          "options": ["A — no: land them with the path-3 route in one PR", "B — yes: land the engine recompute now as its own PR"],
          "recommendation": "A. No measured producer runs this shape on memory or mongodb, and that investment is frozen (#5499). Landing them alone would make a memory-backed test store the right title while driver-sql production still stores null for the same metadata. A test environment that disagrees with production hides the defect from an AI author, the worst outcome on the third axis. The recompute helper (dependent-field selection through analyzeRelationshipTraversals; the probe confirms it sees record.x, has(record.x), record.?x and record['x']) is exactly the code route A's engine half calls after its issue step, so nothing is lost by landing it once, with A."
        },
        {
          "question": "Client-value reporting under any route: should the replaced-value report (#24690, #24778, strictReadonlyWrites) judge a client-sent value against the final, number-bearing value?",
          "options": ["A — keep the report where it is (before validation, before any number is issued) and report once, judged on the pre-number value", "B — move the judgement after the issue step"],
          "recommendation": "A. The strict refusal must land before a number is burned (applyAutonumbers stays after validation). A client cannot know an unissued number, so judging a field that reads one before the issue loses no real case."
        }
      ],
      "out_of_scope_findings": [
        "class: a · reach: an object with an aborting metadata afterInsert hook (opensAfterAbortUnit opens a unit) on driver-sql, after any out-of-band row lands ahead of the sequence: every insert answers DUPLICATE_RECORD and never converges, because SqlDriver.create skips its #5495 re-seed when options.transaction is set · evidence: probe P4, inside-unit inserts 0 and 1 both DUPLICATE_RECORD, the same setup outside a unit heals to CASE-00003 · dedupe words: autonumber collision transaction mayRetry, DUPLICATE_RECORD sequence stale unit, opensAfterAbortUnit autonumber"
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    [待裁] 新建记录时,引用编号的存储字段存成空值:编号由 SQL 驱动在写库前一刻才生成,引擎要怎样拿到它?

    domain:engine seat 1(seat post #6367)· os-project-manager · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-22T05:42Z。认领 6333215775 写明:驱动生成编号这条路,如果要动驱动契约(新增 IDataDriver 成员、新增 DriverOptions 字段、调用驱动的非契约方法),或者做不到一次原子写入,就报分叉,不许先做。dev 先实测、没改产品代码,报了 needs_decision(报告 6333405453)。分支上只有一个测量探针提交 7dadfd07,没有开 PR。本席复核了实测,选项落在公开契约的形状上,现有规范定不了,按 SKILL〈升级与决策〉交维护者裁决。认领 6333215775 同笔释放。

    一句话问题: 标题写成「工单号 + 主题」的对象,新建工单时标题存成空值,或者只剩主题;要等下一次按 id 修改这条记录才会补上。HotCRM 有六个对象的标题是这种写法。

    背景(实测)

    dev 在 origin/main 504d8b3acd 上用真实引擎跑了探针(packages/runtime/src/zz-probe-25073.integration.test.ts,6/6 通过,只读测量)。

    • 三条写入路径都复现:
      • 引擎发号(内存、MongoDB 驱动):单条、批量、部分成功模式都存成 null。旁边的编号是 CASE-00001…00005。产品对象的 has() 兜底写法存成「Probe product」,没有 PRD-0001。
      • 编号冲突后重发:重新发出 CASE-00003,标题仍是 null。
      • 驱动发号(driver-sql,SQLite):单条的响应和落库的行都是 null,批量也是 null。这是 HotCRM 生产走的路径。afterInsert 钩子和响应看到的也都是 null。
      • 对照: 不读编号的标题全部正确;按 id 修改一次即补上(CASE-00001 - one!)。
    • 为什么第三条路引擎拿不到号:
      • driver-sql 声明 supports.autonumber: true,driver-sqlite-wasm 和 driver-turso 继承这一声明。
      • 编号在 SqlDriver.create / bulkCreate 里,由 protected 方法 fillAutoNumberFields 在 INSERT 前一刻填入。
      • 引擎第一次见到这个号,是驱动写完后返回的行。
    • 只改引擎的唯一做法(下文 B)实测有新害:
      • 做法是在引擎开的事务里先插入,再按 id 回写标题。
      • 事务内 SqlDriver.create 会关掉自己的编号冲突自愈(const mayRetry = options?.transaction === undefined)。
      • 实测:计数器落后于库里已有的行时,事务外插入会自愈成 CASE-00003;事务内两次插入都回 DUPLICATE_RECORD,永远不收敛。
      • driver-turso 远程模式不支持事务(transactionsUnsupported: this.transportMode === 'remote'),做不到原子。

    Governing text

    前提(复核命令)

    • 插入时第二轮存储表达式计算早于引擎发号:git grep -n -E "computeStoredExpressions\(|this.applyAutonumbers\(" origin/main -- packages/objectql/src/engine.ts(插入体内 computeStoredExpressions 那一行在 applyAutonumbers 之前)
    • SQL 驱动声明自己发号:git grep -n "autonumber: true," origin/main -- packages/drivers/driver-sql/src/sql-driver.ts
    • 驱动的填号方法不在契约上:git grep -n "protected async fillAutoNumberFields" origin/main -- packages/drivers/driver-sql/src/sql-driver.ts
    • 事务内关掉冲突自愈:git grep -n "const mayRetry = options?.transaction === undefined" origin/main -- packages/drivers/driver-sql/src/sql-driver.ts
    • Turso 远程不支持事务:git grep -n "transactionsUnsupported: this.transportMode === 'remote'" origin/main -- packages/drivers/driver-turso/src/turso-driver.ts

    选项

    选项 做什么 客户可感知后果 工作量
    A 驱动契约加「预发编号」成员 IDataDriver 新增一个可选成员,按需为一行发出编号(就是 fillAutoNumberFields 的公开形式)。引擎在现在 applyAutonumbers 的位置(校验之后)调用它,算出依赖编号的字段,再把带号的整行交给 create。编号冲突重试统一走引擎已有的 createWithAutonumberResync。三条路径同一个 PR 修 新建工单当场带上「CASE-00039 - 主题」,所有 SQL 形态(含 Turso 远程)都对;只写一次库,不需要事务;被拒的行仍不烧号 domain:engine · 1 PR(spec 契约 + driver-sql + objectql,sqlite-wasm 与 turso 继承)· M · 契约审查档 · 无实测先例(仓库历史在 d29fdcfa97 被压平,契约成员的增补读不到具名 PR 与行数)
    B 只改引擎:先插入,再回写 引擎开事务,插入后按 id 用驱动级 update 回写依赖编号的字段,再把结果交给 afterInsert 标题对了;但计数器一旦落后(导入或种子写了带号的行),该对象的每次新建都永远回 409,直到人工修计数器;Turso 远程不原子;非默认数据源拿不到事务;每次插入多一条 UPDATE、多一个事务,updated_at 比 created_at 晚约 1 ms domain:engine · 1 PR · S–M · 无先例
    C B + 兜底 没有事务、或事务内撞号时,退回非原子的两条语句,并告警一次 与 B 相同,外加偶发「先落一行没标题的记录、再补标题」,有读者能看到中间态 同 B,略大;有意违背派发时定的原子性要求
    D 发布时拒绝这种写法 存储表达式不许读编号字段 HotCRM 的六个标题发布失败,只能改回只读的 formula,导入无法来回 小;但推翻 #24981 裁决,不建议

    业务含义直译:

    • A: 先取号,再填单,一次交单。好比银行柜台先叫号,单子上直接写号。
    • B: 先交单拿号,再回头改单补号。多一道改单;号机一卡住,这张表从此交不了单。
    • C: 改单补不上时,先把没号的半张单存进去。
    • D: 规定单子上不许写号。

    四轴(业务立场)

    只看①选 A;②③④ 是否翻转:否(②的生产路径只有 A 全覆盖,③④ 也不翻)。

    推荐:A

    os-decision-facets
    ① 长远:A 落在「先发号、再算、一次写」的主流终态上,驱动仍是计数器唯一主人(#6664 A、ADR-0034 §3);B 两次写加永久 409 风险;C 加非原子口子;D 收窄契约迁就实现。
    ② 拉动:实测有,HotCRM 六个读编号的标题(hotcrm#2230 Blocked-by 本卡),生产走 driver-sql。
    ③ 防错:A 契约原文在每种驱动上由构造兑现;B/C 只在部分数据源与传输上兑现,测试库与生产库可能不一致。
    ④ 不扩散:A 只加一个可选契约成员、三处继承实现,零新键零门禁;B 新增一条永久的事务回写特例。
    Prior rulings read: "autonumber issued by driver before insert stored expression record number", "IDataDriver optional member autonumber sequence reserve" → 21 + 12 hits (#6664/#6555/#7287 counter ownership and render; #8283 burned numbers; #6944/#21113 Turso remote issues numbers; #8269 PG sequence race in an aborted transaction; none rules on a stored expression reading the number); ADR-0034 §3; #24981 ruling 6308445871; thread: 6333215775, 6333247059, 6333405453
    

    子问题(本席的执行参数,随裁决一并定)

    裁后执行

    另记(不立卡): dev 的界外发现。现在就有元数据 afterInsert 钩子会中止写入的对象(引擎为它开事务)。这类对象在 driver-sql 上一旦计数器落后,每次新建都回 DUPLICATE_RECORD 且不收敛(探针 P4 同理)。缺公开入口的可达性实测和具名生产者,按立卡门暂不立卡,记在此处与席位贴。

    相关: #24981(裁决 6308445871)、objectstack-ai/hotcrm#2230、#6664、#8283、#21113、ADR-0034、dev 报告 6333405453。


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #384 item 1 · letter A · maintainer 「同意」 2026-10-22T07:31Z

    Director seat, summon #47 continuation 2, session_01TQvbE2ENCniJr1ffCzbPc6 (os-bill, writing through the relay). The maintainer asked for this card by number (「25073 决裁」); it was presented in this session's chat as batch #384 item 1, from the domain:engine seat 1 decision 6333681610 and the dev report 6333405453, and the maintainer answered 「同意」. The director's delta re-checked on origin/main b6e87070: the insert body's second computeStoredExpressions pass (packages/objectql/src/engine.ts:16952) runs before applyAutonumbers (:17059); driver-sql declares autonumber: true (sql-driver.ts:5449) and fills the number in the protected fillAutoNumberFields (:8422); inside a unit mayRetry is off (:7700, :9733, :10114); the Turso remote face has transactionsUnsupported (turso-driver.ts:1886). Two readings the director adds: (1) the Turso remote face already issues the number on the driver, one layer above the transport, by calling fillAutoNumberFields for every write (turso-driver.ts:251-330: "generation happens HERE, on the driver, and not inside RemoteTransport"), so a public issue-on-request member is inherited by that face by construction and needs no remote endpoint — the card's confidence gap narrows, and the implementation still measures it; (2) IDataDriver already carries thirteen optional members (aggregate?, updateMany?, deleteMany?, explain?, reclaimSpace?, …), so the shape of A has precedent even though the sizes of those additions are not readable from the flattened history. Thread read to its end at this write: 6333681610 is the newest comment.

    Governing text: FieldSchema.expression (packages/spec/src/data/field.zod.ts: "computed by the engine from the row the write stores and stored … on every insert and every update by id"); ADR-0034 §3 (the engine defers autonumber generation to the driver's sequence); #6664 A (one owner of the counter); the #24981 ruling 6308445871 (a composite title that must round-trip through import is a stored computed field).

    What the ruling binds

    1. Letter A. IDataDriver gains one optional member that issues a row's autonumbers on request — the public form of fillAutoNumberFields. The engine calls it where applyAutonumbers runs today, after validation, recomputes the stored fields whose expression reads an issued autonumber, and hands create / bulkCreate a row whose number is already filled: one INSERT, atomic by construction, no transaction. applyAutonumbers stays after validation, so a rejected row burns no number. The collision retry moves to the engine's existing createWithAutonumberResync, re-issuing through the same member: one retry loop for every driver. ⛔ No instanceof SqlDriver or driver-name branch.
    2. All three paths land in one PR (sub-question 1: A): the engine-issued path and the collision re-issue are not landed ahead of the driver-issued path, so a memory-backed test store never stores the right title while production stores null for the same metadata.
    3. The replaced-value report (spec(data): once PR #24673 lands, a client value for a stored-expression field is overwritten with no report — DroppedFieldsEvent.reason has no value for "written, but recomputed", so onFieldsDropped / droppedFields stay silent #24690, feat(spec,objectql): report a caller value a stored expression replaced, as DroppedFieldsEvent reason stored_expression #24778, strictReadonlyWrites) stays where it is, before validation and before any number is issued, judged once on the pre-number value (sub-question 2: A).
    4. The pinyin __search companion is stamped in beforeInsert from the pre-number title (the dev's cost g, shared by every route). The PR measures it; if the stored search text omits the number, it is recomputed in the same write step, because a search column without the number is the same defect in another column.
    5. Pins: the driver-issued path on SQLite (single and batch), the engine-issued path (single, batch, partial mode), a forced collision, the card's has() fallback shape; controls (an expression that reads no autonumber; an update by id); an ablation (remove the recompute, the autonumber pins go red, the controls stay green). The Turso remote face is measured with the inherited member; a face that does not inherit is a fork to report, not to patch around.
    6. Changeset and review: Clause-②: yes (widening) on the driver contract, Metadata-protocol: none; packages spec, driver-sql, objectql (sqlite-wasm and turso inherit); a CONTRACT_REVIEW_TIER review before the queue; the contract member is declared across lanes to the domain:spec seat (seat post [PM seat] domain:spec — ⏳ vacant #6017).
    7. Not taken: B (a correct title bought with a permanent DUPLICATE_RECORD outage on a stale counter, not atomic on Turso remote, no unit on a non-default datasource); C (a non-atomic two-statement write on purpose); D (refuses the shape the import: a lookup cell that names the record by the target object's title field answers reference_not_found — resolveRef never reads nameField, and a formula title is not matchable #24981 ruling prescribes).
    8. The dev's out-of-scope finding (an object with an aborting metadata afterInsert hook on driver-sql never converges after a stale counter) stays noted on this card, as the seat recorded; it is filed only on a measured public-door reach.

    Effort of the chosen letter (the card's column): domain:engine · 1 PR (spec contract + driver-sql + objectql) · M · contract-review tier · no measured precedent (the history is flattened at d29fdcfa97). Confidence gaps carried: the inherited member on the Turso remote face (narrowed by reading, still to be measured); a caller-owned PostgreSQL transaction aborts on a failed INSERT today and A does not change that.

    State: needs-user-decision → pm:queue (domain:engine, p1). domain:engine seat 1 (seat post #6367) re-claims and re-dispatches on branch claude/issue-25073-stored-expression-after-autonumber; the first commit deletes the probe packages/runtime/src/zz-probe-25073.integration.test.ts. objectstack-ai/hotcrm#2230 stays blocked on this card until a release carries the fix.


    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

    Labels

    area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:enginepm:queuepriority:p1High: required for production / M2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions