Skip to content

fix: preserve visibility invariant when copying or moving content - #3024

Open
dqnykamp wants to merge 4 commits into
Doenet:mainfrom
dqnykamp:fix/copy-move-visibility-invariant
Open

dqnykamp wants to merge 4 commits into
Doenet:mainfrom
dqnykamp:fix/copy-move-visibility-invariant

Conversation

@dqnykamp

@dqnykamp dqnykamp commented Aug 12, 2026 •

Copy link
Copy Markdown
Member

Independent of #3023 (both branch from main); the two can land in either order.

Problem

updateVisibility enforces that a child may not be less public than its parent (access/visibility.ts:76-85). copy_move.ts predates unlisted and still reasons in terms of the binary isPublic flag, so it treats an unlisted parent as private and breaks that invariant:

  • Copy (copy_move.ts:656-657): visibility: desiredParentIsPublic ? "public" : "private" — copying into an unlisted folder produced a private child.
  • Move (copy_move.ts:329-335): only promoted when parent.isPublic, so moving content into an unlisted folder left it private.

Practical symptom: someone holding an unlisted folder's link sees its other children but not the newly copied or moved one.

This also undermines the reachability argument that #3023 relies on, which assumes the invariant holds in the data.

Fix

Read the parent's actual visibility instead of isPublic:

  • A copy inherits the parent's visibility directly. For public and private parents this is exactly the previous behavior; unlisted parents now yield unlisted copies rather than private ones.
  • Moved content is raised to the parent's visibility via a new raiseVisibility helper.

Why a new helper rather than updateVisibility

updateVisibility cascades one visibility to every descendant, so it can demote. Using it here would mean that moving a folder containing a public doc into an unlisted folder silently hides that doc.

raiseVisibility promotes only content strictly below the target level, leaving anything already more public untouched. It replaces the previous setContentIsPublic call on the move path; for a public parent the outcome is identical, since nothing can be more public than public.

The library guard ("Cannot move draft from library to published folder/activity") keeps its existing scope: it fires only for a public parent. Moving a draft into an unlisted folder is not publishing it. Its content check now reads visibility rather than the legacy isPublic flag, which is equivalent since the two are kept in sync.

Testing

Three new tests in copy_move.test.ts:

  • copying into an unlisted folder makes the copy unlisted, not private;
  • moving private content into an unlisted folder makes it unlisted;
  • moving a folder into an unlisted folder raises its private child to unlisted while leaving its public child public.

raiseVisibility skips assignments and does not re-check ownership or the public-share
criteria, matching the setContentIsPublic call it replaces: the content is following a
parent that already satisfies them.

Full API suite passes: 383 tests across 27 files.

Not addressed here

Existing rows in production may already violate the invariant, since this bug has been live. A data audit is worth doing separately. The deprecated setContentIsPublic (query/share.ts:20) is another binary-visibility path still in use.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2

copy_move only knew the binary isPublic flag, so an unlisted parent was
treated as private. Copying into an unlisted folder produced a private
child, and moving content there left it private -- both violating the
rule updateVisibility enforces, that a child may not be less public than
its parent. The practical symptom: someone holding an unlisted folder's
link sees its other children but not the newly copied one.

Read the parent's visibility instead. A copy inherits it directly; moved
content is raised to it via a new raiseVisibility helper, which promotes
only content below the target level so that moving a folder holding public
content into an unlisted one does not hide that content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
dqnykamp and others added 3 commits August 12, 2026 16:30
The visibility and share-timestamp updates share one filter that already
selects exactly the rows whose visibility changes, so a single updateMany
does the job and removes the statement-ordering subtlety. Also document
what raiseVisibility does not check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
Read the moved content's `visibility` rather than the legacy `isPublic`
flag, and apply the library draft guard to unlisted parents as well as
public ones: moving into any shared parent raises the draft's visibility,
which shares curated content implicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
An unlisted parent cannot occur for library content: `moveContent` requires
the parent to share the content's owner, and the library account's content is
only ever set public (on publish) or private (on unpublish or creation).
Guarding unlisted parents therefore adds an unreachable, untestable branch and
an error message ("published folder/activity") that would not describe it.

Keep reading `visibility` rather than the legacy `isPublic` flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
@dqnykamp
dqnykamp requested a review from cqnykamp August 12, 2026 21:57
@cqnykamp

cqnykamp commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

@dqnykamp Looks like there are some merge conflicts now.

How does this preserve the "public checks" invariant? If I move an inaccessible doc into a public folder, what happens?

I think what should happen is that: you get a message saying you can't move non-public content into a public folder. That's the easiest thing for us to do that preserves the invariant. If that approach ends up being too annoying, we could find other ways later.

@cqnykamp cqnykamp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments.

This branch has not been deployed

No deployments
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