Skip to content

feat: adopt core 0.43's per-aspect writes, wire parsers and installs - #131

Merged
cuibonobo merged 4 commits into
mainfrom
claude/amazing-ritchie-qyy1cn
Nov 1, 2026
Merged

cuibonobo merged 4 commits into
mainfrom
claude/amazing-ritchie-qyy1cn

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Brings the server up to the latest published core releases: @haverstack/core 0.43, adapter-local 0.42, commons 0.38, wire-types 0.42 and conformance-fixtures 0.37. Where the old code had a per-route exception, this applies one shared rule:

  • One write endpoint per aspect. PATCH /records/:id/content, PUT .../parent, PUT .../unlisted, plus POST (edit list) and PUT (replace) on .../associations and .../permissions. The change-set PATCH /records/:id and the /delete sub-paths are removed. PATCH /entity is now PATCH /entity/content.
  • Every record write reads and checks If-Match through one helper (src/lib/ifMatch.ts), including writes that don't bump the version.
  • Every endpoint core specifies is parsed by core's parser for it. The server's own copies are removed: booleanParam, isOwnerActingAlone, and the hand-written checks on POST /types and migrate. knownParams() and the readJson key list remain only for routes the server defines itself.
  • One paging rule for history. Versions (now a { versions, cursor } envelope) and the journal share readHistoryPage: a 500-row page cap, with a cursor that is null exactly when nothing follows. The query limit clamp is removed, because Stack now sets and caps query defaults itself.
  • Migrate is gated by ScopedStack, so an installed app can commit migrations within its own type families.
  • Soft-deleted records:
    • GET /records/:id hides a tombstone unless called with ?includeDeleted=true.
    • GET .../associations and .../permissions are views of the live record, so they answer 404 for a soft-deleted one and take no includeDeleted. A tombstone carries no associations, and its permissions are on GET /records/:id?includeDeleted=true.
  • App installs:
    • POST /installs is advertised in discovery.
    • /installs/requests is an owner-only surface to list, review the current plan, approve and decline. Approve answers the same { status: 'installed', install } body that POST /installs does.
    • A request is acted on only by the id that names it.
    • requireActingAlone() refuses a delegated session before its body is read.
    • Manifests are compared with keys sorted, so a re-send with reordered keys keeps its id.
    • A pending request shares the handshake token's self-service lifetime (SELF_SERVICE_TTL_MS).
  • Also: LocalAdapter.open({ create }), includeUnlisted read from the change filter, all ten commons types seeded (adds image, post and site), and grantType calls take a type family.

Stored data: pending install requests live in a new sibling file, <DB_PATH>.installs. It is additive, and nothing existing needs migrating.

Docs

  • docs/api.md: Discovery, Records (per-aspect writes, history/restore/migrate, optimistic concurrency), query defaults, tombstones, Unlisted, change-feed kinds, Types, the new Installs section, Entity, Permissions and the grant surface (baseId).
  • docs/deployment.md: the .nonces and .installs sibling stores, and how install requests are bounded.
  • .env.example: the full commons type list.

The wire contract itself is core's. This PR implements core 0.43 and does not change the contract.

Verification

pnpm run format:check && pnpm run lint && pnpm typecheck && pnpm test all pass, with 660 tests across 33 files. pnpm run build also succeeds.

The conformance gate dispatches every 0.37 fixture. The only skips are the same two as before, each of which pins a client obligation rather than a server one.

Notes for reviewers

  • Behavior changes clients will see: the removed endpoints listed above, the versions envelope, and a stale If-Match on a write that doesn't bump the version now answering 412.
  • Install approval has no reviewed-plan check yet. Approve re-plans the stored manifest and applies it; core's installApp() still refuses a plan the stack moved on from while it applies. Checking that the plan is still the one the owner reviewed belongs to core: feat(core): every installApp() names the plan it approves core#478 adds installApp(plan, { fingerprint }). The server will pick it up in the follow-up update that adopts the next core release.
  • Owner review fields: serializePlan names each field it shows, and its return type has to cover every InstallPlan field. A field core adds (such as #478's fingerprint) therefore fails to compile here until someone decides whether the review shows it.
  • Fixture inconsistency in core: the delete-record-soft fixture's body keeps content that the spec's tombstone projection empties. The test pins the spec. Filed as delete-record-soft fixture shows content the tombstone projection empties core#475.
  • Spec text fixes found here: docs: bring wire-format.md and change-feed.md up to date with 0.43 core#476.
  • Not covered here: core main has three unreleased changesets (the ownerEntityId rename, portable copy, reshare-needs-read). They're for the same follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8

Bring the server up to @haverstack/core 0.43 (adapter-local 0.42,
commons 0.38, wire-types 0.42, conformance-fixtures 0.37).

One rule per concern rather than per-route exceptions:

- Every aspect of a record has one write endpoint: PATCH .../content,
  PUT .../parent, PUT .../unlisted, and POST (edit) / PUT (replace) on
  .../associations and .../permissions. The change-set PATCH and the
  /delete sub-paths are gone; PATCH /entity is PATCH /entity/content.
- Every write reads and checks If-Match, including the no-bump ones.
- Every endpoint core specifies is parsed by core's parser for it;
  knownParams()/readJson keys remain only for server-defined routes.
  The server's own booleanParam, isOwnerActingAlone and the POST /types
  and migrate hand checks are removed.
- Versions and the journal page by one rule (readHistoryPage): a
  500-row server ceiling announced through an exact cursor. The query
  limit clamp is gone, since Stack resolves and caps query defaults.
- POST /records/:id/migrate is ScopedStack's to gate, so an installed
  app can commit migrations within its own families.
- GET /records/:id hides tombstones unless ?includeDeleted=true.

New: POST /installs (advertised in discovery) and an owner-only review
surface at /installs/requests. Pending requests live in a new sibling
store, <DB_PATH>.installs — additive, no migration of existing data.

Also: LocalAdapter.open({ create }), includeUnlisted read from the
change filter, the full commons set seeded (image, post, site), and
the conformance gate dispatching every 0.37 fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
Review findings on the install surface:

- GET /installs/requests/:id returns an ETag naming the plan it shows,
  a hash of its canonical JSON. Approve reads If-Match and answers 412
  (precondition_failed) when the plan has changed, so what lands is what
  was reviewed — the same If-Match rule every other write follows.
- Requests are removed by the id that names them, never by key, so a
  manifest re-sent mid-approval is no longer deleted in its place.
- requireActingAlone() refuses a delegated session before its body is
  read, in the same order as requireAuth() and requireOwner().
- Manifests compare as canonical JSON, so reordered keys keep an id.
- ifMatchVersion()/ifMatchTag() live in src/lib/ifMatch.ts, and
  serializePlan spreads every plan field rather than listing them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
Fencing an approval to the reviewed plan is core's invariant:
installApp() already re-plans and compares planFingerprint(), and
refuses a stale plan with StackConflictError. A server-side plan hash
carried in If-Match gave the header a second meaning beside a record
version, answered the same stale plan with 412 or 409 depending on
timing, and minted an error code outside WireErrorCode. Approval
re-plans unconditionally until core lets installApp() take the
fingerprint the owner reviewed.

Also pins that GET /records/:id/associations and /permissions answer
404 for a soft-deleted record, as GET /records/:id does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
…ield

- Approve answers WireInstallResponse ({ status: 'installed', install }),
  the body POST /installs gives the key for the same outcome.
- serializePlan names each field, and its return type covers every
  InstallPlan field, so a field core adds neither reaches the wire
  unchosen nor drops out of the owner's review unnoticed: it fails to
  compile until someone decides. The lint exception it needed goes.
- The handshake token and a pending install request share one
  self-service lifetime, SELF_SERVICE_TTL_MS, rather than two literals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
@cuibonobo
cuibonobo merged commit 951eb2a into main Nov 1, 2026
8 checks passed
@cuibonobo
cuibonobo deleted the claude/amazing-ritchie-qyy1cn branch November 1, 2026 16:29
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