Skip to content

feat(artifact): recipient-scoped private-children collections (#0009) — 0.21.112#1021

Merged
oeway merged 1 commit into
mainfrom
feat/artifact-private-children-0009
Jul 22, 2026
Merged

feat(artifact): recipient-scoped private-children collections (#0009) — 0.21.112#1021
oeway merged 1 commit into
mainfrom
feat/artifact-private-children-0009

Conversation

@oeway

@oeway oeway commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements #9 — a server-enforced, recipient-scoped "private children" collection mode on the artifact-manager, powering svamp notify-style cross-user inboxes.

A collection configured with:

{ "private_children": true, "recipient_field": "recipientEmail" }

becomes a cross-user inbox / drop-box: any writer may create a child addressed to any recipient, but when a non-admin caller lists or searches the collection, the server forces:

WHERE manifest.<recipient_field> == caller.email

so a recipient only ever enumerates the children addressed to them.

Guarantees (all under test)

  • Recipient scoping — a non-admin caller sees only their own children.
  • Filter cannot widen the view — a client-supplied filter on the recipient field is stripped; the forced condition is the single source of truth.
  • No mode="OR" escape — the scoping condition is AND-ed unconditionally, kept out of the OR-able conditions list, so it can't be bypassed by combining it with a permissive keyword/filter clause.
  • Admin bypass — workspace admins (collection owner, root) see all children.
  • Non-recipient filters still honored — scoping composes with (does not disable) other filters.
  • Direct read(child_id) unchanged — privacy is a discovery boundary (unguessable UUID ids + scoped listing), analogous to unlisted visibility. A recipient only learns the ids of their own messages.

Why not enforce direct-read too?

The artifact permission model makes child-read ≈ parent-read: read(child_id) hard-requires read on the parent collection, and when the parent grants read it acts as a fallback that overrides a stricter child ACL. So true per-child read denial isn't expressible via config — #0009 solves the enumeration half server-side and leaves read() untouched. Both non-obvious constraints are now documented in CLAUDE.md.

Changes

  • hypha/artifact.py::search — scoping injection (strip client filter on the recipient field; build a forced manifest.<field> == email condition; AND it unconditionally after the mode combination) + docstring.
  • tests/test_artifact.py::test_private_children_recipient_scoping — real integration test (no mocks, sqlite backend, two distinct users): owner/admin sees all; recipient sees only own; client filter ignored; scoping AND-ed with other filters; discovery boundary; own-child read works.
  • CLAUDE.md — two artifact permission-model constraints learned here.
  • Version bump 0.21.111 → 0.21.112 + CHANGELOG.

Test

pytest tests/test_artifact.py::test_private_children_recipient_scoping   # green (sqlite, no Docker)

Also re-ran test_sqlite_create_and_search_artifacts, test_order_by_custom_json_fields, test_versioning_artifacts — no regression in the search/list path.

🤖 Generated with Claude Code

….21.112)

A collection configured with
  {"private_children": true, "recipient_field": "<manifest field>"}
becomes a cross-user inbox / drop-box: any writer may create a child
addressed to any recipient, but a non-admin caller listing/searching the
collection only ever sees the children addressed to their own email.

list()/search() forces  WHERE manifest.<recipient_field> == caller.email:
  - a client-supplied filter on the recipient field is ignored (cannot
    widen the view);
  - the scoping condition is AND-ed unconditionally, even under mode="OR",
    so it cannot be escaped by combining it with a permissive keyword/
    filter clause;
  - workspace admins (the collection owner, root) bypass and see all.

Direct read(child_id) is intentionally unchanged: privacy is a discovery
boundary (unguessable UUID ids + scoped listing), analogous to "unlisted"
visibility — a recipient only ever learns the ids of their own messages.
The artifact permission model makes child-read == parent-read, so true
per-child read denial is not expressible via config; #9 solves the
enumeration half server-side (documented in CLAUDE.md).

Powers svamp notify-style cross-user notifications.

- hypha/artifact.py::search — scoping + docstring
- tests/test_artifact.py::test_private_children_recipient_scoping (no mocks,
  sqlite backend): owner/admin sees all; recipient sees only own; client
  filter ignored; scoping AND-ed with other filters; discovery boundary
- CLAUDE.md — two artifact permission-model constraints learned here
- version bump 0.21.111 -> 0.21.112 + CHANGELOG

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 22, 2026
@oeway
oeway merged commit 2ea0bd7 into main Jul 22, 2026
7 checks passed
@oeway
oeway deleted the feat/artifact-private-children-0009 branch July 22, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant