Skip to content

get() hides soft-deleted records unless includeDeleted, as query() does #368

Closed

Description

@cuibonobo

Problem

query() hides soft-deleted records by default. get() returns them with deletedAt set, and there's no option to change that, so every app needs its own deletedAt guard after a get().

The spec also disagrees with the code in the same area. versioning.md § Version history says a tombstone "is read back with get(id, { includeDeleted: true })", but GetRecordOptions only has presentAt. The same paragraph says delete() "returns nothing", but it returns DeleteResult ({ referencedFileIds }).

Decision

get() hides soft-deleted records by default, as query() does. get(id) on a tombstone returns null. get(id, { includeDeleted: true }) returns it, still as the tombstone projection under ScopedStack. That makes the spec's includeDeleted sentence true. Separately, the spec's delete() wording is corrected.

Why

  • An app developer doesn't expect deleted records back without asking for them.
  • One rule for both reads: soft-deleted records are hidden unless you pass includeDeleted.
  • null rather than a throw: get() is the nullable read, and a tombstone is "not here" as far as normal reads are concerned.
  • Any reader may pass includeDeleted, as on query(). The record's own permissions still decide whether they see it, so the flag leaks nothing.
  • Writes stay explicit. mutate() on a tombstone still throws "soft-deleted; undelete it first". Reads hide deleted records and writes explain why they're refused.
  • On delete(): it returns DeleteResult, which attachments.md already documents along with deleteAndReturn(). The reason versioning.md gives still holds: a purge leaves no Record behind, so delete() is the one mutating verb that doesn't answer with one. It answers with a report instead.

Wire: GET /records/:id answers 404 for a tombstone unless called with ?includeDeleted=true. That fits the disclosure rule, because 404 already means "missing or unreadable", and a requester who can read the record can opt in to see that it's deleted.

Risk: core's own lookups must opt in. ScopedStack calls this.stack.get(id) in five places, including requireVerb(), which undelete(), the tombstone 409 and history reads all go through. If those get null back, undelete() breaks and a mutation on a tombstone answers 404 instead of 409. Every internal lookup that's supposed to see tombstones has to pass includeDeleted: true.

Actions

  • Add includeDeleted?: boolean to GetRecordOptions (packages/core/src/stack.ts) and default Stack.get() and ScopedStack.get() to hiding tombstones.
  • Audit every internal get() caller in Stack and ScopedStack, including the tombstone refusal in Stack from Unscoped Stack lets you edit a tombstone; ScopedStack refuses #367, and pass includeDeleted: true wherever a tombstone is a valid target.
  • Tests on both Stack and ScopedStack:
    • get() returns null for a tombstone.
    • includeDeleted returns it (as the tombstone projection under ScopedStack).
    • undelete() still works.
    • Mutating a tombstone is still 409, and a stranger still gets 404.
    • getVersions() and getJournal() still serve a deleted record's history.
  • Wire: GET /records/:id takes ?includeDeleted=. Update wire-format.md (the "served as a tombstone" paragraph under § Records), APIAdapter.get() and @haverstack/conformance-fixtures together.
  • Spec:
    • State the default in data-model.md next to the query filter's includeDeleted, and in versioning.md § Deletion / § The tombstone is literal.
    • In versioning.md § Version history, replace "delete() is the one that returns nothing" with: delete() returns a DeleteResult rather than a Record, and deleteAndReturn() is the call to use when the caller needs the tombstone or the purged body. The get(id, { includeDeleted: true }) sentence stays as written.
    • Check docs/spec.md ("delete() excepted") for the same wording.
  • Changesets: minor for @haverstack/core, @haverstack/adapter-api and @haverstack/conformance-fixtures.

Order

Blocked by #367: the tests here assert that mutating a tombstone is 409 on both surfaces, which is only true once #367 lands, and the audit should cover the check #367 adds to Stack.

Activity

  1. added theissue type on Sep 29, 2026
  2. added a commit that references this issue on Oct 3, 2026
    a1bea0e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions