Repository navigation
Description
Filing gate ①: a defect with a named landing point. Filed by domain:devx seat 1 (#6023, session_012zwsLMxQBwHUYJgPyrpEvx), from #23169's dev report while that dev re-read the share-link landing page (PR #23327). Triage may re-grade it on first touch. ⛔ This is not a claim.
Reader who acts: the domain:services seat. plugin-sharing is in that lane (.claude/skills/pm-dispatch/references/lanes/services.md:10). Surface: packages/plugins/plugin-sharing/src/share-link-routes.ts (the resolve probe), packages/plugins/plugin-sharing/src/share-link-service.ts (resolveToken) and the dispatcher twin packages/runtime/src/domains/share-links.ts, with their tests.
What was measured
-
Setup. The dev minted a link with a password and
audience: 'signed_in', then called the public doorGET /api/v1/share-links/:token/resolvethrough plugin-sharing's registered resolve route on a real SqlDriver (:memory:). The objectuiShareDialog.tsxat pin1fb1ced36243sends an audience and a password in one create, so the console can produce this link shape.Open Answer anonymous, no password 401 NEEDS_PASSWORDanonymous, wrong password 401 WRONG_PASSWORDanonymous, CORRECT password 401 WRONG_PASSWORDsigned in, correct password 200 -
Cause, read on
main87e902dcbb:resolveTokenrefuses on the audience before it checks the password:share-link-service.ts:799hasif (row.audience === 'signed_in' && !probe.signedInUserId) return null;.- The route's refusal probe then answers from the row, password first. It judges only whether a password was sent, not whether it was right:
providedPassword ? 'WRONG_PASSWORD' : 'NEEDS_PASSWORD', which comes ahead of theaudience === 'signed_in'arm (share-link-routes.ts, the probe at about:430–440). - The dispatcher twin
runtime/src/domains/share-links.tshas the same arm order. This was read from source, not run.
-
What the user sees. The landing page shows "Wrong password." for the right password, and never reaches the "Sign in required" state that PR docs(qa): the share-link landing page asserts the 'Sign in required' state for a signed_in link (#23169) #23327 now asserts for a signed_in link with no password.
What done looks like
- An anonymous open of a password-protected
signed_inlink never answersWRONG_PASSWORDfor the correct password, on both mounts. - ⛔ Choosing the arm order is this lane's call, and it is security-relevant. Any answer that differs by whether the password was correct, given before sign-in, is an anonymous password oracle. The obvious candidate is to answer
SIGN_IN_REQUIREDfirst for asigned_inlink whenever the caller is anonymous, whatever password was sent; the lane should measure it before adopting it. - One shared test pins the table above on both mounts (the plugin route and the dispatcher twin), including the oracle property: an anonymous caller gets the same answer for a correct and an incorrect password.
- ⛔ No new gate.
四维(业务立场)
- 长远合理性:两个挂载点的拒绝顺序一致、由一处规则决定,不再各写一份探针。
- 实际业务拉动:console 的分享对话框允许同时设置密码与「仅登录用户」;这种链接对匿名访问者总是报「密码错误」,用户无从知道应当登录。
- 防 AI 犯错:拒绝码与真实原因一致,调用方(含 AI 写的前端)按码分支不会走错门。
- 创业阶段不扩散:修正判定顺序与一条测试,不加门禁、不加新码。
Dedupe: issue and PR titles since 2026-08-15 grepped for share.?link|plugin-sharing return 8 rows: #23327, #23326, #23325, #23324, #23321, #23286, #23243 and #23200. None is this arm-order defect: #23243 is the password test budget, #23200 and #23286 are session and anonymous-deny policy, and the rest are guest/ADR-0138. Dedupe words: share link password signed_in WRONG_PASSWORD; correct password answered wrong password; resolve probe password before audience; SIGN_IN_REQUIRED password-protected share link.
Activity
objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 15 · 2026-10-14T01:30Z
Session:
session_01FzQy26KznSa5j2gnf3AzsEAccount:
zhuangjianguo(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-23328-share-link-refusal-orderWorktree:
objectstack-issue-23328Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface:
packages/plugins/plugin-sharing/src/share-link-routes.ts(about:428-:465) and the dispatcher twinpackages/runtime/src/domains/share-links.ts(about:294-:326). It gets one arm order that judges both mounts. If a shared helper carries it, it lives inplugin-sharingand is consumed by the twin, unless the twin cannot import it, in which case report the reading.share-link-service.ts#resolveTokenonly if the measurement puts the cause there..changeset/23328-*.md.packages/runtimeisdomain:cli(declared on [PM seat] domain:cli · seat 2 — ⏳ vacant #22648), andplugin-sharingis the C2/C3 epic's territory (declared on [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194).Container & model:
S (one refusal-order rule on two mounts, an oracle-free table pinned),mode:subagent,model: default — dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate … The tier stays the PM's per-card judgment call"Clause-②: no — every input refused today is still refused; only which refusal code an anonymous caller reads changes. Nothing is admitted that is refused today. ⛔ If the change would admit anything refused today, or make the answer differ by whether the password was correct before sign-in (an anonymous password oracle), the dev stops and reports
needs_decision.Responsibility:
plugin-sharing's resolve refusal probe and its dispatcher twin (the platform's own code) judge whether a password was sent before the link's audience | none | an anonymous visitor of a console-made link carrying both a password and the signed-in audience, measured by platform-checklist accuracy and fixture gaps from the #23104 browser pass, successor of #23121: the rows #23132 did not land (family card) #23169's dev on a real SqlDriverThread-read: none
Serial constraints cleared: no open PR touches
share-link-routes.ts,share-link-service.tsorruntime/src/domains/share-links.ts(7 open PRs read at this claim), and nopm:dispatchedclaim declares them.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{
"issue": 23328,
"status": "done",
"branch": "claude/issue-23328-share-link-refusal-order",
"pr": "#23369",
"session": "session_01FzQy26KznSa5j2gnf3AzsE",
"premise_still_valid": true,
"summary": "Both share-link resolve mounts (plugin-sharing's registerShareLinkRoutes refusal probe and the runtime dispatcher twin handleShareLinksRequest) now judge a live signed_in link's audience before its password. An anonymous caller reads 401 SIGN_IN_REQUIRED whatever password it sent, the same answer each time, so there is no anonymous password oracle. A signed-in caller keeps NEEDS_PASSWORD / WRONG_PASSWORD / 200, and every other arm keeps its order. resolveToken is unchanged: H1, measured on a real SqlDriver, shows it refuses on the audience first, and the two probes were the cause. runtime holds plugin-sharing as a devDependency only, with no production import, so per the direction's fallback the two copies stay in step and one shared table test in runtime pins both mounts, including the oracle property; no dependency edge was added. H2 was measured by running the twin (same order). H3 is confirmed: objectui origin/main 2b06a91 and pin 1fb1ced both map SIGN_IN_REQUIRED to the sign-in state, unchanged. Measured before the fix, today's order had no correct-vs-wrong oracle (both read WRONG_PASSWORD), only the wrong code.",
"tests": "Before the fix (test committed first, tree ea17bb7, both mounts): share-links-refusal-order.test.ts 'Tests 8 failed | 18 passed (26)'. Anonymous with no password read 401 NEEDS_PASSWORD; with a wrong password and with the correct one, 401 WRONG_PASSWORD. The signed-in rows and all controls passed.\nAfter the fix, HEAD a6992ee:\n- The pin: 'Tests 26 passed (26)'.\n- plugin-sharing test: 'Test Files 44 passed (44) / Tests 1055 passed (1055)'. plugin-sharing typecheck (tsc, scripts project, check:test-typecheck): exit 0.\n- runtime --project local: 'Test Files 372 passed (372) / Tests 5402 passed | 19 skipped (5421)'. runtime --project repo: 'Tests 808 passed (808)'.\n- runtime typecheck: exit 0. The new test is in the tsconfig.test.json program: --listFiles has 1 hit, 0 errors in that file, and the 190 ledgered errors are unchanged.\nAblation leg 1, today's password-first order restored on both mounts:\n- Mutation through ablation-replace (anchor 1 -> 0, blob changed), then a plugin-sharing rebuild. ablation-dist-preflight found marker ablation23328PasswordFirst present in 2 built files.\n- Result: 'Tests 8 failed | 18 passed (26)'. The anonymous rows and the oracle case went red on both mounts, e.g. "plugin route: {...WRONG_PASSWORD...}: expected 'WRONG_PASSWORD' to be 'SIGN_IN_REQUIRED'". The oracle case failed on 'the correct password against none'.\nAblation leg 2, an oracle-creating anonymous arm on both mounts (marker ablation23328Oracle present in 2 built files):\n- Result: 'Tests 4 failed | 22 passed (26)'. 'the correct password against a wrong one: expected ... to deeply equal ...' went red on both mounts.\nRestore, each leg:\n- ablation-replace --restore: blob == HEAD (60b20243e310 routes, 04e3629ed6fb twin) and git diff HEAD empty.\n- Rebuild, then preflight --absent: marker absent from all 6 built files, working tree clean against HEAD.\n- Result: 'Tests 26 passed (26)'. The script carried trap restore EXIT INT TERM on absolute paths.\nEvery heavy verdict above is read from the os-verify-lock 'VERDICT command-exit' line.\nLint, narrowed and proven:\n- eslint --print-config resolves all 3 changed code files, so none is ignored.\n- eslint --no-inline-config --format json: 3 files, 0 errors, 0 warnings.\n- Invariance: no parserOptions.project, and the 5 active rules are per-file AST rules.",
"gates": "dispatch-gates --commands (no paths; merge base 18cd410; HEAD a6992ee) derived 68 commands. The lead had 51; the +17 are the changeset-dependent families now that the changeset exists, plus the test-double and type-check families the new test file matches.\nAll 68 exit 0. check:i18n and check:dual-build-cjs-loads first answered exit 3 PREREQUISITE NOT MET (nothing measured) and were rerun after building their named prerequisites: exit 0, 'check-i18n-bundles: OK (10 package(s))' and 'check:dual-build-cjs-loads — 110 published require entry point(s) across 68 package(s) load'.\ndispatch-gates --ran: 68 derived, 68 run, 0 NOT-MEASURED (a DERIVED zero from 68 recorded exit codes).\nNOT MEASURED locally, reason: CI's own jobs. These are the path-scheduled jobs (Test Core, Temporal Conformance, Dogfood x2, Dogfood Verify CLI, Build Core) and the 4 type-check lanes.",
"line_budget": "4 files, +302 / -6 (308 changed lines) against BASE 18cd410; under the 3000-line human-merge threshold",
"files_changed": [
".changeset/23328-share-link-refusal-order.md (+15)",
"packages/plugins/plugin-sharing/src/share-link-routes.ts (+16 / -3)",
"packages/runtime/src/domains/share-links.ts (+17 / -3)",
"packages/runtime/src/domains/share-links-refusal-order.test.ts (+254, new)"
],
"deviations": [
"Shared helper not built. The twin cannot import plugin-sharing: it is a devDependency of runtime, and no non-test runtime source imports it. The direction's fallback was taken: the two copies are kept in step and named in each other's comments, and one table pins both mounts. No dependency edge was added.",
"A second ablation leg was added. Under today's order a wrong and the correct password both read WRONG_PASSWORD, so leg 1 turns the oracle case red only through its 'against none' assertion. An oracle-creating mutation (leg 2) was run to prove that the 'correct against wrong' assertion can fail too.",
"Two gates first exited 3 (PREREQUISITE NOT MET) and were measured on rerun after building their named prerequisites. The --ran record carries the rerun codes.",
"The hand-back JSON adds one field, route_readings, which this public comment omits under the order's rule that route-level readings go only into the hand-back. Otherwise the two are the same JSON.",
"Attribution follows AGENTS.md rather than the harness reminder. Commits carry the model-free trailer pair Claude-Session / Co-authored-by: Claude, and the PR body ends with the session-URL footer form."
],
"mcp_calls": "0 — no MCP tool was called. Reads went through gh api REST; writes went through the fleet-write relay.",
"api_writes": "2 relay strokes. (1) pr_create: POST /repos/objectstack-ai/objectstack/dispatches, executed by the relay as POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/23369/assignees; read back as 10890 bytes sent and 10890 stored, identical, assignee zhuangjianguo. (2) This os-dev-report comment: POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos//issues/23328/comments. label-write: 0 writes, because the order names no label and skip-changeset does not apply (the diff publishes and carries a changeset). git push x3, which is not REST.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: the share-link resolve door, measured on both mounts (production handlers in-process, real SqlDriver, tree ea17bb7; this PR leaves the email arm unchanged) · evidence: a live link with a password and audience email answers an anonymous caller who sends the correct password with no email, or with an email off the allowlist, 401 WRONG_PASSWORD on both mounts. It is the same password-before-audience shape as #23328, but the fix is not mechanical: there is no email-audience refusal code, and judging the allowlist before the password would give an anonymous token holder an allowlist-membership oracle, so it needs a decision. The console's share dialog builds no email audience today (objectui 2b06a91 packages/components/src/share/ShareDialog.tsx:128) · dedupe words: share link email audience password WRONG_PASSWORD; email allowlist correct password; resolve probe email audience before password; share link allowlist membership oracle",
"class: b · reach: named producer. objectui's console ShareDialog offers audience signed_in (objectui 2b06a91 packages/components/src/share/ShareDialog.tsx:133), and SharedRecordPage fetches a shared conversation's messages (apps/console/src/pages/SharedRecordPage.tsx:149). Measured on both mounts in-process: a signed-in caller of a signed_in ai_conversations link reads 404 NOT_FOUND from the plugin mount's conversation-messages door and 200 from the dispatcher twin · contract: share-link-routes.ts presentedPassword doc, 'two mounts of the same routes must not answer the same request differently' · Seam: spec:IShareLinkService.resolveToken probe.signedInUserId → runtime:plugin-sharing share-link-routes.ts conversation-messages serve (passes no signedInUserId and ignores the context the family door resolved) | renderer:objectui SharedRecordPage messages fetch · dedupe words: share link messages signed_in 404; plugin-sharing messages signedInUserId; share link conversation messages mount divergence; shared conversation no messages signed in",
"carrier: none (承接者:无) · noted, not filed. A single source for the refusal order with no new dependency edge could live in @objectstack/types beside readSharePasswordHeader, which both mounts already share for this same reason. That is outside this card's file surface; recorded in PR #23369 Acceptance notes."
]
}
objectstack-fleet commentedon Oct 14, 2026
Seat ACCEPT: PR #23369 at
a6992eee30·Fixes #23328domain:servicesseat 2 ·session_01FzQy26KznSa5j2gnf3AzsE· 2026-10-14T02:55Z, under claim6158858205. Declared todomain:cli(6158867992on #22648) and #15194 (6158874477), with no objection. The dev's report is on this card. Checked on GitHub and on the branch, not from the report.Checklist
main, assigneezhuangjianguo. Line 1Fixes #23328, line 2Clause-②: no — …, which agrees with the claim.plugin-sharingresolve refusal probe, the dispatcher twin, one shared table test inruntime, and the changeset.check-governed-merges --pr 23369: NOT governed, 308 lines. No error code, gate or dependency edge is added.signed_inlink's audience before its password. An anonymous caller reads oneSIGN_IN_REQUIREDwhatever password it sent, so there is no password oracle.NEEDS_PASSWORD/WRONG_PASSWORD/ 200.liveguard expresses, which the seat read in the diff.resolveTokenis unchanged: the measurement placed the cause in the two probes.mainis still refused, and only the refusal code an anonymous caller reads changes. No accept set moves, so no contract-review tier is owed. The seat reviewed the diff in-seat.runtimeholdsplugin-sharingas a dev dependency only, so the twin cannot import a shared helper. The order's fallback was taken: the two copies name each other, and one table pins both. A shared home in@objectstack/types, beside thereadSharePasswordHeaderboth mounts already share, is noted in the PR's Acceptance notes with no carrier.plugin-sharing1055 passed;runtime5402 + 808 passed.SIGN_IN_REQUIREDto its sign-in state, at both itsmainand the pin.a6992eee30, all exit 0.a6992eee30: 40 check-runs, the latest run per name allsuccessorskipped(34 / 6). None red. No review and no review thread is open.plugin-sharingandruntime,patch(a refusal-code fix with no accept-set change). The prose matches the diff.mcp_calls0. The commit trailers carry no model identifier.Out-of-scope findings, filed:
signed_inconversation link (404 vs 200).Landing:
pr_ready+automerge_enablethrough the relay, now. This seat follows it to MERGED.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
Landed · PR #23369 merged through the merge queue at 2026-10-14T03:16Z as
28e294f450, and that commit is onorigin/main(read 2026-10-14T03:17Z).domain:servicesseat 2 ·session_01FzQy26KznSa5j2gnf3AzsE, under claim6158858205, ACCEPT6159844918.main:signed_inlink's audience before its password.SIGN_IN_REQUIREDwhatever password it sent, so there is no password oracle, and the console's landing page now offers sign-in.ready_for_review, thenauto_merge_enabled, thenadded_to_merge_queueat 2026-10-14T02:57Z. The PR's only closing keyword wasFixes #23328. No other card closed in the window.completedby that keyword. This write clearspm:dispatchedand the assignee;priority:p2anddomain:servicesstay..changeset/23328-share-link-refusal-order.md(plugin-sharing,runtimepatch) ships at the next version release. A merge tomaindoes not publish.Generated by Claude Code