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
copy_invitation_link: a sys_invitation still status=pending past expires_at is offered the action and answered a link (visible and the handler read status only) #28384
Filing gate ①: a framework defect found by reading the code, not by a measured run. The cloud#3386 thread found it while surfacing copy_invitation_link on cloud's team page (PR objectstack-ai/cloud#3429).
维护者速读
「复制邀请链接」只看邀请的 status 字段。如果一条邀请已经过了 expires_at、但状态还是 pending(没有任务把它改成 expired),这个操作仍然显示,也仍然给出链接。对方点开时才会被拒,管理员却已经把一个失效的链接发出去了。
The visible expression on sys_invitation's copy_invitation_link row action reads status == 'pending' only.
The handler checks status only, and answers the accept URL.
Expected
The action is hidden, and the handler answers 409, when expires_at is in the past, whatever the stored status says. The same rule applies to resend if it shares the check. Tests cover a pending row past expires_at.
Reader who acts
Framework triage (the sys_invitation actions). cloud needs no change; its page uses the framework's action as-is.
Dedup
I searched objectstack issues for "copy_invitation_link expires_at pending expired invitation": no open card.
Read fresh, in code. copy_invitation_link checks status only, so a pending invitation past expires_at is still offered and still answers a link. It is refused only when opened. The card says cloud needs no change, so this is framework-only work and stays in a framework lane (domain:server), not with the cloud PM.
p3: the link fails closed when opened. Hide the action and answer 409 when expires_at is past, apply the same check to resend, and pin a pending row past expiry.
Claim: PM loop round 4 · card thread · 2026-11-01T03:50Z
Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5mvxN1Jh9iwvcsEv1R
Account: os-project-manager (the seat's linked user; the card's assignee)
Branch: claude/project-thread-rypvzz (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#2
Ruling-ref: the maintainer, 2026-11-01T03:46Z: 「server 车道现在就是你负责,任务有一大堆啊,都可以派,包括 p3」, with 2026-10-26T10:29Z 「p1 p2 处理完了,就可以处理 p3」. Triage 6541933328: hide the action and answer 409 when expires_at is past; pin a pending row past expiry. Resend renews the expiry, so the order keeps it offered (the dev measures).
File surface: packages/plugins/plugin-auth/src/invitation-link-action.ts (the copy-link handler), packages/platform-objects/src/identity/sys-invitation.object.ts (the action's visible), generated translation bundles only through their generator, tests, and changesets.
Container & model: M, mode:thread, model: opus (--tier names no path mandate: PM's call, default), effort: high
Clause-②: yes (expected: narrowing — the handler refuses a call it answers today; a contract review at CONTRACT_REVIEW_TIER is owed before the queue)
Metadata-protocol: none (expected; the dev measures and declares)
Responsibility: the code is plugin-auth's copy-invitation-link action · the platform path is the sys_invitation row action an org admin uses · reached by an admin copying the link of a pending invitation whose expires_at has passed
Thread-read: none
Premise: holds (read 2026-11-01T03:50Z at origin/main: the handler reads status only (≈L154) and visible reads status == 'pending' only); the dev re-measures first
Serial constraints cleared: no open PR touches either file at this read
Resume-from: none — fresh from origin/main
State: → pm:dispatched, assigned to os-project-manager, recorded by this claim.
os-dev-report
{
"issue": 28384,
"status": "done",
"branch": "claude/project-thread-rypvzz",
"pr": "#28529",
"session": "session_01FEkQ5mvxN1Jh9iwvcsEv1R",
"premise_still_valid": true,
"summary": "Premise held at origin/main 1f1a369: the handler read status only and visible read status only. createCopyInvitationLinkHandler now reads expires_at and adds a sixth gate after pending: a row whose expires_at is not in the future (missing or unreadable counts as expired, as the vendor reads it) is refused 409 RESOURCE_CONFLICT, 'This invitation has expired, so its link no longer lets anyone join. Resend the invitation to give the person a link that works.' Reach is still judged first. copy_invitation_link visible adds has(record.expires_at) && record.expires_at != null && datetime(record.expires_at) > now(); the grid harvests record.FIELD refs of action predicates into $select (collectPredicateFieldRefs), so no list view changed. Resend measured: its visible is the lowered feature and reach gates only, it does not share the check, and it stays offered on an expired row with no expiry gate. Clause-2 measured as 'no (narrowing)' (claim expected 'yes'): nothing is widened. Metadata-protocol: none. Head 52e7cd7.",
"tests": "Red first (prod sources at 1f1a369, tests at head, both suites import src): platform-objects invitation-delivery-copy 2 failed / 29 passed (expired pending row offered; pending row without expires_at offered); plugin-auth invitation-delivery-outcome 2 failed / 30 passed (expired row answered a link; unreadable expires_at answered); controls green on base (future row, reach before expiry, Resend). Restore git diff HEAD empty. Ablation via scripts/ablation-replace.mjs, handler gate only (anchor ' if (!isBeforeExpiry(' -> ' if (["ABL28384"].length < 0 && !isBeforeExpiry(', anchor 1->0, blob df97aacb->c0980674), visible kept: plugin-auth 2 failed / 30 passed, the two handler cases; restore blob = HEAD, marker 0. Package suites on 52e7cd7: platform-objects test exit 0 (104 files, 1477 passed), plugin-auth test exit 0 (207 files, 3943 passed, 10 skipped), both typecheck exit 0, scoped eslint --no-inline-config exit 0.",
"gates": "dispatch-gates --commands derived 62 families at 52e7cd7; --ran: 61 run exit 0, 1 NOT MEASURED (pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET, needs every package dist; CI runs it). check-plugin-teardown-shape --self-test exit 3 on the shallow clone, exit 0 after git fetch --depth=1 of its control commit. Hand: check-commit-card-trailers --range origin/main..HEAD exit 0, check-platform-checklist --changed-from origin/main exit 0. CI check-runs on PR #28529: in_progress at report time (not awaited, per os-dev).",
"dogfood_battery": "52e7cd7c · os-verify-lock: VERDICT command-exit 0 (held 19m20s) · Test Files 363 passed / 3 skipped (366) · Tests 2932 passed / 28 skipped (2960) · no failure",
"mcp_calls": "0",
"api_writes": "2 — git push (branch claude/project-thread-rypvzz); relay pr_create (fleet-write run 41274953252 -> PR #28529, no labels, no assignees); this os-dev-report comment via post-stamped is the third",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door, POST /api/v1/auth/organization/invite-member with resend:true (the Resend Invitation row action) measured in the plugin-auth harness on better-auth 1.7.12: on a pending row past expires_at it answers a NEW invitation (different id) and leaves the expired row reading pending, because the vendor's findPendingInvitation filters expired rows out. Resend's confirmation text 'Its expiry date starts over' is not what happens there, and the Pending list then shows two rows for the same person. · dedupe words: resend expired invitation new id, Its expiry date starts over, pending invitation duplicate row, findPendingInvitation expiry filter",
"carrier: none · noted in PR #28529 Acceptance notes, not filed"
]
}
Landed (Fixes): merged as aa46d141. An expired invitation's link is no longer offered or answered. Copy Invite Link is hidden on a pending invitation past its expiry, and the action answers 409 RESOURCE_CONFLICT there. Resend stays offered as the remedy.
Unchanged: an invitation that has not expired still answers its link. Non-pending invitations keep their existing refusal. Resend is unchanged.
Activity
objectstack-fleet commentedon Oct 31, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 真挡得住的权限 | none | P②
Triage: p3 ·
domain:server·area:identity· relabelled fromdomain:cloud· 2026-10-31T09:54Z⛔ Not a claim.
copy_invitation_linkchecksstatusonly, so a pending invitation pastexpires_atis still offered and still answers a link. It is refused only when opened. The card says cloud needs no change, so this is framework-only work and stays in a framework lane (domain:server), not with the cloud PM.expires_atis past, apply the same check toresend, and pin a pending row past expiry.Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo
Claim: PM loop round 4 · card thread · 2026-11-01T03:50Z
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01FEkQ5mvxN1Jh9iwvcsEv1RAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-rypvzz(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2Ruling-ref: the maintainer, 2026-11-01T03:46Z: 「server 车道现在就是你负责,任务有一大堆啊,都可以派,包括 p3」, with 2026-10-26T10:29Z 「p1 p2 处理完了,就可以处理 p3」. Triage
6541933328: hide the action and answer 409 whenexpires_atis past; pin a pending row past expiry. Resend renews the expiry, so the order keeps it offered (the dev measures).File surface:
packages/plugins/plugin-auth/src/invitation-link-action.ts(the copy-link handler),packages/platform-objects/src/identity/sys-invitation.object.ts(the action'svisible), generated translation bundles only through their generator, tests, and changesets.Container & model:
M,mode:thread,model: opus(--tiernames no path mandate: PM's call, default),effort: highClause-②: yes (expected: narrowing — the handler refuses a call it answers today; a contract review at
CONTRACT_REVIEW_TIERis owed before the queue)Metadata-protocol: none (expected; the dev measures and declares)
Responsibility: the code is plugin-auth's copy-invitation-link action · the platform path is the
sys_invitationrow action an org admin uses · reached by an admin copying the link of a pending invitation whoseexpires_athas passedThread-read: none
Premise: holds (read 2026-11-01T03:50Z at
origin/main: the handler readsstatusonly (≈L154) andvisiblereadsstatus == 'pending'only); the dev re-measures firstSerial constraints cleared: no open PR touches either file at this read
Resume-from: none — fresh from
origin/mainState: →
pm:dispatched, assigned toos-project-manager, recorded by this claim.Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#2 · os-project-manager · session_01FEkQ5mvxN1Jh9iwvcsEv1R
os-dev-report
{
"issue": 28384,
"status": "done",
"branch": "claude/project-thread-rypvzz",
"pr": "#28529",
"session": "session_01FEkQ5mvxN1Jh9iwvcsEv1R",
"premise_still_valid": true,
"summary": "Premise held at origin/main 1f1a369: the handler read status only and visible read status only. createCopyInvitationLinkHandler now reads expires_at and adds a sixth gate after pending: a row whose expires_at is not in the future (missing or unreadable counts as expired, as the vendor reads it) is refused 409 RESOURCE_CONFLICT, 'This invitation has expired, so its link no longer lets anyone join. Resend the invitation to give the person a link that works.' Reach is still judged first. copy_invitation_link visible adds has(record.expires_at) && record.expires_at != null && datetime(record.expires_at) > now(); the grid harvests record.FIELD refs of action predicates into $select (collectPredicateFieldRefs), so no list view changed. Resend measured: its visible is the lowered feature and reach gates only, it does not share the check, and it stays offered on an expired row with no expiry gate. Clause-2 measured as 'no (narrowing)' (claim expected 'yes'): nothing is widened. Metadata-protocol: none. Head 52e7cd7.",
"tests": "Red first (prod sources at 1f1a369, tests at head, both suites import src): platform-objects invitation-delivery-copy 2 failed / 29 passed (expired pending row offered; pending row without expires_at offered); plugin-auth invitation-delivery-outcome 2 failed / 30 passed (expired row answered a link; unreadable expires_at answered); controls green on base (future row, reach before expiry, Resend). Restore git diff HEAD empty. Ablation via scripts/ablation-replace.mjs, handler gate only (anchor ' if (!isBeforeExpiry(' -> ' if (["ABL28384"].length < 0 && !isBeforeExpiry(', anchor 1->0, blob df97aacb->c0980674), visible kept: plugin-auth 2 failed / 30 passed, the two handler cases; restore blob = HEAD, marker 0. Package suites on 52e7cd7: platform-objects test exit 0 (104 files, 1477 passed), plugin-auth test exit 0 (207 files, 3943 passed, 10 skipped), both typecheck exit 0, scoped eslint --no-inline-config exit 0.",
"gates": "dispatch-gates --commands derived 62 families at 52e7cd7; --ran: 61 run exit 0, 1 NOT MEASURED (pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET, needs every package dist; CI runs it). check-plugin-teardown-shape --self-test exit 3 on the shallow clone, exit 0 after git fetch --depth=1 of its control commit. Hand: check-commit-card-trailers --range origin/main..HEAD exit 0, check-platform-checklist --changed-from origin/main exit 0. CI check-runs on PR #28529: in_progress at report time (not awaited, per os-dev).",
"dogfood_battery": "52e7cd7c · os-verify-lock: VERDICT command-exit 0 (held 19m20s) · Test Files 363 passed / 3 skipped (366) · Tests 2932 passed / 28 skipped (2960) · no failure",
"mcp_calls": "0",
"api_writes": "2 — git push (branch claude/project-thread-rypvzz); relay pr_create (fleet-write run 41274953252 -> PR #28529, no labels, no assignees); this os-dev-report comment via post-stamped is the third",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door, POST /api/v1/auth/organization/invite-member with resend:true (the Resend Invitation row action) measured in the plugin-auth harness on better-auth 1.7.12: on a pending row past expires_at it answers a NEW invitation (different id) and leaves the expired row reading pending, because the vendor's findPendingInvitation filters expired rows out. Resend's confirmation text 'Its expiry date starts over' is not what happens there, and the Pending list then shows two rows for the same person. · dedupe words: resend expired invitation new id, Its expiry date starts over, pending invitation duplicate row, findPendingInvitation expiry filter",
"carrier: none · noted in PR #28529 Acceptance notes, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo
Landing record · #28384 · PR #28529
domain:serverseat 2 ·session_0186vfjHHMsRXC9EUT9hTFMo· 2026-11-01T05:48Zgit ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-28529-*'git merge-base --is-ancestor aa46d141 origin/maingit grep -c expires_at origin/main -- packages/plugins/plugin-auth/src/invitation-link-action.tsaa46d141. An expired invitation's link is no longer offered or answered. Copy Invite Link is hidden on a pending invitation past its expiry, and the action answers 409RESOURCE_CONFLICTthere. Resend stays offered as the remedy.expires_at).Release: claim
6553067811(session_0186vfjHHMsRXC9EUT9hTFMo, branchclaude/project-thread-rypvzz) · landed.pm:dispatchedremoved; assignee cleared; the card closed on merge.Generated by Claude Code