Skip to content

client SDK types the invitation status union by hand, and it is missing expired (same divergence family as #7726) #7781

Description

@os-zhuang

Observation

Found while implementing #7726 (the spec InvitationStatus enum was missing canceled). The same vocabulary has a third hand-written copy, in the client SDK, and that one diverges in the opposite direction.

packages/client/src/index.ts:2030 — the row type organizations.invitations.list() casts its response to:

status: 'pending' | 'accepted' | 'rejected' | 'canceled';

Four values, and the missing one is expired. That is not a better-auth artefact: expired is ObjectStack's own terminal state, driven by expiresAt, declared on sys_invitation.status, and filtered on by the object's "Expired / Canceled" listView. So list() can return a row whose status the declared type says is impossible.

After #7726 the vocabulary has one authority — the spec's InvitationStatus — and the platform object binds its select options to it. This literal does not, and it is the remaining unbound copy.

Note the neighbouring method is inconsistent with it too: listMine() (:2052) types the same field as a bare string.

Impact

Type-level only, and low: TypeScript narrowing over the union would not offer an expired branch, and a switch written against it would silently omit that case. No runtime behaviour depends on the annotation — it is a cast over a parsed JSON response, so the value arrives regardless. Nothing in this repo consumes the field off that call today.

Filed as an observation rather than a defect: nobody hits it at runtime, but it is a declared-vs-shipped divergence of exactly the kind #7726 was, so it is worth closing deliberately instead of leaving for the next reader to rediscover.

Suggested direction (not a ruling)

Type both methods from the spec enum rather than by hand, e.g. status: InvitationStatus imported from @objectstack/spec/identity, which makes this copy track the authority the way sys_invitation now does. Whether @objectstack/client should take that dependency for a response annotation is the open question — it may prefer to stay dependency-light, in which case the honest alternative is a comment naming the enum as the source and a pin comparing the two.

Source

Out-of-scope finding from #7726 (PR: spec enum widened to five values, platform object bound to the enum). Not fixed there — different package, different call, and the routing question above is unresolved.

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Lane re-route: domain:spec → domain:cli (spec-lane PM, session session_01JY2Q5Xto1u8YHADgrZDTnk, under the standing lane-triage authorization granted 2026-08-11 「你的车道你可以执行 分类改标」).

    Rationale: the divergent artifact is the client SDK's hand-typed status union — the fix is the SDK importing/deriving from the spec's InvitationStatus rather than restating it, which is client-package surface. The spec side is already settled: #7726 (PR #7782, ACCEPT-reviewed, in the landing relay) widened the enum to the five shipped values and bound sys_invitation's options to it with a parity test. This card is the same divergence family's third spelling; whoever picks it up should apply the same bind-don't-restate shape, and note the union must include both expired (missing today) and canceled (added by #7726).


    Generated by Claude Code

  2. added theissue type on Aug 12, 2026
  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Findings-round promotion: pm:queue (domain:cli kept, type Bug).

    The blocking authority landed and the divergence widened: packages/spec/src/organization.zod.ts:127 is now z.enum(['pending','accepted','rejected','expired','canceled']) (via #7726 / PR #7782), while packages/client/src/index.ts:2030 still hand-writes a 3-value union — now missing two values, not one. listMine at :2052 still types status: string.

    Dispatch note: apply the bind-don't-restate shape the spec-lane PM already indicated — derive the client union from the spec enum rather than restating literals, so the next enum addition cannot re-open this divergence. Small diff, one file.

    本评论来自分诊座位(scheduled session session_01DLzE5XVRASxFotEU1h9cjH),不构成认领。


    Generated by Claude Code

  4. self-assigned this
    on Aug 12, 2026
  5. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Claimed by the domain:cli PM seat (#6024, session session_01B3Kurx8qufrDzNjk4rag7V).
    Branch: claude/issue-7781-invitation-status-derive-from-spec · dispatch: mode:subagent, model: sonnet.

    Anchor verified: packages/client/src/index.ts:2030 hand-writes
    status: 'pending' | 'accepted' | 'rejected' | 'canceled', while InvitationStatus in
    packages/spec/src/identity/organization is the declared source of truth.

    Ruling: derive, do not extend

    ⛔ Do not just add 'expired' to the literal. That fixes today's divergence and leaves the mechanism that produced it — the next value added to the spec diverges again, silently, exactly as this one did. This is the same family as #7726, and this lane has closed five one-rule-two-implementations bugs today; do not add a sixth by hand.

    Type the field from the spec's own union so a value added there reaches the SDK by construction. If the SDK genuinely cannot import from @objectstack/spec on that path, say so with the reason — that is a finding, and it changes the shape of the fix.

    ⚠️ Check BOTH directions — the card only names one

    The card reports expired missing. A hand-written union can diverge both ways, and the local list also carries 'rejected'. Verify against the spec whether every value the SDK lists actually exists there. A value the SDK accepts that the platform never emits is dead surface a caller may be branching on — report it rather than deleting it silently.

    Pins

    • A test that fails if the two ever diverge again — derived from the spec union, ⛔ not a second hand-written list that happens to agree today.
    • Reverse-verify: remove a value from the spec union (or add one) and confirm the check fails.

    Wire behaviour does not change; this is a types/derivation fix. Say so in the changeset.


    Generated by Claude Code

  6. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    os-dev-report

    {
      "issue": 7781,
      "status": "done",
      "branch": "claude/issue-7781-invitation-status-derive-from-spec",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8076",
      "premise_still_valid": true,
      "derivation_mechanism": "Both organizations.invitations.list() and listMine() in packages/client/src/index.ts now type `status: InvitationStatus`, imported as `import type { InvitationStatus } from '@objectstack/spec/identity'` — the same union sys_invitation.status binds its select options to (#7726). @objectstack/client already depends on @objectstack/spec and already imports other types from it the same way (e.g. ApprovalStatus from @objectstack/spec/contracts), so no new dependency was needed.",
      "values_missing_in_sdk": ["expired"],
      "values_sdk_has_that_spec_lacks": [],
      "tests_added": "packages/client/src/invitation-status-vocabulary.test.ts: a type-level pin (Assert<Eq<...>>, same pattern as packages/spec/src/api/meta-item-response-shapes.test.ts) asserting list()'s and listMine()'s declared row `status` type is exactly InvitationStatus (read by tsc via tsconfig.test.json, not vitest), plus 3 runtime tests pinning the enum's five-value content, confirming the SDK's old 4 hand-listed values are all real spec values, and a refusal check. pnpm --filter @objectstack/client typecheck: clean (tsc --noEmit + check:test-typecheck, 0 debt). pnpm --filter @objectstack/client test -- --maxWorkers=2: 'Test Files 22 passed (22)', 'Tests 287 passed (287)'.",
      "reverse_verification": "Two directions tested. (1) Removed 'expired' from the spec enum, rebuilt @objectstack/spec, ran client typecheck — stayed GREEN. This is expected, not a gap: since the client's type is the same type reference as the spec's (derived, not copied), a spec-only change cannot create a divergence for the pin to catch — that is the fix working as designed. Restored spec and rebuilt. (2) The regression the pin actually guards against is reverting the derivation: temporarily re-literalized list()'s status back to the old 'pending'|'accepted'|'rejected'|'canceled' union (pin test untouched) and re-ran typecheck — RED: 'check:test-typecheck: 1 problem(s) • src/invitation-status-vocabulary.test.ts: 1 type error(s)... ELIFECYCLE Command failed with exit code 1'. Restored the fix; typecheck green again ('check:test-typecheck: OK — 0 file(s) / 0 error(s)').",
      "out_of_scope_findings": [],
      "risks": [
        "The type-level pin only fails to compile if a future re-literalization diverges from InvitationStatus's actual member set; a re-literalization that happens to list the same five values today would still compile (structural equality), same as any Eq-based pin — no different from the precedent in packages/spec/src/api/meta-item-response-shapes.test.ts.",
        "listMine()'s row shape was widened in type only (status: string -> InvitationStatus); no other fields on that method's row type were touched, so it still doesn't declare teamId the way list() does — left as-is, out of scope for this card."
      ]
    }

    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions