Skip to content

Add unlisted records — enumeration without permissions - #209

Merged
cuibonobo merged 2 commits into
mainfrom
claude/issue-202-implementation-o2vtlc
Aug 30, 2026
Merged

Add unlisted records — enumeration without permissions#209
cuibonobo merged 2 commits into
mainfrom
claude/issue-202-implementation-o2vtlc

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Summary

Adds an unlisted state for records that withholds them from enumeration while keeping them readable by ID. This is orthogonal to permissions: unlistedAt says nothing about who may read a record, only whether it is discoverable without already holding its ID.

New APIs:

  • stack.create(typeId, content, { unlisted: true }) — create a record already unlisted, with no window where it's briefly enumerable
  • stack.setUnlisted(id, unlisted) — toggle unlisted state on an existing record
  • RecordFilter.includeUnlisted and SubscribeOptions.includeUnlisted — opt back into seeing unlisted records (owner-only under ScopedStack)

Behavior:

  • Unlisted records are excluded from unfiltered query() and the change feed by default, matching the posture of soft-deleted records
  • get() still resolves unlisted records for anyone who may read them
  • The unlist transition emits as kind deleted / op unlist (so subscribers already holding the record are told to drop it), while list emits as kind changed / op list (the publish moment)
  • Under ScopedStack, setUnlisted() is gated exactly like setPermissions() — both decide discoverability, not readability. includeUnlisted is refused to everyone but the stack owner acting alone, since enumeration standing rests on nothing but ownership

Spec

Updated:

  • docs/spec/access-control.md — new § Unlisted records, explaining the three-tier enumeration model (enforced, defaulted, advisory) and the owner-only includeUnlisted gate
  • docs/spec/events.md — documents unlist and list ops and their kind mappings; explains why unlist reaches default subscribers despite the post-change state being excluded
  • docs/spec/data-model.md — adds unlistedAt?: Date field to StackRecord
  • docs/spec/wire-format.md — wire format guidance for the new field and query parameter

Verification

  • Added conformance fixtures covering create-unlisted, set-unlisted-true, set-unlisted-false, permission denial on includeUnlisted, and three change feed scenarios (unlist transition, list transition, silent edits while unlisted)
  • Added unit tests in stack.test.ts covering version bumping, no-op idempotence, permission preservation, and query filtering
  • Added ScopedStack tests verifying access control (creator/owner only, stranger gets StackNotFoundError, delegated apps cannot use includeUnlisted)
  • Added change event tests verifying feed exclusion by default, unlist/list op kinds, and owner-with-includeUnlisted seeing all events
  • Added subscription tests verifying feed behavior matches query filtering
  • SQLite adapter tests verify schema, query filtering, and version management
  • All existing tests pass; no breaking changes to public APIs

https://claude.ai/code/session_01RZ3v68CDeYsFRHRPDnsU2k

Closes #202.

StackRecord.unlistedAt is a native field, orthogonal to permissions: it
says nothing about who may read a record, only whether it's enumerable.
An unlisted record is reachable by get() for anyone who may already read
it, and excluded from an unfiltered query() and the change feed by
default — the same posture soft delete already has.

- create({ unlisted: true }) creates a record already unlisted, so
  there's no window where it exists and is briefly enumerable.
- setUnlisted(id, unlisted) toggles it on an existing record, gated
  exactly like setPermissions() under ScopedStack.
- RecordFilter.includeUnlisted and SubscribeOptions.includeUnlisted opt
  back in, refused to everyone but the stack owner acting alone under
  ScopedStack — enumeration standing rests on nothing but ownership.
- The feed matches query()'s exclusion, with one exception: the unlist
  transition emits a dedicated op (kind 'deleted') so a subscriber that
  already knows the record is told to drop it; relisting emits 'list'
  (kind 'changed'), an ordinary upsert like undelete.

Wires the field and filter through every adapter (sqlite-shared,
record-adapter-sqlite, adapter-local, the in-memory test double, and the
adapter-api wire client), extends the conformance fixtures, and updates
the spec docs (access-control.md, events.md, wire-format.md,
data-model.md, versioning.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZ3v68CDeYsFRHRPDnsU2k
@changeset-bot

changeset-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3c28fbe

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

This PR includes changesets to release 9 packages
Name Type
@haverstack/core Minor
@haverstack/sqlite-shared Minor
@haverstack/record-adapter-sqlite Minor
@haverstack/adapter-local Minor
@haverstack/wire-types Minor
@haverstack/adapter-api Minor
@haverstack/conformance-fixtures Minor
@haverstack/blob-adapter-disk Patch
@haverstack/commons 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

…ementation-o2vtlc

# Conflicts:
#	packages/core/src/stack.ts
@cuibonobo
cuibonobo merged commit 092e3fc into main Aug 30, 2026
5 checks passed
@cuibonobo
cuibonobo deleted the claude/issue-202-implementation-o2vtlc branch August 30, 2026 19:19
@github-actions github-actions Bot mentioned this pull request Aug 30, 2026
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