Skip to content
Merged
29 changes: 29 additions & 0 deletions .changeset/18728-identity-wires-relay-the-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/spec": minor
"@objectstack/client": minor
"@objectstack/plugin-auth": minor
---

The identity read routes now serve what `@objectstack/spec/identity` declares: `metadata` arrives DECODED on every organization route that reads the row back, and `updatedAt` is declared optional on `Organization` / `Member` / `Invitation` — the shape better-auth's own serializer documents (#18728).

Clause-②: yes (widening) — `updatedAt` moves from required to optional on three published schemas, so the set a consumer may hand to `OrganizationSchema` / `MemberSchema` / `InvitationSchema` grows by exactly one shape: the key being absent. Nothing previously admitted is refused, nothing is renamed, and no producer is required to write it. Contract-review tier.

Three published schemas could not parse a served response. `OrganizationSchema` declared `updatedAt` required and `metadata` an object; the four organization read routes (`setActive`, `get`, `delete`, `list`) carried no `updatedAt` at all and served `metadata` as the stored JSON text. `@objectstack/client` had recorded that as three 「not relayed」 notes rather than as a defect, and with zero in-repo consumers nothing went red — the audience was entirely external. Maintainer ruling C (batch #158 item 4) fixed the producer and made the one remaining key conditional on a measurement, which is what decided each half:

- **`metadata` is decoded at the producer, unconditionally** — it is our column. plugin-auth's data adapter decodes `sys_organization.metadata` out of its stored JSON text on its READ verbs, so all four routes serve the object the spec declares, and an unset column is OMITTED rather than sent as `null`. ⛔ The write verbs are deliberately untouched: better-auth's own organization adapter decodes the `create` / `update` echoes itself and discriminates on the value still being a string, so decoding there would fold the create echo's `metadata` to `undefined`. Both directions are pinned.
- **`updatedAt` aligns to the documented wire** — ruling C's own fallback A, and its two conditions were measured against the installed better-auth 1.7.3 rather than assumed. The routes are better-auth's endpoints mounted through a single catch-all, each answering `ctx.json(...)` with no ObjectStack post-processing; and the vendor's `organization`, `member` and `invitation` models declare no `updatedAt` field, while its adapter factory's output transform iterates the declared fields only, so an undeclared column is dropped before any route sees it. Control, in the same file: the vendor's `team` and `organizationRole` models DO declare `updatedAt`, so the absence is a reading. For `member` and `invitation` there is additionally no column to serve — `sys_member` and `sys_invitation` are `managedBy: 'better-auth'`, the one disposition under which the platform injects no audit family, and neither declares `updated_at` itself.
- **`@objectstack/client` relays the schemas.** `OrganizationWire` is the spec's `Organization`, `OrganizationMemberWire` is `Member`, and `OrganizationInvitationWire` is `Invitation` with `status` narrowed per route plus the three members the platform adds on top (`teamId` and the two ADR-0105 D8 placement fields, which the non-strict schema strips). The three 「not relayed」 notes are gone.
- **The negative controls are the point.** "The client relays the spec schemas" and "the client stopped validating" look identical from a green positive test, so every accepted body is paired with a refused one — a required field genuinely missing, `metadata` still arriving as the stored JSON TEXT, and a `createdAt` or `updatedAt` present but not a datetime. `.optional()` widened the accept set by absence ONLY; a value that is there is still held to `z.string().datetime()`.

**Not declared breaking, and the reason is the repo's own criterion** rather than the level being convenient. AGENTS.md binds the breaking class to removing or renaming something an author can write, and to the `(narrowing)` arm of the clause-② pair. Neither holds here: nothing is removed, renamed or retired; the one `packages/spec` edit only widens an accept set; and the `metadata` half is a producer brought into line with a contract this package has published all along — `OrganizationSchema.metadata` has declared an object since it was written, and the client's own comment called the served text 「not relayed」 rather than a shape anyone was promised. No ADR-0087 disposition is claimed because no breaking change is declared: no authored metadata moves, so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. These three schemas are not metadata types — not in `DEFAULT_METADATA_TYPE_REGISTRY`, no authorable surface. ⚠️ Stated here rather than assumed silently, because it is the one judgement in this diff that the contract review the `Clause-②: yes` declaration commissions should confirm.

**What a consumer notices**, and where it is delivered: `organization.metadata` was the stored JSON text and is now the decoded object, so a caller that decoded it itself drops that step.

```ts
// before — the caller decoded what the route sent
const meta = JSON.parse(org.metadata ?? '{}');
// after — the producer decoded it; the key is ABSENT when unset
const meta = org.metadata ?? {};
```

The channel that reaches that caller is the compiler, on the line that used to work: `JSON.parse` no longer accepts the value. `updatedAt` needs nothing in either direction — it was never on this family's wire, so no caller can have been reading a value, and the declaration now says so out loud instead of promising one.
6 changes: 3 additions & 3 deletions content/docs/references/identity/organization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const result = InvitationSchema.parse(data);
| **expiresAt** | `string` | ✅ | Invitation expiry timestamp |
| **inviterId** | `string` | ✅ | User ID of the inviter |
| **createdAt** | `string` | ✅ | Invitation creation timestamp |
| **updatedAt** | `string` | | Last update timestamp |
| **updatedAt** | `string` | optional | Last update timestamp (no such column on sys_invitation; absent on the wire) |


---
Expand Down Expand Up @@ -71,7 +71,7 @@ const result = InvitationSchema.parse(data);
| **userId** | `string` | ✅ | User ID |
| **role** | `string` | ✅ | Member role (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary) |
| **createdAt** | `string` | ✅ | Member creation timestamp |
| **updatedAt** | `string` | | Last update timestamp |
| **updatedAt** | `string` | optional | Last update timestamp (no such column on sys_member; absent on the wire) |


---
Expand All @@ -88,7 +88,7 @@ const result = InvitationSchema.parse(data);
| **logo** | `string \| null` | optional | Organization logo URL |
| **metadata** | `Record<string, any>` | optional | Custom metadata |
| **createdAt** | `string` | ✅ | Organization creation timestamp |
| **updatedAt** | `string` | | Last update timestamp |
| **updatedAt** | `string` | optional | Last update timestamp (absent on the better-auth organization wire) |


---
Expand Down
243 changes: 243 additions & 0 deletions packages/client/src/identity-wire-relay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#18728] The identity wires this SDK declares are the spec's own schemas —
* and a served body really parses through them.
*
* ## What this file is for
*
* Maintainer ruling C (batch #158 item 4) ended a state where three PUBLISHED
* schemas could not parse a response: `OrganizationSchema` declared
* `updatedAt` required and `metadata` an object, while the four organization
* read routes carried no `updatedAt` and served `metadata` as the stored JSON
* text. The client recorded that as three 「not relayed」 notes; the spec side
* was untouched. Both ends moved:
*
* - **producer** — plugin-auth's data adapter decodes
* `sys_organization.metadata` on its READ verbs, so `setActive`, `get`,
* `delete` and `list` all serve the object the spec declares, with the key
* omitted when the column is unset;
* - **spec** — ruling C's own fallback A, on measurement: the wire is
* better-auth's serializer and its documented organization / member /
* invitation models declare no `updatedAt`, so the three schemas align to
* the documented wire and declare it optional.
*
* ## Why a RUNTIME parse, next to the type-level pins
*
* `return-type-precision.test.ts` is type-level on purpose and pins that the
* DECLARED types are the spec's. That cannot observe whether a served body
* actually satisfies the schema — the declaration could be a relay and the
* body could still be refused. So this file runs the parse.
*
* ## ⭐ The negative controls are the point of the file
*
* "The client now relays the spec schemas" and "the client stopped validating"
* look identical from a green positive test. Every positive case below is
* therefore paired with a body that MUST be refused:
*
* - a required field genuinely missing (`slug` / `userId` / `inviterId`);
* - ⭐ `metadata` still arriving as the stored JSON TEXT — the exact dimension
* the producer fix moves, so this one distinguishes "the producer decodes"
* from "the schema stopped caring";
* - `createdAt` present but not a datetime, and `updatedAt` present but not a
* datetime — because `.optional()` must widen the accept set by exactly one
* shape (absence) and must NOT drop the format check on a value that is
* there.
*
* Each refusal asserts the ISSUE PATH, not merely `success === false`: a parse
* that fails for an unrelated reason is not evidence about the field named.
*/

import { describe, it, expect, expectTypeOf } from 'vitest';
import {
InvitationSchema,
MemberSchema,
OrganizationSchema,
type Invitation,
type Member,
type Organization,
} from '@objectstack/spec/identity';
import type {
OrganizationInvitationWire,
OrganizationMemberWire,
OrganizationWire,
} from './index';

/** Paths of every issue a `safeParse` reported, as dotted strings. */
function issuePaths(result: { success: boolean; error?: { issues: { path: PropertyKey[] }[] } }): string[] {
return (result.error?.issues ?? []).map((i) => i.path.join('.'));
}

// ---------------------------------------------------------------------------
// The measured bodies — what the four read routes serve AFTER ruling C
// ---------------------------------------------------------------------------

/**
* An organization row as `setActive` / `get` / `delete` / `list` serve it:
* better-auth's own organization columns, `metadata` decoded by the producer,
* `logo` present-and-null for an organization created without one (PR #18718's
* measurement), and NO `updatedAt` — the vendor's output transform walks its
* own declared fields only.
*/
const ORGANIZATION_WIRE = {
id: 'org_01HQ',
name: 'Acme',
slug: 'acme',
logo: null,
createdAt: '2026-09-07T09:27:01.545Z',
metadata: { plan: 'pro' },
};

/** The same row for an organization that never had metadata: the key is absent. */
const ORGANIZATION_WIRE_NO_METADATA = {
id: 'org_01HR',
name: 'Beta',
slug: 'beta',
logo: null,
createdAt: '2026-09-07T09:27:01.545Z',
};

/** A membership row as better-auth serves it — its own member schema, no more. */
const MEMBER_WIRE = {
id: 'mem_01HQ',
organizationId: 'org_01HQ',
userId: 'usr_01HQ',
role: 'owner',
createdAt: '2026-09-07T09:27:01.545Z',
};

/**
* An invitation row as better-auth serves it: its invitation schema plus the
* three members the platform adds — `teamId` (the vendor's own, written `null`
* explicitly) and the two ADR-0105 D8 `additionalFields`.
*/
const INVITATION_WIRE = {
id: 'inv_01HQ',
organizationId: 'org_01HQ',
email: 'invitee@example.com',
role: 'member',
status: 'pending',
teamId: null,
inviterId: 'usr_01HQ',
expiresAt: '2026-09-09T09:27:01.545Z',
createdAt: '2026-09-07T09:27:01.545Z',
businessUnitId: null,
positions: null,
};

describe('[#18728] the identity wires parse through the spec schemas', () => {
it('OrganizationSchema accepts a served read-route body whole', () => {
const parsed = OrganizationSchema.safeParse(ORGANIZATION_WIRE);
expect(issuePaths(parsed)).toEqual([]);
expect(parsed.success).toBe(true);
// The decoded object survives the parse as an object.
expect(parsed.success && parsed.data.metadata).toEqual({ plan: 'pro' });
// `updatedAt` is absent on the wire and stays absent after parsing —
// `.optional()` admits absence, it does not invent a value.
expect(parsed.success && 'updatedAt' in parsed.data).toBe(false);
});

it('OrganizationSchema accepts a row whose metadata column was never set', () => {
const parsed = OrganizationSchema.safeParse(ORGANIZATION_WIRE_NO_METADATA);
expect(issuePaths(parsed)).toEqual([]);
expect(parsed.success).toBe(true);
});

it('MemberSchema accepts a served membership row whole', () => {
const parsed = MemberSchema.safeParse(MEMBER_WIRE);
expect(issuePaths(parsed)).toEqual([]);
expect(parsed.success).toBe(true);
});

it('InvitationSchema accepts a served invitation row whole, stripping the platform members', () => {
const parsed = InvitationSchema.safeParse(INVITATION_WIRE);
expect(issuePaths(parsed)).toEqual([]);
expect(parsed.success).toBe(true);
// The schema is a plain (non-strict) object, so the three keys it does
// not declare are STRIPPED rather than refused. That is what makes the
// relay claim honest: the wire is a superset of the spec's declaration.
if (parsed.success) {
expect('teamId' in parsed.data).toBe(false);
expect('businessUnitId' in parsed.data).toBe(false);
expect('positions' in parsed.data).toBe(false);
}
});

it('relays the spec declarations as the SDK types, not a transcription of them', () => {
expectTypeOf<OrganizationWire>().toEqualTypeOf<Organization>();
expectTypeOf<OrganizationMemberWire>().toEqualTypeOf<Member>();
// The invitation wire narrows `status` per route and adds the three
// platform members, so it is the spec's declaration EXTENDED — every
// key the spec declares still comes from the spec.
expectTypeOf<OrganizationInvitationWire>().toMatchObjectType<Omit<Invitation, 'status'>>();
});
});

describe('⭐ [#18728] negative controls — the spec parse still REFUSES a malformed body', () => {
it('refuses an organization body missing a genuinely required field', () => {
const { slug: _slug, ...withoutSlug } = ORGANIZATION_WIRE;
const parsed = OrganizationSchema.safeParse(withoutSlug);
expect(parsed.success).toBe(false);
expect(issuePaths(parsed)).toContain('slug');
});

it('⭐ refuses an organization body whose metadata is still the stored JSON TEXT', () => {
// This is the body the four read routes served BEFORE the producer fix.
// It must stay refused: if it ever parses, the producer has regressed
// or the schema has been loosened to hide the regression.
const parsed = OrganizationSchema.safeParse({
...ORGANIZATION_WIRE,
metadata: '{"plan":"pro"}',
});
expect(parsed.success).toBe(false);
expect(issuePaths(parsed)).toContain('metadata');
});

it('refuses an organization body whose metadata is null rather than absent', () => {
// The producer OMITS the key for an unset column; `null` is not the
// declared shape and is not quietly admitted.
const parsed = OrganizationSchema.safeParse({ ...ORGANIZATION_WIRE, metadata: null });
expect(parsed.success).toBe(false);
expect(issuePaths(parsed)).toContain('metadata');
});

it('refuses a non-datetime createdAt — the format check is live, not decorative', () => {
const parsed = OrganizationSchema.safeParse({ ...ORGANIZATION_WIRE, createdAt: 'yesterday' });
expect(parsed.success).toBe(false);
expect(issuePaths(parsed)).toContain('createdAt');
});

it('⭐ refuses a non-datetime updatedAt — `.optional()` widened by absence ONLY', () => {
// The one shape fallback A added is the key being ABSENT. A value that
// IS there is still held to `z.string().datetime()`, on all three
// schemas — otherwise the widening would have quietly retired the
// format check as well.
for (const [name, schema, wire] of [
['organization', OrganizationSchema, ORGANIZATION_WIRE],
['member', MemberSchema, MEMBER_WIRE],
['invitation', InvitationSchema, INVITATION_WIRE],
] as const) {
const parsed = schema.safeParse({ ...wire, updatedAt: 'whenever' });
expect(parsed.success, name).toBe(false);
expect(issuePaths(parsed), name).toContain('updatedAt');
}
});

it('refuses a membership row missing userId', () => {
const { userId: _userId, ...withoutUserId } = MEMBER_WIRE;
const parsed = MemberSchema.safeParse(withoutUserId);
expect(parsed.success).toBe(false);
expect(issuePaths(parsed)).toContain('userId');
});

it('refuses an invitation row missing inviterId, and one with an unknown status', () => {
const { inviterId: _inviterId, ...withoutInviter } = INVITATION_WIRE;
const missing = InvitationSchema.safeParse(withoutInviter);
expect(missing.success).toBe(false);
expect(issuePaths(missing)).toContain('inviterId');

const badStatus = InvitationSchema.safeParse({ ...INVITATION_WIRE, status: 'withdrawn' });
expect(badStatus.success).toBe(false);
expect(issuePaths(badStatus)).toContain('status');
});
});
Loading
Loading