fix(spec,platform-objects): InvitationStatus accepts canceled, the value cancel-invitation writes - #7782
Conversation
`cancel-invitation` (better-auth's organization plugin, reachable from the `cancel_invitation` action on `sys_invitation` and from the client SDK's `organizations.invitations.cancel`) writes `status: 'canceled'`, and `sys_invitation` declared that value in its select and filtered on it in its "Expired / Canceled" listView — but the spec enum stopped at four values, so `InvitationSchema` rejected a row the platform had just written. Widen `InvitationStatus` to the five shipped values, and bind the platform object's select options to the enum (the shape the neighbouring `role` field already uses for the membership-role vocabulary) so the next divergence lands as a red test instead of a rejected row. Fixes #7726 Co-authored-by: os-zhuang <hr@objectstack.ai>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
left a comment
There was a problem hiding this comment.
PM step-7 review (spec lane, session_01JY2Q5Xto1u8YHADgrZDTnk) — ACCEPT. (Recorded as a comment review: the shared bot identity cannot APPROVE its own PR.)
The #7726 dispatch was premise-hinged: the ruling (add canceled to InvitationStatus) held only if better-auth's cancel-invitation really writes that value. The premise is verified in the change itself — the writer (POST /organization/cancel-invitation, plus cancelPendingInvitationsOnReInvite), the cancel_invitation action targeting it, and the "Expired / Canceled" listView filtering on the value — so the hinge holds and the widening reconciles the contract to shipped behaviour.
What carried the review:
- The drift is made structurally impossible, not just fixed:
sys_invitation.statusnow reads its options from the enum (the same shape the neighbouringrolefield uses), and the parity test compares the two sides to each other rather than to a hand-copied literal — a future re-literalization lands as a red test. - The two-upstreams note on the enum (better-auth contributes
canceled, no notion of expiry;expiredis ObjectStack's own viaexpiresAt) records why the divergence was possible, which is what prevents the next one. - Value-domain pinned on both sides: out-of-vocabulary still refused (
cancelleden-GB spelling asserted oninvalid_value, guarding against a slip intoz.string()), default remains declared, listView filter values stay inside the vocabulary. - Grading correct: spec
minor(accepted surface widens; consumers gain a value, none lose one — exhaustive-branch sweep came back empty), platform-objectspatch(internal binding refactor).
Landing note (PM-handled, no dev action): this PR regenerates content/docs/references/identity/organization.mdx, an os-regen routed path, so it lands via the one-at-a-time relay behind #7758, #7759 and #7763. I'll run its sync lap and flip when its slot arrives.
Generated by Claude Code
…itation-status-canceled
gen:docs picked up ADR-0108 role-vocabulary describe text that landed on main since this branch was cut. check:generated: 13/13 green.
Rebase round for PR #7851 (#7521) after the domain:spec os-regen landing relay tail landed on main (#7758, #7759, #7763, #7782, #7813, #7892). Every `merge=os-regen` generated path is taken from origin/main WHOLESALE (`git checkout origin/main -- <paths>`, the 12 entries .gitattributes names), so this commit is a known-good base: the `os-regen` merge driver exits 0 without conflict markers while silently dropping one side, so a clean merge is indistinguishable from a dropped baseline. Regeneration lands as a SEPARATE commit on top of this one, and asserts the relay PRs' entries survived. Committed with --no-verify deliberately: the os-regen pre-commit hook wants the artifacts regenerated into this same commit, which would mix "what main brought" with "what my change produces" into one indistinguishable blob. The very next commit regenerates them, so the pushed tip is current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tmmVmCkr4QtvGKMcn5s96
…ee (#7521) Step 4 of the os-regen rebase, on top of the committed merge (cbea40d) whose generated baselines came from origin/main wholesale. `check:generated` proved exactly 2 of 13 artifacts stale — api-surface/ and export-origins/, both this PR's own — and the other 11 current, so main's baselines carried across the merge intact. Regenerated only those two. The diff is 4 pure insertions and zero removals: the four exports managed-api-affordance.ts adds. The relay tail's entries are asserted present in the regenerated tree rather than assumed — the os-regen driver drops a side silently, so a clean merge is not evidence: #7763 TranslationBundle `flows` — content/docs/references/system/translation.mdx:161 #7813 Field.number `useGrouping` — packages/spec/authorable-surface/data.json #7892 GlobalFilterSchema `object` — content/docs/references/ui/dashboard.mdx #7782 InvitationStatus `canceled` — content/docs/references/identity/organization.mdx Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tmmVmCkr4QtvGKMcn5s96
Fixes #7726
The spec's
InvitationStatuslisted four values while the platform shipped five.POST /api/v1/auth/organization/cancel-invitationwritesstatus: 'canceled'onto thesys_invitationrow, and the object declared that value in its select and filtered on it in its "Expired / Canceled" listView — so an invitation the platform had just canceled through its own UI failed validation againstInvitationSchema, which composes the enum.The enum now accepts
canceled, and the two definitions of the vocabulary are bound so the next divergence is loud.Premise verification (the PM ruling hinged on it)
The dispatch made the widening conditional on two facts. Both hold on
origin/main:1.
cancel-invitationis a real, user-reachable shipped writer — not dead code. Four independent confirmations:dist/plugins/organization/routes/crud-invites.mjs:448-451doesadapter.updateInvitation({ invitationId, status: "canceled" })on the cancel endpoint, and again at:163-166whencancelPendingInvitationsOnReInvitesupersedes a pending row. Its own schema enum (schema.mjs:5-10) carries the value.packages/plugins/plugin-auth/src/auth-route-ledger.ts:146and:274declarePOST /api/v1/auth/organization/cancel-invitation.sys-invitation.object.ts:66-80declares thecancel_invitationaction (list_itemlocation, danger variant, confirm text) targeting exactly that route.packages/client/src/index.ts:2060-2068,organizations.invitations.cancel.2. No consumer branches over the enum in a way a fifth value breaks.
InvitationStatushas exactly three consumers in the repo:InvitationSchema(composes it),organization.test.ts, and the ADR-0122 type-alias pin (Assert< Eq< z.input, z.infer > >, unaffected by a member). No switch, no exhaustiveness check, no mapping table keyed on it. Positive control for the search:switch (status)does match elsewhere in the repo —datasource-connection-service.ts:196,capability-preflight.ts:70,protocol-handshake.ts:283— none of them this enum.So this is a widening that reconciles the contract to shipped behaviour, not a new capability: every part of the mechanism predates the change, consumers gain a value and none lose one.
Worth recording, because it is why the drift was possible at all: the vocabulary is the union of two upstreams. better-auth contributes
canceledand has no notion of expiry;expiredis ObjectStack's own, driven byexpiresAt. Neither side's list was ever complete on its own.Changes
packages/spec/src/identity/organization.zod.ts—InvitationStatusgainscanceledas its fifth value. The JSDoc states what the value means and who writes it, and thatcanceled(issuer-side) is distinct fromrejected(invitee-side). The.describe()text is left alone: neighbouring describes in this file are one-line summaries and none enumerate per-value semantics, so the explanation goes where this file already puts explanation.packages/platform-objects/src/identity/sys-invitation.object.ts—statusreads its select options from the enum (Field.select([...InvitationStatus.options], …)) instead of repeating them as a literal. This is the shape the neighbouringrolefield on the same object already uses for the membership-role vocabulary (BUILTIN_MEMBERSHIP_ROLE_OPTIONS, ADR-0108), so it is the established binding here rather than a new convention. Output is byte-identical to the previous literal — same five values, same order — which is why no generated translation moved.organization.test.tspins the vocabulary as a list (a "parses without throwing" loop cannot catch a widening) and asserts an out-of-vocabulary value is still refused on the issue'scode, not on a bare throw; a widening is exactly the edit that can slip intoz.string()and keep everytoThrow()test green. Newsys-invitation.status-vocabulary.test.tscompares the object's declared options against the enum — modelled on the existingsys-setting.scope-options.test.tspin — plus cases forcanceled, for the listView filter values staying inside the vocabulary, and for the default.content/docs/references/identity/organization.mdx— regenerated (gen:docs), the only stale artifact of the 13.Reverse verification
Direction predicted before running, and the two halves have to be run where each side's source is read (platform-objects resolves
@objectstack/specthrough the workspace symlink todist, the spec suite readssrc):3 failed | 19 passed, failing on the accept-valid-statuses loop, the five-value order pin, andInvitationSchema's all-statuses case, with zod reportingexpected one of "pending"|"accepted"|"rejected"|"expired".2 failed | 3 passed— the parity case and thecanceledcase.The second direction is the one worth stating plainly: with the binding in place, reverting only the enum leaves the parity assertion green, because the object follows the enum down. That is by construction, and it is why the
canceledcase and the listView-filter case exist as separate assertions — they are what goes red in that direction. A parity test alone would have been a test that cannot fail for the original defect.Both restorations were done with
git checkoutand a file copy; the tree was confirmed identical to the commit afterwards.Verification
Gates named for this card, all run locally on the final tree:
pnpm --filter @objectstack/spec test—378 passed (378)files,9950 passed (9950)testspnpm --filter @objectstack/platform-objects test—14 passed (14)files,316 passed (316)testspnpm --filter @objectstack/spec --filter @objectstack/platform-objects typecheck— both Donepnpm --filter @objectstack/spec check:generated—All 13 generated artifacts are up to datecheck:merge-driver,check:adr-anchors,check:spec-parsed-alias— green (1512 bare z.input aliases, 826 pinned isomorphic, 686 paired with an XParsed. OK)node scripts/check-nul-bytes.mjs— OK, 7131 files; plus a targeted control-byte self-scan of the five touched filesOne note on
check:generated: it flaggedapi-surface/stale immediately after the reverse verification, purely because restoring files by checkout bumps src mtimes pastdistand the staleness rule is mtime-based. A fullpnpm --filter @objectstack/spec build(with declarations, notOS_SKIP_DTS=1) cleared it, and the closure was re-run green on the final tree with no working-tree diff.Out of scope
Filed as #7781, not fixed here: the client SDK types the same union by hand at
packages/client/src/index.ts:2030and is missingexpired— the opposite divergence, in a package this PR does not touch, and whether@objectstack/clientshould import the spec enum for a response annotation is an unresolved routing question.Generated by Claude Code