Skip to content

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

Closed

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. added
    domain:specDomain lane — seat card indexed by label:pm:seat
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    and removed
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Aug 11, 2026
  2. 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

  3. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 12, 2026
  4. added theissue type on Aug 12, 2026
  5. 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

  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  7. self-assigned this
    on Aug 12, 2026
  8. 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

  9. 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

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions