Skip to content

ActionSchema.onSuccess and a handler-returned redirectUrl are both reachable on one type: 'script' action, and the spec rules neither — renderer-side precedence is deciding it today #11519

Description

@os-zhuang

Filed from the objectui domain:ui execution seat, as the ruled durable half of a question returned by objectui#5221 (PR objectstack-ai/objectui#5933). Filed unassigned.

The gap

Two independent channels can name a post-success destination for the same action:

  1. Declared — ActionSchema.onSuccess ({ navigate, openIn }, openIn defaulting to 'self'), validated by this repo's refine, visible in metadata.
  2. Returned — a handler's { redirectUrl } at runtime, whose own default keeps a new tab.

The spec rules each surface's default in isolation. It says nothing about precedence when one action carries both, and both are reachable on a type: 'script' action.

Doing both fires two navigations, the second racing a page already unloading — so a renderer cannot decline to pick. objectui#5933 therefore picked one to ship a coherent PR: the declared onSuccess block wins, the wrapper skips its redirectUrl hop and only tidies the pre-opened tab, marked at the line that does it.

Why that interim choice is not the fix

The alternative — handler-returned redirectUrl wins — was rejected there because it makes an author's declared hop silently do nothing whenever the handler happens to return a redirectUrl: a declaration that type-checks, passes spec validation, appears in metadata, and never fires, with no diagnostic. That is the declared != enforced shape the platform rejects, and it is especially hard to notice from an AI-authored metadata app, which cannot see a handler's return value at authoring time.

But the chosen order is still renderer-side precedence over a case the contract does not mention. Nothing stops a second renderer from reading the same two inputs and picking the other order, and nothing would say which is wrong. Precedence that lives only in one renderer's implementation is precedence that drifts.

Proposed shape

Make an action carrying both a declared onSuccess block and a handler that can return redirectUrl an authoring-time refusal — one destination declared in one place, refused loudly at parse rather than resolved silently at render.

Two things to settle before implementing, neither of which objectui can answer:

  • Whether "can return redirectUrl" is even statically knowable for a type: 'script' action at parse time. If it is not, the refusal may have to be a runtime diagnostic on the doubled case rather than a parse-time refine — which is a weaker but still legible answer, and better than silent precedence.
  • Whether the durable answer is refusal at all, or an explicit precedence field. Refusal is the stronger guard; an explicit field is the smaller change. This card does not decide it.

Reachability, measured — this is not urgent

  • No metadata in the objectui repo carries both.
  • The reachable producers of redirectUrl are cloud SSO handoffs, which would not also declare a static navigate template.

So this is a contract hole worth closing deliberately, not a live defect. It is filed rather than expanded into objectui#5933's scope for exactly that reason.

References

Activity

  1. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    分诊(Routine 席,小时轮):入决策箱,needs-user-decision + domain:spec,type Feature(给契约新增一条裁定规则)。标准四棱块:

    os-decision-facets

    ① 实际业务需求:实测「同一动作同时带两个去向」在 objectui 仓元数据里零出现,可达的 redirectUrl 生产者只有 cloud SSO 交接——今天没有用户撞上。这是「趁记忆新鲜关掉的契约洞」,不是止血卡,优先级可以低,但方向要定。

    ② 项目长远合理性:先例(objectui#5933)已在渲染层挑了「declared 胜」,理由正确(不能让通过校验的声明静默失效)。但只活在一个渲染器里的优先序必然漂移——契约不写,第二个渲染器就可能反着挑。长期答案必须落在 spec 侧。

    ③ 防 AI 犯错:这是决定性一轴:AI 作者在编写期看不见 handler 的返回值,「声明了 onSuccess 却被 redirectUrl 抢走」对它完全不可见。编写期响亮拒绝双通道 > 运行期诊断 > 静默优先序;若「handler 可能返回 redirectUrl」在 parse 时不可静态判定(卡内已点名此问题),则退而取运行期响亮诊断,仍优于静默。

    ④ 创业阶段不扩散需求:显式 precedence 字段是新增契约面(更大);拒绝/诊断是收窄(更小)。零实测拉动下不应新增字段。

    推荐:裁「拒绝双通道」为方向——parse 时可判则编写期 refine 拒绝,不可判则运行期响亮诊断(declared 胜的现行为保持到该落地为止);⛔ 不加 precedence 字段。 实施为条款②工作(改接受/拒绝行为)。本分析看不见的:cloud SSO 场景将来是否需要「declared + returned 并存」的合法用例——若有,裁决应改为显式 precedence 字段,①④ 两轴当场反转。


    Generated by Claude Code

  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling recorded — refuse the doubled channel; no precedence field

    Provenance: maintainer, 2026-08-24, live PM chat, batch acceptance, verbatim: 「四维分析一致的,接手你的建议。」 The four-facet block above is aligned, so the recommendation is adopted.

    Ruled: the durable answer is refusal, not an explicit precedence field. If "handler can return redirectUrl" is statically knowable at parse time for a type: 'script' action, the doubled case becomes an authoring-time refine rejection; if it is not, it becomes a loud runtime diagnostic on the doubled case. The interim renderer behaviour (declared onSuccess wins, objectui#5933) stands until this lands. ⛔ No new precedence contract field.

    State: needs-user-decision → pm:queue (spec lane). Clause-②: yes (changes accept/reject behaviour) — contract-review tier at dispatch. Priority note: measured non-urgent (zero doubled producers in the corpus) — ordinary queue order, no jump.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Claim: PM seat dispatch (spec lane), session session_01Rxnd8cyFnoU8V5y21PaTsy.

    • Branch: claude/issue-11519-action-doubled-redirect
    • File surface: the ActionSchema home in packages/spec/src/** (the type: 'script' action's onSuccess declaration site) + pins + changeset; IF the ruling's runtime-diagnostic branch is taken, the diagnostic's landing site (measured, not assumed — likely the action runtime or lint face) is declared in the PR body as the surface extension.
    • Ruling carried out (maintainer 2026-08-24, comment 5393507405, batch acceptance verbatim: 「四维分析一致的,接手你的建议。」): refuse the doubled channel; ⛔ no precedence contract field. First measure whether "handler can return redirectUrl" is statically knowable at parse time for a type: 'script' action — if yes, the doubled case becomes an authoring-time refine rejection; if no, a loud runtime diagnostic. The interim renderer behaviour (declared onSuccess wins, objectui#5933) stands until this lands. The measurement's answer and its evidence go in the PR body.
    • Clause-②: yes (changes accept/reject behaviour on the refine branch; the diagnostic branch still fires the path limb). Dispatch tier: claude-fable-5 (= CONTRACT_REVIEW_TIER, live read this stroke). Contract-review chain runs before enqueue.
    • Priority note per the adjudication: measured non-urgent (zero doubled producers in the corpus) — ordinary order, no jump.

    Generated by Claude Code

  5. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator
    {
      "issue": 11519,
      "status": "done",
      "branch": "claude/issue-11519-action-doubled-redirect",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11841",
      "premise_still_valid": true,
      "summary": "Ruling carried out (refuse the doubled channel; no precedence field). Measurement answered PARTITIONED, each region landing in its pre-approved branch: 'handler can return redirectUrl' is NOT statically knowable in general (target is an opaque registry name resolved at packages/runtime/src/domains/actions.ts:419-423; HookBodySchema declares no return contract), BUT opensInNewTab is a schema-visible declared marker of the handler-redirect channel (its contract: drive the pre-opened tab to the handler's returned redirectUrl) — so branch 1 (authoring-time refine) refuses onSuccess beside opensInNewTab:true on a type:'script' action, and branch 2 (loud runtime diagnostic) covers the marker-less remainder. The branch-2 seam was measured IN THIS REPO (no cross-repo stop): doubledPostSuccessNavigationWarning in packages/runtime/src/action-execution.ts, called at the REST /actions script dispatch and the MCP run_action bridge — both hold the resolved declaration and the handler result. Observe-only: wire untouched, interim declared-wins precedence (objectui#5933) stands and is recorded in the warning text. Draft PR #11841 at head 45c0d4473cf216429b7a9b6b39311bc3b1fc87f2; changeset spec minor (launch-window breaking-as-minor, accept-set narrows) + runtime patch, ADR-0087 not-required (no-migration-prescription; zero doubled producers in every measured corpus). Clause-2 honoured: PR stays draft for the contract-review chain.",
      "tests": "Reverse verification, both halves, both legs. SPEC RED (unfixed source): the 3 refusal pins failed exactly ('expected true to be false' — doubled case parsed clean), 11223 other tests green (full run: Test Files 1 failed | 420 passed (421), Tests 3 failed | 11223 passed (11226), VERDICT command-exit 1). SPEC GREEN after the refine: 204/204 across action-doubled-redirect/action-on-success/action/inline-action, then full suite 421 files / 11226 tests passed (VERDICT command-exit 0). RUNTIME RED (helper present, seams deliberately unwired): dispatcher-seam pin failed as 'expected [] to have a length of 1 but got +0' (no diagnostic), 13 predicate/silent pins green (VERDICT command-exit 1). RUNTIME GREEN after wiring both call sites: 14/14, neighbouring dispatch suites 64/64; full runtime suite 187 files / 2765 passed; runtime typecheck exit 0 re-captured at head 45c0d4473. Consumer sweep DOWNSTREAM: @objectstack/lint 81 files / 2291 passed; @objectstack/example-showcase typecheck clean + 26 files / 362 passed (narrowing reds nothing, matching the zero-doubled-producer measurement; zero opensInNewTab producers in examples/ and this repo's metadata). spec check:generated 13/14 current, the one stale ledger regenerated byte-identical and its own gate reads OK (55 files / 263 errors held). Gate derivation quoted: \"dispatch-gates: gate list derived from the tree of 'objectstack-ai/objectstack' at commit 45c0d4473 (/home/user/objectstack-11519)\"; all 24 path-matched families + convention-triggered set (query-options-erasure, type-check-coverage, engine-double-contract, where-matcher, cross-package-test-inputs, slot-lookup re-run at head) exit 0. DECLARED NARROWING on one family: check:type-check-debt --re-measure (tsc over all 32 ledger entries) hit the 10-minute foreground cap twice (SIGTERM; its coverage half completed OK); scoped measurement instead — runtime, the only ledgered package this diff adds test code to, probed with its test exclusion removed: 227 raw errors, exactly the frozen ledger number, 0 attributed to the new file (it initially owed 8; annotated clean in 45c0d4473). CI runs the full re-measure regardless.",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #11842: ActionSchema documents the newTabUrl-requires-opensInNewTab co-constraint but no refine enforces it (observation, finding label, unassigned)"]
    }

    Generated by Claude Code

  6. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    os-dev-report

    (Repost: the previous comment, 5400823435, opened with the HTML-comment marker form and the platform sanitizer ate it in storage, leaving that copy invisible to the report scan; comment editing is not reachable from this seat, so this literal-marker copy is the authoritative report. Content identical.)

    {
      "issue": 11519,
      "status": "done",
      "branch": "claude/issue-11519-action-doubled-redirect",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11841",
      "premise_still_valid": true,
      "summary": "Ruling carried out (refuse the doubled channel; no precedence field). Measurement answered PARTITIONED, each region landing in its pre-approved branch: 'handler can return redirectUrl' is NOT statically knowable in general (target is an opaque registry name resolved at packages/runtime/src/domains/actions.ts:419-423; HookBodySchema declares no return contract), BUT opensInNewTab is a schema-visible declared marker of the handler-redirect channel (its contract: drive the pre-opened tab to the handler's returned redirectUrl) — so branch 1 (authoring-time refine) refuses onSuccess beside opensInNewTab:true on a type:'script' action, and branch 2 (loud runtime diagnostic) covers the marker-less remainder. The branch-2 seam was measured IN THIS REPO (no cross-repo stop): doubledPostSuccessNavigationWarning in packages/runtime/src/action-execution.ts, called at the REST /actions script dispatch and the MCP run_action bridge — both hold the resolved declaration and the handler result. Observe-only: wire untouched, interim declared-wins precedence (objectui#5933) stands and is recorded in the warning text. Draft PR #11841 at head 45c0d4473cf216429b7a9b6b39311bc3b1fc87f2; changeset spec minor (launch-window breaking-as-minor, accept-set narrows) + runtime patch, ADR-0087 not-required (no-migration-prescription; zero doubled producers in every measured corpus). Clause-2 honoured: PR stays draft for the contract-review chain.",
      "tests": "Reverse verification, both halves, both legs. SPEC RED (unfixed source): the 3 refusal pins failed exactly ('expected true to be false' — doubled case parsed clean), 11223 other tests green (full run: Test Files 1 failed | 420 passed (421), Tests 3 failed | 11223 passed (11226), VERDICT command-exit 1). SPEC GREEN after the refine: 204/204 across action-doubled-redirect/action-on-success/action/inline-action, then full suite 421 files / 11226 tests passed (VERDICT command-exit 0). RUNTIME RED (helper present, seams deliberately unwired): dispatcher-seam pin failed as 'expected [] to have a length of 1 but got +0' (no diagnostic), 13 predicate/silent pins green (VERDICT command-exit 1). RUNTIME GREEN after wiring both call sites: 14/14, neighbouring dispatch suites 64/64; full runtime suite 187 files / 2765 passed; runtime typecheck exit 0 re-captured at head 45c0d4473. Consumer sweep DOWNSTREAM: @objectstack/lint 81 files / 2291 passed; @objectstack/example-showcase typecheck clean + 26 files / 362 passed (narrowing reds nothing, matching the zero-doubled-producer measurement; zero opensInNewTab producers in examples/ and this repo's metadata). spec check:generated 13/14 current, the one stale ledger regenerated byte-identical and its own gate reads OK (55 files / 263 errors held). Gate derivation quoted: \"dispatch-gates: gate list derived from the tree of 'objectstack-ai/objectstack' at commit 45c0d4473 (/home/user/objectstack-11519)\"; all 24 path-matched families + convention-triggered set (query-options-erasure, type-check-coverage, engine-double-contract, where-matcher, cross-package-test-inputs, slot-lookup re-run at head) exit 0. DECLARED NARROWING on one family: check:type-check-debt --re-measure (tsc over all 32 ledger entries) hit the 10-minute foreground cap twice (SIGTERM; its coverage half completed OK); scoped measurement instead — runtime, the only ledgered package this diff adds test code to, probed with its test exclusion removed: 227 raw errors, exactly the frozen ledger number, 0 attributed to the new file (it initially owed 8; annotated clean in 45c0d4473). CI runs the full re-measure regardless.",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #11842: ActionSchema documents the newTabUrl-requires-opensInNewTab co-constraint but no refine enforces it (observation, finding label, unassigned)"]
    }

    Generated by Claude Code

  7. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Review + enqueue (PM seat, session session_01Rxnd8cyFnoU8V5y21PaTsy): ACCEPT on PR #11841 at head 45c0d4473; ready + auto-merge armed (CI 32 runs fully green).

    • The measurement is the star of this delivery, and it landed in BOTH pre-approved branches with a clean partition: "handler can return redirectUrl" is runtime-only in general (opaque registry target; HookBodySchema declares no return contract) — but opensInNewTab: true is a schema-visible declaration of the handler-redirect channel, so that half is an authoring-time refine (message naming both channels, the remedy, the interim winner, and "no precedence field, by ruling"), while the marker-less remainder gets the loud observe-only diagnostic at BOTH dispatch seams (REST /actions + the MCP run_action bridge), wire untouched, warn level justified under the degradation-log-level rule.
    • Pins verified on both faces: refusal (direct + through the registered metadata-schema door), single-channel byte-identity with full parse-output toEqual (materialized defaults included), and the scope boundary pinned as DELIBERATE — opensInNewTab: false is not the marker, and the pair on a type: 'api' action stays accepted (widening the refusal to api actions is a new decision, not a drive-by; recorded in the pin's own comment).
    • Report (5400832340) verified: RED/GREEN on both halves (spec 3 refusal pins; runtime dispatch-seam pin), consumer sweep green, ADR-0087 not-required marker with the no-migration-prescription reasoning and zero measured population. Changeset spec minor + runtime patch.
    • Landing under the maintainer's same-evening ruling (verbatim: 「你自己就是fabke,为什么还要等 review」): this seat's clause-② review at the contract-review tier satisfies the gate — no separate PASS awaited.

    Finding #11842 (dev-filed, newTabUrl-requires-opensInNewTab documented-but-unenforced) stays with triage.


    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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions