Skip to content

Record-level write stays one bit — but everything it permits must be recoverable, and irreversible verbs get carved out #59

Description

@cuibonobo

Problem

The permission system currently holds three positions at once about whether write-shaped verbs differ:

  • Grants say they differ: the action vocabulary ships update-own/any and delete-own/any as separate verbs, and requireUpdatable/requireDeletable check them separately (packages/core/src/stack.ts:854–874).
  • Record-level permissions say they're one bit: checkWrite (the write: true boolean on a PermissionEntry) short-circuits both requires, and associate/dissociate ride requireUpdatable too (stack.ts:859, 870, 944, 949).
  • setPermissions says one verb is sacred: it ignores write entirely — owner or creator only (stack.ts:957–959) — precisely so a write-holder can't escalate.

Note the scope: this is about the served topology. For adapter-local, direct adapter access is full trust and the permission model means nothing (#45, settled). Record-level permissions exist exactly for the requester on the far side of the server — a write: true holder who does not have DB access. For them the bit is the only fence, so what it permits is a real policy surface.

And what it permits today is wildly asymmetric in blast radius:

verb (via write: true) reversible?
update Yes — versioned (snapshot first), schema-validated, restoreVersion undoes it
restoreVersion Yes — update-shaped
associate / dissociate No — unversioned, no snapshot, no audit trail (parked in #48's open questions); and association mutation carries privilege side effects (#51: references confer access)
delete (soft) In principle — but there is no undelete API, so in practice no
delete (hard) NeverScopedStack.delete(id, opts) passes opts straight through (stack.ts:964–966), so write: true reaches { hard: true }: record, and its version history, permanently gone

A record shared "so you can edit it" is a record the grantee can permanently destroy.

Decided direction

Keep write as one coarse bit, and make its trust model true: anything a write-holder does, the owner can undo. Recoverability is the backstop, not per-verb fencing. This matches the project's stated scale (per-record sharing among a small cohesive group — nobody will maintain per-verb ACLs) and the stated versioning intent (rollback + recovery, not conflict fencing).

Concretely:

  1. Hard delete is owner-only. Never reachable through the write bit (nor, tentatively, through delete-any grants — an irreversible verb has no place in a delegated-access vocabulary; flagging that sub-decision for confirmation at implementation). This is the one-line carve-out at stack.ts:964 and stands on its own regardless of the rest.
  2. Soft delete becomes actually recoverable. The undelete API (spec already promises soft-delete recoverability; no API exists) is a prerequisite of this model, not polish — without it, "reversible by owner" is false for every deletion.
  3. Association changes become versioned/audited (the Clarify what version is for, and add opt-in optimistic concurrency so concurrent writers can't corrupt version history #48 open question, resolved in this direction), and privilege-creating associations stay gated by ScopedStack.create() passes caller options through unchecked — attachment-association escalation, parent/app spoofing #51's predicate independently of write.
  4. Spec states the model plainly: write = update + soft-delete + associate, all reversible by the owner; irreversible or privilege-bearing operations (hard delete, setPermissions) are owner/creator-only. The existing setPermissions carve-out stops being an unexplained special case and becomes the pattern's first instance.
  5. Grants deliberately stay fine-grained — type-wide access for third-party apps warrants verb precision in a way per-record sharing among intimates doesn't. Document that associate rides update-* on the grant side, with ScopedStack.create() passes caller options through unchecked — attachment-association escalation, parent/app spoofing #51 gating the dangerous cases. The two-granularity design becomes a stated position instead of an accident.

Alternative considered and set aside

Full alignment: record-level permissions adopt the grant verb vocabulary ({ access: 'entity', entityId, actions: ['read','update','delete','associate'] }, with read/write as sugar). Most conceptually coherent — one vocabulary at two scopes — but the PermissionEntry shape change ripples through wire, spec, and every ACL-editing surface, and buys expressiveness the target scale won't use. Rejected in discussion; revisitable if the scale intent ever changes, and nothing in the chosen direction forecloses it.

Dependencies / sequencing

Work items

  • ScopedStack.delete: strip/reject hard for non-owners (decide: silently soften, or StackPermissionError — leaning error, silent downgrades hide intent)
  • Undelete API (separate issue; blocks the spec language, not the carve-out)
  • Association versioning/audit (separate issue resolving Clarify what version is for, and add opt-in optimistic concurrency so concurrent writers can't corrupt version history #48's parked question)
  • Spec §Permissions: the recoverability trust model, verb table for write, owner/creator-only list
  • Tests: write-holder cannot hard-delete; owner can; write-holder soft-delete is undeletable by owner (once API exists); grant-holder with update-any but not delete-any still cannot delete (existing distinction preserved)

Refs #45 (topology scoping of the argument), #48 (association-versioning open question resolved toward versioning), #51 (privilege-creating associations gated independently), #58 (roster mutations already carved out for _group), #52/#53 (wire: hard-delete denial must round-trip as StackPermissionError).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions