Skip to content

fix(core): authority is gated the same way whenever it is written - #468

Merged
cuibonobo merged 6 commits into
mainfrom
claude/affectionate-albattani-egtlgc
Oct 23, 2026
Merged

cuibonobo merged 6 commits into
mainfrom
claude/affectionate-albattani-egtlgc

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #419.

A creator removed from the roster that gave them read could still reshare their Record. editAccess(), setAccess() and setUnlisted() asked only owner-or-creator, never whether the requester can read the Record. That let a removed member grant themselves back in. Fixing it surfaced two related gaps, and the PR closes all three with existing rules rather than exceptions.

1. Resharing needs read. Authority.canReshare() asks owner-or-creator, on both sides of a delegation, plus read. _group Records keep their own rule: roster management.

2. Authority written at create() is gated as a later write of it would be. The gate is asked of the Record the create would make:

  • That Record carries its creator, and a _group's stamped first admin (via createAssociations() in access.ts, shared with Stack.create()).
  • It carries none of the authority the call writes, neither permissions nor a roster, so no call can satisfy its own gate.
  • The gates themselves are the update path's own: passesWriterFence() is one predicate deciding every family's fence (owner-alone for _grant/_install, managers for _group, none otherwise) on both create and update, and canReshare() decides permissions and unlisted.

mayGrantAccess(), the two delegated-only refusals in create(), requireOwnerAloneWriter(), and gateVerb()'s separate group branch are gone.

3. A roster entry never counts on the principal's side. Group-targeted grants already followed this rule; Group management didn't. A delegated app could name itself admin and then manage and reshare that Group, through editAccess() on main too. Under delegation, isGroupManager() now requires the principal to be the owner and the subject to be an admin or the owner. So a delegated app neither creates nor manages a Group, and the owner's own software acting for a subject still can.

Tests pin:

  • read lost through a group-targeted grant and through a record-level group grantee;
  • the drop-box contributor, refused after the create and within it, while a read-own contributor passes;
  • the delegated unlisted refusal at create;
  • an app on a roster, with full _group grants, refused management (editAccess, patchContent);
  • an app refused group creation, with or without naming itself admin;
  • the owner's own software creating and managing a Group for its subject.

Changeset: minor for @haverstack/core.

Spec

  • access-control.md § Write implies read: resharing reads the same way, asked at create of the Record being made, which carries none of the authority being written. Authorship alone confers no read.
  • access-control.md § Delegation: principal and subject: Group management is the owner on the principal's side; a rostered app manages a Group only when signing as itself; authority written at create() is gated as later.
  • access-control.md § Reference-creation gating: permissions and unlisted at create pass the reshare gate.
  • access-control.md § Per-verb gates: the reshare row says "asked of both sides of a delegation", and the family fences apply to create() too.
  • identity.md § Group: the delegated management rule; the create bullet asks the management rule of the Group being made; removal withdraws what the roster conveyed.

Verification

All pass locally: pnpm run format:check, check:refs, lint, test, build, typecheck. Each new test fails without its fix.

Notes for reviewers

  • A delegated app can no longer manage a Group, even one it created and named itself admin of. That's observable on main. A later way for the owner to give an app management directly, through a grant rather than a roster entry, would add to this without undoing it.
  • On a _group create that also sets permissions or unlisted, the management check is asked twice: once as the family fence, once inside canReshare(). That's the same order the update path uses (gateReshare()), and the second can't fail once the first passes. Folding them would need a _group branch at create, which is what this PR removes.
  • An app signing as itself, undelegated, is an ordinary entity, and its roster entries count. That's unchanged.
  • A creator refused after the create gets StackNotFoundError (disclosure.md). At create time the refusal is StackPermissionError.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY

A creator removed from the roster that conveyed their read could still
editAccess() their Record and grant themselves back in: the reshare gate
asked owner-or-creator but never whether the requester reaches the
Record. canReshare() now asks read too, so "mutation is never blind"
covers the authority aspect as it already covered the data aspect.

Spec: access-control.md states the read requirement on resharing;
identity.md § Group gains "Governing a group stack" — owner authority,
unrecorded owner reads, and what removal takes away.

Closes #419

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

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4366c68

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

Resharing needs read on the Record; setting `permissions` or `unlisted`
at create() is the same capability exercised before the Record exists,
so it is asked the same question. A non-owner creator may pass either
only if their type grants alone would let them read the Record — the
permissions being written cannot supply that read. A drop-box
contributor can no longer name themselves a reader at create time.

Also: the changeset is minor (a reshare that succeeded now refuses),
and identity.md § Governing a group stack is trimmed to links and
states what removal does and does not take away.

Refs #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY
…make

The create-time check restated canReshare() by hand, with its own
_group branch and its own delegation check. It now builds the Record
the create would produce — stamped as Stack.create() stamps it, without
the permissions being written — and asks canReshare() of it. The stamped
admin answers the _group case, and owner-or-creator answers delegation,
so mayGrantAccess() and the two delegated refusals in create() go.

Spec: the create-time rule and the delegation paragraph state it as the
reshare gate; the per-verb row is simplified; "authorship alone confers
no read". identity.md drops § Governing a group stack: the owner points
are already owned by access-control.md and journal.md, and the removal
rule by access-control.md, which § Group now links in one sentence.

Refs #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY
A delegated app could name itself admin on a Group it created, then
manage and reshare that Group — at create time once the reshare gate
replaced mayGrantAccess(), and through editAccess() afterwards on main.
Group-targeted grants already refuse roster-derived authority on the
principal's side; group management now follows the same rule: under
delegation the principal must be the owner and the subject an admin or
the owner.

Creating a Group writes its roster, so the create is asked the
management rule too, of the Group it would make — the same move as the
reshare gate on create-time permissions. Both now share one prospective
Record, built by createAssociations(), which Stack.create() also uses
to stamp a Group's first admin.

Spec: identity.md § Group states the delegated management rule and the
create gate; access-control.md fixes the per-verb row, the delegation
sentence, and names unlisted beside permissions at create.

Refs #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY
@cuibonobo cuibonobo changed the title fix(core): resharing needs read, like every other mutation fix(core): authority is gated the same way whenever it is written Oct 23, 2026
passesWriterFence() decides every family's fence — none for ordinary
families, the owner acting alone for _grant and _install, the Group's
managers for _group — and is asked of a stored Record on update and of
the Record a create would make. It replaces requireOwnerAloneWriter()
and gateVerb()'s own group branch, so "the family fences apply to
create() too" is the same code rather than an outcome of grant rules.

The create-time Record now carries none of the authority the call
writes: the caller's roster is left out as its permissions already
were, per "no call can combine gates".

Spec: the delegation paragraph that let a rostered app manage a Group
for another admin states the rule as it is.

Refs #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY
@cuibonobo
cuibonobo merged commit 6ca5b93 into main Oct 23, 2026
9 checks passed
@cuibonobo
cuibonobo deleted the claude/affectionate-albattani-egtlgc branch October 23, 2026 18:59
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.

Spec: state who governs a group stack, and what removal takes away

2 participants