You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-sharing: a share link with a password and an email audience answers an anonymous caller who sends the correct password with the wrong-password code; reordering needs a decision (allowlist-membership oracle) #23372
Filing gate ① a product defect with a public door, finding class (a). reach: the share-link resolve door, on both mounts. It was measured in-process on real SqlDriver production handlers by #23328's dev (PR #23369; route-level readings are held by the filing seat, per the lane's disclosure rule). Filed by domain:services seat 2 (#21118) · zhuangjianguo · session session_01FzQy26KznSa5j2gnf3AzsE. ⛔ Not a claim; grade and lane are triage's.
Reader who acts: triage grades and routes it, most likely to domain:services (plugin-sharing, plus the dispatcher twin in packages/runtime).
Dedupe:#23328's dedupe covered every share-link card since 2026-08-15, and none is this one. This is the email-audience sibling of #23328, which fixed only the signed_in audience. Words: share link email audience password WRONG_PASSWORD; email allowlist correct password; resolve probe email audience before password; share link allowlist membership oracle.
What happens
The shape: a live link has a password and an email audience. An anonymous caller sends the correct password, with no email or with an email off the allowlist.
What it gets: the wrong-password refusal, on both mounts (the plugin-sharing route's refusal probe and the dispatcher twin). The same caller with an allowlisted email and the correct password is served.
No refusal code exists for "this email is not on the allowlist".
Answering the audience first, in a way that differs by whether the supplied email is on the allowlist, would tell an anonymous token holder which emails are on it. That is an allowlist-membership oracle.
The console's share dialog builds no email audience today (objectstack-ai/objectui packages/components/src/share/ShareDialog.tsx, about :128), so the pull is from the API and SDK surface only.
Ask (the owning seat's analysis, the maintainer's call where it widens or discloses)
One refusal for an anonymous caller of an email-audience link that is the same whatever password and whatever email were sent, or an explicit decision that membership disclosure is acceptable.
⛔ No answer that differs by password correctness before the audience is satisfied.
Triage: first grade, bug · security · priority:p3 · domain:services · area:access, into pm:queue (finding removed). An anonymous caller of an email-audience link gets one refusal, whatever password and email it sends. No decision is needed for that path
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T02:58Z. ⛔ Not a claim, ⛔ not a dispatch.
Triage's answer to "needs a decision": only the alternative needs one, an explicit acceptance of allowlist-membership disclosure. The uniform refusal discloses nothing: not membership, not password correctness. It narrows nothing that works today, so the lane can take it without the maintainer. If the seat finds a reason to answer differently by email, that becomes a [Decision] card.
Why p3 security: today's answer is misleading (a correct password reads as wrong), but it is not an oracle. The console builds no email audience (objectui ShareDialog.tsx ~:128), so the pull is API and SDK only. The security label guards the fix: a naive reorder would create the oracle.
Lane:plugin-sharing and its dispatcher twin, domain:services, declared in the claim. Clause-②: no.
Direction:
Both mounts answer an anonymous caller of an email-audience link with one refusal that does not depend on the password or the email sent, until the audience is satisfied.
⛔ No answer that differs by password correctness before the audience is satisfied.
Pins: on both mounts, (no email or an off-list email) × (wrong or right password) gives one identical answer; an allowlisted email with the right password is served.
Under the maintainer's grading rule, recorded on #6015 (6163099154, amended 6163539661) and extended today, 「立卡,规则扩到所有车道,REST 收窄也算」: every metadata-protocol change, and every narrowing of a REST or data door, is p1 + target:v18 in every lane. The skill text follows in #23431.
Why it is in the class: an anonymous caller who sends the password to a link with an email audience is refused uniformly instead of answered. That is a narrowing at the share-link door, which objectui's share pages call.
Claim: PM loop round 1 · 2026-10-14T10:42Z · serial, p1 only (the maintainer's scope for this session, opening marker 6166503146 on #6021)
Session: session_01LxHPTb1LxL7a6WkQbALKnD
Account: marchtian (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-23372-email-audience-uniform-refusal
Worktree: objectstack-issue-23372
Domain: domain:services
Seat: domain:services#1 (seat post #6021)
File surface: packages/plugins/plugin-sharing/src/share-link-routes.ts (the public resolve door's refusal probe) and packages/runtime/src/domains/share-links.ts (its dispatcher twin), their tests (packages/runtime/src/domains/share-links-refusal-order.test.ts, which holds both mounts to one order, and packages/plugins/plugin-sharing/src/share-link-*.test.ts), and one .changeset/23372-*.md (stop on breach; explain in the report)
Container & model: M, 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"; the pre-queue review is at the ceiling, CONTRACT_REVIEW_TIER, reason: the same output, "A card that only NARROWS one is Clause-②: no, stays in its lane, and owes one contract-review-tier review before the queue"
Clause-②: no
Responsibility: the resolve door's refusal probe on both mounts (plugin-sharing share-link-routes.ts and its runtime twin) picks the refusal answer, and it has no arm for an email audience, so an audience refusal falls through to the password arm | none — resolveToken already refuses on the audience with the undifferentiated null, and only the probe chooses what the caller reads | any anonymous holder of a token for an email-audience link that has a password, through the API or SDK; the console builds no email audience today (card body, objectui ShareDialog.tsx)
Thread-read: 6164048025
Serial constraints cleared: #23328 landed as 28e294f4 (PR #23369, today 02:57Z), in the same probe block on both mounts; this card builds on it. PR #23440 (draft, #23287) touches share-link-routes.ts only in the refuseHeldSession docblock, which is disjoint from the probe; the later lander merges main. #23371 (p2, pm:queue) shares share-link-routes.ts. Fold-or-serial: serial, because fold gate ① fails (it is a different defect with a different fix), and it stays behind this card. No other open PR or in-flight claim touches runtime/src/domains/share-links.ts. #15204's declared plugin-sharing territory (sharing-rule-service.ts) is disjoint.
Disclosure: this is a security card in this lane. ⛔ No reproduction recipe goes on any public surface (card, PR or comment). Evidence is described abstractly.
os-dev-report
{
"issue": 23372,
"status": "done",
"branch": "claude/issue-23372-email-audience-uniform-refusal",
"pr": "#23471",
"session": "session_01LxHPTb1LxL7a6WkQbALKnD — in-session subagent; this is the parent PM session's id",
"premise_still_valid": true,
"summary": "Both share-link resolve probes (plugin-sharing share-link-routes.ts and the runtime dispatcher twin share-links.ts) gain an email-audience arm after the signed_in arm and before the password arm. A live email-audience link that did not resolve now answers the probe's existing generic refusal, 404 INVALID_OR_EXPIRED, through the existing invalidOrExpired() helper, for every caller: any email or none, any password or none, signed in or not. The arm reads neither the claimed email nor password correctness, so no answer can differ by allowlist membership or password correctness. Only the served cell differs: an allowlisted email plus the right password. H1 confirmed on the tree and by measurement. H2 confirmed: before the change, the 9-cell grid was NEEDS_PASSWORD for no password, WRONG_PASSWORD for any password including the correct one, and served only for allowlisted plus right; there was no membership oracle, so the defect was a misleading claim, not an oracle. H3: the after-grid has one identical answer (status, envelope and headers) in every refusal cell; it equals the unknown-token answer and the answer a password-less email link has always given, and nothing answers by email, so needs_decision is not triggered. H4: no new code; Clause-②: no. Code choice rationale: an allowlisted recipient with a typo reads 404 'invalid, expired, or revoked' and loses the 'Incorrect password' hint; the oracle constraint forces that loss, because their cell must equal the off-list cell. An off-list caller reads the same 404 their password-less email links always gave them. Rejected: NEEDS_PASSWORD (it tells a correct-password caller a password is required, the console loops on its prompt forever, and it discloses that the link has a password before the audience is satisfied), WRONG_PASSWORD (the defect), SIGN_IN_REQUIRED (false: sign-in does not satisfy an email audience), EXPIRED_OR_REVOKED (false for a live link), and a new ledger code (Clause-② yes, spec lane). Narrowing named in the changeset: an allowlisted email with no password now reads 404 where it read NEEDS_PASSWORD, so API/SDK callers send the email and password together. The console never sends ?email=, so it now shows the invalid page at once instead of a prompt that could never succeed.",
"tests": "All runs under scripts/pm/os-verify-lock.sh, each with its log redirected in the same invocation; the VERDICT lines are quoted. (1) Pins first, committed 36d7df9, run against main's probes: pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 src/domains/share-links-refusal-order.test.ts printed 'Tests 24 failed | 36 passed (60)' and 'VERDICT command-exit 1'. That is 12 red per mount: 10 email rows, the no-password-oracle pin and the generic-refusal pin. The membership pin was green on main, as H2 predicts. (2) Fix 20d78be; plugin-sharing rebuilt, because the runtime test resolves it through exports to dist/ (KNOWN_UNALIASED_TEST_IMPORTS runtime to plugin-sharing). scripts/ablation-dist-preflight.mjs printed '✓ dist/: marker present in 2 built files' for the emitted spelling with --source-marker. Same file: 'Tests 60 passed (60)', 'VERDICT command-exit 0'. (3) Full grid printed by an untracked scratch test (deleted; tree clean) on both mounts, before and after. Before: anonymous and signed-in alike, no email or off-list x {none, wrong, right} = 401 NEEDS_PASSWORD / 401 WRONG_PASSWORD / 401 WRONG_PASSWORD; allowlisted = NEEDS_PASSWORD / WRONG_PASSWORD / 200. Password-less email link: unsatisfied = 404 INVALID_OR_EXPIRED, allowlisted = 200. Unknown token = 404 INVALID_OR_EXPIRED. After: every unsatisfied cell = 404 INVALID_OR_EXPIRED with message 'Share link is invalid, expired, or revoked'; allowlisted plus right = 200. Both mounts identical in every cell. (4) Ablations through scripts/ablation-replace.mjs (anchor x1 to x0; restore 'blob == HEAD and git diff HEAD is empty'), each with its own EXIT/INT/TERM trap. Twin A1 (email arm removed): 'Tests 12 failed | 48 passed', all 12 in the dispatcher twin; excerpt: no-password-oracle pin '- code NEEDS_PASSWORD + code WRONG_PASSWORD'. Twin A2 (arm answers by email): 'Tests 5 failed | 55 passed', all in the twin; excerpt: membership pin 'anonymous, no password: expected { status: 401 } to deeply equal { status: 404 }', '+ code NEEDS_PASSWORD'. Plugin A1: rebuilt; preflight --absent '✓ dist/: marker absent from all 6 built files'; 'Tests 12 failed | 48 passed', all 12 in the plugin route. Plugin A2: rebuilt; preflight '✓ dist/: marker present in 2 built files'; 'Tests 5 failed | 55 passed', all in the plugin route. Each plugin restore leg was rebuilt, with preflight 'fix line present in 2 built files', A2 marker '--absent ✓', and '✓ tree: working tree clean against HEAD'. Directions as predicted: A1 leaves the membership pin green, and A2 is its failing leg. (5) At final head 3393488: plugin-sharing vitest 'Test Files 44 passed (44) / Tests 1055 passed (1055)'. runtime vitest --project local 'Test Files 373 passed (373) / Tests 5489 passed | 19 skipped (5508)'; --project repo 'Test Files 3 passed (3) / Tests 808 passed (808)'; 'VERDICT command-exit 0'. typecheck for both packages 'VERDICT command-exit 0', including check:test-typecheck. tsc -p tsconfig.test.json --listFiles counts the test file once, with zero error lines naming it; the exit 1 is the ledgered test-typecheck debt. (6) Gates at 3393488: dispatch-gates --ran 'Run reconciliation — 68 derived, 67 run, 1 NOT-MEASURED, 0 UNRUN' (exit 0). Battery: 71 exit 0, 3 exit 3. Of those 3, check-plugin-teardown-shape --self-test passed on re-run ('48 cases pass') after git fetch --depth=1 of its pinned commit, and check:i18n passed on re-run ('OK (10 package(s) — all bundles in sync') after building its named closure. Plus 6 roster families outside the derivation, all exit 0: check:route-ledger-census, check:tenant-chokepoint, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check-changeset-fixed. (7) Lint, narrowed: eslint --no-inline-config --format json on the 3 changed TS files reports 3 files linted (none 'ignored'), 0 errors, 0 warnings. Invariance: eslint.config.mjs enables no type-aware linting (its own header ~:328; parserOptions carry only ecmaVersion/sourceType), so the diff cannot move any untouched file's verdict. Repository-wide pnpm lint is CI's. NOT MEASURED: check:dual-build-cjs-loads, reason: 'PREREQUISITE NOT MET … some package has no dist/' (39 unbuilt packages; it has no scoping option; the full build is CI's). Narrowed stand-in: require() of plugin-sharing dist/index.js and runtime dist/index.cjs both load (exit 0).",
"mcp_calls": "0 — no MCP GitHub tool called, read or write",
"api_writes": "2 relay strokes (scripts/pm/with-fleet.sh --via dispatch and scripts/pm/post-stamped.mjs, executed as objectstack-fleet[bot]); each is POST /repos/objectstack-ai/objectstack/dispatches. Stroke 1, pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft, #23471) and POST /repos//issues/23471/assignees (marchtian); read-back '10101 byte(s) sent, 10101 stored — identical'. Stroke 2: POST /repos//issues/23372/comments (this os-dev-report). Label writes: 0, because the dispatch named no label and skip-changeset does not apply (two published packages change, and a changeset is present). git push x3, not REST: empty-branch probe, then 20d78be, then 3393488. REST reads only otherwise: GET the issue, its comments, and PR 23471.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack --ran at 3393488: 68 derived, 67 run, 1 NOT-MEASURED (pnpm check:dual-build-cjs-loads :: exit 3), 0 UNRUN, exit 0. Outside the derivation and run, exit 0: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing, pnpm check:filter-alias-parity, pnpm check:route-ledger-census, pnpm check:tenant-chokepoint.",
"line_budget": "+172 / -10 across 4 files; dispatch-gates changed lines 182, against the human-merge threshold of 3000: under. No governed surface touched.",
"files_changed": [
".changeset/23372-share-link-email-audience-uniform-refusal.md",
"packages/plugins/plugin-sharing/src/share-link-routes.ts",
"packages/runtime/src/domains/share-links.ts",
"packages/runtime/src/domains/share-links-refusal-order.test.ts"
],
"deviations": [
"git fetch --depth=1 origin 621a487: makes the pinned positive-control commit of check-plugin-teardown-shape --self-test readable. It adds one shallow root to the shared object store; no ref moved.",
"Per the lane clause, the gate battery (check:* included) ran under os-verify-lock.sh and held the lock 1098s. os-dev.md describes check:* gates as running outside the lock; this is not a hard conflict, and it is flagged only because of the long hold.",
"Commit trailers use the AGENTS.md model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-named trailer. The PR body footer uses the AGENTS.md session-URL form, not the harness reminder's form."
],
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door GET /api/v1/share-links/:token/messages, measured in-process through both mounts' production handlers on SqlDriver. With 400 UNSUPPORTED as the 'it resolved' control (a public link on a non-conversation object reads 400 on both mounts): on both mounts, an email-audience link with an allowlisted ?email= (with or without its correct password) reads 200 on /resolve but 404 NOT_FOUND on /messages, because neither mount's /messages forwards recipientEmail to resolveToken. On the plugin mount only, a signed-in caller of a signed_in link reads 200 on /resolve but 404 NOT_FOUND on /messages, because plugin /messages forwards no signedInUserId either; the dispatcher twin forwards it and answers 400, so the two mounts diverge. Named producer: the console's SharedRecordPage fetches /messages for ai_conversations links. Seam: runtime: plugin-sharing share-link-routes.ts /messages resolveToken call (no signedInUserId, no recipientEmail) and runtime/src/domains/share-links.ts /messages (no recipientEmail). Not fixed here: a different door and a different defect, so the bounded in-place-fix exemption fails on ① and ④. · dedupe: share link messages 404 signed_in audience; share-links messages recipientEmail not forwarded; /messages audience gate mount divergence; shared conversation messages not loading",
"carrier: 承接者:无 · noted, not filed — the email audience is satisfied by the caller's own unverified ?email= claim, so the gate admits anyone who presents a listed address. It is an observation about the audience's design; no contract text claims verification. It is not in the PR's Acceptance notes; the seat may add a line there if it wants it recorded."
]
}
domain:services seat 1 · marchtian · session_01LxHPTb1LxL7a6WkQbALKnD · 2026-10-14T11:50Z, under claim 6166524822. The review was against GitHub and origin/main, ⛔ not against the report (6167578994).
Scope: 4 files, all inside the claim's surface: the two probes, share-links-refusal-order.test.ts and .changeset/23372-share-link-email-audience-uniform-refusal.md. No content/docs/releases/.
Changeset:patch for @objectstack/plugin-sharing and @objectstack/runtime, both published (no private). I read each sentence against the diff:
"404 INVALID_OR_EXPIRED … signed in or not": holds. The arm reads neither the email nor the session.
"Unchanged: … the expired-or-revoked 410 EXPIRED_OR_REVOKED": holds. The plugin arm sits inside the live-row block, and the twin's arm is guarded by live &&.
"a public link's NEEDS_PASSWORD / WRONG_PASSWORD": holds. public is an audience (ShareLinkAudience), and its arms are untouched.
Spot readings on the diff:
Each probe gains one arm, row.audience === 'email', which returns the existing invalidOrExpired() helper. It is placed after the signed_in arm and before the password arm.
No ledger code is added, and error-code-ledger.zod.ts is untouched, so Clause-②: no holds.
resolveToken is unchanged.
Pins:
They live in the one table that holds both mounts, so a divergence between the mounts fails there.
Every refusal row asserts status and error.code. Three pins compare whole answers (status, envelope and headers): no password oracle, no membership oracle, and the unknown-token answer.
The pins were committed red before the fix: 24 failed on main's probes, 12 per mount.
Ablation ran on both mounts, with two legs each. A1 (the arm removed) turns 12 red. A2 (the arm answering by email, the oracle) turns 5 red, including the membership pin.
Direction check: the after-grid puts every refusal cell of an email link on one answer, an allowlisted email with a wrong password included. That cell is what the card's rule forces ("⛔ No answer that differs by password correctness before the audience is satisfied", with "satisfied" here being an unverified claim). The PR body's oracle argument records why. Nothing answers by email, so by triage's rule (6159876342) no [Decision] card is owed.
Deviations accepted:
the shallow fetch of a gate's pinned control commit, which moved no ref;
The email audience is satisfied by the caller's own unverified ?email= claim. Recorded in Acceptance notes, through the PR body's "The oracle argument". Not filed at this act: the issue-search read failed its positive control (it did not return this card), so no dedupe was possible. Carried to the seat's round report.
Product cost, open to the maintainer's veto: an allowlisted recipient who mistypes the password now reads "invalid, expired, or revoked", where they read "Incorrect password" before. The no-oracle rule forces this, because that cell must equal the off-list cell.
Landing (pending; the seat holds it):
Every check is green on 33934882 (it was converging at this read).
A contract-review-tier record on this head (Served-tier: CONTRACT_REVIEW_TIER) reads PASS. That reviewer is running, isolated and read-only.
Then pr_ready and automerge_enable go through the relay. The path face is not governed (no .claude/**, docs/adr/**, skills/**, .github/workflows/** or AGENTS.md), and the diff is 182 lines, under the 3000-line human-merge threshold.
0ecac961 is on main. git grep shows the email arm in both probes (1 hit each in share-link-routes.ts and runtime/src/domains/share-links.ts). The changeset .changeset/23372-share-link-email-audience-uniform-refusal.md is on main.
Contract review
PASS on head 33934882, Served-tier: CONTRACT_REVIEW_TIER (6167789839 on the PR)
Lane reconciliation
domain:services open cards went from 22 to 21. Only this card left, so no closing keyword closed anything else.
State change in this act: the card was closed completed by the merge. pm:dispatched and the assignee are now cleared; the domain, type and priority labels stay.
Activity
objectstack-fleet commentedon Oct 14, 2026
Triage: first grade,
bug·security·priority:p3·domain:services·area:access, intopm:queue(findingremoved). An anonymous caller of an email-audience link gets one refusal, whatever password and email it sends. No decision is needed for that pathTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T02:58Z. ⛔ Not a claim, ⛔ not a dispatch.[Decision]card.security: today's answer is misleading (a correct password reads as wrong), but it is not an oracle. The console builds no email audience (objectuiShareDialog.tsx~:128), so the pull is API and SDK only. Thesecuritylabel guards the fix: a naive reorder would create the oracle.plugin-sharingand its dispatcher twin,domain:services, declared in the claim.Clause-②: no.objectstack-fleet commentedon Oct 14, 2026
Regrade: p3 → p1 ·
target:v18· protocol-first rule, all lanes · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T08:18ZUnder the maintainer's grading rule, recorded on #6015 (
6163099154, amended6163539661) and extended today, 「立卡,规则扩到所有车道,REST 收窄也算」: every metadata-protocol change, and every narrowing of a REST or data door, is p1 +target:v18in every lane. The skill text follows in #23431.Lane, scope and Clause ② are unchanged.
objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 1 · 2026-10-14T10:42Z · serial, p1 only (the maintainer's scope for this session, opening marker
6166503146on #6021)Session:
session_01LxHPTb1LxL7a6WkQbALKnDAccount:
marchtian(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-23372-email-audience-uniform-refusalWorktree:
objectstack-issue-23372Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface:
packages/plugins/plugin-sharing/src/share-link-routes.ts(the public resolve door's refusal probe) andpackages/runtime/src/domains/share-links.ts(its dispatcher twin), their tests (packages/runtime/src/domains/share-links-refusal-order.test.ts, which holds both mounts to one order, andpackages/plugins/plugin-sharing/src/share-link-*.test.ts), and one.changeset/23372-*.md(stop on breach; explain in the report)Container & model:
M,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"; the pre-queue review is at the ceiling, CONTRACT_REVIEW_TIER, reason: the same output, "A card that only NARROWS one isClause-②: no, stays in its lane, and owes one contract-review-tier review before the queue"Clause-②: no
Responsibility:
the resolve door's refusal probe on both mounts (plugin-sharing share-link-routes.ts and its runtime twin) picks the refusal answer, and it has no arm for an email audience, so an audience refusal falls through to the password arm | none — resolveToken already refuses on the audience with the undifferentiated null, and only the probe chooses what the caller reads | any anonymous holder of a token for an email-audience link that has a password, through the API or SDK; the console builds no email audience today (card body, objectui ShareDialog.tsx)Thread-read: 6164048025
Serial constraints cleared: #23328 landed as
28e294f4(PR #23369, today 02:57Z), in the same probe block on both mounts; this card builds on it. PR #23440 (draft, #23287) touchesshare-link-routes.tsonly in therefuseHeldSessiondocblock, which is disjoint from the probe; the later lander mergesmain. #23371 (p2,pm:queue) sharesshare-link-routes.ts. Fold-or-serial: serial, because fold gate ① fails (it is a different defect with a different fix), and it stays behind this card. No other open PR or in-flight claim touchesruntime/src/domains/share-links.ts. #15204's declaredplugin-sharingterritory (sharing-rule-service.ts) is disjoint.Disclosure: this is a
securitycard in this lane. ⛔ No reproduction recipe goes on any public surface (card, PR or comment). Evidence is described abstractly.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{
"issue": 23372,
"status": "done",
"branch": "claude/issue-23372-email-audience-uniform-refusal",
"pr": "#23471",
"session": "session_01LxHPTb1LxL7a6WkQbALKnD — in-session subagent; this is the parent PM session's id",
"premise_still_valid": true,
"summary": "Both share-link resolve probes (plugin-sharing share-link-routes.ts and the runtime dispatcher twin share-links.ts) gain an email-audience arm after the signed_in arm and before the password arm. A live email-audience link that did not resolve now answers the probe's existing generic refusal, 404 INVALID_OR_EXPIRED, through the existing invalidOrExpired() helper, for every caller: any email or none, any password or none, signed in or not. The arm reads neither the claimed email nor password correctness, so no answer can differ by allowlist membership or password correctness. Only the served cell differs: an allowlisted email plus the right password. H1 confirmed on the tree and by measurement. H2 confirmed: before the change, the 9-cell grid was NEEDS_PASSWORD for no password, WRONG_PASSWORD for any password including the correct one, and served only for allowlisted plus right; there was no membership oracle, so the defect was a misleading claim, not an oracle. H3: the after-grid has one identical answer (status, envelope and headers) in every refusal cell; it equals the unknown-token answer and the answer a password-less email link has always given, and nothing answers by email, so needs_decision is not triggered. H4: no new code; Clause-②: no. Code choice rationale: an allowlisted recipient with a typo reads 404 'invalid, expired, or revoked' and loses the 'Incorrect password' hint; the oracle constraint forces that loss, because their cell must equal the off-list cell. An off-list caller reads the same 404 their password-less email links always gave them. Rejected: NEEDS_PASSWORD (it tells a correct-password caller a password is required, the console loops on its prompt forever, and it discloses that the link has a password before the audience is satisfied), WRONG_PASSWORD (the defect), SIGN_IN_REQUIRED (false: sign-in does not satisfy an email audience), EXPIRED_OR_REVOKED (false for a live link), and a new ledger code (Clause-② yes, spec lane). Narrowing named in the changeset: an allowlisted email with no password now reads 404 where it read NEEDS_PASSWORD, so API/SDK callers send the email and password together. The console never sends ?email=, so it now shows the invalid page at once instead of a prompt that could never succeed.",
"tests": "All runs under scripts/pm/os-verify-lock.sh, each with its log redirected in the same invocation; the VERDICT lines are quoted. (1) Pins first, committed 36d7df9, run against main's probes: pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 src/domains/share-links-refusal-order.test.ts printed 'Tests 24 failed | 36 passed (60)' and 'VERDICT command-exit 1'. That is 12 red per mount: 10 email rows, the no-password-oracle pin and the generic-refusal pin. The membership pin was green on main, as H2 predicts. (2) Fix 20d78be; plugin-sharing rebuilt, because the runtime test resolves it through exports to dist/ (KNOWN_UNALIASED_TEST_IMPORTS runtime to plugin-sharing). scripts/ablation-dist-preflight.mjs printed '✓ dist/: marker present in 2 built files' for the emitted spelling with --source-marker. Same file: 'Tests 60 passed (60)', 'VERDICT command-exit 0'. (3) Full grid printed by an untracked scratch test (deleted; tree clean) on both mounts, before and after. Before: anonymous and signed-in alike, no email or off-list x {none, wrong, right} = 401 NEEDS_PASSWORD / 401 WRONG_PASSWORD / 401 WRONG_PASSWORD; allowlisted = NEEDS_PASSWORD / WRONG_PASSWORD / 200. Password-less email link: unsatisfied = 404 INVALID_OR_EXPIRED, allowlisted = 200. Unknown token = 404 INVALID_OR_EXPIRED. After: every unsatisfied cell = 404 INVALID_OR_EXPIRED with message 'Share link is invalid, expired, or revoked'; allowlisted plus right = 200. Both mounts identical in every cell. (4) Ablations through scripts/ablation-replace.mjs (anchor x1 to x0; restore 'blob == HEAD and git diff HEAD is empty'), each with its own EXIT/INT/TERM trap. Twin A1 (email arm removed): 'Tests 12 failed | 48 passed', all 12 in the dispatcher twin; excerpt: no-password-oracle pin '- code NEEDS_PASSWORD + code WRONG_PASSWORD'. Twin A2 (arm answers by email): 'Tests 5 failed | 55 passed', all in the twin; excerpt: membership pin 'anonymous, no password: expected { status: 401 } to deeply equal { status: 404 }', '+ code NEEDS_PASSWORD'. Plugin A1: rebuilt; preflight --absent '✓ dist/: marker absent from all 6 built files'; 'Tests 12 failed | 48 passed', all 12 in the plugin route. Plugin A2: rebuilt; preflight '✓ dist/: marker present in 2 built files'; 'Tests 5 failed | 55 passed', all in the plugin route. Each plugin restore leg was rebuilt, with preflight 'fix line present in 2 built files', A2 marker '--absent ✓', and '✓ tree: working tree clean against HEAD'. Directions as predicted: A1 leaves the membership pin green, and A2 is its failing leg. (5) At final head 3393488: plugin-sharing vitest 'Test Files 44 passed (44) / Tests 1055 passed (1055)'. runtime vitest --project local 'Test Files 373 passed (373) / Tests 5489 passed | 19 skipped (5508)'; --project repo 'Test Files 3 passed (3) / Tests 808 passed (808)'; 'VERDICT command-exit 0'. typecheck for both packages 'VERDICT command-exit 0', including check:test-typecheck. tsc -p tsconfig.test.json --listFiles counts the test file once, with zero error lines naming it; the exit 1 is the ledgered test-typecheck debt. (6) Gates at 3393488: dispatch-gates --ran 'Run reconciliation — 68 derived, 67 run, 1 NOT-MEASURED, 0 UNRUN' (exit 0). Battery: 71 exit 0, 3 exit 3. Of those 3, check-plugin-teardown-shape --self-test passed on re-run ('48 cases pass') after git fetch --depth=1 of its pinned commit, and check:i18n passed on re-run ('OK (10 package(s) — all bundles in sync') after building its named closure. Plus 6 roster families outside the derivation, all exit 0: check:route-ledger-census, check:tenant-chokepoint, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check-changeset-fixed. (7) Lint, narrowed: eslint --no-inline-config --format json on the 3 changed TS files reports 3 files linted (none 'ignored'), 0 errors, 0 warnings. Invariance: eslint.config.mjs enables no type-aware linting (its own header ~:328; parserOptions carry only ecmaVersion/sourceType), so the diff cannot move any untouched file's verdict. Repository-wide pnpm lint is CI's. NOT MEASURED: check:dual-build-cjs-loads, reason: 'PREREQUISITE NOT MET … some package has no dist/' (39 unbuilt packages; it has no scoping option; the full build is CI's). Narrowed stand-in: require() of plugin-sharing dist/index.js and runtime dist/index.cjs both load (exit 0).",
"mcp_calls": "0 — no MCP GitHub tool called, read or write",
"api_writes": "2 relay strokes (scripts/pm/with-fleet.sh --via dispatch and scripts/pm/post-stamped.mjs, executed as objectstack-fleet[bot]); each is POST /repos/objectstack-ai/objectstack/dispatches. Stroke 1, pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft, #23471) and POST /repos//issues/23471/assignees (marchtian); read-back '10101 byte(s) sent, 10101 stored — identical'. Stroke 2: POST /repos//issues/23372/comments (this os-dev-report). Label writes: 0, because the dispatch named no label and skip-changeset does not apply (two published packages change, and a changeset is present). git push x3, not REST: empty-branch probe, then 20d78be, then 3393488. REST reads only otherwise: GET the issue, its comments, and PR 23471.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack --ran at 3393488: 68 derived, 67 run, 1 NOT-MEASURED (pnpm check:dual-build-cjs-loads :: exit 3), 0 UNRUN, exit 0. Outside the derivation and run, exit 0: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing, pnpm check:filter-alias-parity, pnpm check:route-ledger-census, pnpm check:tenant-chokepoint.",
"line_budget": "+172 / -10 across 4 files; dispatch-gates changed lines 182, against the human-merge threshold of 3000: under. No governed surface touched.",
"files_changed": [
".changeset/23372-share-link-email-audience-uniform-refusal.md",
"packages/plugins/plugin-sharing/src/share-link-routes.ts",
"packages/runtime/src/domains/share-links.ts",
"packages/runtime/src/domains/share-links-refusal-order.test.ts"
],
"deviations": [
"git fetch --depth=1 origin 621a487: makes the pinned positive-control commit of check-plugin-teardown-shape --self-test readable. It adds one shallow root to the shared object store; no ref moved.",
"Per the lane clause, the gate battery (check:* included) ran under os-verify-lock.sh and held the lock 1098s. os-dev.md describes check:* gates as running outside the lock; this is not a hard conflict, and it is flagged only because of the long hold.",
"Commit trailers use the AGENTS.md model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-named trailer. The PR body footer uses the AGENTS.md session-URL form, not the harness reminder's form."
],
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door GET /api/v1/share-links/:token/messages, measured in-process through both mounts' production handlers on SqlDriver. With 400 UNSUPPORTED as the 'it resolved' control (a public link on a non-conversation object reads 400 on both mounts): on both mounts, an email-audience link with an allowlisted ?email= (with or without its correct password) reads 200 on /resolve but 404 NOT_FOUND on /messages, because neither mount's /messages forwards recipientEmail to resolveToken. On the plugin mount only, a signed-in caller of a signed_in link reads 200 on /resolve but 404 NOT_FOUND on /messages, because plugin /messages forwards no signedInUserId either; the dispatcher twin forwards it and answers 400, so the two mounts diverge. Named producer: the console's SharedRecordPage fetches /messages for ai_conversations links. Seam: runtime: plugin-sharing share-link-routes.ts /messages resolveToken call (no signedInUserId, no recipientEmail) and runtime/src/domains/share-links.ts /messages (no recipientEmail). Not fixed here: a different door and a different defect, so the bounded in-place-fix exemption fails on ① and ④. · dedupe: share link messages 404 signed_in audience; share-links messages recipientEmail not forwarded; /messages audience gate mount divergence; shared conversation messages not loading",
"carrier: 承接者:无 · noted, not filed — the email audience is satisfied by the caller's own unverified ?email= claim, so the gate admits anyone who presents a listed address. It is an observation about the audience's design; no contract text claims verification. It is not in the PR's Acceptance notes; the seat may add a line there if it wants it recorded."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
ACCEPT → PR #23471 @
33934882f849b1fcdd311113e73f86a3b1fb964fdomain:servicesseat 1 ·marchtian·session_01LxHPTb1LxL7a6WkQbALKnD· 2026-10-14T11:50Z, under claim6166524822. The review was against GitHub andorigin/main, ⛔ not against the report (6167578994).Checklist
main. The body's first line isFixes #23372, and no other closing keyword sits beside an open card number (the only other reference, plugin-sharing: a share link with both a password and audience 'signed_in', opened anonymously with the CORRECT password, is answered 401 WRONG_PASSWORD, so the landing page says 'Wrong password.' and never offers sign-in #23328, is closed and carries no keyword). TheClause-②: noline is at the start of a line.share-links-refusal-order.test.tsand.changeset/23372-share-link-email-audience-uniform-refusal.md. Nocontent/docs/releases/.patchfor@objectstack/plugin-sharingand@objectstack/runtime, both published (noprivate). I read each sentence against the diff:live &&.publicis an audience (ShareLinkAudience), and its arms are untouched.row.audience === 'email', which returns the existinginvalidOrExpired()helper. It is placed after thesigned_inarm and before the password arm.error-code-ledger.zod.tsis untouched, soClause-②: noholds.resolveTokenis unchanged.statusanderror.code. Three pins compare whole answers (status, envelope and headers): no password oracle, no membership oracle, and the unknown-token answer.main's probes, 12 per mount.emaillink on one answer, an allowlisted email with a wrong password included. That cell is what the card's rule forces ("⛔ No answer that differs by password correctness before the audience is satisfied", with "satisfied" here being an unverified claim). The PR body's oracle argument records why. Nothing answers by email, so by triage's rule (6159876342) no[Decision]card is owed./messagesforwards neither the recipient email (both mounts) nor the signed-in user (plugin mount) toresolveToken. Folded into plugin-sharing: the two mounts of the share-link messages door disagree for a signed-in caller of a signed_in conversation link — the plugin mount answers 404, the dispatcher 200 — because the plugin mount passes no signed-in user to resolveToken #23371 as the family's closing card (6167657863). ⛔ No single-point card.emailaudience is satisfied by the caller's own unverified?email=claim. Recorded in Acceptance notes, through the PR body's "The oracle argument". Not filed at this act: the issue-search read failed its positive control (it did not return this card), so no dedupe was possible. Carried to the seat's round report.Landing (pending; the seat holds it):
33934882(it was converging at this read).Served-tier: CONTRACT_REVIEW_TIER) reads PASS. That reviewer is running, isolated and read-only.Then
pr_readyandautomerge_enablego through the relay. The path face is not governed (no.claude/**,docs/adr/**,skills/**,.github/workflows/**orAGENTS.md), and the diff is 182 lines, under the 3000-line human-merge threshold.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
Landed: PR #23471 →
0ecac961onmaindomain:servicesseat 1 ·marchtian·session_01LxHPTb1LxL7a6WkQbALKnD· 2026-10-14T12:45Z, under claim6166524822and ACCEPT6167684492.merged: trueat 2026-10-14T12:43:43Z, merge commit0ecac9613ba8be9aa8b4ab0732902bc62579584eready_for_review12:11:41Z →auto_merge_enabled12:11:45Z →added_to_merge_queue12:13:26Zorigin/main0ecac961is on main.git grepshows theemailarm in both probes (1 hit each inshare-link-routes.tsandruntime/src/domains/share-links.ts). The changeset.changeset/23372-share-link-email-audience-uniform-refusal.mdis on main.33934882,Served-tier: CONTRACT_REVIEW_TIER(6167789839on the PR)domain:servicesopen cards went from 22 to 21. Only this card left, so no closing keyword closed anything else.State change in this act: the card was closed
completedby the merge.pm:dispatchedand the assignee are now cleared; the domain, type and priority labels stay.Follow-ups, with their owners:
/messagesforwards neither audience input on every mount. That is plugin-sharing: the two mounts of the share-link messages door disagree for a signed-in caller of a signed_in conversation link — the plugin mount answers 404, the dispatcher 200 — because the plugin mount passes no signed-in user to resolveToken #23371, folded in at6167657863,pm:queue, behind this card on the shared file. This merge unblocks it.email-audience refusal may depend on caller input. That is security(sharing): the share-link resolve gate may answer an email-audience refusal at a cost that depends on caller input — detail withheld pending maintainer #23474 (finding,security, detail withheld pending maintainer). It is triage's to grade, and its first step is a reachability measurement.Generated by Claude Code