Repository navigation
Adopt core 0.30's change-set envelope for PATCH /records/:id - #123
Merged
Merged
Conversation
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
cuibonobo
commented
Sep 12, 2026
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
force-pushed
the
claude/server-core-sync-bhkqi5
branch
from
September 12, 2026 03:31
ccfe281 to
83d2784
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
core 0.29 replaced
update(),setPermissions(),setUnlisted()andsetParent()with a singlemutate(id, changes), and moved the wire format with it.PATCH /records/:idnow carries a change set — an envelope naming any combination ofcontentPatch,parentId,permissions,associationsandunlisted, applied as one atomic write producing exactly one version — andPUT .../permissionsandPUT .../unlistedare gone. OneIf-Matchfences 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/wireowns 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 waycreateOptionsFromWireRecord()already ownsPOST /records. The two retiredPUThandlers each carried a hand-rolled shape check the envelope parser makes redundant.GET /records/:id/permissionsstays.PATCH /entitykeeps its own{ content }envelope — no fixture governs it and its shape is this server's own — and moves topatchContent(), the content-only spellingupdate()left behind.Change-feed frames carry
ops: ChangeOp[]in place ofop, withupdaterenamedpatchandreparentadded. 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.
minimumReleaseAgeExcludenow 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.ymlpicks 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 thoughPATCH /records/:idno longer accepts a bare content patch and two endpoints are gone. Afeat:prefix now carries it to the minor, which is where the compatibility boundary sits on a 0.x package.The
parentIdanswers, and who sees whichAn earlier revision of this PR skipped
error-bad-request-malformed-parent-idanderror-conflict-parent-does-not-exist:canReadReferent()resolved a caller-named destination and refused a missing one beforeStackran 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""bad_requestpermission"1hk153xffffz"(no such record)conflictpermissionA 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 exportedMissingCapabilityandfiltersContent(), andassertQueryTravels()in@haverstack/core/wire. None of it reaches this server —capabilitydoes not travel (serializeError()does not carry it),assertQueryTravels()is the build-side half of a refusalparseQueryParams()/parseQueryBody()already apply here, and discovery spreadsstack.featuresrather 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 twoPUTrows 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; theIf-Matchendpoint list; the soft-delete refusal list; the ## Unlisted section's spelling; and the change-feed paragraph forops. The reparent note sits with the change-feed filtering rule it qualifies, since aparentIdfilter is the one place where what a frame matched on and what it carries come apart.docs/deployment.md— no change needed; its onePATCH /records/:idmention is about thread routing, which is unaffected..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 testall clean, pluspnpm build. 524 tests passing, 27 files (520 before the two fixtures came back, 522 before the shape-error cases below).Checked by hand:
method/path/requestBodyfrom the fixtures package and matched the routes against them, rather than inferring the shape from the changelog.PATCHcall 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 needingcontentPatch. Two were hand-fixed rather than wrapped:tests/routes/versions.test.tssent a wire-record-shaped body whosecontent/version/updatedAtkeys had been landing as content fields under the old bare-patch rule, and the reserved-content-key test builds its body throughJSON.parse.parentIdanswers directly againstadapter-localon both tiers, before and after the core upgrade — the table above is measured, not read off the changelog.associations, and may setparentIdonly 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
permissionsandassociations, which had been resting on theunlistedcase 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:
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._config.entityIdtest keeps its 409.entityIdis a declared content field of_config(ConfigContent), so wrapping it incontentPatchstill reachescheckConfigEntityIdUnchanged()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 missingentityId, a bare string) is stored verbatim, and anullentry throws aTypeErrorout of the validator as a 500. It fails closed — a junk entry grants nothing — and it is not a regression, since the retiredPUT .../permissionsdid the same array-only check. It belongs in core rather than in this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb