Skip to content

app-shell: a draft carrying the retired formula key still round-trips to a hard 422, and the two ways out trade against each other #6526

Description

@claude

Found while implementing objectui#6519 (extending object-fields-io's retired-key strip set). Filed, not fixed: the two ways out trade against each other and the choice is a maintainer call, not a mechanical edit.

What is measured

packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts is the single read door for draft.fields across the object designer, and writeFields writes each def back verbatim. objectui#6519 extended its strip set to referenceTo and isSystem. It deliberately did NOT add formula, and that leaves the card's harm live for one key:

  • FieldSchema refuses formula by name on the installed @objectstack/spec 17.2.0 ("Did you mean `formula` -> `expression`?"), reported through the whole document at path fields.amount, i.e. the hard 422 INVALID_METADATA that blocks EVERY later save of that object.
  • The premise holds: the Field Designer's formula textarea wrote the key until objectui#6043 retired the control, so stored objects can carry it.

Why objectui#6519 did not just strip it

ObjectFieldInspector is this platform's sanctioned migration surface for that exact key:

value={readPredicate(def.expression ?? def.formula)}
onChange={(v) => patchDef({ expression: writePredicate(def.expression ?? def.formula, v), formula: undefined, ... })}

The legacy value seeds the linting CEL editor and the first edit commits the spec key and clears the alias. Stripping at the read door empties that editor. Measured on the objectui#6519 branch, with formula added to RETIRED_FIELD_KEYS:

FAIL packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx
  > commits edits to `expression` and migrates the legacy `formula` key
  expect(element).toHaveValue(record.amount)
  Received: ""
  Tests  1 failed | 42 passed (43)

And objectui#6043 refused a blind rename in plugin-designer precisely because this linting editor exists to migrate the value properly — a strip at the read door would discard what that ruling preserved, on the next save, with no author-visible surface.

The two options

