Skip to content

Adopt the conformance-fixtures wire contract for records - #64

Merged
cuibonobo merged 3 commits into
mainfrom
claude/issue-32-conformance-fixtures
Aug 18, 2026
Merged

Adopt the conformance-fixtures wire contract for records#64
cuibonobo merged 3 commits into
mainfrom
claude/issue-32-conformance-fixtures

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Aug 17, 2026

Copy link
Copy Markdown
Member

Summary

@haverstack/conformance-fixtures is now published (core#167 cleared). This issue's text named two work items (PATCH body shape, /migrate), but reading the actual fixture data (createRecordFixtures, patchContentFixtures, deleteRecordFixtures/undeleteRecordFixtures, associateFixtures/dissociateFixtures, setPermissionsFixtures, errorResponseFixtures, queryRecordsFixtures, commitMigrationFixtures) turned up a much larger set of divergences in the records route surface. Fixed all of them:

  • POST /records accepts a full record body, not just {typeId, content}. id is client-minted (12 lowercase Crockford base-32 characters, no reserved _ prefix) and optional — forwarded to ScopedStack.create()'s opts.id, which generates one when omitted. createdAt/updatedAt/version are never accepted from the client, same treatment as entityId/principalId. Returns 200, not 201 — matches every other write in this API.
  • PATCH /records/:id: the body IS the content patch (RFC 7396), never an envelope. Real wire-dishonesty bug: a conforming client's {"title": "New"} read as body.content (undefined) and silently no-opped while still bumping version. Also wires up If-MatchifVersion for optimistic concurrency (412 version_conflict on mismatch).
  • Added POST /records/:id/undelete — had no wire endpoint at all.
  • Added POST /records/:id/migrate — owner-acting-alone only (per ScopedStack.commitMigration()'s own doc comment, added in core 0.10.0), body {toTypeId, content}, validates toTypeId is registered, leaves a pre-migration snapshot.
  • Moved association removal from DELETE /:id/associations (a body-bearing DELETE — undefined semantics per RFC 9110 §9.3.5) to POST /:id/associations/delete.
  • PUT /:id/permissions now returns 204 with no body, instead of echoing the permissions back at 200.
  • Verified, no code changes needed: malformed/unknown-sort-field cursors → 400 bad_request; reserved content keys (__proto__ etc.) on PATCH → 422; payload-too-large record bodies → 413; omitting limit → one default-sized page (50), never everything.
  • Id-format validation (malformed charset, wrong length, reserved _ prefix) now correctly returns 400 bad_request — was 422 validation in core 0.9.0, fixed upstream in 0.10.0.

Core update

Bumped @haverstack/core to ^0.10.0, @haverstack/adapter-local to ^0.8.0, and @haverstack/wire-types to ^0.8.0 — all now consistently on core ^0.10.0, so no pnpm.overrides pin is needed. (Briefly was: bumping core alone broke error serialization repo-wide, since wire-types' then-published version still resolved core ^0.9.0 via its workspace:^ snapshot from its last release, producing two separate installed copies of @haverstack/core and silently-failing instanceof checks in serializeError(). Fixed properly once wire-types republished rather than carrying the override.)

Two other stale-core paths remain in the dependency tree (blob-adapter-disk@0.7.0, transitive via adapter-local, still on core ^0.9.0; conformance-fixtures@0.1.0 pulls in an old wire-types too) but neither matters in practice — verified the full suite passes clean, including every attachment test exercising blob-adapter-disk, and conformance-fixtures is dev-only with no runtime interaction. Not overridden preemptively for a problem that isn't actually occurring.

Also adopted @haverstack/conformance-fixtures as a dev dependency for reference, though the tests here are hand-written per the codebase's existing style rather than a generic fixture-iterating harness.

Test plan

  • pnpm typecheck
  • pnpm lint
  • pnpm format:check
  • pnpm test — 167/167 passing. Covers: client-minted id (success, generated-when-omitted, three malformed cases → 400, duplicate → 409), If-Match/412 concurrency, POST /:id/undelete (success, idempotency, 403, 404), POST /:id/migrate (success, 403 non-owner, 401, unregistered toTypeId, schema failure, 404, pre-migration snapshot), PUT/GET /:id/permissions (previously untested), reserved content keys via PATCH, pagination defaults, malformed/unknown-sort-field cursors, oversized record body → 413.

…rface

@haverstack/conformance-fixtures published (core#167 cleared), so this
brings the records routes in line with what it actually pins, going well
beyond this issue's original two work items once the full fixture set was
read:

- POST /records accepts a full record body, not just {typeId, content}:
  id is client-minted (12 lowercase Crockford base-32 chars, no reserved
  "_" prefix) and optional -- forwarded to ScopedStack.create() as opts.id,
  which generates one when omitted. createdAt/updatedAt/version are never
  accepted from the client, same as entityId/principalId. Returns 200, not
  201, matching every other write in this API.

- PATCH /records/:id: the body IS the content patch (RFC 7396), never an
  envelope -- fixes the wire-dishonesty bug where a conforming client's
  {"title": "New"} read as body.content (undefined) and silently no-opped
  while still bumping version. Also wires up If-Match -> ifVersion for
  optimistic concurrency (412 version_conflict on mismatch).

- Added POST /records/:id/undelete (ScopedStack.undelete() had no wire
  endpoint at all).

- Moved association removal from DELETE /:id/associations (a body-bearing
  DELETE, undefined semantics per RFC 9110 SS9.3.5) to POST
  /:id/associations/delete, the shape the fixtures pin.

- PUT /:id/permissions now returns 204 with no body instead of echoing the
  permissions back at 200.

- Verified (no code changes needed, core already gets these right once the
  right value reaches it): malformed/unknown-sort-field cursors -> 400
  bad_request; reserved content keys (__proto__ etc) on PATCH -> 422 now
  that the raw patch reaches Stack.update()'s validateReservedKeys();
  payload-too-large record bodies -> 413 via the existing JSON body limit;
  omitted `limit` on GET/POST query -> one default-sized page (50), not
  everything.

Two things intentionally NOT done here, both documented rather than
worked around:

- POST /records/:id/migrate is not implemented. core@0.9.0's ScopedStack
  has no scoped, permission-checked path for an ad-hoc per-record
  migration commit -- commitMigration lives only on the raw StackAdapter,
  called exclusively by the unscoped Stack.migrateAll() (bulk,
  registered-migration-fn driven, not the client-computed-content shape
  this endpoint needs). Implementing it would mean either bypassing
  ScopedStack's permission layer or duplicating it at the route level --
  both the exact anti-pattern recent sync work has been eliminating.
  Needs a core-side addition first, same shape as putAttachment()'s
  history.

- Three id-format validation fixtures (malformed charset, wrong length,
  reserved "_" prefix) expect 400 bad_request; installed
  @haverstack/core@0.9.0 (npm's latest) still throws StackValidationError
  (422) for all three, with the same message text the fixtures expect for
  400. This is a version-skew gap between the fixtures and the latest
  published core, not a server bug -- tests pin the actual current
  behavior with an explanatory comment instead of reclassifying errors by
  string-matching, which the codebase has been deliberately removing
  elsewhere (see #33).

Refs #32. Also adopts @haverstack/conformance-fixtures as a dev dependency
for reference, though the tests here are hand-written per the codebase's
existing style rather than a generic fixture-iterating harness.
claude added 2 commits August 18, 2026 13:03
…odes

Both core-side gaps flagged in the previous commit are fixed upstream:

- ScopedStack.commitMigration(id, toTypeId, content, opts?) now exists,
  owner-acting-alone only per its own doc comment. Implements
  POST /records/:id/migrate: {toTypeId, content} body, requireOwner()
  gated (matching the doc's explicit instruction to answer 403 to anyone
  else), validates toTypeId is a registered type before calling in,
  accepts If-Match the same as PATCH.

- Id-format validation (malformed charset, wrong length, reserved "_"
  prefix) now throws StackQueryError (400 bad_request) instead of
  StackValidationError (422). Updated the three tests that were pinned
  to the old behavior with an explanatory comment; that comment and the
  version-skew note are gone.

Bumping @haverstack/core alone broke error serialization repo-wide: 57
tests started returning 500 where a typed status was expected.
@haverstack/wire-types (0.7.0, npm's latest) and the previously-installed
@haverstack/adapter-local (0.7.0) both still depend on core ^0.9.0, so
pnpm resolved two separate copies of @haverstack/core — server code threw
errors as 0.10.0 class instances, but wire-types' serializeError() did
`instanceof` checks against its own 0.9.0-resolved classes. Those checks
silently failed, serializeError() returned null, and every core error
fell through to the generic 500 handler instead of its real status/code.

@haverstack/adapter-local has a 0.8.0 already published against core
^0.10.0, bumped to it. @haverstack/wire-types has no core-0.10-compatible
release yet, so a pnpm.overrides pin forces a single deduped
@haverstack/core resolution across the tree in the meantime — verified
by confirming the 57 spurious 500s disappear with it and reappear
without it. This should come out once wire-types republishes.

Refs #32.
wire-types 0.8.0 depends on core ^0.10.0 (its "@haverstack/core":
"workspace:^" now resolves correctly), so the pnpm.overrides pin forcing
a single deduped core install is no longer needed for the path it was
protecting -- confirmed by removing it and running the full suite clean.

Two other stale-core paths remain in the dependency tree
(blob-adapter-disk@0.7.0, a transitive dep of adapter-local, still on
core ^0.9.0; conformance-fixtures@0.1.0 pulls in an old wire-types
which pulls in core 0.9.0 too) but neither matters in practice: all 167
tests pass, including every attachment test that exercises
blob-adapter-disk, and conformance-fixtures is dev-only with no runtime
interaction. Left alone rather than overridden preemptively for a
problem that isn't actually occurring.

Refs #32.
@cuibonobo
cuibonobo merged commit cc40b82 into main Aug 18, 2026
4 checks passed
@cuibonobo
cuibonobo deleted the claude/issue-32-conformance-fixtures branch August 18, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants