Skip to content

feat(core): check a typed collection's write expectation in Stack - #431

Merged
cuibonobo merged 2 commits into
mainfrom
claude/issue-408
Oct 10, 2026
Merged

cuibonobo merged 2 commits into
mainfrom
claude/issue-408

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #408. Every write verb (mutate, patchContent, associate, dissociate, amendAssociations, grantAccess, revokeAccess, amendAccess, delete, undelete, restoreVersion) now accepts an internal write expectation, passed under a module-private symbol key (WRITE_EXPECTATION in packages/core/src/write-expectation.ts). The expectation names a baseId, can name an exact typeId, and can set exact to hold every write (not only a content write) to that typeId.

  • Stack checks the expectation against the read each verb already makes, before anything is written.
    • A family mismatch is refused with StackNotFoundError.
    • A typeId mismatch on a content patch (or on any write, with exact) throws StoredVersionError, which carries the stored record. It is checked after the tombstone refusal and the ifVersion precondition, so a deleted record is never handed back and a stale caller learns of the conflict first.
  • ScopedStack checks the family ahead of its permission gate, so another family's record is "not found" whatever the requester may do to it. When a patch targets another version, it skips its content gates (which read the stored Type) and forwards with ifVersion pinned to its own read, so Stack must refuse it. The association and access verbs gained an optional options argument, taking options third and surface fourth, as Stack's do.
  • Purge under an expectation is pinned to the version it checked: a write in between is a conflict, never an unchecked purge. A missing record purges nothing. ScopedStack pins its delete to its own read the same way.
  • Typed mutate() / patchContent() now pass an exact expectation instead of making their own read first: one read per write, with no gap between the check and the write.

Spec

docs/spec/data-model.md § Type handles is updated. Observable change: a typed write to a record of another family now throws StackNotFoundError (it was StackBadRequestError). A record of the family stored at another version is still refused with StackBadRequestError, and a deleted record or stale ifVersion is refused first, as for an untyped write. The changeset is minor.

Verification

All passing locally:

pnpm run format:check && pnpm run check:refs && pnpm run lint && pnpm test && pnpm run build && pnpm run typecheck

Tests in packages/core/tests/write-expectation.test.ts cover both Stack and ScopedStack:

  • Every verb refuses another family, and the record and its journal are unchanged afterwards.
  • Every verb makes no more adapter reads with an expectation than without one.
  • A version mismatch on a content write carries the stored record.
  • A deleted record is refused as deleted, and a stale ifVersion as a version conflict, ahead of the stored version.
  • A purge under an expectation is pinned to the version it checked, and a missing record purges nothing.
  • A write that leaves content alone checks only the family.
  • Under delegation, another family's record is not found even where the write itself would be refused.

packages/core/tests/type-handle.test.ts pins the typed write: another family is not found, another version throws before writing (content and association-only), and a typed write reads no more often than an untyped one.

Notes for reviewers

  • Purge on unscoped Stack makes one extra read when an expectation is present, since a purge otherwise reads nothing first. The returned record still comes from the purge itself.
  • Pinning a delete or purge to the version read is a trade-off. An unrelated edit landing between the read and the write makes it fail with StackVersionConflictError, naming an ifVersion the caller never passed. The pin also only partly guards the case it's meant for: an ID purged and re-created in another family starts again at version 1, so it would still match a pin of 1. It's kept because it's cheap and retrying after a conflict is a reasonable response.
  • restoreVersion checks the family only. A snapshot is validated against its own stored Type, so the record's current version doesn't affect the write.
  • No typed way to pass the option from outside core. The symbol isn't exported; Typed access through collections, with records that don't fit reported rather than thrown #409 will need an internal typed view, or a cast as the tests use.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pzh2Awag1hD5tK4Y2yarfz

A typed collection must refuse a write to a record outside its Type
family, and notice a content write to a record stored at an older
version. Doing either from outside Stack costs a read per write, or
throws only after the write has landed.

Every write verb now accepts an internal expectation under a
module-private symbol key: the family, plus an exact typeId checked on a
content patch. Stack checks it against the read it already makes; a
family mismatch is StackNotFoundError, a version mismatch is a
StoredVersionError carrying the stored record. ScopedStack checks the
family ahead of its permission gate, so another family's record is not
found whatever the requester may do to it, and passes the expectation
through.

Closes #408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GomaLzoB7N2ipg547Kr6h4
@changeset-bot

changeset-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 74c834b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@haverstack/core Minor
@haverstack/adapter-api Patch
@haverstack/adapter-conformance Patch
@haverstack/adapter-local Patch
@haverstack/blob-adapter-disk Patch
@haverstack/blob-adapter-s3 Patch
@haverstack/commons Patch
@haverstack/record-adapter-do-sqlite Patch
@haverstack/record-adapter-sqlite Patch
@haverstack/wire-types Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…ugh them

- Stack.mutate checks the stored version after the tombstone and ifVersion
  refusals, so a deleted record is never handed back in a StoredVersionError
  and a stale caller gets a version conflict first.
- ScopedStack skips its content gates when the patch targets another
  version, and pins ifVersion to its read so Stack must refuse it.
- A purge under an expectation is pinned to the version it checked, and
  purges nothing when the record is missing. ScopedStack pins its delete
  the same way.
- typedMutate passes an exact expectation instead of its own pre-read:
  one read per write, and no race between the check and the write.
- Changeset raised to minor; seedShelf now grants SHARE as documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pzh2Awag1hD5tK4Y2yarfz
@cuibonobo
cuibonobo merged commit 957077e into main Oct 10, 2026
9 checks passed
@cuibonobo
cuibonobo deleted the claude/issue-408 branch October 10, 2026 12:55
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.

Stack checks a typed collection's write expectation

2 participants