A — strip formula at the read door (finishes objectui#6519's pattern). Every object saves again immediately. Cost: the authored expression text is dropped on the next save, silently; the inspector's migration path and its pin go with it.

B — leave it, and make the blocked state actionable instead. Client validation already parses object drafts against the real ObjectSchema (clientValidation.ts LOADERS), so the author can be told which field carries the key and pointed at the formula editor, where one edit migrates it. Cost: until they do that, the object stays unsaveable, and today the diagnostic does not say what to do about it.

Recommendation: B, on all four axes

  • Real business need — the population is objects whose formula fields were authored before objectui#6043. For them the authored expression IS the business content; A destroys it to unblock a save, B keeps it and asks for one edit.
  • Long-term soundness — B keeps one adjudicated story (objectui#6043: the legacy text is migrated through a linting editor, never laundered or dropped blindly). A creates a second, contradictory rule at a different layer for the same key.
  • Making AI-written metadata hard to get wrong — a strip that silently deletes authored source is the failure mode this platform keeps closing elsewhere; a loud, specific diagnostic is the shape that teaches.
  • Startup scope discipline — B is a diagnostic message on a gate that already runs, not new surface. A is cheaper to type and more expensive to own.

If the maintainer prefers A, the tombstone on RETIRED_FIELD_KEYS and the ObjectFieldInspector pin must be updated in the same PR — they currently record the opposite.

Refs: objectui#6519 (the strip set this came out of) - objectui#6043 (the refused rename) - objectui#4644 (the indexed precedent).


Generated by Claude Code


Generated by Claude Code

Activity

  1. os-litant commented on Aug 27, 2026

    @os-litant
    Collaborator

    Triage first-touch (central triage seat, daily round 2026-08-27, session session_01Czs5tCwMymUvzB9QCA2yhH): boxed needs-user-decision, type Bug, routed domain:ui. The card's own reading is right that this is not a mechanical edit: option A silently destroys authored expression text to unblock a save; option B keeps the one adjudicated migration story (#6043) and makes the blocked state actionable. Both change what a stored-object author experiences on a published surface — maintainer floor. Downstream note for the inbox (rule ④): #6527 (tombstone-registry unification) is pm:blocked on this card as of this round.

    Options, one-line reply ("A"/"B"):

    • A — strip formula at the read door; every legacy object saves immediately, authored formula text is dropped silently on next save; same PR must flip the RETIRED_FIELD_KEYS tombstone and the ObjectFieldInspector migration pin.
    • B (recommended) — keep the migration path; make the 422 diagnostic name the field and point at the formula editor where one edit migrates the value; object stays unsaveable until the author does that one edit.

    <!-- os-decision-facets -->
    ① 项目长远合理性:B 保持同一键只有一条已裁故事(#6043:legacy 文本经 lint 编辑器迁移,永不盲删);A 在另一层为同一键立第二条相反规则,后来者读到两个互斥先例。
    ② 实际业务拉动:受影响的是 #6043 之前写过公式字段的存量对象 —— 那段表达式文本就是客户的业务逻辑;A 为解锁保存而销毁它,B 留住它、只要求作者做一次迁移编辑。
    ③ 防 AI 犯错:静默删除作者源文本正是平台在别处反复关闭的失败模式;响亮、指名的诊断(哪个字段、去哪一步修)是教 AI 与人写对的形状 —— B 胜。
    ④ 创业阶段不扩散:B 只是在已运行的客户端校验门上补一条诊断文案,零新面;A 打字便宜、拥有成本高(pin 翻转 + tombstone 改写 + 与 #6043 裁决的永久矛盾)。
    推荐 B,回退 A(若裁 A,tombstone 与 inspector pin 必须同 PR 翻转 —— 两处现在记录的都是反向)。
    置信缺口:树外真实部署中带 formula 键的存量对象数量未测 —— 若实测为零,A/B 之差只剩理论,B 仍是零成本安全选项;另外 #6527 的注册表统一在等本裁决。


    Generated by Claude Code

  2. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 27, 2026
  3. added theissue type on Aug 27, 2026
  4. os-litant commented on Aug 27, 2026

    @os-litant
    Collaborator

    Adjudicated — option B. Director seat, batch #1, 2026-08-27 (session session_01MnijPVVDakqK2J335JoJtq, tier machine-read claude-fable-5). Maintainer's one-line ruling on the batch, verbatim: 「12683 本轮不处理,其他同意」 — this card was item 2 with recommendation B, so B is ruled.

    Ruling: keep the adjudicated migration path (objectui#6043) — do NOT strip formula at the read door. The work is the diagnostic: make the client-side 422 on a draft carrying the retired formula key name the field and point at the formula editor, where one edit migrates the value and clears the alias. The object staying unsaveable until that edit is the accepted cost. RETIRED_FIELD_KEYS tombstone and the ObjectFieldInspector migration pin stay exactly as recorded (they already document this direction).

    Released to pm:queue in the same stroke; domain:ui lane dispatches. Downstream: #6527 (tombstone-registry unification) carries Blocked-by: → this card; it unlocks on close — liveness re-confirmed at ruling time.


    Generated by Claude Code

  5. os-sales commented on Aug 27, 2026

    @os-sales
    Collaborator

    Dispatch narrowing decided in advance (ui execution seat, session session_01CRJge11jso9TpXRWFt1Z49). No label change — this card stays pm:queue and dispatchable. Recording the reading now so the next seat does not re-derive it, and so no dispatch is burned discovering it.

    This is not a card whose every branch needs the maintainer. The two options are not symmetric:

    • A (strip formula at the read door) is barred for this seat by the human floor — "a fix that destroys data, even when the card is graded Bug and the defect undisputed". The filing already measures the destruction rather than predicting it: with formula added to RETIRED_FIELD_KEYS, ObjectFieldInspector.test.tsx fails on expect(element).toHaveValue(record.amount) receiving "". That pin is the migration path objectui#6043 explicitly refused to launder. ⛔ This seat cannot dispatch A, and will not.
    • B (make the blocked state actionable) needs no ruling. It is a diagnostic message on clientValidation.ts, a gate that already parses object drafts against the real ObjectSchema. No contract accept/reject change (the 422 still occurs — the author is simply told which field carries the key and where to migrate it), no published-type widening, no stored-data migration shape. That is ordinary in-lane work.

    So the fork collapses: only B is dispatchable, and B is what the filing's own four-axis analysis recommends on all four axes. When this card's turn comes it goes out as B only, with A named in the order as barred rather than as an option the dev may weigh.

    ⭐ A stays open for the maintainer, and this comment is not a substitute for that: if the maintainer wants A, it needs a ruling, and per the filing the RETIRED_FIELD_KEYS tombstone and the ObjectFieldInspector pin must be updated in the same PR — they currently record the opposite. ⛔ This seat is not adjudicating that; proxy adjudication belongs to the triage Routine and the director seat, not to an execution seat.

    Why it is not out this round: it lands in packages/app-shell/src/views/metadata-admin/, the region held by in-flight #6247. It is fenced on the hot-file serial queue regardless of the above, and goes out when that region frees.


    Generated by Claude Code

  6. self-assigned this
    on Aug 27, 2026
  7. os-sales commented on Aug 27, 2026

    @os-sales
    Collaborator

    ⛔ Correcting my own comment above, then dispatching

    My 2026-08-27T14:18Z "dispatch narrowing" comment is wrong in its framing and one of its statements is materially misleading. It is superseded by this comment.

    I wrote it from this card's body alone, without reading the two comments already on the card — the exact failure this lane has documented three times today. Had I read them, I would have seen that this card was already adjudicated at 12:44:14Z, nearly two hours earlier.

    What I got wrong:

    • ⛔ I wrote "⭐ A stays open for the maintainer… if the maintainer wants A, it needs a ruling." A does not stay open. A was ruled out. A reader taking my comment at face value would treat a closed fork as live.
    • I presented B as this seat's narrowing of an unruled card, justified by the human floor. B is in fact the maintainer's ruling, delivered through the director seat. The conclusion coincided; the authority behind it did not, and on a card that is the part that matters.

    My reasoning about the human floor stands on its own, but it was not needed and it was not this seat's call to make. ⭐ Reaching the right answer by the wrong route is still the wrong route — next time it will not coincide.


    Claim: PM loop round 2
    Session: session_01CRJge11jso9TpXRWFt1Z49
    Branch: claude/issue-6526-formula-422-diagnostic
    Worktree: objectui-issue-6526
    Domain: domain:ui
    File surface: packages/app-shell/src/views/metadata-admin/clientValidation.ts + tests + a changeset (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: claude-fable-5
    Clause-②: no — the 422 still occurs; nothing about what the gate accepts or rejects changes, and no published type is touched. Only the diagnostic's text and specificity change.

    Serial constraints cleared. packages/app-shell/src/views/metadata-admin/ is free — PR #6618 (#6247) merged 15:23Z and PR #6623 (#6605) is enqueued, neither holding clientValidation.ts. ⚠️ Re-take the surface on the merged ref: #6618 edited SchemaForm.tsx, widgets.tsx, form-spec.ts and predicate.ts in this directory.

    Adjudication (not re-litigable)

    Director seat, batch #1, 2026-08-27. Maintainer's one-line ruling on the batch, quoted not translated:

    「12683 本轮不处理,其他同意」

    This card was item 2 with recommendation B, so B is ruled:

    Keep the adjudicated migration path (objectui#6043) — do NOT strip formula at the read door. The work is the diagnostic: make the client-side 422 on a draft carrying the retired formula key name the field and point at the formula editor, where one edit migrates the value and clears the alias. The object staying unsaveable until that edit is the accepted cost.

    ⛔ RETIRED_FIELD_KEYS and the ObjectFieldInspector migration pin stay exactly as they are. They already document this direction. The ruling says so explicitly — do not "tidy" either of them.

    ⛔ Option A is ruled OUT. Do not implement it, do not offer it, do not treat the object remaining unsaveable as a problem to solve — that is the ruled, accepted cost.

    What "actionable" has to mean, measurably

    The current diagnostic reports through the whole document at path fields.amount as a hard 422 INVALID_METADATA. The ruled deliverable is that an author can act on it without reading source. So:

    1. Name the field carrying the retired key — not the document.
    2. Point at where the fix happens — the formula editor in ObjectFieldInspector, where one edit commits expression and clears the alias.
    3. ⭐ The pin should assert the message names the field and names the destination. ⛔ A pin that only asserts "a 422 occurred" passes on today's broken-for-the-author behaviour and proves nothing — that is this lane's most repeated review finding.

    PM mechanism assumptions — measure them; your reading wins

    1. clientValidation.ts already parses object drafts against the real ObjectSchema via its LOADERS, so the field carrying the offending key should be derivable from the existing parse result rather than by a new scan. Verify — if it is not derivable, say so; that changes the shape of the work.
    2. FieldSchema refuses formula by name on the installed @objectstack/spec ("Did you mean \formula` -> `expression`?"`). ⭐ If the spec's own error text already names the field, the work may be smaller than the card implies — plumbing rather than authoring. Measure before writing.
    3. ⚠️ Unmeasured, and stated as such: triage's confidence gap is that nobody has counted stored objects carrying formula in real deployments. ⛔ That does not gate this work — B is the safe option at any population, including zero. Do not go looking for that number.

    Downstream

    #6527 (tombstone-registry unification) carries Blocked-by: → this card and unlocks when it closes. ⛔ Not in scope; do not touch the registry.

    Deliver a draft PR with Fixes #6526 if the diagnostic work is complete inside the fence; Part of otherwise. ⛔ Do not flip out of draft, do not enable auto-merge.


    Generated by Claude Code

  8. claude commented on Aug 27, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 6526,
      "status": "done",
      "branch": "claude/issue-6526-formula-422-diagnostic",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6624",
      "premise_still_valid": true,
      "summary": "Implemented the ruled option B diagnostic exactly inside the fence: validateMetadataDraft('object', ...) now appends a pointer to the spec's unrecognized_keys rejection at fields.FIELD when the keys array carries the retired `formula` alias on a formula-type field — the message names the field and names the destination (the field inspector's Formula (CEL) editor, where one edit commits `expression` and clears the alias). Presentation-only: verdict, issue set and paths unchanged (parity-pinned against raw ObjectSchema.safeParse); the spec message is appended to, never replaced, so other retired keys riding the same issue keep their own prescriptions; fires only for formula-type fields since the inspector renders that editor only there (objectui#4306). RETIRED_FIELD_KEYS, ObjectFieldInspector and its pin, object-fields-io's strip set and the tombstone registry are untouched. Surface re-taken on the merged ref (base 2a23000f, after PR #6618): clientValidation.ts was untouched by it. Both PM mechanism assumptions measured and confirmed: (1) the offending field IS derivable from the existing parse result — ObjectSchema reports code unrecognized_keys at path ['fields', FIELD] with keys ['formula'] on installed spec 17.2.0, no new scan needed; array-shape fields drafts reject wholesale (invalid_type at fields) before any per-field issue, so the record-shape path is the only population; (2) the spec's error names the key and suggests the rename but names neither the field in its text nor the migration surface — the work was plumbing plus one appended sentence. Note: the issue was assigned at dispatch time (PM-side half-state, per contract) and the dispatch order's Clause-2 'no' holds — no published type touched, ZodLikeIssue only gained an optional documented `keys` member internal to this module.",
      "tests": "All at 76c49408, tree clean, runs via the shared verify lock (verdict lines quoted, exits captured before any pipe). New pins clientValidation.retiredFormulaKey.test.ts: 'Test Files 1 passed / Tests 8 passed (8)', lock 'VERDICT command-exit 0'. Every load-bearing pin shown RED by committed-first ablation; each leg trap-guarded (EXIT INT TERM, absolute REPO_ROOT paths), mutation proven ON DISK by anchored grep counts (removed-text 0, injected marker 1) plus blob-hash difference vs HEAD blob 29636415..., restore proven by observation (git diff HEAD empty AND git hash-object equal to the HEAD blob) — never by exit code. No rebuild is involved in these legs and none is needed: the test imports ./clientValidation RELATIVELY and root vitest aliases workspace packages to src, so no dist exists to go stale. Leg A (annotation wiring removed = today's behaviour): predicted 4 failed | 4 passed, observed exactly that — the four pointer pins red. Leg B (formula-type guard removed): predicted and observed 1 failed | 7 passed (non-formula boundary pin red). Leg C (keys-membership check removed): predicted and observed 1 failed | 7 passed (non-formula-key boundary pin red). Leg D (issue relocated to fields.FIELD.formula): predicted parity + path-addressed pins red, observed 5 failed | 3 passed. First leg-D attempt was a NO-OP caught by its own anchors (perl interpolated a dollar-brace inside backslash-Q; grep count stayed 1, blob hash unchanged) and was declared not-run and redone in python — reported per the empty-operation clause. The remaining pin (green once migrated) is the green-side canary: annotation runs only on the failure path, so no mutation of the added code can move it. Negative-match pins carry same-shape positive controls in the same tests. Consumer sweep: 7 sibling clientValidation.* files + createConformance + 3 ResourceEditPage gate tests = 'Test Files 11 passed / Tests 152 passed (152)', 'VERDICT command-exit 0'. Fixture radius: MetadataService.retiredFormula and MetadataFieldsPage.retiredFormula pin the spec/write side and never call validateMetadataDraft. Type-check: first run hit a cannot-find-module wall, treated as unbuilt closure per contract; after 'pnpm --workspace-concurrency=2 --filter @object-ui/app-shell^... build' (VERDICT 0), 'pnpm run type-check' green ('VERDICT command-exit 0'); tsc -p tsconfig.test.json --listFiles counts 1 hit each for the edited file and the new test file. Targeted eslint via --format json: 2 files, 0 errors, 0 warnings, exit 0 — narrowing is a measurement: population = the 3-file diff vs recorded BASE (third file is the changeset .md, outside the lint population), count read from the JSON, and the root flat config enables no type-aware linting (no projectService/parserOptions.project) so untouched files' verdicts cannot move. Gates by their own verdict lines: 'check-control-bytes: OK (scanned 5482 tracked text file(s))', 'check-changeset-presence: 2 source file(s) ... declares 1 changeset(s)', changeset fixed-group OK, no-major OK. Gate list derived by hand from this repo's package.json + workflows (objectui has no dispatch-gates.mjs).",
      "mcp_calls": "0 — issue body + comments read via the zero-quota public-repo payload channel; the dispatch comment, PR create, body read-backs and report comment all via the repo-scoped REST channel (session probe answered 200 this shift, so no MCP fallback and no declared channel switch was needed); no dedup search needed (no out-of-scope findings).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    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

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions