Skip to content

fix(review): #1294 review findings, round 1 - #1296

Merged
SudoThijn merged 6 commits into
fix/1285-group-member-share-savefrom
review/pr-1294-fixes
Oct 20, 2026
Merged

SudoThijn merged 6 commits into
fix/1285-group-member-share-savefrom
review/pr-1294-fixes

Conversation

@rjzondervan

Copy link
Copy Markdown
Member

Fixes for the round-1 review of #1294 (#1294 (review)). Merge into fix/1285-group-member-share-save, then re-request the review; nothing here is merged by the reviewer.

Findings → commits

Finding Commit What changed
f1 🔴 A group member's save leaves the owner no version to restore b25708a2 ShareSyncService takes an appended, nullable SecretVersionService; writeCopyBlobs() snapshots the source's pre-update state, inside the transaction, before anyone but its owner overwrites it (share-save and /sync, so team-folder writers and delegates too). Only when a value changes, as SecretService::shouldSnapshot(); the owner's own save takes no extra snapshot. Only the source gets a version: secret-version-history keeps the canonical history on the owner's copy.
f2 🟡 /sync opened to group members without share-save's guards aceeb9d0, b6c14936, 2d90c220 For a group-write caller: an empty expectedUpdatedAt is refused; under the lock the source is loaded again and the caller re-checked (read-only, use-only, still a writer); the source and every live copy must be named. A copy may go without the new value only when its holder has no vault key (share-save's refusedSkips rule), and then its row is not written. The two tests that synced with '' now pass a base.
f4 🟡 user-sharing spec still says either party's update propagates d0ee2e51 The suggestion at L194, and L12 and L440 reworded to name the owner, delegates and write-grade recipients; git grep "either party" -- openspec/specs is empty.
f6 🟢 Two body claims without a test 47a16bed A team-folder copy plus group membership gets write; membership lost, or the group share revoked, between resolve() and the lock refuses the save.

Left to the author (threads stay open)

Checks run on this branch

  • Full unit suite in the dev container (PHP 8.4, phpunit -c phpunit-unit.xml) → 2365 tests, OK, 46 skipped (database-backed).
  • Mutations, one at a time: removing the source snapshot turns GroupMemberWritesForEveryoneTest::testAGroupMembersSyncKeepsTheSourcesPreviousValueAsAVersion, ShareSaveServiceTest::testAWriteGradeMembersSaveKeepsTheSourcesPreviousValueAsAVersion and ::testAGroupMemberWritesTheSourceAndTheOtherCopiesAndIsAuditedAsAGroupWrite red; allowing the empty base turns testAGroupMembersSyncWithoutABaseIsRefused red; dropping the guards under the lock turns four tests red; dropping the read-only and the use-only re-check each turns its own test red; accepting a valueless copy of a holder with a vault key, or writing a keyless holder's copy, each turns its own test red.
  • composer phpcs (lib/) → 0 errors.
  • Pre-push adversarial check (separate agent), two rounds → round 1 findings fixed in b6c14936 and 2d90c220, round 2 clean.
  • Not run: the database-backed tests (ShareSaveVersionLockTest), phpstan, psalm and the e2e spec; CI runs them.

🤖 Generated with Claude Code

rjzondervan and others added 6 commits October 20, 2026 10:16
…sion to restore

#1294 (comment)

ShareSyncService::writeCopyBlobs() now keeps the source's state before a
write by anyone but its owner as a version (SecretVersionService::snapshot,
actor `user:<writer>`), under the same rule SecretService applies to the
owner's own update: only when a value changes and the source held one.
SecretVersionService is an appended, nullable, DI-resolved constructor
parameter of ShareSyncService.

Only the source is snapshotted: secret-version-history keeps the canonical
history on the owner's copy and scopes a recipient's history to their own
copy, and the owner's own update snapshots no recipient copy either. The
caller's own copy is already snapshotted by SecretService::update().

Class sweep: the same snapshot covers every non-owner write of the source,
on share-save and on /sync: a group member, a team-folder writer and a
delegate. The owner's own /sync takes none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ave's guards

#1294 (comment)

A member of a group the secret is shared with (GROUP_WRITE) now gets
share-save's three guards on PUT /secrets/{id}/sync:
- expectedUpdatedAt '' is refused, so no blind overwrite;
- the writer is checked again inside atomic(), after the source lock, so a
  leave or a group-share revoke that committed meanwhile refuses the sync;
- the updates must name the source and every copy whose access has not
  ended (an ended or gone copy may be named and is not written), as
  ShareSaveScope::assertComplete() requires; ShareSaveScope depends on
  ShareSyncService, so the check is mirrored, not called.

The owner's, a delegate's and a team-folder writer's /sync are unchanged.
GroupMemberWritesForEveryoneTest's successful group-member syncs now pass
a real base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…update propagates

#1294 (comment)

user-sharing/spec.md: the Sync on Update requirement (L194) takes the
review's suggestion and names delegates; the Purpose (L12) and the
acceptance criterion (L440) say the same instead of "either party".

Class sweep: no other live copy of the sentence in docs/, README or
openspec/changes/ (only archived changes, which stay as recorded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1294 (comment)

- GroupMemberWritesForEveryoneTest: a member whose copy came through a
  team folder (with a `read` grade there) and who is in a group the secret
  is shared with gets `write` and saves for everyone.
- ShareSaveServiceTest: a member who left the group, or whose group share
  was revoked, between resolve() and the lock is refused under the lock
  and the save rolls back. groupAuth() answers isInGroup / findBySecret
  differently once the save's transaction began, as goneUnderLock does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se-only before the sync's lock

#1294 (comment)

assertGroupWriteGuards() checked the writer again on the freshly loaded
source but not, as share-save's assertStillWriter() does through
assertWritable(), whether that source became a read-only or use-only copy
meanwhile: neither moves the version the lock checks. It now refuses both,
with the same refusals syncUpdate() gives before the lock
(ForbiddenException for read-only, 'A use-only copy cannot be changed').
assertWritable() is private to ShareSaveScope, which depends on
ShareSyncService, so the two checks are mirrored, not called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly for a holder without a vault key

#1294 (comment)

assertGroupWriteGuards() required every live copy to be named but not to
carry a value, so a group member's /sync could name a copy with no blob:
it got only the new updatedAt and kept the old ciphertext, and its holder
read a stale value under a fresh version.

Share-save's rule is followed: a copy may be left without the new value
only when its holder has no vault key (ShareSaveScope::refusedSkips()),
and then its row is not written at all. A named row with no non-empty
key, login or additionalFields is now refused when its holder (the
owner for the source) has an active key pair, and otherwise dropped from
the rows to write, so its version does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@SudoThijn
SudoThijn merged commit af89599 into fix/1285-group-member-share-save Oct 20, 2026
2 checks passed
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