Skip to content

Adopt core 0.30's change-set envelope for PATCH /records/:id - #123

Merged
cuibonobo merged 5 commits into
mainfrom
claude/server-core-sync-bhkqi5
Sep 12, 2026
Merged

cuibonobo merged 5 commits into
mainfrom
claude/server-core-sync-bhkqi5

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

Summary

core 0.29 replaced update(), setPermissions(), setUnlisted() and setParent() with a single mutate(id, changes), and moved the wire format with it. PATCH /records/:id now carries a change set — an envelope naming any combination of contentPatch, parentId, permissions, associations and unlisted, applied as one atomic write producing exactly one version — and PUT .../permissions and PUT .../unlisted are gone. One If-Match fences a whole multi-aspect edit, where each aspect previously cost a version and a round trip of its own and could not be fenced together at all.

changesFromWireBody() from @haverstack/core/wire owns the entire read of that envelope — which keys it may carry, the 400 an unrecognized or wholly absent key earns, the 422 a malformed value earns — so the route parses nothing itself, the same way createOptionsFromWireRecord() already owns POST /records. The two retired PUT handlers each carried a hand-rolled shape check the envelope parser makes redundant. GET /records/:id/permissions stays.

PATCH /entity keeps its own { content } envelope — no fixture governs it and its shape is this server's own — and moves to patchContent(), the content-only spelling update() left behind.

Change-feed frames carry ops: ChangeOp[] in place of op, with update renamed patch and reparent added. A reparent is matched against both containers a move concerns, so a subscription filtered on the origin learns the record left it.

Dependencies move in lockstep: core 0.31.0, wire-types 0.30.0, commons 0.25.0, adapter-local 0.30.0, conformance-fixtures 0.24.0. minimumReleaseAgeExclude now carries the scope pattern '@haverstack/*' in place of the seven per-version entries it had been accumulating — pnpm appends to that list itself on any install that has to hold a too-new package, and since Haverstack releases in lockstep, every sync was writing the whole scope back a version at a time. Third-party dependencies keep the cooldown.

Release

This lands as 0.8.0. release.yml picks the bump from the commits since the last tag, and none of the envelope work carried a conventional-commit prefix, so the release would have gone out as 0.7.9 — a patch — even though PATCH /records/:id no longer accepts a bare content patch and two endpoints are gone. A feat: prefix now carries it to the minor, which is where the compatibility boundary sits on a 0.x package.

The parentId answers, and who sees which

An earlier revision of this PR skipped error-bad-request-malformed-parent-id and error-conflict-parent-does-not-exist: canReadReferent() resolved a caller-named destination and refused a missing one before Stack ran either check, so both collapsed into 403 for every requester — the owner included, despite their documented exemption from that gate. Filed as haverstack/core#276.

Fixed in core 0.31.0 (core#279), so both fixtures pass unchanged and their dispatch tests are back. The anti-oracle property is intact for everyone else, and the docs now say so rather than leaving it implied. Probed against adapter-local:

parentId owner acting alone non-owner write-holder
"" 400 bad_request 403 permission
"1hk153xffffz" (no such record) 409 conflict 403 permission

A non-owner learns they may not name the container, never whether it exists. docs/api.md § The change set carries that split, since the section had promised both answers to every caller.

core#278 needs nothing here

core 0.31.0 also lands StackQueryError.capability, the exported MissingCapability and filtersContent(), and assertQueryTravels() in @haverstack/core/wire. None of it reaches this server — capability does not travel (serializeError() does not carry it), assertQueryTravels() is the build-side half of a refusal parseQueryParams()/parseQueryBody() already apply here, and discovery spreads stack.features rather than re-deriving any of it. Recorded so a reviewer need not re-check.

Docs

Yes — the wire contract for record writes changed.

  • docs/api.md — the Records endpoint table (PATCH re-described, the two PUT rows removed); a new ### The change set section covering the envelope, presence-not-truthiness, the merge asymmetry, per-key authority for all five keys, the 400/422/409 split and which requester sees which; the permissions-envelope paragraph; the If-Match endpoint list; the soft-delete refusal list; the ## Unlisted section's spelling; and the change-feed paragraph for ops. The reparent note sits with the change-feed filtering rule it qualifies, since a parentId filter is the one place where what a frame matched on and what it carries come apart.
  • docs/deployment.md — no change needed; its one PATCH /records/:id mention is about thread routing, which is unaffected.
  • README config table and .env.example — no change; no configuration moved.

The spec and conformance fixtures for this change live in haverstack/core — the envelope is core#269, the reparenting that precedes it is core#263 and core#266, the schema and group-admin rules released alongside are core#267 and core#271, and the gate fix is core#279.

Verification

pnpm run format:check && pnpm run lint && pnpm typecheck && pnpm test all clean, plus pnpm build. 524 tests passing, 27 files (520 before the two fixtures came back, 522 before the shape-error cases below).

Checked by hand:

  • Dumped every affected fixture's method/path/requestBody from the fixtures package and matched the routes against them, rather than inferring the shape from the changelog.
  • Audited all 52 PATCH call sites across the suite and classified each before touching any — fixture-bodied, already-a-change-set, /entity (a different envelope), or a bare content patch needing contentPatch. Two were hand-fixed rather than wrapped: tests/routes/versions.test.ts sent a wire-record-shaped body whose content/version/updatedAt keys had been landing as content fields under the old bare-patch rule, and the reserved-content-key test builds its body through JSON.parse.
  • Probed the parentId answers directly against adapter-local on both tiers, before and after the core upgrade — the table above is measured, not read off the changelog.
  • Probed per-key authority the same way for the two keys the docs had left undescribed: a non-owner write-holder may set associations, and may set parentId only when it can also read the destination it names.

New coverage for the five fixtures the reparenting work added: the two parent-change fixtures, the reparent feed frame, and the snapshot/restore pair that settles a record's container. Plus shape-error cases for permissions and associations, which had been resting on the unlisted case alone — a core bump that stopped rejecting a malformed value for either would have passed the whole suite.

Notes for reviewers

Two judgment calls worth a look:

  1. tests/routes/versions.test.ts — the old body almost certainly did not mean what it did. I rewrote it as { contentPatch: { body: 'v2' } }, which is what the test's name says it wants (a v2 to have history for). If it was deliberately probing that native-looking keys land as content, that intent is now gone and wants its own test.
  2. The _config.entityId test keeps its 409. entityId is a declared content field of _config (ConfigContent), so wrapping it in contentPatch still reaches checkConfigEntityIdUnchanged() rather than being refused as undeclared. Worth confirming, since it turns on schema details rather than on the envelope.

One thing found in review and deliberately not fixed here: core's validatePermissions() checks only the write-without-read rule, so a malformed permission entry ({ "access": "bogus" }, a missing entityId, a bare string) is stored verbatim, and a null entry throws a TypeError out of the validator as a 500. It fails closed — a junk entry grants nothing — and it is not a regression, since the retired PUT .../permissions did the same array-only check. It belongs in core rather than in this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb

core 0.29 replaced `update()`, `setPermissions()`, `setUnlisted()` and
`setParent()` with a single `mutate(id, changes)`, and moved the wire
format to match: `PATCH /records/:id` now carries a change set naming any
combination of `contentPatch`, `parentId`, `permissions`, `associations`
and `unlisted`, and `PUT .../permissions`, `PUT .../unlisted` and
`PUT .../parent` are gone. One `If-Match` fences a whole multi-aspect
edit, where each aspect previously cost a version and a round trip of its
own and could not be fenced together at all.

`changesFromWireBody()` from `@haverstack/core/wire` owns the whole read
of that envelope — which keys it may carry, the 400 an unrecognized or
wholly absent key earns, the 422 a malformed value earns — so the route
parses nothing itself, the same way `createOptionsFromWireRecord()`
already owns `POST /records`. The two retired PUT handlers each carried a
hand-rolled shape check that the envelope parser now makes redundant.

`PATCH /entity` keeps its own `{ content }` envelope and moves to
`patchContent()`, the content-only spelling `update()` left behind.

Change-feed frames carry `ops: ChangeOp[]` in place of `op`, with
`update` renamed `patch` and `reparent` added; a reparent is matched
against both containers a move concerns, so a subscription filtered on
the origin learns the record left it.

Conformance fixtures rename with the endpoints they exercise
(`setPermissionsFixtures` → `permissionsChangeFixtures`, and likewise for
unlisted and parent), and the reparenting work adds five fixtures: the
two parent-change ones, the reparent feed frame, and the snapshot/restore
pair that settles a record's container. All are covered here except two,
which are skipped with the reason in full at the skip site: a caller-named
`parentId` cannot reach core's format and existence checks through a
`ScopedStack`, because `canReadReferent()` refuses a missing referent
before either runs — right for a non-owner, since it is what stops the
gate being an existence oracle, but it also catches the owner, who is
documented as exempt. Nothing this server can do fixes it; the fix
belongs in core.

Haverstack dependencies move in lockstep: core 0.30.0, wire-types 0.29.0,
commons 0.24.0, adapter-local 0.29.0, conformance-fixtures 0.23.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb
The skip reason was written before the core issue existed. Filed now, so
the comment names it and a reader can check whether it is still true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb
core 0.31.0 exempts the owner acting alone from the reference-creation
gate (#276, fixed in core#279), so `canReadReferent()` no longer refuses a
missing destination ahead of the checks a caller-named `parentId` is owed.
Both fixtures skipped in the previous commit now pass unchanged, and their
dispatch tests come back:

  error-bad-request-malformed-parent-id   400 bad_request
  error-conflict-parent-does-not-exist    409 conflict

The anti-oracle property is intact for everyone else, which the docs now
state rather than leaving implied — probed against adapter-local, a
non-owner write-holder naming either an empty or a nonexistent parentId
still gets one indistinguishable 403, where the owner gets the 400 and the
409. `docs/api.md` § The change set carries that split, since the section
previously promised both answers to every caller.

core 0.31.0 also lands #278 — `StackQueryError.capability`, the exported
`MissingCapability` and `filtersContent()`, and `assertQueryTravels()` in
`@haverstack/core/wire`. None of it reaches this server: `capability` does
not travel (`serializeError()` does not carry it), `assertQueryTravels()`
is the build-side half of a refusal this server's parsers already apply,
and discovery spreads `stack.features` rather than re-deriving any of it.
No change needed, recorded here so the next reader need not re-check.

Dependencies move in lockstep: core 0.31.0, wire-types 0.30.0, commons
0.25.0, adapter-local 0.30.0, conformance-fixtures 0.24.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb
Comment thread docs/api.md Outdated
Comment thread pnpm-workspace.yaml Outdated
Two review comments.

The change-set section closed on a paragraph contrasting the envelope with
the four single-aspect endpoints it replaced. Removed: docs/api.md
describes the server as it is, and the endpoint table already says which
routes exist. Nothing else in the section was written that way. One test
comment narrated the pre-0.31 gate behavior on the same grounds and now
states the present rule instead — which requester reaches the conflict,
and what everyone else gets.

`minimumReleaseAgeExclude` was growing because pnpm writes to it. pnpm 11
defaults `minimumReleaseAge` to 1440, and an install that has to hold a
too-new package appends it to this list (no prompt unless
`minimumReleaseAgeStrict` is set). Haverstack releases in lockstep, so
every sync lands the whole scope on its release day and pnpm wrote all
seven back, one version at a time — which is the churn every previous sync
commit shows.

A scope pattern ends it: pattern support landed in pnpm 10.17 and this repo
is on 11.24. Verified against the registry rather than assumed — with no
exclusions, a clean resolve appends all seven (core@0.31.0 among them,
published hours ago); with `'@haverstack/*'`, the same resolve completes
and the file is untouched. The lockfile still passes the supply-chain
check, and third-party dependencies keep the cooldown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb
Three review findings on the change-set docs and tests.

The per-key authority paragraph gave gates for contentPatch, permissions
and unlisted but said nothing about the two keys left over. Measured
against adapter-local rather than inferred: a non-owner write-holder may
set associations, and may set parentId only when it can also read the
destination — naming one it cannot read is the same indistinguishable 403
the paragraph below already describes.

"A filtered connection never receives a frame outside its filter" was
false as written. A reparent is matched against both containers the move
concerns, so a connection filtered on the origin receives a frame whose
own parentId is the destination — it matched, but not on what it carries.
The qualification existed but sat under ## Unlisted, three sections from
the filtering rule it qualifies, where no feed implementer would find it.
It now follows that rule, and the rule claims what the server actually
guarantees: never a frame it did not match.

Only unlisted had a shape-error test, so a core bump that quietly stopped
rejecting a malformed permissions or associations value would have passed
the suite on the boolean case alone. Both are covered now.

The `feat:` prefix is what sets this PR's version bump, and is the reason
it sits on a docs-and-tests commit. release.yml picks the bump from every
commit since the last tag; the commits adopting the envelope landed
without a conventional prefix, so the release would otherwise have gone
out as 0.7.9 — a patch — though PATCH /records/:id no longer accepts a
bare content patch and two PUT endpoints are gone. `feat:` lands it as
0.8.0 instead: on a 0.x package the minor is the compatibility boundary,
and a `!` would only have forced 1.0.0, which this is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmcYMq3PtT1UCYJ4xNzauL
@cuibonobo
cuibonobo force-pushed the claude/server-core-sync-bhkqi5 branch from ccfe281 to 83d2784 Compare September 12, 2026 03:31
@cuibonobo
cuibonobo merged commit 59a52bd into main Sep 12, 2026
7 checks passed
@cuibonobo
cuibonobo deleted the claude/server-core-sync-bhkqi5 branch September 12, 2026 03:32
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