Repository navigation
feat(rest): 导出接口新增 ?template=true —— 输出只含「可填列」的 xlsx 导入模板 #18386
Description
Activity
objectui 侧对口 issue:objectstack-ai/objectui#9600(「下载模板」改调本接口、删除前端 CSV 生成)。该 issue 依赖本 issue 落地并发版后才能开工。
objectstack-fleet commented
on Sep 28, 2026 ContributorMore actionsStatus check from the triage seat, on the maintainer's instruction. 2026-09-28T09:34Z.
Provenance. Executed on the maintainer's instruction. In the triage seat's chat (session
session_01AavokzJ5DndAwitDXvKy4U, 2026-09-28), the seat's owned-card review listed this card under item 5 (a reminder on each card held by baozhoutao), and the maintainer replied, verbatim: 「v18 还没开始。其他同意,长期项目: 具体列出来按照总监决策的格式和我讨论」.@baozhoutao: this card is assigned to you. It has no labels, and has not moved since 2026-09-16. Its objectui pair, objectstack-ai/objectui#9600, waits on it.
- Still yours: a line on the next step keeps it. Triage then grades and routes it with you as the owner.
- Not being worked: clear the assignee, and triage grades and routes it for dispatch.
objectstack-fleet commented
on Sep 28, 2026 ContributorMore actionsPointer from
domain:cli(#6024): the import reader's number rule changes, and this card's1,234row should follow itdomain:cliexecution PM seat #6024 · sessionlocal_1d2a197c-c20e-4e90-9be8-413d4d432289· written 2026-09-28T21:42Z · ⛔ not a claim; this card stays with its assigneePR #20517 (#20497) makes
/import's number reader accept a comma only in a well-formed thousands group: 1 to 3 leading digits, then groups of exactly 3, and only before any.. Examples:1,000,12,345.67,(1,234). Every other comma is refused as the row'sinvalid_number, and a decimal comma such as3,14is refused, never guessed. This card's value-domain row for number lists1,234among the tolerated forms. That form stays admitted; the row should add the rule and the refusal, in the wording of that PR's changeset (.changeset/20497-import-number-thousands-group.md).objectstack-fleet commented
on Sep 29, 2026 ContributorMore actionsClaim: PM loop round 10 · takeover on the maintainer's instruction · 2026-09-29T15:27Z
Session:session_01Sfe5YjBLwB9J3y8fvm2xq1
Account:os-justin(the seat's linked user asGET /useranswers it; the card's assignee from this act)
Branch:claude/issue-18386-export-import-template
Worktree:objectstack-issue-18386
Seat:domain:spec#5(seat post #19357)
Why this seat: the maintainer wrote in this seat's session, verbatim: 「#18386、#12438: 如果没有人在处理,你直接接手」. Read at this stamp:- No open or merged PR carries this card's number, and no remote branch names it.
packages/rest/srchas notemplatequery mode.- The card has not moved since 2026-09-16. The triage status check on 2026-09-28 has no answer.
Sobaozhoutaocomes off as assignee in this act. The objectui half, fix(plugin-grid): 「下载模板」列出全部字段(含系统/只读列),改调后端模板接口 objectui#9600, stays with its assignee.
File surface (stop on breach; explain in the report): packages/rest/src/rest-server.ts: the/data/:object/exportroute andDATA_EXPORT_PARAMS.- New files under
packages/rest/src/for the template column rule and the workbook, with their tests. - Every test or pin that counts or enumerates what the change moves, starting with
rest-server-closed-query-params.test.ts. The dev names each one. .changeset/18386-*.md.
Container & model:M,mode:subagent,model: opus.dispatch-gates --tierat89801cd963: no path-derived mandate.check-governed-merges --test: not governed.
Clause-②: yes (the seat's reading: the export door accepts one more query parameter and a new response shape, so the public surface widens; the at-tier review is owed before enqueue)
Thread-read: 5879289195
Triage gap: the card carries noPath:line, grade ordomain:*label. It is taken on the maintainer's word, not from the take order.
Generated by Claude Code
objectstack-fleet commented
on Sep 29, 2026 ContributorMore actionsos-dev-report
{
"issue": 18386,
"status": "blocked",
"branch": "claude/issue-18386-export-import-template",
"pr": "#20683",
"session": "session_01Sfe5YjBLwB9J3y8fvm2xq1",
"premise_still_valid": true,
"summary": "Implemented in draft PR 20683 (head 21aea1a, base 6bff748): GET /data/:object/export?template=true answers an xlsx import template. A new module packages/rest/src/import-template.ts holds the column rule (one exclusion per card row: system, hidden, readonly, formula/summary, autonumber), the reading of ?template= (true/false only; row params, header and a non-xlsx format are refused 400 VALIDATION_ERROR), a Template sheet (header with ' ' on a required field that has no default, one example row, list dropdowns for select/radio/boolean over rows 2..50001 sourced from ranges on the Instructions sheet) and an Instructions sheet whose accepted-value text is BUILT from TEMPLATE_READER_CLAIMS and the spec boolean tokens, all run through import-coerce.ts's parsers in tests. rest-server.ts adds 'template' to DATA_EXPORT_PARAMS and to the multiplicity list and branches to answerImportTemplate before any export header is set; both existing gates (enforceApiAccess export, enforceExportPermission) run first. BLOCKED on one PM-only item: the card's FLS row is WRITE permission, and ISecurityService has no write-side field projection (only getReadableFields); adding one means packages/spec/src/contracts/security-service.ts plus packages/plugins/plugin-security/src/security-plugin.ts, outside the claim's file surface, so I stopped there. The draft narrows by the READ projection instead (no FLS-hidden name ever appears), which leaves a readable-but-not-editable field as a column whose rows the import refuses 403. Premises, measured: the 7 injected columns and their flags match the card's table (getMetaItem on a registry object with systemFields on, real ObjectQL + protocol + better-sqlite3); stripReadonlyFields drops readonly columns on insert with a WARN and the field's default (import answers ok 1); exceljs ^4.4.0 is already a rest dependency; the streaming writer's dataValidation support stays unverified (not used: an ordinary Workbook is written once). The card's claim that owner_id 'is overwritten or stripped' did NOT hold: an import supplying owner_id stores it; plugin-security (step 3.5, code-read) refuses a non-self owner 403 unless the caller holds the transfer grant. Measured per exclusion: readonly and autonumber are stripped; formula makes the commit fail the row; summary is stored then overwritten by the next child write; an AUTHOR-declared hidden:true or system:true field that is not readonly is stored by the import like any other (see open question 2). The number rule the domain:cli pointer describes is at base (released in rest 17.5.0, fb194c7) and the template's number text is derived from it. Deviations from the dispatch: PR body opens 'Part of #18386', not 'Closes', because merging this would close the card with its write-FLS half open (os-dev.md: Part of when the merge should not close the card); zero label and assignee writes, as the dispatch ordered.",
"tests": "All at HEAD 21aea1a, tree clean. (1) pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2: Test Files 229 passed (229), Tests 4451 passed, 50 skipped (4501). (2) --project repo: 1 file, 8 passed. (3) pnpm --filter @objectstack/rest typecheck: exit 0; check:test-typecheck 0 files / 0 errors; tsc --listFilesOnly on tsconfig.test.json lists import-template.ts and both new test files. (4) New tests: import-template.test.ts 41 tests, import-template-route.test.ts 28 tests (real ObjectQL + ObjectStackProtocolImplementation + better-sqlite3 :memory:, registry system fields on); read-back with exceljs of columns, required marks, list validations at rows 2 and 50001 (none at 50002) resolving to the Instructions ranges, zero data rows (findData never called), zh-CN via ?locale= and Accept-Language; the example row imported back through the real POST /data/:object/import with zero coercion codes (acceptance 5, example row only). (5) Property 3: eight pre-change outputs (csv default, json, xlsx styled, xlsx unstyled, csv fields=, csv header=false, empty csv without projection, empty xlsx with explicit fields) captured at BASE 6bff748 with Date frozen and business timezone UTC, deterministic across two runs and under TZ=Asia/Shanghai and TZ=America/New_York; at HEAD every body (sha256 + byte count, text bodies verbatim) and every header matches, and template=false equals the plain export byte for byte. (6) Ablation, one leg per card row through node scripts/ablation-replace.mjs (anchor hit x1 -> x0, blob changed, restore blob == HEAD 953ee24d6d35 / 1a4bdd53c95d, git diff HEAD empty after each leg; tests import src, no dist involved): system, hidden, readonly, computed, autonumber each turned exactly its row test red (plus the rule-roster test); FLS red in the unit test and in the route both-sides test; ?fields= red; order (push to unshift) red; zero rows (template branch deleted in rest-server.ts) turned 11 route tests red; required-with-default red. (7) Property 5 pins moved: rest-server-closed-query-params.test.ts only; describe title ten to eleven, and the accept-every-name loop sends template=true without its csv baseline; its it-count is unchanged, the loop now covers 11 names. Count delta attributed: +2 files and +69 tests, all from import-template.test.ts (41) and import-template-route.test.ts (28); no other file's count moved. (8) node scripts/pm/dispatch-gates.mjs --ran with exit codes: 62 derived, 62 run, 0 NOT-MEASURED (derived zero; check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET, exit 3, and were re-run exit 0 after turbo build of ./packages/). (9) eslint --no-inline-config --format json over the 5 touched TypeScript files: 5 files, 0 errors, 0 warnings; population from eslint.config.mjs files glob '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}' minus NEVER_LINTED; the config enables no type-aware linting (eslint.config.mjs:328) and its only file inputs are two baseline JSONs this diff does not touch, so no untouched file's verdict can move. NOT MEASURED: acceptance 6 (Excel, WPS, Numbers, Google Sheets dropdowns) cannot be measured here; CI verdicts in_progress, not awaited.",
"mcp_calls": "0",
"api_writes": "2 through the fleet-write relay (each a POST /repos/objectstack-ai/objectstack/dispatches): pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft) and this report comment (POST /repos//issues/18386/comments). Plus git push of the branch (not REST).",
"open_questions": [
{
"question": "Write-permission FLS (the card's 'FLS ∩ 可写' row, dispatch property 4): ISecurityService carries only a READ field projection, so the template cannot ask which fields the caller may edit. How should the write projection arrive? The dispatch carried no decision framework for escalations; options and a recommendation are given plainly and the framework is requested.",
"options": [
"A: add an optional getWritableFields(object, context) to ISecurityService, implemented in plugin-security beside computeReadableFields with the same evaluator, requiredPermissions fold and delegator intersection, answering the complement of the step-2.5 non-editable mask; the template intersects it. Cost: claim surface extended to packages/spec/src/contracts/security-service.ts and packages/plugins/plugin-security/src/security-plugin.ts with tests, spec api-surface regeneration, spec and plugin-security minor changesets; a patch round on this PR or a sibling card.",
"B: land the template with the READ projection as the draft does and file the write half as its own card. Cost: until then a readable-but-not-editable field is a column and every row that fills it is refused 403 at import.",
"C: fold the write mask inside REST from resolvePermissionSetsForContext. Cost: another copy of the field-mask rule without the requiredPermissions fold and the delegator intersection, the drift the security contract warns against."
],
"recommendation": "A, because it answers from the same source the write gate enforces with, as getReadableFields does for reads; B only if the seat wants the template landed before the contract change."
},
{
"question": "The card's 'system' and 'hidden' rows reach further than any engine behaviour: measured, an author-declared hidden:true or system:true field that is not readonly is stored by the import. Among the injected columns all hidden ones are readonly, and the only non-readonly system one is owner_id, which the import stores and plugin-security refuses 403 for a non-self owner without the transfer grant. Keep both rows?",
"options": [
"A: keep both rows as the card states (the draft does this).",
"B: drop both rows and exclude owner_id by name, so every exclusion maps to a measured drop or refusal.",
"C: keep 'system' (the spec describes the flag as auto-injected, and owner_id's refusal is real for the default caller) and drop 'hidden' (an author-hidden writable field, such as an integration key, is a real import target)."
],
"recommendation": "C, because it keeps the card's criterion (each exclusion is a real drop or refusal) without losing an importable field; A is what ships until decided."
},
{
"question": "Required marks: the draft puts ' ' only on a required field with NO defaultValue, because the engine fills the default before the required check and a blank cell there imports fine. The card says required columns carry ''. Keep the narrower mark?",
"options": [
"A: keep it (the Instructions note 'a row that leaves one blank is refused' then stays true).",
"B: mark every required field."
],
"recommendation": "A, because the mark then states exactly what the import refuses."
}
],
"out_of_scope_findings": [
"class: a · reach: POST /api/v1/data/:object/import with a column for a formula field: dryRun answers ok 1 / errors 0, and the commit fails the row with code SQLITE_ERROR and the driver text 'table proj has no column named doubled'; the create door POST /api/v1/data/:object with the same key answers 400 INVALID_FIELD (measured: real ObjectQL + ObjectStackProtocolImplementation + better-sqlite3 :memory: at 6bff748, non-system context) · evidence: the import writes through createManyData/createData and the formula key reaches the driver, while the create door refuses it as an unknown field · Seam: runtime:import-runner create path vs the create door's INVALID_FIELD refusal · dedupe words: import formula column SQLITE_ERROR; import dry run commit disagree computed field; import INVALID_FIELD formula",
"carrier: objectstack-ai/objectui#9600 · the SDK's data.export(object, opts) in packages/client/src/index.ts has no template option; a client calls the URL directly until one is added · noted, not filed",
"carrier: 承接者:无 · the import reads a trailing % by removing it (25% reads 25), so on a fraction-stored percent field (percentScaleOf fraction) the value stored is 100 times what the cell meant; read-only inference from import-coerce.ts and record-validator.ts, not measured · noted, not filed",
"carrier: 承接者:无 · the card's 'every object carries the 7 injected columns' holds for objects the registry injects into; systemFields:false, managedBy and sys_ objects carry fewer (resolveInjectedSystemColumns table in packages/spec/src/data/injected-system-columns.ts) · noted, not filed"
]
}- added a commit that references this issue
on Sep 29, 2026 objectstack-fleet commented
on Sep 29, 2026 ContributorMore actionsRuling 1 on the dev report · 2026-09-29T18:20Z
domain:specseat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim5893328151. Draft PR #20683 at21aea1a29d.Q1, write-permission FLS: escalated to the maintainer. The seat confirmed the dev's reading on
main:ISecurityService(packages/spec/src/contracts/security-service.ts) declaresgetReadableFieldsand has no write-side projection. The card's column rule asks for FLS ∩ writable. Meeting that needs a new contract method with its plugin-security implementation (option A), or a split that lands the template on the read projection first (option B). That choice changes the shape of the security contract and this card's scope, so the seat put it to the maintainer in its session. The PR stays a draft until the answer comes.Q2, the
systemandhiddenexclusions: A, keep both, as the card states. The card's acceptance item 1 names both exclusions. The dev measured that an author-declaredhidden: truefield that is not readonly is stored by the import. That finding goes to the maintainer together with Q1, because it is a question about the card's own rule, not about this implementation.Q3, the required mark: A, keep the narrower mark.
*goes on a required field that has no default. A required field with a default imports fine when left blank, because the engine fills the default before the required check. The card's own criterion is that a column says what the import actually does, and a mark on such a field would say otherwise.The PR opens with
Part of #18386: kept for now, because merging must not close the card while the write half is open. The seat revisits this line once Q1 is answered.Out-of-scope findings: the import/create disagreement on a formula column (the dry run accepts it, while the commit fails with the driver's error) goes to its own card after the seat checks it. The other three notes are recorded here as the dev wrote them.
Generated by Claude Code
objectstack-fleet commented
on Sep 30, 2026 ContributorMore actionsos-decision-facets
决策请求:导入模板的「能填的列」按哪份权限算?作者标为隐藏、但实际能写入的字段,要不要进模板? · 2026-09-30T03:29Z
domain:specseat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1),本卡认领者(认领5893328151)。草稿 PR #20683(head21aea1a29d)已按卡片做完,只剩下面两个问题。
⚠️ 这两个问题从 2026-09-29T18:20Z 起只在聊天里问过,没有落卡。这违反了「卡先于弹窗」,本评论补上;本卡同时转入needs-user-decision。一句话问题
- Q1: 模板应只列「这个人导入时真能写进去的列」,但平台今天只能回答「这个人能读哪些列」,回答不了「能写哪些列」。
- Q2: 卡片规定模板排除所有「隐藏」列,但实测:作者标为隐藏、又没设只读的字段,导入时会正常写入。
Governing text
- 本卡正文的判据:「这列我填了,导入后真的会落库吗?答"会"才进模板。」列规则表的 FLS 一行写「∩ 可写」;验收 1 点名排除
hidden。 packages/spec/src/contracts/security-service.ts:10-15:这个契约面的目的就是「不漂移」。调用方自己推导权限,会在执行路径一变时漂移,所以应该问这个服务。- 同一文件
:298只声明了getReadableFields,没有写侧的字段投影。 packages/plugins/plugin-security/src/security-plugin.ts:5684-5687:写入时,有对象的增删改权限、但对某字段没有editable的调用方,只要请求里写了那个字段,就被拒为PERMISSION_DENIED。- 协议声明:Q1 的 A 给安全契约加一个方法,扩大公开面(
Clause-②: yes)。B、C 不改契约。Q2 只改本卡的列规则,不改协议。
前提(每条带复查方法)
- 契约里没有写侧字段投影。复查:
git show origin/main:packages/spec/src/contracts/security-service.ts | grep -c getWritableFields应为0(本席 2026-09-30T03:29Z 读数为 0)。 - 草稿按「可读」收窄。复查:分支
claude/issue-18386-export-import-template的packages/rest/src/rest-server.ts:10103调的是getReadableFields。- 后果(dev 实测,报告
5894515898):能读但不能改的字段会出现在模板里,填了它的行导入时被拒 403。
- 后果(dev 实测,报告
- 作者声明
hidden: true而非只读的字段,导入会正常写入(dev 实测,同一报告)。- 平台注入的系统列里,隐藏的都是只读。
- 唯一非只读的系统列是
owner_id:导入会写入,但写的不是本人、又没有转移授权时,被 plugin-security 拒 403。
Q1 选项与代价
选项 做什么 客户看到的后果 A 契约加 getWritableFields,plugin-security 用读侧同一套求值器实现,模板用它收窄模板只列真能写的列。本 PR 多一轮:契约、插件、测试和两个 minor changeset B 先按「可读」落地模板,写的一半另开卡 马上能用。但「能看不能改」的字段会出现在模板里,填了就整行被拒 403:响亮,不静默 C 在 REST 里自己算写权限 马上完整。但它复制了一份权限规则,缺委托人交集等步骤,日后会和真实写闸不一致 业务直译:A = 下载的模板永远和系统的真实写权限一致;B = 先给一个能用的模板,配了字段级「只读权限」的组织,导入会在那几列上报错;C = 模板自己猜权限。
Q2 选项与代价
选项 做什么 客户看到的后果 A 保留卡片原规则,排除所有 hidden和system列(草稿现状)作者隐藏、但要导入的字段(如对接外部系统的编号)不在模板里,用户得自己加列 B 两条都去掉,只点名排除 owner_id每条排除都对应一个实测的丢弃或拒绝,规则变成点名 C 保留 system,去掉hidden符合卡片自己的判据:隐藏但可写的字段进模板 业务直译:A = 模板更短,少数隐藏字段要手加;C = 模板里有什么就能导什么,按卡片判据一条不多一条不少。
四轴(业务立场)
- 长远合理性: Q1 选 A,模板与写闸同源,这正是安全契约「不漂移」的设计。Q2 选 C,每条排除规则都对应一个真实行为。两年后的样子:模板由平台权限服务决定,不由导出路由自己判断。主流参照(本席未实测):Salesforce 的 Data Import Wizard 按字段级权限只允许导入当前用户可编辑的字段。
- 实际业务拉动: 模板的用户是做批量导入的业务管理员。卡片实测的问题(系统列、只读列被放进模板)今天就在发生。配了「可读不可写」字段级权限的组织有多少,本席没测。
- 防 AI 犯错: Q1 的 B 出错时整行 403,响亮。C 出错时模板与真实权限不一致,没人会发现,是静默的,不荐。
- 创业阶段不扩散: Q1 的 A 加一个契约方法,是永久义务;但它是读侧已有方法的对称补全,不是新概念。
推荐
- Q1:A。 只看①选 A;②③④ 是否翻转:否。回退:B(如果要模板先上线)。置信缺口:有多少组织配了「可读不可写」没测;插件实现量只按 dev 的估计。
- Q2:C。 只看①选 C;②③④ 是否翻转:否。回退:A(卡片原文)。置信缺口:真实部署里作者隐藏的字段有多少是导入目标,没测。
裁后执行(维护者只裁方向)
- Q1=A: 本席把认领的文件面扩到
security-service.ts、security-plugin.ts、测试、api-surface 重生成和两个 minor changeset。同一个 dev 在 PR feat(rest): GET /data/:object/export?template=true answers an xlsx import template (#18386) #20683 上做第二轮,然后达档复核、落地。PR 首行改为Closes #18386。 - Q1=B: 本 PR 按读投影落地,保留
Part of #18386;写的一半本席另开卡。 - Q2: 随同一轮改列规则;卡片验收 1 的写法由本席按裁定改正文。
四棱
① 项目长远合理性:Q1 的 A 缩小特例(权限只有一个来源);Q2 的 C 去掉一条没有行为支撑的排除。
② 实际业务拉动:做批量导入的业务管理员今天就会撞上;字段级「可读不可写」的组织数未测。
③ 防 AI 犯错:Q1 的 A 与 B 出错都是响亮拒绝;C 是静默漂移,不荐。
④ 创业阶段不扩散:A 加一个契约方法(读侧的对称补全);B、C 不加。
Prior rulings read: getWritableFields, writable fields, write-side field projection → 0 hits indocs/adr; ADR: none found (git grep -n -i -E "writable ?fields|getWritableFields" origin/main -- docs/adr); thread: 本卡 6 条评论,其中本席裁定5896083518把两题上交维护者。
推荐:Q1 A,Q2 C。只看①选 A / C;②③④ 是否翻转:否。置信缺口:见上。
Generated by Claude Code
objectstack-fleet commented
on Sep 30, 2026 ContributorMore actionsRuling: batch #251 item 2 · letter Q1 A · Q2 C · maintainer 「同意」 2026-09-30T05:38Z
Director seat (objectstack#12708, summon #30 续 2,
session_01AsCNgFBs8HCjwhyHQsFbx3). Provenance: maintainer, live PM chat with the director seat, 2026-09-30, replying 「同意」 to batch #251 as presented (this card as item 2, with #20281 and #18164); 「同意」 is to the director's recommendation in chat, which adopted the seat's decision request5903489348(Q1 A, Q2 C) after this seat's own reading:packages/spec/src/contracts/security-service.ts:298declaresgetReadableFieldsand no write-side projection, and the contract's own preamble says its purpose is that callers do not derive permissions and drift;plugin-security's write gate (step 2.5) refuses a payload naming a field the caller cannot edit; the dev's measurements in report5894515898(an author-declaredhidden: truewritable field IS stored by the import; every platform-injected hidden column is readonly;owner_idissystem).Ruled: Q1 A.
ISecurityServicegainsgetWritableFields(object, context)— the write-side twin ofgetReadableFields, with the same advisory, fail-soft semantics the contract already states for projections — plugin-security implements it with the read-side evaluator, and the template narrows its columns by it: the template lists exactly the columns the caller can write. ⛔ Not C (a second copy of the permission rule in REST drifts silently). B (ship on the read projection, write half on a new card) is the recorded fallback, not ruled.Ruled: Q2 C. The column rule keeps the
systemexclusion and drops thehiddenexclusion: an author-declaredhidden: truefield that is writable enters the template, because the card's own criterion is "will the value land?" and the import stores it.owner_idstays out throughsystem. Every remaining exclusion —system,readonly,formula/summary,autonumber, FLS ∩ writable — corresponds to a measured drop or refusal.Readings recorded with the ruling:
- Long-run: one source of permission truth; the template is decided by the platform's permission service, never by the export route.
- Pull: the administrators who bulk-import hit the injected-column defect today.
- AI-safety: A refuses loudly (403 names the field); C would drift silently.
- Startup scope: one permanent contract method, the symmetric completion of the read side; no new concept.
Execution parameters (ruled in the same stroke):
- The same dev runs round 2 on PR feat(rest): GET /data/:object/export?template=true answers an xlsx import template (#18386) #20683. The claim's file surface widens to
packages/spec/src/contracts/security-service.ts,packages/plugins/plugin-security/src/security-plugin.ts, their tests, the api-surface regeneration and twominorchangesets (@objectstack/spec—Clause-②: yes, the contract widens — and@objectstack/plugin-security). The PR's first line becomesCloses #18386. - A security service that does not implement the new method is detected the way the contract already prescribes for advisory projections (
typeof svc.getWritableFields === 'function'), and the template then narrows by the read projection, stated in the response as the contract's soft-fail rule requires — never a silent widening. - Q2: the column rule drops
hidden; the seat corrects the card's acceptance item 1 on the body under the Ruled line; the pins move with it. - Acceptance 6 (Excel / WPS / Numbers / Google Sheets dropdowns) stays a hand check before the card closes.
- Confidence gaps recorded: how many organizations configure read-not-edit FLS, unmeasured; the plugin implementation size is the dev's estimate; how many author-hidden fields are import targets, unmeasured.
State:
needs-user-decision→pm:dispatched(the claim5893328151bydomain:specseat 5 stands; assigneeos-justin); the Ruled line goes on the body in the same stroke. objectui#9600 (the console's download-template button) stays blocked on this card's landing and release.22 remaining items
objectstack-fleet commented
on Sep 30, 2026 ContributorMore actionsClaim: PM loop round 26 (the fix for the non-conformity the acceptance-6 machine verification
5914688255found) · 2026-09-30T15:46Z
Session:session_01Sfe5YjBLwB9J3y8fvm2xq1
Account:os-justin(the seat's linked user asGET /useranswers it; the card's assignee from this act)
Branch:claude/issue-18386-template-option-default
Worktree:objectstack-issue-18386-fix
Domain:domain:spec
Seat:domain:spec#5(seat post #19357)
Clause-②: no (the template's required mark and one xlsx attribute; no accept set moves)
Scope:- The required mark.
packages/rest/src/import-template.tsisTemplateRequiredmust mirror the engine's own insert-time default (packages/objectql/src/engine.tsresolveOptionDefault). A required field whoseoptionscarry adefault: truegets no*, and its instructions row does not say it is required. Everything the instructions sheet says about starred columns ("a blank row is refused") must hold for every starred column. - The
errorTitleon each list validation is truncated to Excel's 32-character limit. Theerrortext is already capped at 255. - Pins: a fixture with a required select whose option carries
default: true. It is unstarred, and a blank cell imports to the default through the real import door. Another fixture has a label longer than 32 characters; itserrorTitleis 32 characters or fewer.
File surface (stop on breach and explain it in the report):packages/rest/src/import-template.ts, its unit and route tests, and one.changeset/18386-*.md(@objectstack/restpatch).
Container & model:S,mode:subagent,model: opus.
Review: an at-tier contract review is owed before enqueue.
Thread-read: 5914688255
Serial constraints cleared: read at this stamp, no open PR touchespackages/rest/src/import-template.ts.
Then: the machine verification re-runs on the landed fix. Per the maintainer's A, the card closes once that passes.
Generated by Claude Code
- The required mark.
objectstack-fleet commented
on Sep 30, 2026 ContributorMore actionsos-dev-report
{
"issue": 18386,
"status": "done",
"branch": "claude/issue-18386-template-option-default",
"pr": "#20904",
"session": "session_01Sfe5YjBLwB9J3y8fvm2xq1 (mode:subagent, so this is the dispatching seat's id)",
"premise_still_valid": true,
"summary": "Engine semantics (packages/objectql/src/engine.ts, read at base 4edb614). applyFieldDefaults runs on INSERT only. It fills a field that is omitted or null:defaultValuewins wheneverdefaultValue != null, so''is a real default andnullis none. Otherwise it falls back to resolveOptionDefault, which reads only the canonicaldefault === true, applies to any field type that has anoptionsarray, skips a marked option whosevalueis null or missing, and skips string options. A multi-valued field (declaredMultiValued: isMultiValueField over type plusmultiple === true) gets every marked value as an array; a single-valued field gets the first marked value. validateRecord runsrequiredafter defaults. It never runs it on system or readonly fields or on autonumber, and it refuses null, a blank string, and[]on a multi-valued field. Before:isTemplateRequired = def.required === true && def.defaultValue === undefined. After: not required unlessrequired === true. False for system, readonly and autonumber. Otherwise true exactly whentemplateInsertDefault(def)(defaultValue when != null, else the option default as above) is empty for required. Why a copy and not a reuse: resolveOptionDefault and applyFieldDefaults are private members of the ObjectQL class, and @objectstack/objectql is only a devDependency of @objectstack/rest. Moving the rule into spec would go outside the file surface. The copy is held to the engine by a parity test through the real import door. The system, readonly and autonumber exemption goes one step past the dispatch's wording. It only matters for columns named in an explicit ?fields= list, and it is needed for property 2: under the old predicate, such a required column was starred although the import accepts a blank in it, and the parity test measures exactly that. The instructions sheet's Required column follows the same predicate (No / 否 for a column filled by a default). The notes sentence 'a row that leaves one of them blank is refused' now holds for every starred column in the 19-shape test. errorTitle:c.header.slice(0, TEMPLATE_VALIDATION_ERROR_TITLE_MAX)(32), written like the existingerrorcut, which now reads TEMPLATE_VALIDATION_ERROR_MAX (255). Changeset: .changeset/18386-template-required-option-default.md, @objectstack/rest patch, Clause-②: no. Files touched: exactly the 4 on the claim's surface. PR assignee os-justin, set and read back.",
"tests": "All runs went through os-verify-lock unless noted, and verdicts are read from its VERDICT line. (1) Build:pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' buildgave exit 0. After merging origin/main into the branch (merge daf0aa8; spec moved, no conflicts, no lockfile change, no regen pending):pnpm install --frozen-lockfileexit 0, then--filter '@objectstack/rest...' buildexit 0 (held 4m28s). (2) Pins, verbose run at daf0aa8:pnpm exec vitest run --project local --maxWorkers=2 --reporter=verbose src/import-template.test.ts src/import-template-route.test.tsgave 'Test Files 2 passed (2) / Tests 88 passed (88)'. These include: the route test 'is not starred, its instructions row says not required, and a blank cell imports to the default' (template header ['Title *','Status'], instructions row D6 'No', the filled xlsx sent through POST /data/ticket/import with the Status cell blank reports total 1 ok 1 errors 0 created 1, stored status 'backlog'); the route test 'for every shape of default the engine reads' (19 shapes; for each, the header from GET export?template=true&fields=f is starred exactly when a JSON row with f '' through the real import door is refused with code 'required' on field f, and each filled blank stores what templateInsertDefault predicts, e.g. multiselect ['a','c'], select multiple ['b'], boolean false; measured: '' default and multiselect [] default are refused, readonly, system and autonumber are accepted); the unit test 'a dropdown's error title is cut to Excel's 32 characters, and its message to 255' (a 46-character label gives errorTitle '(label *)'.slice(0,32), length 32; a short title is written whole); and the unit tests for isTemplateRequired and templateInsertDefault. (3) Ablation, on committed HEAD cc26158:node scripts/ablation-replace.mjsput backreturn def?.required === true && def.defaultValue === undefined;(anchor x1 to x0, replacement x0 to x1, blob c5f1888b2aee to 24d5a851b719; on-disk grep during the run: new-line count 0, old-line count 1). Result: 'Tests 7 failed | 81 passed (88)'. Red: 4 isTemplateRequired unit tests, the describeTemplateColumns option-default test, the route option-default test (got ['Title *','Status *']), and the parity test, which listed 11 disagreements (option_default, option_default_first_wins, option_default_multiselect, option_default_select_multiple, option_default_any_type: starred but accepted; default_value_null, default_value_empty_string, default_value_empty_array: unstarred but refused; readonly, system, autonumber: starred but accepted). The observed direction is the ordinary one: green turned red. Restore: the tool restored the file and printed 'blob == HEAD (c5f1888b2aee) and git diff HEAD is empty'. My own trap then checked out HEAD again and printed 'RESTORE PROVEN'. The subject resolves through relative source imports (./import-template.js, ./rest-server), not through a package export, so dist and ablation-dist-preflight do not apply. (4) Package at daf0aa8:vitest run --project local --maxWorkers=2(the package'spnpm test) gave 'Test Files 238 passed (238) / Tests 4664 passed | 106 skipped (4770)'.pnpm --filter @objectstack/rest typecheckexit 0: 'check:test-typecheck: OK, 0 file(s) / 0 error(s)'. (5) Gates:node scripts/pm/dispatch-gates.mjs --commandsderived 60 commands, the same list before and after the merge (4 changed paths, +313/-7). All 60 ran at daf0aa8, and--ranreconciled them: '60 derived, 58 run, 2 NOT-MEASURED, 0 UNRUN'. Among the 58 green: check:nul-bytes 'OK (scanned 9527 text file(s) ... no raw ASCII control bytes)', check:cross-package-test-inputs, check:test-source-alias, check:doc-authoring, check:engine-double-contract, check:published-files, check:type-check-coverage, and the changeset gates (adr-0087-registration, changeset-no-major, empty-changeset). NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt; reason: exit 3 PREREQUISITE NOT MET, both need a whole-repo build (42 packages have no dist); declared to CI. (6) Lint, narrowed and proven: eslint --no-inline-config --format json on the 3 changed .ts files at daf0aa8 gave 3 files, 0 errors, 0 warnings. Which files eslint checks comes from eslint.config.mjs's ownfilesglobs: the .ts files match, and the changeset .md is outside them (eslint answered 'File ignored because no matching configuration was supplied'). The count comes from the JSON output. The config has no parserOptions.project or projectService, so no rule reads types across files, and this diff cannot move the lint result of any untouched file. The fullpnpm lintover the repo is left to CI. (7) CI after push, one read of check-runs on head daf0aa8, deduplicated by name: 17 names. 6 success (Auto Label, Check Documentation Links, Check PR Size, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card), 1 skipped (Packed-tarball smoke (opt-in)), 10 in_progress (Check Changeset, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, The card this PR closes must claim this branch, Type Check · consumer gates / debt ledger / source gates / workspace, filter). Of the seven required contexts, Lint & Repo Gates and Governed Surface Queue Guard were in_progress, and TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core and Temporal Conformance had not been created yet. CI status: in_progress; not polled.",
"mcp_calls": "0",
"api_writes": "3 REST writes, each sent as one POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204) through scripts/pm/fleet-write and executed by the relay as objectstack-fleet[bot]: (1) pr_create, request fw-20260930T170235Z-ca1cce, run 36748579186, success: POST /repos/objectstack-ai/objectstack/pulls (draft forced) opened #20904; body read back, 3371 bytes sent and 3371 stored, identical. (2) label-write --assign os-justin, request fw-20260930T170315Z-e8ec1a, run 36748658451, success: POST /repos//issues/20904/assignees; read back 'MATCHES the target'. No labels written: the dispatch named none, and the diff publishes a changeset, so skip-changeset does not apply. The labels documentation, size/m, tests and tooling were already on the PR from the platform's labelers. (3) This os-dev-report comment, via scripts/pm/post-stamped.mjs: POST /repos//issues/18386/comments. Not REST: 4 git pushes (empty-branch probe, fix, changeset, merge of origin/main).",
"open_questions": [
{
"question": "The unreleased changeset .changeset/18386-export-import-template.md (the template's minor) says the header carries ' ' 'after each field that is required and has no default value'. This round adds the option default and the system/readonly/autonumber exemption to what 'default value' means. Does that sentence need a change before release? (Editing it would be outside this round's file surface, so I left it.)",
"options": [
"A: leave it. Read loosely it is still true, and the new patch changeset, which ships in the same release, states the exact rule.",
"B: the seat changes it to 'required and filled by no default (defaultValue or an option marked default: true)' in its own small edit."
],
"recommendation": "A, because both changesets compile into the same release notes, the patch one names every case, and a second edit to a card-owned changeset costs another write and review with no change for the reader."
}
],
"out_of_scope_findings": [
"carrier: the change that lands server-side resolution ofpicklistreferences (the picklist kind's follow-up; spec kind added in commit addbbf0) · noted, not filed. On main nothing at runtime resolves a picklist reference intooptions, so the object read the template uses and the engine's registry schema both see no options and agree today. When resolution lands it must reach both, or a picklist option marked default: true would unstar a column the engine still refuses; the parity test in import-template-route.test.ts is the place to add a picklist shape. Dedupe words: picklist resolution, option default, import template required, resolveOptionDefault.",
"carrier: none · noted in the PR's Acceptance notes only. A before hook that fills a field, and an expression default that fails to evaluate at insert (the engine leaves the field unset and logs a warning), cannot be seen from metadata. The template counts every default an author declared, if it is not empty, as filling the field.",
"carrier: none · noted in the PR's Acceptance notes only. The 32 and 255 cuts count UTF-16 code units, so a label whose 32nd unit falls inside a surrogate pair would keep half of the pair. No shipped label reaches that."
]
}objectstack-fleet commented
on Sep 30, 2026 ContributorMore actionsRuling on the fix report
5915980985· 2026-09-30T17:07Zdomain:specseat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim5914714137. Draft PR #20904 atdaf0aa882a.Accepted as delivered:
- The required mark now follows the engine's insert-time defaults. A value comes from
defaultValuewhen it is not null, and otherwise from the options markeddefault: true, the first one for a single-valued field and all of them for a multi-valued field.system,readonlyandautonumbercolumns are never starred, becausevalidateRecordnever refuses a blank in them. - The copy is pinned to the engine through the real import door. The parity test covers 19 default shapes, and an ablation turns 11 of them red.
errorTitleis cut to 32 characters. The instructions sheet's Required column follows the same predicate.- The exemption for
system/readonly/autonumbergoes one step past the claim's wording. It holds because it is what makes "a blank starred cell is refused" true for every starred column.
Open question (the unreleased template changeset's wording): A. It stays as it is, because the patch changeset in the same release states the exact rule.
Out of scope: when picklist references resolve at runtime, the template's object read and the engine's schema must both see the resolved
options. The parity test is where a picklist shape is added. That belongs to #19519, and a pointer goes there.Next:
needs:contract-reviewgoes on the PR and this card. The at-tier review runs once CI is green. After the fix lands, the machine verification runs again. It passes if the*onshowcase_task.statusis gone, and on a pass the card closes per the maintainer's 「A,机器验证通过就直接关卡」.
Generated by Claude Code
- The required mark now follows the engine's insert-time defaults. A value comes from
objectstack-fleet commented
on Sep 30, 2026 ContributorMore actionsLanded: PR #20904 →
6f1f1c1035(the fix for the non-conformity); the machine verification re-runs now · 2026-09-30T17:55Zdomain:specseat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim5914714137.- Landed: PR fix(rest): the import template stars a column only when the import refuses a blank in it #20904 merged through the merge queue at 2026-09-30T17:53Z as
6f1f1c1035, with one parent,165c1d49e3. - Content check: each of the 4 files has the same
git patch-id --stableas the reviewed headdaf0aa882aagainst its merge base33b6e8bece. - Review: the at-tier PASS
5916304647was taken atdaf0aa882a, which is the head that landed. - Delivered:
- The required mark follows the engine's insert-time defaults,
defaultValueor an option markeddefault: true. system,readonlyandautonumbercolumns are unstarred.errorTitleis cut to 32 characters.- A parity test over 19 shapes runs through the real import door.
- The required mark follows the engine's insert-time defaults,
- Next: the verification agent re-runs steps 1, 2 and 4 of
5914688255onmain, including6f1f1c1035. The pass condition:showcase_task.statusis unstarred, a blank status imports asbacklog, every other reading holds, and everyerrorTitleis 32 characters or fewer. On a pass, the card closes per the maintainer's 「A,机器验证通过就直接关卡」. Step 3 (LibreOffice) waits for the maintainer's A/B on installing Calc.
Generated by Claude Code
- Landed: PR fix(rest): the import template stars a column only when the import refuses a blank in it #20904 merged through the merge queue at 2026-09-30T17:53Z as
objectstack-fleet commented
on Sep 30, 2026 ContributorMore actions验收 6 机器验证(修复后复跑):第 1、2、4 步全部通过 · 2026-09-30T18:21Z
domain:specseat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1)。本轮复跑的被测树是main6f1f1c1035,即修复提交 PR #20904 本身,也包含e5c7d07ed4。测法与5914688255相同:用最小服务挂真实的RestServer路由,加载 showcase 的对象和翻译包。复现脚本在本席 scratchpadverify-18386/repro.sh,读数在out/。- 状态列不再带
*。- zh-CN 表头为
状态,「必填」一栏为「否」;en 表头为Status,对应一栏为No。 - 仍带
*的只剩标题 *和项目 *。 - 检查脚本的判据已改为与引擎一致;拿新判据复查上一轮
2d5fe76f43的模板,只在状态列报错,说明它确实能抓到旧缺陷。
- zh-CN 表头为
- 状态留空的行被接受。 zh-CN 与 en 各导入一行,都经真实导入接口,结果 ok 1,落库值为
status=backlog。 errorTitle不超过 32 字符。 showcase 里最长的是 11 字符。另用一个临时夹具验证截断:34 字的中文标签和 57 字的英文标签,都被截到 32 字符,表头仍保留完整标签。- 其余读数不变。 DV 结构、
sqref、formula1、区域值和 error 文本逐项相同,唯一的差别是状态列的表头与errorTitle。4 份模板加 6 份改后文件都通过 XSD 校验;往返导入全部 ok,没有invalid_option/invalid_boolean。 - 每个带
*的列,留空都会被拒绝。 每次只清空一个带星的格子,再经真实导入接口导入:title、project、name、f_master_detail都以required被拒;不清空的对照行都是 ok。
结论:第 1、2、4 步通过。 第 3 步(用 LibreOffice 打开)还等维护者决定是否安装 Calc。若不安装,本卡按维护者「A,机器验证通过就直接关卡」直接关闭。
语言绑定(zh-CN 模板必须用 zh-CN 请求导回)的读数与上一轮相同,已转告 objectui#9600(
5915156263)。模板挂在哪道权限闸后面,另见决策卡 #20896。
Generated by Claude Code
- 状态列不再带
objectstack-fleet commented
on Sep 30, 2026 ContributorMore actions关卡:维护者选 B,不补测 LibreOffice,按「A,机器验证通过就直接关卡」关闭 · 2026-09-30T22:42Z
domain:specseat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1),认领5914714137的持有者。- 维护者答复: 2026-09-30 在本席位会话中答「18386 B」。即第 3 步(用 LibreOffice 打开模板)不安装 Calc 补测,直接关卡。
- 已落地:
- PR feat(rest): GET /data/:object/export?template=true answers an xlsx import template (#18386) #20683 →
e5c7d07ed4; - 验收 6 首轮机器验证
5914688255查出「必填*」与引擎不一致,由 PR fix(rest): the import template stars a column only when the import refuses a blank in it #20904 →6f1f1c1035修复。
- PR feat(rest): GET /data/:object/export?template=true answers an xlsx import template (#18386) #20683 →
- 机器验证: 复跑
5917156039,第 1、2、4 步和 5 项确认全部通过:status列不再带*;- 状态留空的行导入为
backlog; errorTitle不超过 32 字符;- 其余读数不变;
- 每个带
*的列留空都会被拒绝。
- 未测(维护者选择不补): 第 3 步,即 LibreOffice 实际打开模板时的表现。
- 不随本卡关闭的事项:
- 语言绑定(zh-CN 模板须用 zh-CN 请求导回)已转 objectui#9600;
- 模板挂在哪道权限闸后面,另见决策卡 The import template (
GET /data/:object/export?template=true) sits behind the EXPORT gate (allowExport): a caller who may import but not export cannot download it — gate it by the import door instead? (from #18386 acceptance-6 verification) #20896,仍等维护者选择。
- 关卡动作: 去掉
pm:dispatched和指派,状态改为 closed(completed)。
Generated by Claude Code
- added 3 commits that reference this issue
on Oct 7, 2026
Ruled: 5904855243 · letter Q1 A · Q2 C —
ISecurityService.getWritableFieldsis added and the template narrows by it (round 2 on PR #20683,Part of #18386: acceptance 6 is a hand check in four spreadsheet apps, so the card stays open for it after the PR lands; ruling5906743185); the column rule keepssystemand dropshidden; acceptance 1 is corrected accordingly · 2026-09-30T05:41Z问题
导入模板目前没有统一实现,两个仓库各做了一套,且都把用户填了也写不进去的列放进了模板。
objectui 的「下载模板」按钮前端自行拼 CSV,列取自
importTargetFields的全量结果;framework 这边GET /data/:object/export的默认列是buildFieldMetaMap(schema)的全部 key,只按 FLS 收窄,不看system/hidden/readonly。注释里rest-server.ts明确写了「an empty export doubles as an import template」——但空导出只保证「没有数据行」,没回答「表头该有哪些列」。实测一个业务对象(项目),模板前 7 列是 registry 注入的系统字段:
organization_idcreated_atcreated_byupdated_atupdated_byowner_idowning_business_unit_id因为是 registry 注入,每个对象都这样,不是个别对象配错。
比「列太多」更严重的是:这些
readonly列会被stripReadonlyFields在写入时静默剥掉(engine.ts)。用户照模板填了「创建人」,导入成功、无报错、数据没进去。模板在教用户填注定被丢弃的列。为什么不能直接改导出的默认列
导出和模板的诉求相反:
同一套列规则无法同时满足。所以导出的列规则保持不变,模板作为新模式引入 —— 这样对现网调用方零影响。
方案:一个接口,两个模式
目标列规则
systemhidden5904855243Q2 C)readonlyformula/summaryautonumber?fields=显式指定*标记)判据一句话:「这列我填了,导入后真的会落库吗?」答"会"才进模板。 每条排除都对应引擎里一个真实的丢弃或拒绝行为,不是审美判断。
输出格式:xlsx(不是 CSV)
模板必须表达值域,CSV 做不到:
00123)123具体结构:
*)+ 一行示例值;select/radio/boolean列挂数据验证下拉lookup写「填 <目标对象> 的名称」。同时兼作下拉数据源(Excel 内联下拉有 255 字符上限,选项多时必须引用区域)值域怎么写
导入端(
import-coerce.ts)实际接受的范围比模板告诉用户的宽得多,模板应如实传达:value精确 ②label大小写不敏感 ③ 翻译后 label,;、换行 拆分、连两个真实选项;说明列出四种分隔符true/t/yes/y/1/on/是/对/✓/√+ 反面trueresolveRef解析reference_ambiguous1,234/$¥€£¥/25%/(100)实现要点
createXlsxStream。0 行数据不需要流式,而 streaming writer 对dataValidation的支持未经验证(exceljs 类型里WorksheetModel.dataValidations是注释掉的)。用普通new ExcelJS.Workbook()+workbook.xlsx.write(res)。exceljs@^4.4.0已是packages/rest的既有依赖,loadExcelJs()懒加载封装已存在。?template=true需要进DATA_EXPORT_PARAMS允许集,否则被refuseUnknownQueryParams挡掉。权限沿用现有两道闸(已被 The import template (enforceApiAccess('export')+enforceExportPermission)。GET /data/:object/export?template=true) sits behind the EXPORT gate (allowExport): a caller who may import but not export cannot download it — gate it by the import door instead? (from #18386 acceptance-6 verification) #20896 的裁定 A(5921162178)取代:模板分支template=true改由导入闸判定(enforceApiAccess(..., 'import')加该对象的新建权限),导出的两道闸不再挡在它前面;非模板导出不变。已定的边界
autonumber。要 upsert 的人自己加列,映射步骤里照样选得到 —— 功能不丢。理由:为少数场景在模板里塞一列「填了新建时无效」的东西,又回到"教人填没用的列"的老问题。验收
?template=true,下载的 xlsx 中不含任何system/readonly/formula/summary/autonumber列;作者声明hidden: true且可写入的字段保留(裁定5904855243Q2 C)fields声明顺序一致;必填且没有默认值的列,表头带*(裁定5896083518Q3 A)select/boolean列在 Excel 中可下拉选择?template=true的导出,列与本 issue 之前逐字节一致(现网零行为变更)invalid_option/invalid_boolean关联
objectui 侧改动(「下载模板」改调本接口、删除前端 CSV 生成)见 objectstack-ai/objectui 的对应 issue。本 issue 落地后 objectui 那侧才能动。