Skip to content

RFC: version history is the private recovery surface — gate reads to writers, strip snapshot permissions #109

Description

@cuibonobo

Problem

ScopedStack.getVersions()/getVersion() gate on canRead(current record) (packages/core/src/stack.ts:2086-2098). #61 made snapshots full-state (content, associations, and permissions). Two things leak that the review never discussed, and that the current-read gate makes automatic:

  1. Content history predating a reader's access. A record shared after sensitive content was edited out exposes those earlier revisions to every current reader.
  2. ACL history. Snapshot permissions — which the spec explicitly frames as "for audit and deliberate owner action, not automatic restore" (§Versions) — are served to any reader, revealing who else had access historically.

The through-line: because history read gates on canRead(current), a record that is public now has a transitively public past. "This page is public, therefore its entire history is public" is precisely the accidental-exposure footgun — a draft, an edited-out paragraph, a brief share with one person, all exposed as a side effect of the current ACL.

Decided direction

Reframe: version history is the library's private recovery/rollback substrate — the mutation surface — not a read surface and not a publishing API. Two rules, both in ScopedStack:

  1. Strip snapshot permissions from history reads for everyone but the owner. They're owner-only audit data by the spec's own description; a reader (or a write-holder) never needs the ACL trail. Uncontroversial, and it directly closes leak Validate baseTypeId argument in migrateAll() #2.
  2. Content history (getVersions/getVersion) requires the mutate surface — a write-holder, or owner/creator — not plain read. This is not "you need write to do a read": history is the undo/recovery surface, and that surface belongs to whoever can mutate the record (the write bit's whole justification, §Permissions, is "anything a write-holder does, the owner can undo"). Pure readers get the current state — what they were actually shared on. Contributors (especially in groups, which are shared workspaces) get the deeper view.

Explicitly out of scope (the complexity trap to avoid): time-slicing history by when each reader was granted access, so a contributor added today can't see content deleted last year. Grants aren't per-version timestamped and ACLs move over time; this is the "too complicated to implement or understand" version. The two-tier model deliberately accepts that a write-holder sees full history including pre-their-involvement content — correct for intimates/groups.

Publishing history is a projection, not a history read

The natural objection — "but an owner may want to publish a page's history (e.g. a static-site generator showing a changelog)" — is what makes the writers-only rule right, not wrong. The resolution is that publishing history is a deliberate act of projecting the chosen revisions into first-class application records, not exposing the raw internal history:

  • The app defines e.g. com.example.ssg/page-revision@1 records, each with its own content (rendered HTML, summary, the author label the app chooses to show) and its own permissions ({ access: 'public' }, or group-scoped).
  • Curation/redaction: the owner publishes the revisions they mean to. The embarrassing draft or the edited-out paragraph never leaks unless deliberately materialized — the opposite of raw-history-as-public.
  • Per-revision permissions, a stable web-shaped schema (decoupled from the internal RecordVersion shape, which carries audit fields like snapshot permissions), and decoupling from rollback (restoreVersion and publishing don't affect each other).

This is the same properties-vs-perspectives split already in the design: raw history is the property (the library's honest record of what happened); the published changelog is a perspective the app constructs — mirroring how _entity is "a card about a DID, not the identity itself."

For a static generator specifically, the build runs as the owner / a token-authed writer, so it can read raw history at build time and bake HTML regardless — the materialized-page-revision approach is what you reach for when you want curated / per-revision-permissioned / live publishing. And auto-materializing a page-revision on each edit is a motivating use case for the event/hook system (#3) — an on:update hook keeps the published trail in sync without the app hand-rolling it.

Capability being removed (name it as a decided tradeoff)

Today "public record → public history" works via the current-read gate. Writers-only breaks that automatic linkage on purpose — it's the footgun. Publishing history becomes an explicit, curated act (projection into app records) instead of an implicit side effect of the current ACL.

Work items

  • ScopedStack.getVersions()/getVersion(): gate on the mutate surface (write bit, or owner/creator, or group-manager for _group) instead of canRead; deny pure readers
  • Strip permissions from RecordVersions returned to any non-owner (both methods) — consider whether entityId (change attribution) stays; leaning keep, as it's useful for group attribution and far less sensitive than the ACL
  • Spec §Versions + §Permissions: state the two-tier rule ("history is the recovery/mutation surface"), the permissions-stripping, and the "publishing history = projection into app records" pattern with the SSG worked example
  • Spec §API Adapter Wire Format: GET /records/:id/versions[/:version] require the same mutate-surface authorization (403 for a read-only requester)
  • @haverstack/conformance-fixtures: pin 403 for a read-only requester on the versions endpoints, and that snapshot permissions are absent for a non-owner
  • Tests: read-only requester denied history; write-holder allowed; owner sees permissions, non-owner write-holder does not; group admin/member behavior on a _group's own history

Related follow-up (separate, don't bundle)

A write-holder's restoreVersion() can re-attach references (an attachment association or file-ref field) from a snapshot without re-running the #51 reference-creation checks, re-conveying file access. Defensible under recoverability ("the owner can undo"), but now that write-holders provably reach history, it deserves its own decision + a spec sentence so it reads as chosen rather than missed. (stack.ts:1098-1135, and §A4's closing note in the assessment.)

Refs

#61 (full-state snapshots), #58 (group roles / mutate surface), #51 (reference-creation gating, the restore follow-up), #3 (event hooks — auto-materializing published revisions), #62 (restoreVersion semantics). From docs/design-assessment-2026-07.md §A4 (PR #105).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions