Skip to content

feat(desktop): implement 30178 team catalog backend - #5112

Merged
wpfleger96 merged 1 commit into
mainfrom
duncan/team-catalog-backend
Aug 27, 2026
Merged

feat(desktop): implement 30178 team catalog backend#5112
wpfleger96 merged 1 commit into
mainfrom
duncan/team-catalog-backend

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 6, 2026

Copy link
Copy Markdown
Member

Implements the Rust/backend half of 30178 team catalog sharing on the community catalog. No TS callers yet — this is PR 1 of 2; #3995 (stacked here) adds the parse layer, hooks, CommunityCatalogDialog, and e2e tests.

What this adds

Projection builder (team_catalog.rs): build_team_catalog_event/content produces a 30178 event from a team + member definitions. Size contracts: 192 KiB total ceiling, per-field bounds (name 256 B, text 4 KiB, system-prompt 16 KiB, avatar URL 32 KiB for projection fields; https URLs additionally validated at 2,048 bytes). Avatar handling: oversized raster data URLs downscaled to fit; oversized built-in avatars silently omitted; oversized https URLs rejected with a named error.

Share/unshare/tombstone (commands/teams/pending.rs): prepare_team_publication_at retains a signed 30178 head for the flush loop; share state is relay-scoped (one community can share while another does not). refresh_or_retract_shared_head_at rebuilds or tombstones the head immediately on team/member edit — no stale publication until the next boot. Both the 30178 catalog and 30176 team tombstones are signed with a created_at that strictly dominates the retained head's (read inside the delete transaction), so a future-dated head cannot survive its own deletion under the relay's created_at <= soft-delete gate.

Serialized-publisher share command (commands/teams/sharing.rs, managed_agents/persona_events.rs): set_team_shared publishes the retained head through flush_pending_events_at rather than submitting the prepared event directly. A direct submit ran outside managed_agents_store_lock and raced delete_team: the delete atomically purges the head's retained row and enqueues a newer 30178 tombstone in one transaction, and a delayed direct submit could land the old shared head after that tombstone. Because 30178 replacement has no deletion watermark, the deleted team would go publicly live again. Routing through the flush is necessary but not sufficient, because the flush is not itself a single publisher: several call sites (the 30s sweep, this share toggle, managed-policy updates) invoke it concurrently, and each invocation has an await gap between its per-row re-read and its relay POST. A second flush could publish the tombstone in that gap while an earlier flush's delayed POST lands the just-purged head after it. The flush now acquires a per-scope publisher mutex — keyed by the canonical retention db_path, which already is the durable scope identity (hashed normalized relay URL + owner pubkey) — and holds it across its entire invocation: snapshot, per-row re-read, POST, and mark_synced. Serialized-per-scope flush ⟹ within one scope the only interleavings are head-before-tombstone (the head lands first, then is dominated by the later tombstone) or purged-row-skip (the delete committed first, so the re-read skips the head) — a purged head can never publish after its tombstone. The lock is a LazyLock<Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>> static rather than an AppState field: it keeps the invariant at its acquisition site and out of the size-ratcheted app_state.rs (precedent: agent_models_databricks.rs's AUTH_GATE), and the std-mutex map guard is released before the async guard is awaited so it never spans an await point. Keying per scope — rather than one process-global lock — means a stalled or hostile relay in one community can no longer block publication in every other community, and each per-row submit is additionally wrapped in a tokio::time::timeout(PUBLISH_TIMEOUT = 60s): submit_signed_event_at_with_keys first waits on the process-wide admission gate (up to 300s on a 429) and then POSTs on the app-wide http_client, which leaves reqwest's connect/read/total timeouts unset, so a relay that accepts the connection and never finishes the response would otherwise pin the lock forever. A timeout takes the same Err/continue path as a relay rejection — the row stays pending for the next 30s sweep and a timed-out tombstone keeps its replacement deferred this pass — so a live admission gate now surfaces as timeout-pending rather than a held lock, the correct durable behavior since the sweep retries. publicationStatus (Published/Queued) is derived by re-reading the retained row's pending flag after the flush. The previously-unused relayMessage field is dropped from SetTeamSharedResult: the flush loop swallows every per-event relay rejection to its own log and only surfaces local DB faults (which the status re-read already propagates), so the field was permanently null on this path. #3995's tauriTeams.ts mapping and useTeamActions.ts log branch drop with it.

Domination-aware flush (managed_agents/persona_events.rs): a strictly-dominating tombstone can be signed past the relay's ingest acceptance window (MAX_TIMESTAMP_DRIFT_SECS, ±900s from server time). Republishing such a byte-frozen event verbatim from the pending queue lets it age out of the window and be rejected forever, stranding the head live. The flush loop is now domination-aware for every retained kind:5 (covering both 30176 and 30178). For a pending tombstone with floor f (= the retained row's own created_at):

  • f <= now → re-date and re-sign at now (mirrors the existing archive-request re-sign branch; mark_synced stays keyed to the untouched retained row, so a re-date can't mask a concurrent edit),
  • now < f <= now + 900 → publish verbatim at f, inside the window,
  • f > now + 900 → skip the sweep; the event stays pending and its replacement keeps deferring (via failed_tombstones) so out-of-order retraction remains impossible, converging as the wall clock advances.

No path emits an event the gate rejects, and a boundary reject self-heals through the submit-error requeue. Durable across offline gaps of any length.

Atomic adopt (commands/teams/adopt/): add_team_from_catalog re-fetches and signature-verifies the head from the relay, then plans and commits a multi-entity add across two store writes with byte-exact rollback on any failure (crash window between writes explicitly retained). plan_add resolves full member provenance (owner, d-tag, member-key, projection-hash), reuses a recipient's own local built-in only when the published slug matches and the reuse hint's projection_hash — recomputed from the member's own embedded fields at the parse boundary and rejected on mismatch — equals (case-insensitively, matching the boundary's hex tolerance) the recipient's local built-in hash, and reactivates deactivated copies on re-add. Because the boundary already proves the hint hash describes the reviewed projection, a publisher cannot pair a real built-in's slug + hash with arbitrary reviewed fields to make adoption install the recipient's built-in in place of what was shown. commit_stores snapshots both stores before writing and restores them on failure. The commit and the retention enqueue are sequenced inside commit_and_enqueue, the sole route to a durable adoption commit: once the store write succeeds it enqueues a pending 30175 for every member copy the add wrote or reactivated and a pending 30176 for the team, so a crash before the next boot reconcile cannot lose the only adopted copy. A provenance match on an already-active copy is retained too (not just a reactivation): a recovery retry after a crash between the persona write and post-commit retention finds the copy active with no 30175 row, and plan_add short-circuits once the team row exists, so this reuse branch is the only place that retry can re-enqueue the orphaned member head. Retaining unconditionally is conservative, not exact — an active copy still referenced by a standalone managed agent can already hold a live head, and re-retaining only bumps it monotonically; reused built-ins are handled separately and never reach this branch. A byte-identical reused built-in and an idempotent replay write nothing and enqueue nothing; a failed commit propagates and enqueues nothing. Enqueue is best-effort per row (the boot reconcile is the backstop). The frontend refreshes via the useAddTeamFromCatalogMutation query invalidation in #3995, so no agents-data-changed emit is needed here.

Startup reconcile (event_sync.rs): reconcile_team_catalog_heads_at walks all retained 30178 heads at boot: republishes heads whose content changed, tombstones heads whose team or member was deleted, skips unshared heads and unchanged content. Multi-team continuation — all shared teams processed in one pass.

Cross-device catalog retention (commands/personas/inbound.rs): both recovery paths above — the boot reconcile worklist and the interactive refresh_or_retract_shared_head_at — key off a retained 30178 row and guard-return without one. A second device therefore never retained the owner's own catalog head published from another device, so its later edit or delete could never supersede or retract that discoverable head. The inbound reconcile now retains an inbound 30178 head as this device's publication witness through retain_inbound_catalog_witness, a self-gating dispatcher invoked unconditionally on the production non-deletion path: newest-wins via retain_inbound_event, arrival-scoped, no local JSON store, and deliberately no refresh or republish on arrival — a 30178 arrival is either this device's own echo or the other device's publication, and rebuilding on either would make two devices ping-pong identical heads. Retention advances the witness and stops. The tombstone router accepts a kind:5 covering a 30178 coordinate, so an inbound deletion purges the retained head on the receiving device (the covered-head purge already happens inside commit_inbound_tombstone_with_store; a 30178 head has no local record to remove). After a successful inbound persona/team upsert, this device refreshes the affected shared heads so the community catalog tracks the inbound edit (persona edit → every team whose resolved members include it, resolving the local persona id by d-tag; team edit → that team's head); after an inbound tombstone, a team deletion retracts its 30178 coordinate and a persona deletion refreshes the teams that listed it. The refresh is idempotent across devices: refresh_or_retract_shared_head_at skips the publish when the rebuilt projection is byte-identical to the retained head and still shared, so the editing device's own published head triggers no churn republish on the receiving device.

Executable-text concealment gate (team_catalog.rs, definition_validation.rs): validate_team_catalog_content — the single chokepoint both the publish builder (build_team_catalog_content) and the adopt parser (team_catalog_content_from_event) funnel through — now rejects invisible, default-ignorable, and bidirectional-override characters (e.g. U+200B, U+2066, U+202E) in every field delivered verbatim to the ACP harness or rendered as reviewed identity in the catalog UI. This is the same invariant the persona catalog already enforces at its own parse boundary (persona_catalog::parse_agent); the 30178 boundary was the outlier. Member display_name + system_prompt go through validate_agent_definition_text per member (exact parity with parse_agent: display-name rule with no layout controls, prompt rule allowing \n/\t); name_pool entries take the display-name rule since they are minted verbatim as instance display names; team instructions take the visible-text rule with layout controls allowed, since they reach BUZZ_ACP_TEAM_INSTRUCTIONS multiline. The team name takes the display-name rule (no layout controls) and the description takes the visible-text rule with layout controls allowed, since both are rendered verbatim in the catalog UI as reviewed identity. validate_visible_text is exposed pub(crate) from definition_validation.rs and re-exported via managed_agents. A signed, shared, current head can no longer smuggle concealed control characters into executable configuration or reviewed catalog text through either the publish or the adopt path; emoji (VS16/ZWJ) names and multiline instructions still pass.

Types: TeamRecord and AgentDefinition extended with shared, catalog_source, team_catalog_source fields. All commands registered in lib.rs.

Tests

  • team_catalog/tests.rs: projection, size contracts, member-key stability, tombstone rollback, fixture matrix
  • adopt/tests.rs: head verification, store planning, provenance, rollback
  • team_catalog/tests/concealment.rs: the chokepoint rejects default-ignorable (U+200B) and bidi controls (U+2066, U+202E) in member display_name, system_prompt, name_pool, team instructions, and the team name/description, on both the publish and adopt paths; an emoji-bearing display name and multiline instructions still pass, so no legitimate team becomes unshareable
  • team_catalog/tests/reuse_hint.rs: a member pairing a real built-in's slug with that built-in's genuine projection_hash but carrying unrelated reviewed fields is rejected at the parse boundary, so adoption can never substitute the recipient's built-in for the reviewed projection; an honestly-stamped built-in reuse hint (including an uppercase form of its true hash) still passes the boundary. Removing the boundary recompute lets the tampered member validate, proving the test discriminates the substitution class
  • adopt/tests/reuse.rs: reusable_builtin reuses a local built-in for an exact-match hint (one record, no copy), reuses it just the same when the genuine hash is uppercased (case-insensitive, matching the boundary — one record, not two), and falls through to an authoritative embedded copy when the hash does not match. Comparing the hash case-sensitively turns the uppercase case red (two records instead of one reused)
  • adopt/tests/concealment.rs: a signed, shared, current head carrying a bidi override drives the add_verified_team sequence (verify+parse → plan_addcommit_and_enqueue) through real temp stores and a real retention scope, asserting the head is rejected AND the personas store, teams store, and retention rows are all left byte-unchanged. Stripping the concealment call at the chokepoint turns it red — the parse then succeeds and both stores are written, proving the test discriminates a validate-after-write regression, not just an error return
  • adopt/tests/retention.rs: adoption drives commit_and_enqueue through a spy commit + a real temp-dir retention scope and asserts persisted pending rows — commits-then-enqueues (30175 per minted member + 30176 team), a failed commit enqueues nothing, an idempotent replay skips both the commit and the enqueue, a reused built-in retains only the team, a reactivated copy is re-retained, and a partial-commit crash recovery (active member copy with no retention row, team row absent) re-enqueues the orphaned member's 30175. Deleting the enqueue inside the seam turns these red — the wiring, not just the helper, is protected
  • sharing/tests.rs: publish/queue lifecycle plus three concurrency gate tests. (a) concurrent_flushes_never_land_the_head_after_its_tombstone prepares a share, runs a concurrent delete's purge+tombstone, flushes the tombstone to a recording relay, then releases the delayed share and asserts the purged 30178 head is never published after its tombstone and no pending row survives (removing the lock turns it red — the relay sees the resurrected head after the tombstone). (b) a_stalled_scope_does_not_block_publication_in_another_scope pins one scope's flush mid-POST on a stalled relay and runs a second scope's flush to completion, asserting it publishes without waiting (re-globalizing the key turns it red). (c) a_stalled_relay_releases_the_publisher_lock_within_the_bound proves a never-completing POST returns within PUBLISH_TIMEOUT, leaves the row pending, and releases the lock so a subsequent same-scope flush proceeds (removing the timeout turns it red). test_relay_rejection_stays_durably_queued asserts queued + still-pending rather than a relay-message string, matching the flush-routed contract where the rejection text is no longer surfaced
  • pending/tests.rs: share/unshare/tombstone lifecycle, edit refresh/retract, tombstone timestamp domination, typed outcomes, cross-device catalog convergence — device B retains device A's inbound head then supersedes it on a member edit and tombstones the coordinate on a delete, a byte-identical rebuild is a no-op (Noop, created_at untouched), and an inbound retention alone queues no outbound publish (the no-ping-pong guard). Neutralizing the inbound retention leg turns the supersede/tombstone regressions red — B stays blind (Noop, no dominating tombstone) — proving they discriminate the load-bearing leg
  • catalog_reconcile_tests.rs: a signed kind:30178 head is driven through the real production entrypoint reconcile_inbound_persona_event_blocking over a MockRuntime AppHandle (retention scope resolved from the handle's app_data_dir under an overridden $HOME/$XDG_DATA_HOME), asserting the arrival witness is retained at the owner coordinate with pending_sync=false, stored verbatim, and no outbound publish is queued. An early return for KIND_TEAM_CATALOG immediately before the production retain_inbound_catalog_witness invocation turns it red, proving the seam under test is the production dispatch path and not a test-only shim
  • pending/tests/gate.rs: flush driven through a stub relay that logs every POST /events with its accept/reject status and enforces the real ±900s ingest gate, for both 30176 and 30178 — within-window publish+dominate; beyond-window stays-pending with zero POSTs (the gate never receives a rejectable event); and the delayed/offline-retry case where a tombstone signed strictly past a then-future head has aged more than 900s into the past, so flush must re-date to now to publish. The reversal check — restore the byte-frozen replay in persona_events.rs and the delayed-retry and zero-POST assertions go red — is what proves the suite discriminates the fix from the rejected implementation
  • teams/tests.rs: 30176 tombstone timestamp domination and no-head fallback
  • event_sync_team_catalog_tests.rs: reconcile scenarios including multi-head continuation
  • 26 shared JSON parity fixtures (tests/fixtures/team_catalog_content/) consumed by both this PR's Rust tests and feat(desktop): add team sharing to community catalog #3995's TS tests

Durable ordering, deletion reconcile, and relay-contract alignment

The catalog paths above sit on the shared inbound/deletion retention seam. This PR makes that seam's ordering structural rather than conventional, adds a negative-side (deletion) counterpart to the existing positive-side boot backstop, and aligns inbound resolution with the relay's actual soft-delete and NIP-33 winner rules.

Preflight-then-commit (commit_inbound_with_store, commands/personas/inbound.rs + retention.rs). A named primitive runs the fallible store mutation first and advances the durable retention head only on success; an event that loses preflight returns Skipped without touching the store. The persona/team upsert arms and the inbound kind:5 removal path all route through it, so no inbound arm can advance the head ahead of the store write it represents. The managed-agent arm keeps its own preflight (its runtime transition must not run for a skipped event) and still advances the head only after save_managed_agents.

Atomic + monotonic tombstone helpers (30175 / 30176 / 30177). The three ordinary tombstone helpers (commands/personas/pending.rs, commands/agents_pending.rs, commands/teams/mod.rs) each read the prior head, sign, delete, and retain inside one BEGIN IMMEDIATE transaction, and sign the kind:5 with a created_at that strictly dominates the prior head (monotonic_created_at(prior_head)). This mirrors the 30178 tombstone_team_catalog_coordinate precedent. The three siblings deliberately duplicate the BEGIN IMMEDIATE shape rather than sharing a helper this round — the shared-helper consolidation is deferred to the persona-tombstone follow-up PR where the flush-replay class already lives.

Deletion reconcile (event_sync.rs, negative-side counterpart of the positive-side boot backstop). The positive side already reconstructs missing retained heads at boot from surviving disk records. Deletion is the asymmetric gap: an atomic tombstone helper preserves the head on failure, but boot reconcile enumerated disk records — deletion already removed them — so an orphan retained head was never tombstoned. The deletion reconcile enumerates retained 30175/30176 heads and tombstones only genuine orphans (a retained head with no matching disk record), routing through the now-atomic helpers. It reconciles only against a successfully parsed store: a truncated, malformed, or wrong-shape JSON store fails loudly (never triggers a tombstone); a missing file is treated as empty. Managed agents (30177) are excluded by design: their inbound sync retains a head without minting a local disk record (agents carry device-local secrets that can't come from a relay event), so a retained 30177 head with no matching record is the normal cross-device state for every agent created on another device — not a lost deletion. Sweeping it would tombstone and archive another device's live agents at boot. Agent deletion-retry therefore stays a pre-existing gap owned by the direct delete path (tracked in Follow-ups).

Inbound relay-contract alignment (retention.rs, commands/personas/inbound.rs). Two inbound rules now match the relay:

  • Equal-second tie-break. inbound_event_outcome previously treated every equal timestamp as stale; the relay resolves equal created_at by lowest event id. Preflight now matches the relay tuple — strictly newer timestamp wins, equal timestamp resolves by lower event id, an exact echo skips — so two devices authoring different same-second successors converge instead of one silently republishing an event the relay refuses.
  • Covered-head resolution on inbound kind:5. reconcile_inbound_tombstone previously consulted only the retained kind:5 row, so a historical tombstone replayed after a newer recreation deleted the recreated record and never purged the covered head. Preflight now resolves both the tombstone row and the covered (target_kind, owner, d_tag) head: a target head strictly newer than the tombstone preserves JSON and skips; an actually-covered head is removed from disk first (fallible), then the tombstone row commit and covered-head purge happen atomically. A JSON-save failure advances neither, so the identical event stays retryable.

Atomic agent-archive coupling (commands/agents_pending.rs). The 30177 tombstone and the 9035 archive request now enqueue in one BEGIN IMMEDIATE transaction, with the archive's persona_id derived from the retained head (persona_id_from_head) rather than the deleted record, where it survives the tombstone as owner-signed historical alias data. The standalone archive_managed_agent_pending production callers are removed — there is no double-enqueue path. Unlike personas/teams, this coupling is not re-enqueued by the boot deletion reconcile (30177 is excluded, above): a crash after the disk-authoritative record is removed but before this transaction commits leaves agent deletion-retry a pre-existing gap owned by this direct delete path (tracked in Follow-ups).

Follow-ups

publish_prepared_persona (commands/personas/sharing.rs) has the identical direct-submit-outside-the-lock race for 30175 persona heads that this PR fixes for 30178 team heads. It predates this work and is tracked in the separate persona-tombstone follow-up PR, not folded here.

The app-wide http_client leaves reqwest's connect/read/total timeouts unset (app_state.rs builder configures only pool options). This PR bounds the team-publish call site with a tokio::time::timeout, but every other consumer of the shared client remains exposed to a non-responding endpoint. A client-wide default timeout is the broader fix; it is pre-existing on main and affects all consumers, so it is out of scope here.

Managed-agent deletion-retry: the atomic 30177 tombstone + 9035 archive is best-effort, and unlike personas/teams it is deliberately excluded from the boot deletion reconcile (a device-local-absent 30177 head is the normal cross-device state, not a deletion). A crash between the delete's store write and its retention enqueue therefore has no boot backstop for agents. The durable fix is a local deletion intent written before the record is removed; deferred rather than folded into this round.

Stack: this PR → #3995

@wpfleger96
wpfleger96 requested a review from a team as a code owner August 6, 2026 21:08
@wpfleger96 wpfleger96 changed the title feat(desktop): add 30178 team catalog backend feat(desktop): implement 30178 team catalog backend Aug 6, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch 2 times, most recently from 16a1ff3 to e5e5c2a Compare August 12, 2026 15:48

@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.

P1 — the tombstone can be older than the retained catalog head, so deletion silently leaves the team public.

prepare_team_publication_at and reconcile intentionally assign replacement heads max(now, prior.created_at + 1), which permits a retained 30178 head to be future-dated (commands/teams/pending.rs:159-183; persona_events.rs:112-127). But tombstone_team_catalog_coordinate signs the kind:5 at plain wall-clock now before it opens or reads the retention database (managed_agents/team_catalog.rs:728-756). The relay only deletes coordinate versions whose created_at is at or before the tombstone (crates/buzz-relay/src/handlers/side_effects.rs:2170-2181).

Reproduction shape: retain a shared 30178 head with created_at = now + 60, then delete the team or trigger either unrebuildable-head retraction path. The tombstone is accepted and flushed but applies to zero relay rows. Locally, this helper has already purged the retained 30178 row and queued only that ineffective kind:5 (team_catalog.rs:756-772), so successful flush removes the sole retry witness. The supposedly deleted/retracted team remains publicly discoverable indefinitely. All direct-delete, edit-retraction, and boot-reconcile paths converge on this helper.

Please load the retained head timestamp inside the same BEGIN IMMEDIATE transaction, sign the tombstone with monotonic_created_at(Some(head.created_at)) (falling back to None only when no head exists), then purge and retain atomically. Add a future-dated retained-head regression asserting the tombstone timestamp strictly dominates the head; call-path coverage for direct delete and both retraction paths would prevent wiring regressions.

@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch 3 times, most recently from 728ae20 to 9a086df Compare August 18, 2026 16:05

@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.

Reviewed exact head 9a086df0167a34482d427a35d2d524315275e0c3 with the Royal Court. Requesting changes for one merge-blocking lifecycle defect.

P1: future-dated tombstones can be rejected, then become permanently unpublishable

The new deletion protocol signs the 30178 kind:5 tombstone strictly after the retained head and atomically purges that head (desktop/src-tauri/src/managed_agents/team_catalog.rs:724-786). That fixes replacement ordering locally, but it conflicts with the relay contract: ingest rejects every event outside ±900 seconds (crates/buzz-relay/src/handlers/ingest.rs:2005-2011). Pending flush republishes ordinary events, including kind:5, byte-for-byte; only identity archive requests receive a fresh signature/timestamp (desktop/src-tauri/src/managed_agents/persona_events.rs:308-337).

The added regression demonstrates the failure shape while accidentally blessing it: it seeds a head at now + 86,400, requires an even later tombstone, and never sends that tombstone through relay ingest (desktop/src-tauri/src/commands/teams/pending/tests.rs:720-788). That tombstone is rejected until it enters the relay's 15-minute future window. If Desktop does not flush successfully during the resulting narrow acceptance interval, the unchanged event becomes stale and is rejected forever. Because the 30178 head was already purged atomically, the durable witness needed to regenerate a usable deletion is gone while the public catalog coordinate can remain live.

The same incompatible pattern exists for the 30176 team tombstone (desktop/src-tauri/src/commands/teams/mod.rs:297-339), so fixing only the catalog helper would leave sibling deletion broken.

Please implement a relay-compatible durable recovery protocol for both coordinates that preserves domination of the target head without relying on publishing an arbitrarily future-dated event. Validate it through the actual relay timestamp gate, including delayed/offline retry past the original acceptance window. A local assertion that tombstone.created_at > head.created_at is not sufficient.

Non-blocking completeness note: the auto-retraction paths emit team-catalog-auto-retracted, but a scoped search under desktop/src found no listener. If user notification is part of the intended behavior, wire the frontend consumer or adjust the claim.

No additional actionable defects survived consolidation. Existing CI was used for broad validation; I did not duplicate CI-equivalent suites locally.

@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch 2 times, most recently from a66f7fa to 6a57da7 Compare August 19, 2026 00:44

@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.

Re-reviewed exact head 6a57da79961be71055724ca328a1018f2ad8e5b9 with the Royal Court. The prior P1 is resolved, and no new blocking findings survived consolidation.

flush_pending_events_at now treats a retained kind:5 timestamp as the domination floor: it re-signs at max(now, floor) when that timestamp is relay-acceptable, leaves a farther-future tombstone pending without submitting it, and defers a same-coordinate replacement for the sweep (desktop/src-tauri/src/managed_agents/persona_events.rs:311-360). Compare-and-clear still uses the untouched retained row (persona_events.rs:364-372), preserving the concurrent-update fence.

Regression coverage drives both kind:30176 and kind:30178 tombstones through the submission path against the relay’s ±900-second timestamp contract, including in-window domination, zero-POST future deferral, and delayed/offline stale-floor re-signing (desktop/src-tauri/src/commands/teams/pending/tests/gate.rs:157-402). The production 30-second flush loop provides the durable retry path (desktop/src-tauri/src/lib.rs:570-594).

All current PR checks pass. Existing CI provided broad validation; reviewers did not duplicate CI-equivalent suites locally.

Verdict: no blocking findings at this head. Wes retains final approval authority.

@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.

Re-reviewed exact head 6a57da79961be71055724ca328a1018f2ad8e5b9 against base bbd20fae75ecc3bd7a83cc12a65379fac22a2b79.

[P1] Catalog adoption does not enqueue owner-device sync heads

add_verified_team commits the adopted personas and team, regenerates Nest, and returns (desktop/src-tauri/src/commands/teams/adopt/apply.rs:109-136), but the adoption path never calls retain_persona_pending or retain_team_pending. The analogous team-snapshot import enqueues each member and the team after its writes (desktop/src-tauri/src/commands/team_snapshot.rs:751-761).

Consequently, a successful catalog adoption has no pending kind:30175/30176 heads for another device owned by the same user. The only apparent repair is the later startup migration (desktop/src-tauri/src/event_sync.rs:16-31,57-77,202-229), leaving a window where a crash or device loss before restart can lose the only adopted copy despite the command reporting success.

Please enqueue every newly copied or reactivated non-built-in persona and the adopted team after the atomic store commit. Add command-path regression coverage asserting pending 30175 and 30176 rows, including idempotent replay.

The independent schema/protocol and relay/authorization reviews found no additional blocker. Exact-head required checks are green, but they do not cover this successful-adoption retention contract.

@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch 2 times, most recently from da22f55 to 43026a7 Compare August 20, 2026 21:22

@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.

Re-reviewed exact head 43026a72a604c976c11b785b1fe00bc0f6638932 against base cd0d33f08507d07c8e8b8511bba92290c046ef03. Requesting changes for two merge-blocking lifecycle defects.

[P1] A delayed share can resurrect a deleted team’s public catalog entry

set_team_shared prepares and retains a 30178 head while holding managed_agents_store_lock, then releases that lock before directly submitting the prepared event (desktop/src-tauri/src/commands/teams/sharing.rs:54-82,85-108). A concurrent delete_team can acquire the lock, remove the team, purge that retained head, and enqueue/flush a newer 30178 tombstone (desktop/src-tauri/src/commands/teams/mod.rs:465-482; desktop/src-tauri/src/managed_agents/team_catalog.rs:756-796).

If the tombstone reaches the relay before the delayed direct submission, the old shared head is accepted afterward and becomes live again. Coordinate deletion only affects rows that exist and are no newer than the tombstone (crates/buzz-db/src/event.rs:819-861), while ordinary 30178 replacement consults only live rows and has no durable deletion watermark (crates/buzz-db/src/lib.rs:5218-5245,5267-5338). Locally, the 30178 head has already been purged and the tombstone may be marked synced, so no durable retry witness remains.

Please serialize this publication against deletion through submission, or perform a last-moment stale-publication check that verifies both team existence and the exact retained 30178 event identity before submitting. Add the ordering regression: prepare share, delete and flush its 30178 tombstone, then release the delayed publish; assert no live relay 30178 head and no local state that suppresses recovery.

[P1] Partial-commit retry can still omit an adopted member’s 30175 head

The new adoption seam enqueues freshly created/reactivated personas and the team after a successful commit, which fixes the ordinary success path. However, the documented crash recovery path remains incomplete. Store commit writes personas before teams (desktop/src-tauri/src/managed_agents/storage.rs:682-705), and the adoption module explicitly relies on retry reusing orphaned member copies after a crash between writes (desktop/src-tauri/src/commands/teams/adopt.rs:11-15; adopt/apply.rs:11-16). On that retry, an already-active provenance match gets retain: false (adopt/apply.rs:299-318), so plan_add omits it from retain_personas (apply.rs:241-252) and the successful retry enqueues only the team (apply.rs:177-210). The member copy never received a 30175 row because the first attempt did not reach post-commit retention.

Please conservatively retain non-built-in provenance reuse when creating the missing team, or otherwise prove the reused copy already has its owner-retention head. Add the exact partial-commit regression: seed only the active persona output from a first plan, with no team and no retention row; retry through commit_and_enqueue; assert pending 30175 and 30176 rows. Preserve ordinary replay and built-in reuse exclusions.

Exact-head required checks are green. Existing CI does not exercise either ordering/recovery contract.

@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch 4 times, most recently from 3b091fe to 2c299e9 Compare August 21, 2026 19:09

@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.

Reviewed exact head 2c299e9fd28ff122981455020df577caca5837a3 against base aeb741fd31044ec560d953b0986dec2e7e93e2c6 with the Royal Court. Requesting changes for one merge-blocking security boundary defect.

[P1] Reject concealed executable text before adopting a 30178 definition

The 30178 parser bounds member names/prompts and team instructions but never applies the repository’s executable-definition concealment validator (desktop/src-tauri/src/managed_agents/team_catalog.rs:480-629,688-698). A correctly signed, current, shared head can therefore contain, for example, display_name: "Review\u200Ber", system_prompt: "Run\u2066hidden", or instructions: "Ignore\u202E all review" and pass every adoption check.

Adoption then copies the member name/prompt and team instructions verbatim into the local stores (desktop/src-tauri/src/commands/teams/adopt/apply.rs:235-279,394-446). These are executable configuration: the member prompt reaches BUZZ_ACP_SYSTEM_PROMPT, and team instructions reach BUZZ_ACP_TEAM_INSTRUCTIONS at spawn (desktop/src-tauri/src/managed_agents/runtime.rs:691-715). Thus invisible/default-ignorable and bidi controls can make what the agent executes differ from the definition the recipient believed they reviewed.

This bypasses an established invariant, not a new policy preference. validate_agent_definition_text explicitly rejects controls and default-ignorables for human-reviewed executable definitions (desktop/src-tauri/src/managed_agents/definition_validation.rs:1-42,63-81), and the existing persona catalog applies it while parsing shared definitions (desktop/src-tauri/src/persona_catalog.rs:216-225).

I reproduced the full path with a focused temporary regression at this head: a signed/shared 30178 carrying U+200B, U+2066, and U+202E passed verified_head_content, then plan_add preserved all three values in the resulting persona/team records; the canonical validator rejected the resulting persona. Test result: 1 passed, with 2,924 tests filtered out. The temporary probe was removed and the worktree returned clean.

Please apply the existing executable-text validation policy at the 30178 parse/build boundary to every member display name and system prompt, and apply the same visible-text rule to executable team instructions (plus any other human-reviewed definition text covered by that contract). Add signed inbound regressions for default-ignorable/bidi controls and assert adoption performs no store write on rejection.

The prior publication-ordering and partial-commit retention blockers are resolved. No additional blocking finding survived the publication lifecycle or adoption/reconcile reviews. All applicable GitHub checks are green at this head; git diff --check also passed.

@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch 3 times, most recently from 3adde41 to 989b9a5 Compare August 22, 2026 15:58

@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.

Reviewed exact head 989b9a5a7dab630d9c4d484ba0b84cb27a1068df against base 040b203f73576e15ef749b0ff0ee6243f06a5c48 with the Royal Court. Requesting changes for two merge-blocking persistence defects.

[P1] Inbound events are consumed before their JSON mutation is durable

Persona/team upserts commit retain_inbound_event before their fallible save_personas / save_teams operations (desktop/src-tauri/src/commands/personas/inbound.rs:231-267). Inbound kind:5 does the same before removing records from all three stores (inbound.rs:441-485). If a filesystem write fails, the retention row has already advanced; retrying the identical relay event is then permanently skipped because equal timestamps are treated as stale (desktop/src-tauri/src/managed_agents/retention.rs:269-315).

Startup does not repair this: history fetch routes these events through the same reconcile command (desktop/src/features/agents/lib/usePersonaSync.ts:65-103). For a failed upsert, boot disk-to-retention reconcile can instead sign the stale JSON state as a newer local persona/team head before inbound replay begins (desktop/src-tauri/src/event_sync.rs:144-195,284-320; commands/workspace.rs:287-304).

Please preflight retention, durably save the authoritative store, then commit the inbound head, matching the existing managed-agent ordering (inbound.rs:231-240,269-332). Add fault-injection regressions proving persona/team upserts and tombstone removals remain retryable after save failure.

[P1] Team deletion can lose its only tombstone retry witness

The ordinary kind:30176 team deletion reads/signs its tombstone, deletes the retained head, and inserts the kind:5 row as separate SQLite operations (desktop/src-tauri/src/commands/teams/mod.rs:327-349). A crash or insert failure between those operations loses the head without durably queueing its tombstone. The production caller has already deleted the authoritative team and intentionally swallows retention failure (commands/teams/mod.rs:465-489,294-301), leaving no recovery source while the relay may continue serving the old team.

Please use the atomic BEGIN IMMEDIATE transaction pattern already implemented for kind:30178 (desktop/src-tauri/src/managed_agents/team_catalog.rs:757-819), including reading/signing inside the transaction, and add a rollback/fault-injection test proving the 30176 head survives unless its tombstone is queued.

The security/schema and catalog-adoption lifecycle lanes found no additional blocker at this head. Existing CI provided broad validation; reviewers did not duplicate CI-equivalent suites locally.

@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 989b9a5 to 75c92f8 Compare August 24, 2026 22:36
wpfleger96 added a commit that referenced this pull request Aug 24, 2026
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.

Stack: #5112 -> this PR

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@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.

Reviewed exact head 75c92f8ce781b266d68bed143376496ddbd07ac8 against base 30d2fc52f96138311f2006627ffc1a6d5ff1865b with the Royal Court. The two prior persistence blockers are resolved, but the new deletion-recovery sweep introduces one merge-blocking cross-device lifecycle defect.

[P1] Boot reconciliation deletes agents that exist only on another owner device

Inbound kind:30177 sync intentionally retains every accepted owner-authored agent head, but does not create a local ManagedAgentRecord when that agent is not provisioned on this device. The no-match behavior is explicit because the local secret key is unavailable (desktop/src-tauri/src/commands/personas/inbound.rs:270-339,561-575). This is the normal state after device B receives an agent created on device A.

On device B’s next workspace apply, the new negative boot reconcile enumerates every retained 30177 head and classifies it as deleted whenever its pubkey is absent from B’s local managed-agents.json (desktop/src-tauri/src/event_sync.rs:641-739). It then calls tombstone_managed_agent_at, which atomically purges the valid inbound head and queues both a kind:5 deletion for the owner’s 30177 coordinate and a kind:9035 identity archive request (desktop/src-tauri/src/commands/agents_pending.rs:75-167). The pending flush publishes those owner-signed effects, deleting/archiving an agent that still legitimately exists on device A. Device-local absence is not evidence of owner-global deletion for a secret-bearing record.

Reproduction shape:

  1. Create/publish an agent on device A.
  2. Let device B’s owner sync receive the 30177 head. apply_inbound_managed_agent no-ops because B has no local secret-bearing record, but retention still commits the head.
  3. Restart or reapply the workspace on B. reconcile_deleted_heads_at sees the retained d-tag missing from B’s local agent set and queues the tombstone plus archive.

Please remove kind:30177 from this absence-based sweep, or persist a separate durable local-deletion intent/witness that distinguishes a failed local tombstone from a legitimate remote-only retained head. Add the two-device regression above and assert boot leaves the remote-only head and identity live; preserve recovery for an actual local deletion whose tombstone transaction failed.

The prior inbound durability and kind:30176 atomic-delete findings are fixed: persona/team store writes now precede retention advancement, covered-head purge and inbound kind:5 commit are transactional, and ordinary team head purge plus tombstone enqueue roll back together. Exact-head required checks are green. git diff --check passed. A focused local Rust probe was attempted but the environment lacked cmake/Opus; existing CI supplied broad validation.

@wpfleger96
wpfleger96 force-pushed the duncan/team-catalog-backend branch from 75c92f8 to 8ea38b7 Compare August 25, 2026 17:19
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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. usePersonaSync.test.mjs asserts the expanded kind set.

Stack: #5112 -> this PR

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 bb3f031 to 8091ab8 Compare August 27, 2026 05:19
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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. On a fresh device's first sync the startup backfill orders
catalog heads last (orderCatalogHeadsLast): the relay serves history
newest-first, so a freshly shared 30178 head would otherwise reconcile before
the personas/team it projects — the backend's team refresh would then resolve
against an empty roster and retract the owner's valid head with a dominating
false tombstone. Deferring catalog heads past their constituents preserves
newest-wins within every other coordinate; live events, arriving singly, are
unaffected. usePersonaSync.test.mjs asserts the expanded kind set and the
backfill ordering.

Stack: #5112 -> this PR

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 8091ab8 to 1480db4 Compare August 27, 2026 06:56
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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. On a fresh device's first sync the startup backfill orders
catalog heads last (orderCatalogHeadsLast): the relay serves history
newest-first, so a freshly shared 30178 head would otherwise reconcile before
the personas/team it projects — the backend's team refresh would then resolve
against an empty roster and retract the owner's valid head with a dominating
false tombstone. Deferring catalog heads past their constituents preserves
newest-wins within every other coordinate; live events, arriving singly, are
unaffected. usePersonaSync.test.mjs asserts the expanded kind set and the
backfill ordering.

Stack: #5112 -> this PR

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 1480db4 to cbb8aca Compare August 27, 2026 07:28
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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. On a fresh device's first sync the startup backfill orders
catalog heads last (orderCatalogHeadsLast): the relay serves history
newest-first, so a freshly shared 30178 head would otherwise reconcile before
the personas/team it projects — the backend's team refresh would then resolve
against an empty roster and retract the owner's valid head with a dominating
false tombstone. Deferring catalog heads past their constituents preserves
newest-wins within every other coordinate; live events, arriving singly, are
unaffected. usePersonaSync.test.mjs asserts the expanded kind set and the
backfill ordering.

Stack: #5112 -> this PR

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 cbb8aca to 8ae111e Compare August 27, 2026 14:20
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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, with 30178 catalog
heads dropped because their constituents never hydrated. 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>

@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.

Re-reviewed exact head 8ae111ecc388584a1f23be2a1629ff992968affb against base c856be0fb954c9e5267d622841098c24e3381e8f with the Royal Court. The prior cross-device 30178 witness blocker is resolved, but one merge-blocking community-boundary defect remains.

[P1] Adoption can cross a workspace switch and publish community A's team into community B

add_team_from_catalog resolves the catalog query from the currently active relay and then awaits it without snapshotting or fencing the workspace (desktop/src-tauri/src/commands/teams/adopt.rs:58-74,100-121). After that await, add_verified_team acquires the store lock and commit_and_enqueue resolves the then-current retention scope (commands/teams/adopt/apply.rs:99-145,165-185). There is no post-await relay, owner, or workspace-generation check tying the verified head to the scope that receives the adopted 30175/30176 rows.

Reproduction: start Add for a valid team in community A, hold A's /query response, switch the app to community B, then release the response. The command accepts A's correctly signed head, writes its projected team/personas into the workspace-global stores, and enqueues their owner heads in B's retention database. B's flush can then publish A's embedded prompts and configuration into the wrong community.

Please snapshot the relay, owner, and workspace generation/scope before querying; authenticate and query against that snapshot; and reject before any store mutation if the active workspace no longer matches. Enqueue into the same captured scope only after that fence succeeds. Add a delayed-query regression covering relay and identity switches.

The new inbound 30178 retention path otherwise closes the previous cross-device gap: owner catalog heads are retained without republish, inbound tombstones purge the witness, and persona/team changes refresh or retract an existing shared head with byte-identical no-op protection. Existing CI supplied broad validation; reviewers did not execute PR code locally.

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, the team instructions, and the team name/description — 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 or rendered in the
  catalog UI.
  validate_member also recomputes each built-in reuse hint's hash from
  the member's own embedded fields and rejects a mismatch, so a publisher
  cannot pair a real built-in's slug + hash with arbitrary reviewed
  fields to make adoption install the recipient's built-in in place of
  the reviewed projection.
- 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.
  add_team_from_catalog now snapshots the community boundary — relay,
  owner, and retention scope — BEFORE the verifying relay query, queries
  against that snapshot (query_relay_at_with_keys pins the captured relay
  + owner auth), and fences under the store lock before any store
  mutation: assert_adoption_scope_unchanged rejects if the live workspace
  switched relay or identity mid-await, and retention enqueues into the
  captured scope, not a re-resolved live one. Without the fence an
  adoption started in community A but completed after a switch to B would
  commit A's team into the workspace-global stores and enqueue A's owner
  heads in B's retention db, so B's flush publishes A's config into the
  wrong community.
- commands/personas/inbound.rs: cross-device catalog retention. Both
  recovery paths for a shared 30178 head — the boot reconcile worklist
  and the interactive refresh_or_retract_shared_head_at — key off a
  retained row and guard-return without one, so a second device that
  received the owner's own catalog head published from another device
  never retained it and could never supersede or retract that
  discoverable head. The inbound reconcile now retains an inbound 30178
  head as this device's publication witness (retain_inbound_catalog_witness,
  the single production routing decision for a catalog arrival):
  newest-wins via retain_inbound_event, arrival-scoped, NO local JSON
  store, and deliberately NO refresh or republish on arrival — a 30178
  arrival is this device's own echo or the other device's publication,
  and rebuilding on either would make two devices ping-pong identical
  heads. Fresh-device first sync is dependency-ordered on the desktop
  backfill (orderCatalogHeadsLast, #3995): the relay serves history
  newest-first, so a freshly shared 30178 head would otherwise be
  reconciled before the personas/team it projects, the team refresh would
  resolve against an empty roster, and the resolution-failure arm would
  purge the just-retained witness and queue a dominating false tombstone —
  deleting the owner's valid catalog entry on ordinary first sync. The
  tombstone router accepts a kind:5 covering a 30178
  coordinate so an inbound deletion purges the retained head on the
  receiving device. After a successful inbound persona/team upsert this
  device refreshes the affected shared heads (persona edit -> every team
  whose members include it, resolving the local id by d-tag; team edit ->
  that team's head); after an inbound tombstone a team deletion retracts
  its 30178 coordinate and a persona deletion refreshes the teams that
  listed it. refresh_or_retract_shared_head_at is now idempotent across
  devices: it skips the publish when the rebuilt projection is
  byte-identical to the retained head and still shared, so the editing
  device's own head triggers no churn republish on the receiver.
  The reconcile call-chain (reconcile_inbound_persona_event_blocking and
  every managed-agent store/retention/refresh callee it reaches) is generic
  over `<R: tauri::Runtime>` so production `AppHandle<Wry>` callers still infer
  Wry while a test can drive the real entrypoint under `MockRuntime` — the
  standard Tauri pattern for exercising a command's own body headless.
- 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.

Durable-ordering hardening (deletion/inbound retention layer):
- Deletion-side boot reconcile (event_sync.rs reconcile_deleted_heads):
  the negative-side counterpart of the existing positive-side boot
  backstop. Positive reconcile enumerates DISK records; a rolled-back
  deletion leaves an orphan retained 30175/30176/30177 head that disk
  enumeration never revisits. The sweep enumerates retained heads per
  kind, and for any head with no matching disk record runs the (atomic)
  tombstone helper via a single fn-pointer over the three uniform
  tombstone_*_at(&Path, &Keys, &str) helpers. It reads all three
  authoritative stores strict-first: a missing store is legitimately
  empty; a malformed store fails loud with a .invalid backup and
  tombstones nothing.
- Atomic inbound tombstone (retention.rs commit_inbound_with_store,
  commit_inbound_tombstone_with_store): the durable store write now
  precedes the head advance, and the tombstone row + covered-head purge
  commit in one BEGIN IMMEDIATE. Inbound tombstones now resolve the
  covered (target_kind, owner, d_tag) head, not just the kind:5 row: a
  target head strictly newer than the tombstone preserves the record and
  skips; an actually-covered head is purged atomically with committing
  the tombstone, but only after the fallible JSON save succeeds so an
  identical replay stays retryable.
- Inbound relay-contract alignment (retention.rs inbound_event_outcome):
  equal-created_at resolution now matches the relay's NIP-01 head
  retention — lowest event id wins instead of always skipping — so two
  same-second authors converge on the relay's winner rather than
  diverging permanently. Event ids come from the retained raw_event JSON;
  no schema change.
- Atomic agent identity archival (agents_pending.rs
  tombstone_managed_agent_at): the 30177 tombstone and its NIP-IA
  kind:9035 archive request now enqueue in one transaction. The archive's
  persona_id payload is derived from the retained 30177 head's content
  (owner-signed historical alias data), NOT the already-deleted record —
  so reconcile_deleted_heads re-enqueues BOTH effects on restart for free.
- Disclosed: the three ordinary-kind tombstone helpers and the inbound
  seam each carry their own BEGIN IMMEDIATE block rather than sharing one
  abstraction; the duplication is intentional this round to keep each
  seam's transaction boundary local and reviewable.

- Rust tests: team_catalog/tests.rs + team_catalog/tests/concealment.rs
  (parse-boundary rejection of default-ignorable/bidi controls in each
  protected field; emoji and multiline-instruction positives still pass),
  adopt/tests.rs + adopt/tests/concealment.rs (the concealed head drives
  the add_verified_team sequence through real temp stores and a real
  retention scope, asserting rejection leaves personas, teams, and
  retention byte-unchanged) + 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) +
  adopt/tests/scope_fence.rs (a relay switch and a same-relay identity
  switch between scope capture and commit are each rejected before any
  write — stores byte-unchanged, no retention db — while an unchanged
  workspace commits normally; disabling the fence turns both switch tests
  RED),
  pending/tests.rs (incl. cross-device convergence: an inbound 30178 head
  driven through the production dispatcher retains an arrival-scoped
  witness and queues no publish, device B then supersedes device A's head
  on a member edit and tombstones the coordinate on a delete, a
  byte-identical rebuild is a Noop, an inbound retention alone queues no
  outbound publish, and a fresh-device first sync retains the witness when
  catalog heads are ordered last — the relay-newest-first order is the
  reversal that purges the witness and false-tombstones it; and a production-seam regression that
  drives a signed 30178 through the real
  reconcile_inbound_persona_event_blocking over a MockRuntime AppHandle
  and asserts the arrival-scoped witness lands with pending_sync=false
  and no outbound publish — an early return for KIND_TEAM_CATALOG before
  the production invocation turns it RED),
  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; deletion-reconcile orphan-sweep +
  malformed-store fail-loud tests; atomic inbound tombstone + covered-head
  purge + JSON-save-failure-retryable tests; equal-second convergence and
  pending winner/loser tests; atomic 30177+9035 enqueue + split-txn
  rollback tests
- 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 8ae111e to 895ac26 Compare August 27, 2026 18:05
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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 kind-5 deletions targeting those coordinates — 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>
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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>

@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.

Reviewed exact head 895ac26b3d42e0be76d12be6182af5f5393fb093 against base 0ccf934b88f610f5f235862ecd51e2dcbae2cb74 with the Royal Court. The prior community-boundary blocker is resolved, and no actionable code, product, or security defect survived consolidation.

The adoption flow now captures relay, owner keys, and retention scope before the verifying query, authenticates that query against the captured community, then checks the live relay and signer under the store lock before any load/write and enqueues only into the captured scope (commands/teams/adopt.rs:69-92,113-145; commands/teams/adopt/apply.rs:99-160,168-185). The exact-head projection/adoption boundary also preserves signature-derived provenance, byte-exact rollback, reuse-hint integrity, concealment validation, and post-commit retention.

End-to-end review also covered the duplicated 30175/30176/30177/30178 and kind:5 lifecycle sets: team share/unshare/delete publication is per-scope serialized; tombstones dominate retained heads and defer replacements through relay-window-safe replay; inbound equal-second resolution matches lowest-event-id NIP-33 ordering; covered-head purge is atomic and store-first; cross-device 30178 witnesses retain without ping-pong and refresh/retract after owner persona/team changes; boot positive reconcile precedes malformed-store-safe persona/team orphan deletion reconcile, with 30177 correctly excluded from absence-based deletion.

This was a read-only GitHub API/exact-source review. No PR code was checked out or executed. Existing CI supplied broad validation; the separate review-automation job failures were not treated as product-code evidence.

Verdict: no blocking findings at this head. Wes retains final approval authority.

@wpfleger96
wpfleger96 merged commit a7c7414 into main Aug 27, 2026
30 of 32 checks passed
@wpfleger96
wpfleger96 deleted the duncan/team-catalog-backend branch August 27, 2026 21:45
wpfleger96 pushed a commit that referenced this pull request Aug 27, 2026
…-history

* origin/main:
  feat(desktop): implement 30178 team catalog backend (#5112)
  feat(model-capabilities): humanize Databricks UC model families (#6955)
  feat(agent): discover Databricks Unity Catalog models (#6918)
  test(db): use canonical channel roster fixtures (#6819)
  preserve channel description paragraph breaks (#6946)
  fix(cli): enrich template cardinality error with per-candidate presence and profile hints (#4825)
  Fix Codex security review authorization (#6913)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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>
wpfleger96 added a commit that referenced this pull request Aug 27, 2026
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>
wpfleger96 pushed a commit that referenced this pull request Aug 27, 2026
…arer-auth

* origin/main:
  fix(ci): bump Codex CLI to 0.150.1 to unhang security review jobs (#6962)
  feat(desktop): implement 30178 team catalog backend (#5112)
  feat(model-capabilities): humanize Databricks UC model families (#6955)
  feat(agent): discover Databricks Unity Catalog models (#6918)
  test(db): use canonical channel roster fixtures (#6819)
  preserve channel description paragraph breaks (#6946)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96 added a commit that referenced this pull request Aug 28, 2026
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>
jrobotham-square added a commit that referenced this pull request Aug 28, 2026
…age-rw

* origin/main: (21 commits)
  fix(desktop): resolve exact typed mentions on space (#6862)
  perf(desktop): restore project context during startup (#6939)
  fix(desktop): lift right auxiliary pane above shared header backdrop (#6966)
  fix(ci): bump Codex CLI to 0.150.1 to unhang security review jobs (#6962)
  feat(desktop): implement 30178 team catalog backend (#5112)
  feat(model-capabilities): humanize Databricks UC model families (#6955)
  feat(agent): discover Databricks Unity Catalog models (#6918)
  test(db): use canonical channel roster fixtures (#6819)
  preserve channel description paragraph breaks (#6946)
  fix(cli): enrich template cardinality error with per-candidate presence and profile hints (#4825)
  Fix Codex security review authorization (#6913)
  fix(db): disable heartbeat vacuum truncation (#6898)
  chore(deps): update rui314/setup-mold digest to 7e4f20a (#6663)
  chore(deps): update dependency vitest to v4.1.11 (#6667)
  chore(deps): update dependency @tanstack/react-virtual to v3.14.10 (#6666)
  chore(deps): update ubuntu:24.04 docker digest to 33ceb71 (#6664)
  fix(projects): allow owners to delete agent projects (#6533)
  Fade expanded video controls on hover (#6926)
  fix(db): exclude kind:30179 ciphertext from brownfield FTS (#6822)
  fix(client): resurface hidden DMs from live activity (#6885)
  ...

Signed-off-by: Joel Robotham <jrobotham@squareup.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