Skip to content

feat(desktop): add team sharing to community catalog - #3995

Open
wpfleger96 wants to merge 1 commit into
mainfrom
duncan/team-catalog-desktop
Open

feat(desktop): add team sharing to community catalog#3995
wpfleger96 wants to merge 1 commit into
mainfrom
duncan/team-catalog-desktop

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Stack: #5112 → this PR

Stacks on the team catalog backend PR (#5112). Contains the desktop/** changes that turn the add-agent surface into a single Community Catalog browsing both shared agents and shared teams.

Owner catalog sync pipeline

usePersonaSync.ts gains a hydration pipeline in startPersonaSync so a fresh device that comes online after another already published the owner's 30178 catalog head reconstructs the complete state without falsely retracting it. The owner's persona/team/managed-agent/30178/deletion history is backfilled up front (a live-only subscription gets no history — reconnect-replay's since-cursor is undefined until the first live event), then a live subscription takes over.

Four properties keep a fresh sync from purging the owner's valid shared head:

  • Paged backfill with a safe termination guarantee. The relay serves each REQ newest-first and clamps limit to its advertised max_limit, so a large owner's history overflows one page — a newer 30178/30176 could return in-page while an older required 30175 constituent falls beyond it. fetchOwnerHistoryToExhaustion pages the full window with the until time cursor (the only cursor the WS REQ filter exposes; the DB before_id keyset is REST-only), deduping the inclusive-boundary rows. A short page terminates normally. A full page whose oldest event cannot advance the time-only cursor is a dense boundary — more than one page of events share one created_at second, which the WS filter has no (created_at, id) cursor to escape — and raises PersonaHistoryDenseBoundaryError rather than silently completing as if the history were exhausted and dropping the older constituents behind it.
  • Constituents-before-catalog ordering. Over the complete batch, orderCatalogHeadsLast stably defers every 30178 head past its 30175/30176 constituents. Reconciling a 30178 head before its personas hydrate makes the inbound team refresh fail member resolution and queue a dominating false tombstone; deferring the heads guarantees the constituents are all applied first, while newest-wins order within every other coordinate is untouched.
  • Hydration boundary for concurrent live events. The backfill fetch and the live subscription start concurrently into one reconcile chain. A live or replayed 30178 that arrives before the backfill reconciles its constituents would reproduce the same false-tombstone purge, so live events are buffered until the ordered backfill is dispatched and then drained in arrival order. Steady-state live events (after hydration completes) reconcile immediately.
  • Explicit backfill failure policy. A transient history-fetch rejection is retried with bounded backoff. When backfill cannot complete — retries exhausted, or a deterministic dense boundary — the pipeline enters a degraded-live state rather than leaving the subscription permanently inert with live events accumulating in the buffer: the hydration boundary still opens so buffered and future live events keep reconciling, but the whole catalog dependency set is dropped — the 30178 head, its 30175/30176 constituents, and any kind-5 deletion carrying a dependency-targeting a tag (classified by scanning all a tags, matching the backend's deletion router, which find_maps across every tag and routes the first signer-owned coordinate — so a malformed or foreign first a tag ahead of an owned 30176 cannot slip a destructive deletion through) — because backfill never fully hydrated the owner's constituents. Dropping only the 30178 head is not enough: the backend refreshes the catalog head after every team/persona save, and live delivery is newest-first, so a 30176 edit that adds a new member would reach the backend before that member's 30175 and falsely tombstone a witness-holding device's valid team. Holding the prior hydrated run's constituents on disk only proves the old revision is resolvable — it says nothing about a new member — which is why the entire dependency set is held rather than just the head. 30177 managed-agent runtime policy stays live (it drives no catalog refresh). A degraded device stays stale on team/persona edits until it self-heals on the next effect re-run (restart, or an identity/community switch) — the correct trade against destroying valid shared state.

Data layer

Relay paging, signature verification, NIP-33 head selection, and untrusted-content parsing for the kind 30178 team catalog live natively in the fetch_team_catalog Tauri command (team_catalog.rs), structurally mirroring fetch_persona_catalog (persona_catalog.rs). A catalog refresh crosses IPC once and never verifies a signature on the webview thread. teamCatalogRelay.ts is now a thin presentation and local-linkage layer over the verified projection — it shapes entries for display and links each to a local team, and never parses or verifies.

Parsing is all-or-nothing, identical to the add-time re-fetch in add_team_from_catalog: a team with any invalid member fails to parse and the publication is dropped from the catalog, matching persona behavior. Behavior delta: the previously reviewed partial-render of invalid-member teams — a warning banner on an entry that could never be added — is removed. Invalid publications are dropped entirely rather than surfaced as un-addable.

Hooks

useTeamCatalogRelay.ts mirrors the persona catalog hook: a community-keyed query over fetch_team_catalog, live invalidation on kind 30178, share/unshare, and add-from-catalog (which invalidates both the teams and personas stores, since adopting a team copies its members as local personas).

CommunityCatalogDialog

Single unified surface replacing the former separate dialogs. Agents and Teams appear as labeled sections with type-tagged selection and a teams-preferred launch. TeamsSection's discover entry and the new-agent card both open this one dialog. PersonaCatalogDialog.tsx is removed; persona browsing now lives inside the unified dialog.

TeamShareDialog

Publishes and unshares team catalog entries via set_team_shared.

e2e + screenshots

team-catalog.spec.ts covers the browse + adopt flow; team-catalog-screenshots.spec.ts produces the pixel-regression set. e2eBridge.ts gains mockTeamCatalogPublications, which mirrors the native command's head selection and shared gate and performs only a shallow v/name/members-array shape check; per-member validation stays in the Rust command.

Follow-ups

None. The shared agent-definition text-safety policy (Unicode-control/bidi/zero-width rejection) already covers the team surface: #5112's validate_team_catalog_content chokepoint gates every field delivered verbatim to the harness on both the publish and adopt paths, and this PR's parse layer consumes that verified projection rather than re-validating on the webview thread.

@wpfleger96
wpfleger96 requested a review from a team as a code owner July 31, 2026 19:36
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from 5c11456 to 8bd3c8e Compare July 31, 2026 20:18
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from 28c614f to a4c603a Compare July 31, 2026 21:32
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch 6 times, most recently from 8ed79f3 to f573bdc Compare August 4, 2026 18:53

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewing on Wes's behalf. Requesting changes for two security blockers in the catalog review surface:

  1. Verify relay events before they can affect paging, coordinate selection, attribution, or displayed content. fetchCatalogEvents currently inserts every raw RelayEvent returned by the socket into byId and derives oldestCreatedAt from it; sharedCatalogHeads then trusts the raw id, pubkey, created_at, tags, and content. RelayClient.fetchEvents is only a transport fetch and does not verify signatures. A forged/unsigned 30178 event can therefore appear under another publisher, claim or retract their coordinate ahead of a valid signed head, and control the pagination cursor. The backend re-fetch and verification correctly prevents adoption, but it does not protect browsing, publisher attribution, remote image loads, or denial/shadowing of legitimate entries. Please verify ID + signature on a fresh wire-shaped object before adding an event to the paging map or deriving a cursor/head from it, and add regressions for a forged newer head, forged author/content, malformed signature, and an invalid event attempting to control until. The equivalent hardening and tests already exist in PR #4220 for kind 30175; this shared helper should preserve that invariant for both 30175 and 30178 rather than regress it.

  2. Render executable team/member instructions byte-for-byte, not as Markdown, and reject non-reviewable controls at the adoption boundary. TeamCatalogDialog.tsx passes both team.instructions and member.systemPrompt through the chat Markdown renderer. Markdown can conceal spoiler bodies, link destinations, and image sources, so the instructions reviewed in the dialog can differ materially from the plaintext later executed by the adopted agents. The current team catalog validators only enforce schema/size bounds, so bidi/default-ignorable controls can also make reviewed text differ from execution. Please use the literal instruction-review component/invariant from PR #4220 and apply its executable-text validation to team instructions and every member prompt before publication/adoption. Add coverage proving Markdown syntax stays literal, hidden controls fail closed, and accepted bytes are preserved exactly through adoption.

The feature has unusually substantial coverage otherwise: this head adds/changes 62 test/spec/fixture files, including the Rust adoption/pending/sharing/reconcile suites, TS relay/parser tests, and Playwright catalog/screenshot specs. CI is green at e64aa20ff81e236ca068aab3b9ad93142fb839e3, but the existing fixtures use placeholder sig: "sig" values and therefore do not exercise the first trust boundary.

@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from e64aa20 to 131a906 Compare August 6, 2026 16:39
wpfleger96 pushed a commit that referenced this pull request Aug 6, 2026
@wpfleger96

Copy link
Copy Markdown
Member Author

Community Catalog — browse (teams section)

Team list with name, member count, description, and "Add team" footer.
catalog-browse

Community Catalog — member expanded

Per-member row expanded showing Type, Preferred model/runtime/provider, and agent instruction.
catalog-member-expanded

Community Catalog — team instructions

Team instructions section collapsed above member rows.
catalog-team-instructions

Community Catalog — already added

Footer button reads "Added to my teams" (disabled) after the team is adopted.
catalog-added

Community Catalog — empty state

Both agents and teams empty: "Nothing shared yet" placeholder.
catalog-empty

Community Catalog — both sections

Agents and Teams section headers both visible with one shared entry each.
catalog-both-sections

Share dialog — not yet shared

Share dialog before enabling catalog access.
share-not-shared

Share dialog — published

Share dialog with catalog toggle on and published toast.
share-published

wpfleger96 pushed a commit that referenced this pull request Aug 6, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from 3bcca7e to f869c80 Compare August 6, 2026 21:10
@wpfleger96
wpfleger96 changed the base branch from main to duncan/team-catalog-backend August 6, 2026 21:10
@wpfleger96 wpfleger96 changed the title feat(desktop): implement 30178 team catalog sharing for the community catalog feat(desktop): add community catalog UI, team share dialog, and e2e tests Aug 6, 2026
@wpfleger96 wpfleger96 changed the title feat(desktop): add community catalog UI, team share dialog, and e2e tests feat(desktop): add team sharing to community catalog Aug 6, 2026
wpfleger96 added a commit that referenced this pull request Aug 12, 2026
Implements the backend for 30178 team catalog sharing on the community
catalog. No UI callers yet — this PR is the backend half of a two-PR
split; desktop PR #3995 (stacked here) adds the TS parse layer, hooks,
CommunityCatalogDialog, and e2e.

Changes:
- team_catalog.rs: 30178 projection builder + size contracts (32 KiB
  ceiling, per-field bounds, avatar downscaling for raster data URLs);
  build_team_catalog_event/content, team_catalog_content_from_event,
  tombstone_team_catalog_coordinate
- commands/teams/pending.rs: publish/unshare/tombstone commands with
  relay-scoped share state; refresh_or_retract_shared_head_at for
  immediate retraction on member edits that exceed the size contract;
  persona-edit refresh guard so unrelated personas are never embedded
- commands/teams/adopt/: add_team_from_catalog with backend head
  verification + byte-level rollback across both stores; plan_add with
  full member provenance (owner, d-tag, member-key, projection-hash);
  builtin reuse via hint matching; commit_stores atomic writer
- event_sync.rs: reconcile_team_catalog_heads_at startup reconcile;
  republish changed heads, tombstone unrebuildable ones, skip unshared;
  multi-head continuation so a single pass handles all shared teams
- Rust tests: team_catalog/tests.rs (999 lines), adopt/tests.rs (999),
  pending/tests.rs (679), event_sync_team_catalog_tests.rs (436)
- 27 shared JSON parity fixtures (team_catalog_content/)
- Existing TeamRecord + AgentDefinition types extended with sharing
  fields (team_catalog_source, catalog_source, shared flag, etc.)
- All commands registered in lib.rs; dormant until UI PR merges

Stack: this PR -> #3995
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 0acacf4 to 16a1ff3 Compare August 12, 2026 14:50
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from f869c80 to 72c1491 Compare August 12, 2026 14:52
wpfleger96 added a commit that referenced this pull request Aug 12, 2026
Implements the backend for 30178 team catalog sharing on the community
catalog. No UI callers yet — this PR is the backend half of a two-PR
split; desktop PR #3995 (stacked here) adds the TS parse layer, hooks,
CommunityCatalogDialog, and e2e.

Changes:
- team_catalog.rs: 30178 projection builder + size contracts (32 KiB
  ceiling, per-field bounds, avatar downscaling for raster data URLs);
  build_team_catalog_event/content, team_catalog_content_from_event,
  tombstone_team_catalog_coordinate
- commands/teams/pending.rs: publish/unshare/tombstone commands with
  relay-scoped share state; refresh_or_retract_shared_head_at for
  immediate retraction on member edits that exceed the size contract;
  persona-edit refresh guard so unrelated personas are never embedded
- commands/teams/adopt/: add_team_from_catalog with backend head
  verification + byte-level rollback across both stores; plan_add with
  full member provenance (owner, d-tag, member-key, projection-hash);
  builtin reuse via hint matching; commit_stores atomic writer
- event_sync.rs: reconcile_team_catalog_heads_at startup reconcile;
  republish changed heads, tombstone unrebuildable ones, skip unshared;
  multi-head continuation so a single pass handles all shared teams
- Rust tests: team_catalog/tests.rs (999 lines), adopt/tests.rs (999),
  pending/tests.rs (679), event_sync_team_catalog_tests.rs (436)
- 27 shared JSON parity fixtures (team_catalog_content/)
- Existing TeamRecord + AgentDefinition types extended with sharing
  fields (team_catalog_source, catalog_source, shared flag, etc.)
- All commands registered in lib.rs; dormant until UI PR merges

Stack: this PR -> #3995
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 16a1ff3 to e5e5c2a Compare August 12, 2026 15:48
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from 72c1491 to dc6f777 Compare August 12, 2026 15:48
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from da22f55 to 43026a7 Compare August 20, 2026 21:22
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch 2 times, most recently from f0c8190 to f82b09b Compare August 20, 2026 22:21
wpfleger96 added a commit that referenced this pull request Aug 21, 2026
Implements the backend for 30178 team catalog sharing on the community
catalog. No UI callers yet — this PR is the backend half of a two-PR
split; desktop PR #3995 (stacked here) adds the TS parse layer, hooks,
CommunityCatalogDialog, and e2e.

Changes:
- team_catalog.rs: 30178 projection builder + size contracts (32 KiB
  ceiling, per-field bounds, avatar downscaling for raster data URLs);
  build_team_catalog_event/content, team_catalog_content_from_event,
  tombstone_team_catalog_coordinate
- commands/teams/sharing.rs: set_team_shared routes publication through
  the flush loop (the single publisher) rather than submitting the
  prepared head directly. A direct submit ran outside the store lock and
  could land a shared head after a concurrent delete_team's tombstone —
  and 30178 replacement has no deletion watermark, so the deleted team
  went publicly live again. The flush re-reads each row before
  publishing, so once the delete has committed the purged head's row is
  gone and the flush skips it; Published/Queued is derived from the
  re-read pending flag.
- commands/teams/pending.rs: publish/unshare/tombstone helpers with
  relay-scoped share state; refresh_or_retract_shared_head_at for
  immediate retraction on member edits that exceed the size contract;
  persona-edit refresh guard so unrelated personas are never embedded
- commands/teams/adopt/: add_team_from_catalog with backend head
  verification + byte-level rollback across both stores; plan_add with
  full member provenance (owner, d-tag, member-key, projection-hash);
  builtin reuse via hint matching; commit_stores atomic writer. The
  commit and the retention enqueue are sequenced inside commit_and_enqueue,
  the sole route to a durable adoption commit: after the store write
  succeeds it enqueues retention heads for every member copy the add
  wrote or reactivated plus the adopted team, so a crash before the next
  boot reconcile cannot lose the only adopted copy. A provenance match on
  an already-active copy is now retained too, so a recovery retry after a
  crash between the persona write and post-commit retention still enqueues
  the orphaned member's 30175; reused built-ins and replays write nothing
  and enqueue nothing, and a failed commit enqueues nothing.
- event_sync.rs: reconcile_team_catalog_heads_at startup reconcile;
  republish changed heads, tombstone unrebuildable ones, skip unshared;
  multi-head continuation so a single pass handles all shared teams
- persona_events.rs: domination-aware flush for kind:5 tombstones. A
  tombstone is signed strictly past the future-dated head it retracts,
  so its retained created_at is the domination floor. The relay ingest
  gate rejects events beyond ±900s of server time, so a byte-frozen
  future-dated replay can age out of the acceptance window and strand
  the head live forever. Flush now re-dates to now when the floor has
  passed, publishes at the floor when it is within the window, and
  leaves the tombstone pending to converge when the floor is further
  ahead than the window — never emitting an event the relay rejects.
- Rust tests: team_catalog/tests.rs, adopt/tests.rs + adopt/tests/
  retention.rs (adoption drives commit_and_enqueue through a spy commit +
  temp-dir scope: commits-then-enqueues, commit-failure enqueues nothing,
  idempotent replay skips both, reused-builtin/reactivation provenance,
  partial-commit retry enqueues the orphaned member head),
  pending/tests.rs, sharing/tests.rs (incl. a gate test driving a delete's
  tombstone then a delayed share through a recording relay to prove the
  purged head is never republished after its tombstone),
  event_sync_team_catalog_tests.rs
- 27 shared JSON parity fixtures (team_catalog_content/)
- Existing TeamRecord + AgentDefinition types extended with sharing
  fields (team_catalog_source, catalog_source, shared flag, etc.)
- All commands registered in lib.rs; dormant until UI PR merges

Stack: this PR -> #3995

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 43026a7 to fb0a637 Compare August 21, 2026 16:47
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from f82b09b to 7c9992c Compare August 21, 2026 16:54
wpfleger96 added a commit that referenced this pull request Aug 21, 2026
Implements the backend for 30178 team catalog sharing on the community
catalog. No UI callers yet — this PR is the backend half of a two-PR
split; desktop PR #3995 (stacked here) adds the TS parse layer, hooks,
CommunityCatalogDialog, and e2e.

Changes:
- team_catalog.rs: 30178 projection builder + size contracts (32 KiB
  ceiling, per-field bounds, avatar downscaling for raster data URLs);
  build_team_catalog_event/content, team_catalog_content_from_event,
  tombstone_team_catalog_coordinate
- commands/teams/sharing.rs: set_team_shared routes publication through
  the flush loop (the single publisher) rather than submitting the
  prepared head directly. A direct submit ran outside the store lock and
  could land a shared head after a concurrent delete_team's tombstone —
  and 30178 replacement has no deletion watermark, so the deleted team
  went publicly live again. The flush re-reads each row before
  publishing, so once the delete has committed the purged head's row is
  gone and the flush skips it; Published/Queued is derived from the
  re-read pending flag.
- commands/teams/pending.rs: publish/unshare/tombstone helpers with
  relay-scoped share state; refresh_or_retract_shared_head_at for
  immediate retraction on member edits that exceed the size contract;
  persona-edit refresh guard so unrelated personas are never embedded
- commands/teams/adopt/: add_team_from_catalog with backend head
  verification + byte-level rollback across both stores; plan_add with
  full member provenance (owner, d-tag, member-key, projection-hash);
  builtin reuse via hint matching; commit_stores atomic writer. The
  commit and the retention enqueue are sequenced inside commit_and_enqueue,
  the sole route to a durable adoption commit: after the store write
  succeeds it enqueues retention heads for every member copy the add
  wrote or reactivated plus the adopted team, so a crash before the next
  boot reconcile cannot lose the only adopted copy. A provenance match on
  an already-active copy is now retained too, so a recovery retry after a
  crash between the persona write and post-commit retention still enqueues
  the orphaned member's 30175; reused built-ins and replays write nothing
  and enqueue nothing, and a failed commit enqueues nothing.
- event_sync.rs: reconcile_team_catalog_heads_at startup reconcile;
  republish changed heads, tombstone unrebuildable ones, skip unshared;
  multi-head continuation so a single pass handles all shared teams
- persona_events.rs: domination-aware flush for kind:5 tombstones. A
  tombstone is signed strictly past the future-dated head it retracts,
  so its retained created_at is the domination floor. The relay ingest
  gate rejects events beyond ±900s of server time, so a byte-frozen
  future-dated replay can age out of the acceptance window and strand
  the head live forever. Flush now re-dates to now when the floor has
  passed, publishes at the floor when it is within the window, and
  leaves the tombstone pending to converge when the floor is further
  ahead than the window — never emitting an event the relay rejects.
- Rust tests: team_catalog/tests.rs, adopt/tests.rs + adopt/tests/
  retention.rs (adoption drives commit_and_enqueue through a spy commit +
  temp-dir scope: commits-then-enqueues, commit-failure enqueues nothing,
  idempotent replay skips both, reused-builtin/reactivation provenance,
  partial-commit retry enqueues the orphaned member head),
  pending/tests.rs, sharing/tests.rs (incl. a gate test driving a delete's
  tombstone then a delayed share through a recording relay to prove the
  purged head is never republished after its tombstone),
  event_sync_team_catalog_tests.rs
- 27 shared JSON parity fixtures (team_catalog_content/)
- Existing TeamRecord + AgentDefinition types extended with sharing
  fields (team_catalog_source, catalog_source, shared flag, etc.)
- All commands registered in lib.rs; dormant until UI PR merges

Stack: this PR -> #3995

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from fb0a637 to 01b312a Compare August 21, 2026 17:36
wpfleger96 added a commit that referenced this pull request Aug 21, 2026
Implements the backend for 30178 team catalog sharing on the community
catalog. No UI callers yet — this PR is the backend half of a two-PR
split; desktop PR #3995 (stacked here) adds the TS parse layer, hooks,
CommunityCatalogDialog, and e2e.

Changes:
- team_catalog.rs: 30178 projection builder + size contracts (32 KiB
  ceiling, per-field bounds, avatar downscaling for raster data URLs);
  build_team_catalog_event/content, team_catalog_content_from_event,
  tombstone_team_catalog_coordinate
- commands/teams/sharing.rs: set_team_shared routes publication through
  the flush loop (the single publisher) rather than submitting the
  prepared head directly. A direct submit ran outside the store lock and
  could land a shared head after a concurrent delete_team's tombstone —
  and 30178 replacement has no deletion watermark, so the deleted team
  went publicly live again. The flush re-reads each row before
  publishing, so once the delete has committed the purged head's row is
  gone and the flush skips it; Published/Queued is derived from the
  re-read pending flag.
- commands/teams/pending.rs: publish/unshare/tombstone helpers with
  relay-scoped share state; refresh_or_retract_shared_head_at for
  immediate retraction on member edits that exceed the size contract;
  persona-edit refresh guard so unrelated personas are never embedded
- commands/teams/adopt/: add_team_from_catalog with backend head
  verification + byte-level rollback across both stores; plan_add with
  full member provenance (owner, d-tag, member-key, projection-hash);
  builtin reuse via hint matching; commit_stores atomic writer. The
  commit and the retention enqueue are sequenced inside commit_and_enqueue,
  the sole route to a durable adoption commit: after the store write
  succeeds it enqueues retention heads for every member copy the add
  wrote or reactivated plus the adopted team, so a crash before the next
  boot reconcile cannot lose the only adopted copy. A provenance match on
  an already-active copy is now retained too, so a recovery retry after a
  crash between the persona write and post-commit retention still enqueues
  the orphaned member's 30175; reused built-ins and replays write nothing
  and enqueue nothing, and a failed commit enqueues nothing.
- event_sync.rs: reconcile_team_catalog_heads_at startup reconcile;
  republish changed heads, tombstone unrebuildable ones, skip unshared;
  multi-head continuation so a single pass handles all shared teams
- persona_events.rs: domination-aware flush for kind:5 tombstones. A
  tombstone is signed strictly past the future-dated head it retracts,
  so its retained created_at is the domination floor. The relay ingest
  gate rejects events beyond ±900s of server time, so a byte-frozen
  future-dated replay can age out of the acceptance window and strand
  the head live forever. Flush now re-dates to now when the floor has
  passed, publishes at the floor when it is within the window, and
  leaves the tombstone pending to converge when the floor is further
  ahead than the window — never emitting an event the relay rejects.
- Rust tests: team_catalog/tests.rs, adopt/tests.rs + adopt/tests/
  retention.rs (adoption drives commit_and_enqueue through a spy commit +
  temp-dir scope: commits-then-enqueues, commit-failure enqueues nothing,
  idempotent replay skips both, reused-builtin/reactivation provenance,
  partial-commit retry enqueues the orphaned member head),
  pending/tests.rs, sharing/tests.rs (incl. a gate test driving a delete's
  tombstone then a delayed share through a recording relay to prove the
  purged head is never republished after its tombstone),
  event_sync_team_catalog_tests.rs
- 27 shared JSON parity fixtures (team_catalog_content/)
- Existing TeamRecord + AgentDefinition types extended with sharing
  fields (team_catalog_source, catalog_source, shared flag, etc.)
- All commands registered in lib.rs; dormant until UI PR merges

Stack: this PR -> #3995

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 01b312a to 3b091fe Compare August 21, 2026 17:57
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from 7c9992c to 5f65b69 Compare August 21, 2026 18:02
wpfleger96 added a commit that referenced this pull request Aug 21, 2026
Implements the backend for 30178 team catalog sharing on the community
catalog. No UI callers yet — this PR is the backend half of a two-PR
split; desktop PR #3995 (stacked here) adds the TS parse layer, hooks,
CommunityCatalogDialog, and e2e.

Changes:
- team_catalog.rs: 30178 projection builder + size contracts (32 KiB
  ceiling, per-field bounds, avatar downscaling for raster data URLs);
  build_team_catalog_event/content, team_catalog_content_from_event,
  tombstone_team_catalog_coordinate
- commands/teams/sharing.rs: set_team_shared routes publication through
  the flush loop (the single publisher) rather than submitting the
  prepared head directly. A direct submit ran outside the store lock and
  could land a shared head after a concurrent delete_team's tombstone —
  and 30178 replacement has no deletion watermark, so the deleted team
  went publicly live again. The flush re-reads each row before
  publishing, so once the delete has committed the purged head's row is
  gone and the flush skips it; Published/Queued is derived from the
  re-read pending flag.
- commands/teams/pending.rs: publish/unshare/tombstone helpers with
  relay-scoped share state; refresh_or_retract_shared_head_at for
  immediate retraction on member edits that exceed the size contract;
  persona-edit refresh guard so unrelated personas are never embedded
- commands/teams/adopt/: add_team_from_catalog with backend head
  verification + byte-level rollback across both stores; plan_add with
  full member provenance (owner, d-tag, member-key, projection-hash);
  builtin reuse via hint matching; commit_stores atomic writer. The
  commit and the retention enqueue are sequenced inside commit_and_enqueue,
  the sole route to a durable adoption commit: after the store write
  succeeds it enqueues retention heads for every member copy the add
  wrote or reactivated plus the adopted team, so a crash before the next
  boot reconcile cannot lose the only adopted copy. A provenance match on
  an already-active copy is now retained too, so a recovery retry after a
  crash between the persona write and post-commit retention still enqueues
  the orphaned member's 30175; reused built-ins and replays write nothing
  and enqueue nothing, and a failed commit enqueues nothing.
- event_sync.rs: reconcile_team_catalog_heads_at startup reconcile;
  republish changed heads, tombstone unrebuildable ones, skip unshared;
  multi-head continuation so a single pass handles all shared teams
- persona_events.rs: domination-aware flush for kind:5 tombstones. A
  tombstone is signed strictly past the future-dated head it retracts,
  so its retained created_at is the domination floor. The relay ingest
  gate rejects events beyond ±900s of server time, so a byte-frozen
  future-dated replay can age out of the acceptance window and strand
  the head live forever. Flush now re-dates to now when the floor has
  passed, publishes at the floor when it is within the window, and
  leaves the tombstone pending to converge when the floor is further
  ahead than the window — never emitting an event the relay rejects.
- Rust tests: team_catalog/tests.rs, adopt/tests.rs + adopt/tests/
  retention.rs (adoption drives commit_and_enqueue through a spy commit +
  temp-dir scope: commits-then-enqueues, commit-failure enqueues nothing,
  idempotent replay skips both, reused-builtin/reactivation provenance,
  partial-commit retry enqueues the orphaned member head),
  pending/tests.rs, sharing/tests.rs (incl. a gate test driving a delete's
  tombstone then a delayed share through a recording relay to prove the
  purged head is never republished after its tombstone),
  event_sync_team_catalog_tests.rs
- 27 shared JSON parity fixtures (team_catalog_content/)
- Existing TeamRecord + AgentDefinition types extended with sharing
  fields (team_catalog_source, catalog_source, shared flag, etc.)
- All commands registered in lib.rs; dormant until UI PR merges

Stack: this PR -> #3995

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 3b091fe to 2c299e9 Compare August 21, 2026 19:09
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-desktop branch from 5f65b69 to 7ea27a1 Compare August 21, 2026 19:14
kaalph pushed a commit to kaalph/buzz that referenced this pull request Aug 21, 2026
kaalph pushed a commit to kaalph/buzz that referenced this pull request Aug 21, 2026
kaalph pushed a commit to kaalph/buzz that referenced this pull request Aug 21, 2026
kaalph pushed a commit to kaalph/buzz that referenced this pull request Aug 21, 2026
kaalph pushed a commit to kaalph/buzz that referenced this pull request Aug 21, 2026
kaalph pushed a commit to kaalph/buzz that referenced this pull request Aug 21, 2026
kaalph pushed a commit to kaalph/buzz that referenced this pull request Aug 21, 2026
wpfleger96 added a commit that referenced this pull request Aug 22, 2026
Implements the backend for 30178 team catalog sharing on the community
catalog. No UI callers yet — this PR is the backend half of a two-PR
split; desktop PR #3995 (stacked here) adds the TS parse layer, hooks,
CommunityCatalogDialog, and e2e.

Changes:
- team_catalog.rs: 30178 projection builder + size contracts (32 KiB
  ceiling, per-field bounds, avatar downscaling for raster data URLs);
  build_team_catalog_event/content, team_catalog_content_from_event,
  tombstone_team_catalog_coordinate
  validate_team_catalog_content now applies the executable-text
  concealment validator to every member display name/prompt, name-pool
  entry, and the team instructions — the same invariant the persona
  catalog enforces at its parse boundary. Both publish
  (build_team_catalog_content) and adopt (team_catalog_content_from_event)
  funnel through this chokepoint, so a signed shared head cannot smuggle
  invisible/bidi-override characters into text delivered verbatim to the
  ACP harness (BUZZ_ACP_SYSTEM_PROMPT/BUZZ_ACP_TEAM_INSTRUCTIONS).
- commands/teams/sharing.rs: set_team_shared routes publication through
  the flush loop (the single publisher) rather than submitting the
  prepared head directly. A direct submit ran outside the store lock and
  could land a shared head after a concurrent delete_team's tombstone —
  and 30178 replacement has no deletion watermark, so the deleted team
  went publicly live again. The flush re-reads each row before
  publishing, so once the delete has committed the purged head's row is
  gone and the flush skips it; Published/Queued is derived from the
  re-read pending flag.
- commands/teams/pending.rs: publish/unshare/tombstone helpers with
  relay-scoped share state; refresh_or_retract_shared_head_at for
  immediate retraction on member edits that exceed the size contract;
  persona-edit refresh guard so unrelated personas are never embedded
- commands/teams/adopt/: add_team_from_catalog with backend head
  verification + byte-level rollback across both stores; plan_add with
  full member provenance (owner, d-tag, member-key, projection-hash);
  builtin reuse via hint matching; commit_stores atomic writer. The
  commit and the retention enqueue are sequenced inside commit_and_enqueue,
  the sole route to a durable adoption commit: after the store write
  succeeds it enqueues retention heads for every member copy the add
  wrote or reactivated plus the adopted team, so a crash before the next
  boot reconcile cannot lose the only adopted copy. A provenance match on
  an already-active copy is now retained too, so a recovery retry after a
  crash between the persona write and post-commit retention still enqueues
  the orphaned member's 30175; reused built-ins and replays write nothing
  and enqueue nothing, and a failed commit enqueues nothing.
- event_sync.rs: reconcile_team_catalog_heads_at startup reconcile;
  republish changed heads, tombstone unrebuildable ones, skip unshared;
  multi-head continuation so a single pass handles all shared teams
- persona_events.rs: domination-aware flush for kind:5 tombstones. A
  tombstone is signed strictly past the future-dated head it retracts,
  so its retained created_at is the domination floor. The relay ingest
  gate rejects events beyond ±900s of server time, so a byte-frozen
  future-dated replay can age out of the acceptance window and strand
  the head live forever. Flush now re-dates to now when the floor has
  passed, publishes at the floor when it is within the window, and
  leaves the tombstone pending to converge when the floor is further
  ahead than the window — never emitting an event the relay rejects.
- Rust tests: team_catalog/tests.rs, adopt/tests.rs + adopt/tests/
  retention.rs (adoption drives commit_and_enqueue through a spy commit +
  temp-dir scope: commits-then-enqueues, commit-failure enqueues nothing,
  idempotent replay skips both, reused-builtin/reactivation provenance,
  partial-commit retry enqueues the orphaned member head),
  pending/tests.rs, sharing/tests.rs (incl. a gate test driving a delete's
  tombstone then a delayed share through a recording relay to prove the
  purged head is never republished after its tombstone),
  event_sync_team_catalog_tests.rs
- 27 shared JSON parity fixtures (team_catalog_content/)
- Existing TeamRecord + AgentDefinition types extended with sharing
  fields (team_catalog_source, catalog_source, shared flag, etc.)
- All commands registered in lib.rs; dormant until UI PR merges

Stack: this PR -> #3995

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 2c299e9 to 3700e31 Compare August 22, 2026 14:54
wpfleger96 added a commit that referenced this pull request Aug 22, 2026
Implements the backend for 30178 team catalog sharing on the community
catalog. No UI callers yet — this PR is the backend half of a two-PR
split; desktop PR #3995 (stacked here) adds the TS parse layer, hooks,
CommunityCatalogDialog, and e2e.

Changes:
- team_catalog.rs: 30178 projection builder + size contracts (32 KiB
  ceiling, per-field bounds, avatar downscaling for raster data URLs);
  build_team_catalog_event/content, team_catalog_content_from_event,
  tombstone_team_catalog_coordinate
  validate_team_catalog_content now applies the executable-text
  concealment validator to every member display name/prompt, name-pool
  entry, and the team instructions — the same invariant the persona
  catalog enforces at its parse boundary. Both publish
  (build_team_catalog_content) and adopt (team_catalog_content_from_event)
  funnel through this chokepoint, so a signed shared head cannot smuggle
  invisible/bidi-override characters into text delivered verbatim to the
  ACP harness (BUZZ_ACP_SYSTEM_PROMPT/BUZZ_ACP_TEAM_INSTRUCTIONS).
- commands/teams/sharing.rs: set_team_shared routes publication through
  the flush loop (the single publisher) rather than submitting the
  prepared head directly. A direct submit ran outside the store lock and
  could land a shared head after a concurrent delete_team's tombstone —
  and 30178 replacement has no deletion watermark, so the deleted team
  went publicly live again. The flush re-reads each row before
  publishing, so once the delete has committed the purged head's row is
  gone and the flush skips it; Published/Queued is derived from the
  re-read pending flag.
- commands/teams/pending.rs: publish/unshare/tombstone helpers with
  relay-scoped share state; refresh_or_retract_shared_head_at for
  immediate retraction on member edits that exceed the size contract;
  persona-edit refresh guard so unrelated personas are never embedded
- commands/teams/adopt/: add_team_from_catalog with backend head
  verification + byte-level rollback across both stores; plan_add with
  full member provenance (owner, d-tag, member-key, projection-hash);
  builtin reuse via hint matching; commit_stores atomic writer. The
  commit and the retention enqueue are sequenced inside commit_and_enqueue,
  the sole route to a durable adoption commit: after the store write
  succeeds it enqueues retention heads for every member copy the add
  wrote or reactivated plus the adopted team, so a crash before the next
  boot reconcile cannot lose the only adopted copy. A provenance match on
  an already-active copy is now retained too, so a recovery retry after a
  crash between the persona write and post-commit retention still enqueues
  the orphaned member's 30175; reused built-ins and replays write nothing
  and enqueue nothing, and a failed commit enqueues nothing.
- event_sync.rs: reconcile_team_catalog_heads_at startup reconcile;
  republish changed heads, tombstone unrebuildable ones, skip unshared;
  multi-head continuation so a single pass handles all shared teams
- persona_events.rs: domination-aware flush for kind:5 tombstones. A
  tombstone is signed strictly past the future-dated head it retracts,
  so its retained created_at is the domination floor. The relay ingest
  gate rejects events beyond ±900s of server time, so a byte-frozen
  future-dated replay can age out of the acceptance window and strand
  the head live forever. Flush now re-dates to now when the floor has
  passed, publishes at the floor when it is within the window, and
  leaves the tombstone pending to converge when the floor is further
  ahead than the window — never emitting an event the relay rejects.
- Rust tests: team_catalog/tests.rs, adopt/tests.rs + adopt/tests/
  retention.rs (adoption drives commit_and_enqueue through a spy commit +
  temp-dir scope: commits-then-enqueues, commit-failure enqueues nothing,
  idempotent replay skips both, reused-builtin/reactivation provenance,
  partial-commit retry enqueues the orphaned member head),
  pending/tests.rs, sharing/tests.rs (incl. a gate test driving a delete's
  tombstone then a delayed share through a recording relay to prove the
  purged head is never republished after its tombstone),
  event_sync_team_catalog_tests.rs
- 27 shared JSON parity fixtures (team_catalog_content/)
- Existing TeamRecord + AgentDefinition types extended with sharing
  fields (team_catalog_source, catalog_source, shared flag, etc.)
- All commands registered in lib.rs; dormant until UI PR merges

Stack: this PR -> #3995

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 3700e31 to 3adde41 Compare August 22, 2026 15:31

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

One security/privacy blocker remains on this head:

Do not fetch publisher-controlled member avatars merely because someone opens the Teams catalog. team_catalog.rs projects each catalog member's remote avatar_url, and CommunityCatalogDialog.tsx automatically selects the first team on a Teams launch and immediately renders every collapsed member row through ProfileAvatar. That component assigns the URL to an image source, while mediaUrl.ts deliberately leaves external hosts unchanged. A publisher can therefore attach up to 64 unique tracking URLs to a team and learn a viewer's IP/network metadata and precise catalog-view timing as soon as the viewer opens Discover Teams. referrerPolicy="no-referrer" removes the referrer but does not prevent the request.

Please keep untrusted remote member avatars from loading during catalog browse, for example by rendering initials unless the user explicitly opts in, or by using a privacy-preserving image proxy with strict size/type/time limits. Lazy-loading only after expansion reduces the unsolicited fan-out but still exposes the viewer after an ordinary review action. Add a regression proving that opening/browsing a team does not request publisher-controlled avatar hosts.

The previously reported signature-verification and executable-text issues are addressed: verification occurs before paging/head selection, malformed or unshared newest heads suppress older entries, add-time adoption re-fetches the claimed event, and team/member instructions render literally after native concealment validation.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes for two blocking defects on this head:

  1. The previously reported catalog-avatar privacy leak is unchanged. team_catalog.rs still projects each publisher-controlled member.avatar_url; opening Discover Teams still auto-selects the first team; and CommunityCatalogDialog.tsx immediately renders every collapsed member row through ProfileAvatar. That component assigns external URLs to <AvatarImage src>, while rewriteRelayUrl deliberately returns non-relay origins unchanged. Merely opening the catalog can therefore request attacker-hosted images and expose the viewer’s IP/network metadata and precise browse timing. referrerPolicy="no-referrer" does not prevent the request. Please render non-network initials for untrusted catalog members, or use an approved privacy-preserving proxy only after explicit consent, and add a regression proving catalog browse makes no publisher-controlled avatar requests.

  2. The catalog pager truncates permanently at a full timestamp boundary. fetch_team_catalog pages with only inclusive until; merge_verified_page advances using only the oldest verified created_at and stops when the next page adds no IDs. If 500 verified events share one second, the relay may return those same 500 for the next request; the code then declares completion, omitting every remaining event at that second and all older teams. Since the query is community-wide, one publisher can crowd older entries out with signed coordinates. Please use a tie-safe continuation strategy and cover more than one page of same-second events.

The earlier signature-verification and literal executable-text blockers remain fixed. The native add path also re-fetches and verifies the claimed head before mutation, with locked commit/rollback and retained publication retry state.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes for two blocking defects that remain at this exact head:

  1. The catalog still fetches publisher-controlled member avatars on browse. team_catalog.rs projects every member.avatar_url into the renderer. A Teams launch auto-selects the first team, and CommunityCatalogDialog.tsx immediately renders all collapsed member rows with ProfileAvatar avatarUrl={member.avatarUrl}. ProfileAvatar assigns that external URL to <AvatarImage src>, so merely opening Discover Teams can send requests to up to 64 attacker-controlled hosts and disclose the viewer’s IP/network metadata and precise browse timing. referrerPolicy="no-referrer" does not prevent the request. Please render non-network placeholders for untrusted catalog members, or use an approved privacy-preserving proxy after explicit consent, and add a regression proving catalog browse makes no publisher-controlled image requests.

  2. The community-wide catalog pager still truncates at a full timestamp boundary. fetch_team_catalog pages with only inclusive until; merge_verified_page advances using only the oldest verified created_at and returns Done when the next page adds no IDs. With 500 verified events sharing one second, the relay can return the same 500 again, causing every remaining event at that second and all older teams to be silently omitted. The new dense-boundary failure/degraded policy exists only in usePersonaSync's owner-specific history hydration; it does not make fetch_team_catalog tie-safe. Please implement a continuation strategy that cannot claim completion at this boundary, and test more than one catalog page sharing the same timestamp.

The native verification/head-selection and literal executable-text paths remain sound on this head. Add-time adoption also re-fetches and verifies the exact shared coordinate before mutating local stores. CI is green, but the two privacy/completeness defects above remain directly reproducible from the exact-head source.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes for two blocking defects that remain at this exact head:

  1. The catalog still fetches publisher-controlled member avatars on browse. team_catalog.rs projects every member.avatar_url into the renderer. A Teams launch auto-selects the first team, and CommunityCatalogDialog.tsx immediately renders all collapsed member rows with ProfileAvatar avatarUrl={member.avatarUrl}. ProfileAvatar assigns the resulting external URL to <AvatarImage src>, while rewriteRelayUrl deliberately returns non-relay origins unchanged. Merely opening Discover Teams can therefore send requests to up to 64 attacker-controlled hosts and disclose the viewer’s IP/network metadata and precise browse timing. referrerPolicy="no-referrer" does not prevent the request. Please render non-network placeholders for untrusted catalog members, or use an approved privacy-preserving proxy after explicit consent, and add a regression proving catalog browse makes no publisher-controlled image requests.

  2. The community-wide catalog pager still truncates at a full timestamp boundary. fetch_team_catalog pages with only inclusive until; merge_verified_page advances using only the oldest verified created_at and returns Done when the next full page adds no IDs. With 500 verified events sharing one second, the relay can return the same 500 again, causing every remaining event at that second and all older teams to be silently omitted. The dense-boundary failure/degraded policy added to usePersonaSync protects owner-specific history hydration only; it does not make this separate native catalog pager tie-safe. Please implement a continuation strategy that cannot claim completion at this boundary, and test more than one catalog page sharing the same timestamp.

The native trust/adoption path and the pending publication/cross-device lifecycle were also reviewed at this head with no additional blockers found. Exact-head CI is green, but it does not invalidate these privacy and completeness defects.

Extend the unified add-agent dialog (#5015) into a single Community Catalog
surface that browses both shared agents and shared teams. The dialog keeps
sections for personas and teams with type-tagged selection and a
teams-preferred launch. TeamsSection's discover entry and the new-agent card
both open this one dialog.

Relay paging, signature verification, NIP-33 head selection, and untrusted
content parsing for the kind 30178 team catalog live natively in the
fetch_team_catalog Tauri command (team_catalog.rs), structurally mirroring
fetch_persona_catalog. A catalog refresh crosses IPC once and never verifies a
signature on the webview thread. teamCatalogRelay.ts is now a thin
presentation and local-linkage layer over the verified projection.

Parsing is all-or-nothing, identical to the add-time re-fetch: a team with any
invalid member fails to parse and is dropped from the catalog, matching persona
behavior. The prior partial-render of invalid-member teams (a warning banner on
an entry that could never be added) is removed. Instruction review renders
verbatim in a <pre> on all three surfaces so the text a user reviews is the
text sent to the agent. TeamShareDialog publishes and unshares team catalog
entries.

Playwright e2e covers the unified create/catalog/import navigation, the teams
catalog flow, and the screenshot regression set.

usePersonaSync now subscribes to kind 30178 (both backfill and live sub) so a
second device retains the owner's own team catalog head as a publication
witness — without it that device never learns another device published, and
its later edit or delete cannot supersede or retract the discoverable head.
The head carries no local record; the backend (#5112) retains it and drives
supersede/retract.

Owner catalog sync runs one hydration pipeline in startPersonaSync. The
history fetch and the live subscription start concurrently into one reconcile
chain, so a live/replayed 30178 that arrives before its 30175/30176
constituents hydrate would drive the backend's team refresh against an empty
roster and retract the owner's valid head with a dominating false tombstone.
The pipeline closes that: it pages the owner's history with the relay's
inclusive `until` cursor, orders catalog heads after their constituents within
the complete batch (orderCatalogHeadsLast), then opens a hydration boundary
that buffers concurrent live events until the ordered backfill is dispatched
and drains them in arrival order.

Pagination terminates safely on a short page. A full page whose oldest event
cannot advance the time-only cursor is a dense boundary (more than one page of
events share one second, which the WS filter has no id cursor to escape); it
raises PersonaHistoryDenseBoundaryError rather than silently completing
backfill as exhaustive and dropping older constituents. A transient fetch
failure is retried with bounded backoff. When backfill cannot complete —
retries exhausted or a deterministic dense boundary — the pipeline enters
degraded-live rather than leaving the subscription inert: the boundary still
opens so buffered and future live events keep reconciling, but the whole
catalog dependency set is dropped — the 30178 head, its 30175/30176
constituents, and any kind-5 deletion carrying a dependency-targeting `a`
tag (scanned across all tags, since the backend's deletion router does the
same) — because
backfill never fully hydrated the constituents. Dropping only the 30178 head
is not enough: the backend refreshes the catalog head after every team/persona
save and live delivery is newest-first, so a 30176 edit that adds a new member
would reach the backend before that member's 30175 and falsely tombstone a
witness-holding device's valid team; the prior run's on-disk constituents only
prove the old revision resolvable, not a new member. 30177 runtime policy
stays live. Degraded state self-heals on the next effect re-run.
usePersonaSync.test.mjs asserts the expanded kind set, the ordered hydration,
dense-boundary detection, and the retry/degraded failure policy.

Stack: #5112 -> this PR

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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