sync: ambient directories, receiving rules, and the gates that were red - #625
Merged
Conversation
…to it Sync sharing was three long open sections on one page. Model Settings already solves this with an uppercase title, a chevron and content below - but hand-rolled per screen, so this lands it as one component in core and uses it for Sending, Receiving and Ambient sharing. Ambient keeps its ACTIVE indicator in the header, so the one thing worth knowing survives collapsing the section.
…iles An active licence rendered a status card, a desktop link, then half a screen of nothing. Empty space that says nothing is a bug, so the space now carries what the licence opened: rows into Sync, Clipboard and your own tools, each gated on the screen actually being registered so nothing advertises a dead end. Also squares away two anti-patterns from the design philosophy: the 40px filled circles behind the pillar and desktop icons were decorative tiles, and round ones at that.
The narrowing and row-shaping rule now comes from @offgrid/sync, and the store holds shared sync's own preview type instead of a lossy copy of it, so the Mac appends identical rows.
The bar animated width with useNativeDriver false, so every frame was computed on the JS thread - the one thread busy loading a model for the entire time the bar is on screen. It stuttered, which reads as a hang when nothing is wrong. Now a full-width bar slides in from the left inside the clipping track, on the native driver, so it stays smooth however busy JS is. Also drops an animated layout property, which the design philosophy rules out.
Tapping "Settings changed - tap to reload model" ran an async callback with no catch. If the unload or the load rejected, it ended as an unhandled rejection: the banner stayed, the model never came back, and the tap read as a dead button. The reload now lives in its own module, logs its outcome, and surfaces a failure as an alert instead of nothing.
loadedSettings is persisted, so it outlives the model. With nothing selected the
"Settings changed - tap to reload model" banner still appeared and its tap
correctly refused - a dead button, which is what it looked like on the device
("[ModelReload] ignored: modelId=none"). The offer and the action now come from one
predicate so they cannot disagree.
Visibility only. This flag has exactly one consumer, the banner in
ChatMessageArea, and gates nothing in send, generation, or model loading.
The selected id is persisted; the downloaded list is rebuilt by scanning the models directory at launch. When a rebuild produces a different id for the same file, an exact-id lookup finds nothing and every surface answers "no model selected" while the engine has that exact file loaded - a live model, a refused send, and "please select a model" (device, 2026-07-31). activeModelService.resolveSelectedTextModel is now the one answer, falling back to the file the id ends with, because the file on disk is the durable identity. The drift is reported once per id instead of swallowed. The rule is pure and lives apart from the service (resolveModel + selectedTextModel), so it is testable without the store.
…te copy The chat screen re-derived "which model is active" with its own memo - the same remote-first, then find-by-id rule useActiveTextModel already owned, including the same id-drift bug. It now uses that hook, whose local branch delegates to activeModelService, so a rebuilt id resolves instead of silently reading as "no model selected". Adds useActiveModelStatus: one subscription to the service for the selected / loaded / loading snapshot, for the surfaces that render load state.
Tapping a row records a SELECTION - the load is deferred to the first message - but the sheet set its own loading flag on tap and cleared it only when the parent's isLoading went false. That transition never came, so the row span forever: a spinner for a load nothing was running (device, 2026-07-31). Row state now derives from the active-model snapshot the service owns, through one subscription (useActiveModelStatus) and one pure rule (loadingTextRowId), so the sheet can no longer invent a load. The selected row is also resolved by the service, so a rebuilt id still marks its row.
activeModelId is the selection; lastTextModelId is only written when a model is picked from a sheet. Three callers each decided the order for themselves, and the chat's deferred-load path read lastTextModelId ALONE - so after a load that came from anywhere else it loaded the older model while the newer one sat selected on screen (device: picked Qwen 3.5, loaded SmolVLM-256M). activeModelService.selectedTextModelId is now the one answer, used by the chat load path, image generation and the preloader. The rule itself is pure (selectedTextModelIdOf), and the snapshot projection moved out of the service (snapshot.ts) so each piece owns one thing.
PM10: automatic screenshot sharing existed only on iOS, and the TypeScript boundary also required Platform.OS === 'ios' - so even once a module existed Android would report "unavailable in this build". A Kotlin ContentObserver on MediaStore.Images, filtered to the Screenshots bucket, copies each new screenshot into the app and emits the same SyncScreenshotCaptured payload iOS emits, so the boundary is one file with no platform branch: presence of the module IS the capability. The newest existing screenshot is the baseline, so turning sharing on does not share your history. PM11: Android 11 stopped letting the file picker grant the Download directory at all - "For your safety, share another folder" was Android's own refusal, not our bug. On Android the folder is now read through MediaStore with a media permission and no picker. The shared directory source treats a grant as an opaque string, so a sentinel grant travels through it and no rule in the engine knows the difference. The honest limit, reported rather than hidden: with a media permission MediaStore returns MEDIA in Download. A PDF another app downloaded is not media and needs either the folder grant Android refuses or the all-files permission Play restricts.
The model-state SSOT work added two service methods the app now calls on every render - resolveSelectedTextModel and selectedTextModelId - and this suite stubs the service, so all 155 of its tests died on a missing function. The stub resolves from the store the way the real service does, so it answers what the app would answer rather than a constant. Noted for later: this suite mocks OUR OWN service, which is the pattern the testing doctrine rules out; it should move to the integration harness.
The model-state SSOT work added two methods every render calls, and twenty suites stub that service by hand - so each of them died on "resolveSelectedTextModel is not a function": 366 failing tests from one missing seam. They now spread activeModelSelectionStub, which resolves from whatever store the suite is using, so the next method added to that seam is added once rather than hunted through twenty files, and a suite that sets up an active model actually sees it. Mobile: 166 failing tests before, 41 after - and the 41 that remain are stale suites for code that moved (deviceFingerprint, syncService.acceptIncomingPairing) plus the Pro-access refactor currently uncommitted in the tree.
ChatMessage was 622 lines against a 500-line cap, so every commit that touched the chat bubble was blocked by a rule it could not satisfy. The tool surfaces - what the model asked a tool to do, what came back, the routed-tools row - are their own subject and shared nothing with the bubble but the styles object. 397 lines now. A pure move: no logic changed. Verified by the 231 tests over the chat bubble and chat screen, green before and after.
723 lines against a 500-line cap, with a 452-line render against a 350-line cap - so any commit touching Settings was blocked by a rule it could not satisfy. Split the way the Storage and Remote Servers screens already are: the style sheet to its own file, and the two blocks that carry no screen state (appearance, and the newsletter and community links) to their own components. 386 lines, 341-line render. A pure move: verified by the 19 Settings tests, green before and after.
Every screen carried its own copy of the same header tokens, so the copies drifted: Sync lost the shadow under the band and used a chevron in a 44px box, Clipboard did the same, and the Pro screen had no header at all - a logo row that scrolled away with the content. ScreenHeader is those tokens in one place (arrow-left at 20 beside the title, h2, surface band with its border and shadow, 60 minimum height), with the touch target from hitSlop so the arrow can sit at the standard inset. The Pro screen now has a real header with its entitlement state where a screen's actions belong, and it no longer scrolls away.
GenerationMeta.gpu becomes optional in the process: a message synced from another device carries the facts that travel and none of the local ones, so claiming gpu:false would be inventing a measurement this device never took.
Most of what you want the model to know is not a file - it is a page you copied, a spec, a thread - and saving it as a document first just to import it is a detour through the filesystem. Paste sits beside Add in the knowledge base header, because once saved they are the same kind of thing. Pasted text becomes a real .txt document and goes through indexDocument, so it gets the same dedupe, chunking, embedding, rollback-on-failure and knowledge-document sync as an imported file. Nothing downstream knows it was pasted. An untitled note is stamped with the moment it was saved rather than refused, and the size recorded is the byte length, not the character count. The Project Detail suite carried its own one-component safe-area stub, so pulling a bottom sheet into that tree took all 45 tests down on useSafeAreaInsets. It now uses the library's shipped mock, which is what jest.setup.ts already does for the same reason.
Platform.OS decides whether an mDNS hostname counts as an address, which is the host's question to answer, not shared sync's.
…nce card Android hands an app with a media permission only the media in Download, so the PDFs and zips that make up most of a real Downloads folder were invisible - the feature looked broken because it was quietly sharing a third of the folder. All-files access is now offered as an explicit escalation from the card, the folder is read as a folder when it is granted, and staging accepts a path as well as a content uri. Directories are no longer offered as files: they are rows in MediaStore too, with no mime type and a block size for their length. The Pro licence card was a different shape from every other card and clipped under the header. It is now the same surface, uppercase title and divided rows as the rest, with the scroll view starting clear of the header.
Screenshots stopped reaching the Mac. The watcher read exactly one row per change notification - the newest by DATE_ADDED - and dropped it unless its id beat the watermark. The system screenshot service inserts its row while it is still writing, as PENDING, and a pending row owned by another app is hidden from us: the notification arrived, the query answered with the PREVIOUS screenshot, the id was not newer, and the capture was lost. Nothing recovered it, because one row cannot catch up. It now asks for every row above the watermark, oldest first, excluding pending ones, and emits each. Late notifications, ties on DATE_ADDED, and screenshots taken while the app was away all survive. A catch-up is capped at twenty and says how many it skipped rather than capping silently, and the native side logs when it starts watching and what it emitted.
"Paste" named the gesture rather than the thing being added. The control says Text, with a type icon instead of a clipboard, and the sheet is Add text.
Both drive the real screens over a faked device boundary and assert what the user sees. Each was falsified before being kept: the Android copy tests go red when the access description is removed, and the model test goes red when the send side refuses a package with an mmproj. - Android asks for media access rather than a folder, and prints what it cannot see - all-files access clears that limitation once granted, on the trip back from Settings - iOS keeps its folder picker - a vision package is offered to a paired device; a LiteRT model is withheld from an iPhone
Kotlin wrote file.lastModified().toString() and the JS path wrote Date.now().toString(). Both satisfy createdAt: string. Neither is a date. iOS wrote ISO-8601 all along, which is why image sync worked from the iPhone and has never once worked from an Android phone. Both now write ISO-8601, so the two native implementations of one contract agree.
The producers are corrected, but a phone that has generated images already holds the old value and nothing else would ever rewrite it. The gallery showed those as an invalid date and no peer would accept them.
The Enhanced prompt card is CONSTRUCTED by the app, not streamed by a model. It carries its own label and its own body inside the content. A separate reasoning channel was preferred over both, so the card rendered as 'Thinking...' with the single word 'Generated' in it - whatever the model happened to be mid-sentence on while the card was being written. A labelled block now wins. An unlabelled one still defers to the channel, which is the ordinary model case and is unchanged.
getConversationContext sent message.content, which is the STORAGE form. So the enhancement's context carried the enhancement's own card markup and the caption the app writes under a finished picture. Four of the last six messages were ours. Imitation beat instruction: the model emitted <think>__LABEL:Enhanced prompt__ token by token and returned 'Generated image for: "Draw a fox"' as its idea of an enhanced prompt. A marker invented for the screen must never be model input. App-authored assistant messages are dropped and the rest go through the one display parse. The user's own turn states the request and is kept.
A probe that timed out falls back to '4096 context and nothing else', which is stored exactly like a real answer and is indistinguishable from a model with no features. One flaky moment at discovery hid the thinking toggle and stopped the kwarg being sent, for the life of the install. This phone holds two records for the same gateway - one with everything false, one with everything true - which is what that looks like from the outside. A record shaped like a failed probe is now UNKNOWN, not an answer, and is re-discovered before it is believed.
A new install now QUEUES for an absent device, so the drop this test asserts is the user's explicit choice and the test has to make it explicitly rather than inherit it from the default.
BYTES_PER_GB, getMaxContextForDevice and getGpuLayersForDevice answer one question - what will fit on THIS device - from the device's memory and nothing else. They sat among the llama.rn context helpers, so a pure sizing rule read as an engine detail and every caller of BYTES_PER_GB pulled in the native binding. Re-exported from llmHelpers, so no call site changes and there is still one place each rule is defined.
A new install queues for a device that is away instead of dropping the file, so the persistence tests state that and say why. An attachment with no usable declared type follows the FILE rather than falling straight to a document - that is what hung a synced generated image in the chat as a file row. Added the case that locks the other half of the rule: a PDF with no declared type is still a document.
The tour is gone from every surface: 26 AttachStep wrappers, the provider in AppNavigator, the 336-line step config, the pending-spotlight state module, the per-screen effects that consumed it on mount, the shownSpotlights ledger in the app store, the react-native-spotlight-tour dependency, and 9 test files. The onboarding CHECKLIST stays, because it is a different thing: a list of things worth trying. Tapping a step used to close the sheet, queue a pending spotlight, navigate, and fire a timed goTo that several screens had to cooperate with. It now closes the sheet and goes to the right tab, which is the part anyone wanted. That destination map moved to checklistNavigation, next to the checklist it serves rather than inside a tour config that no longer exists.
Removing the tour left three dead exports behind. ENHANCED_PROMPT_LABEL and lastVisibleMessage are used only inside their own modules, and react-dom was pulled in by the spotlight tests alone.
Consumes the new orchestrator report, so the debug log carries the reason a codeless join failed instead of an admission followed by silence.
The dependency moved in 06732ff and the lockfile was left behind, so a clean pod install disagreed with the manifest.
Reordering only, written by Xcode on a device build. No target, file or setting changed. Committed so the next build does not present it again as a diff.
Carries tonight's pro work: admission confirmed with Keygen, the local eviction record dropped, and a credential the peer disowned no longer used to resume.
The address was only ever re-read when the USER did something - a pull to refresh, a rename, a discoverability toggle. So carrying a phone from one network to another left it advertising the address it used to have, and peers went on dialling a house it had moved out of. That is what took the two phones down today. One moved from 192.168.1.38 to 192.168.1.26 and said so in its own log, but the other had already resolved .38 seven seconds earlier and never asked again. By then .38 belonged to a different machine on the office network, so the dial was refused: right port, wrong host, and nothing reporting why. The owner of "what is my address" already existed. Only the trigger was missing. A poll, not a platform network event: reading this device's own address is one cheap native call that every platform already answers, so there is nothing to add per platform and nothing to keep in step, and only a CHANGE does any work. The response to a change is also written once now. A rescan did both halves; a rename did only one, and the half that gets forgotten is the re-advertisement, which is the half peers depend on.
hasCredential travels beside getSharedSecret so @offgrid/sync can seed a peer's relationship from storage rather than from whether a host felt like handing the secret over.
…cts back peerLink for rendering, notePeerLink for reporting. The host needs both to stop keeping its own copy.
A received file is stored under `<syncId>-<name>` so two files with one name cannot collide. That is a LOCAL convention, and the portable name was being read back off that local path - so the name gained another syncId on every hop. This Mac's own image had come back around as `<id>-<id>-img-1786424851569.png`, and left alone it would eventually pass what a filesystem accepts and stop transferring at all. A generated image now carries the name the mesh knows, set from the record when it arrives and used when it is described again. Message attachments always did this; only generated images were missing it, which is why only they grew. The existing fixture already had the distinction: its record is named "a lighthouse at dusk.png" while its local path ends "lighthouse.png". Re-describing renamed the file on the wire, and nothing noticed.
A new dependency the fakes did not implement. Both suites share files made on the device under test, so there is no origin to keep them away from.
It was drawn at a fixed 140 square, so every picture sat in a band of empty bubble and anything that was not square was cropped to fit a height nobody chose. The width now fills the bubble and the height follows the picture's own ratio. The ratio comes from the attachment, because only it knows its shape - and those dimensions travel with a shared file, so an image from another device has them too. Square when the sender did not say: a wrong guess at least fills the space evenly, where a fixed height crops. One rule for both kinds, since a generated image and an attached one are drawn by the same component.
The flows, the seeding script, run-tests.sh, the three npm scripts, the knip binary allowance and the two docs that drew the tree all leave together. A removed suite still named in package.json and the codebase guide reads as a suite someone can run.
v73, v75, v79 and v81 are replaced, and v69 leaves: no shipped device targets it, and a kernel we do not build for is a kernel nobody can prove.
d68e21a took react-native-spotlight-tour out of package.json but left the jest.setup mock and the lock entry. Local node_modules still held the package, so it only failed on a clean install: jest.mock threw at setup and all 74 suites died before their first assertion.
Both version labels read package.json, which is the LIVE PRODUCTION version: promote.sh bumps it when a beta is blessed, so during a beta cycle it is deliberately one patch behind the binary under test. So every 0.0.103 beta introduced itself as 0.0.102 on the About screen, in the Settings row and in every feedback mail, while the git tag, Play and TestFlight all said 0.0.103. The version a user reads now comes from the artifact - Android versionName / iOS MARKETING_VERSION, via react-native-device-info - which is the same string Play, TestFlight and the OS app list show them. A release script that forgets a bump can no longer produce a label that disagrees with the file they installed. One owner (src/utils/appVersion.ts) rather than four call sites each deciding for themselves where the version comes from, so there is one place to correct when the answer is wrong. Also drops the await around DeviceInfo.getBuildNumber(): it is synchronous - a native build constant, not a lookup - and awaiting it implied a version could fail to arrive. The rendered strings are unchanged, so the SettingsScreen assertion still proves the screen shows its version; its fake just moves to the native boundary where jest.setup already fakes that package. The AboutScreen mock of package.json asserted nothing and is deleted with it.
…he one the app shows uat.sh moved Android versionName, iOS MARKETING_VERSION and the git tag to the next version but only READ package.json to compute it. So four cuts in a cycle all shipped 0.0.103, and the number only advanced when a promote finally bumped package.json. package.json is now bumped to the cut's version and committed with it, for both platforms, which is what makes the NEXT cut compute the next number instead of rebuilding 0.0.103 four times. The GitHub release, both stores and the app therefore all read the same version. npm version rather than a sed, because npm owns package-lock's copy of the number. The bump joins the existing trap, so a build that fails still reverts it and burns no version.
With every beta now cut at its own version and committed, the number on the tested commit is usually already the one being promoted - so `git add` stages nothing and `git commit` exits non-zero, which under `set -euo pipefail` would abort the promote before it reached either store. The tag is the point of that step, not the commit. With nothing to change, v<version> lands directly on the tested commit, which is strictly closer to "promote what was tested" than a commit on top of it, and the fast-forward push that follows becomes an up-to-date no-op.
…t does not block the push SonarQube server 13.7 raised the scanner's Java floor, so the bundled npm scanner (3.1.0) now exits with "Java 17 is not supported. Please upgrade to Java 21 or newer" on a Mac with only openjdk@17. That took the pre-push hook down with it: typecheck, lint and knip all passed and the push still failed, on every branch, for a reason that has nothing to do with the code being pushed. This script already states the policy - the authoritative analysis is SonarCloud's server-side Automatic Analysis, so a local scan is best-effort and must never block a push - and already skips the two other cases where a local scan cannot run. A JDK the scanner refuses to start on is the same kind of fact about this machine, so it joins them. Every other scanner error still hard-fails. The durable fix is a Java 21+ JDK (or a scanner new enough to auto-provision its own JRE); until then a push is not the place to discover that.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Your phone and your Mac become one device you can trust: files, chats, clipboard and models move between them by themselves, over your own network, and nothing leaves either device that you did not agree to.
293 commits, 335 files, +39,567 / -10,225.
What this gives you
Your devices find each other and stay paired. Discovery over the LAN with a persistent device name, a code you confirm on the other screen, and pairings that survive an app restart, a reinstall and an OS upgrade. Android stops advertising a LAN route it cannot actually dial, so a row never says reachable when it is not.
Files arrive on their own, but only the ones you chose. Screenshots and downloads share ambiently per source and per destination, with "auto", "ask me" and "off" obeyed exactly. Media access is requested at the moment you turn screenshot sharing on, not at launch. A synced files library holds what arrived, attributed to the device that sent it, and tells "we have this" apart from "we know about this" so Open and Share are never offered on a file that is gone.
The clipboard follows you, opt-in. Copy on one device, paste on the other, with the origin device preserved so you can see where a snippet came from. Bridged natively on Android, with guided access on iOS.
Chats and projects converge. A message that arrives from another device shows up when it arrives, not when something else happens to reload. Received messages keep the tools they were offered. Project knowledge bases accept pasted text directly.
Models transfer between devices. A model you already downloaded on one device can be sent to the other and is admitted as a real installed model, checksum-verified, rather than re-downloaded over cellular.
You decide what lands. Per-device receiving rules, a clipboard gate, and rules that are cleared on unpair so an id reused by a future device never inherits a decision you made about a different one.
Licensing and the device cap. Entitlement bootstraps during pairing, revalidates on launch, normalises a pasted key, and replaces the least-recently-used seat when you hit the cap instead of refusing.
Verification
jest --coverage --forceExit --runInBand.:app:testDebugUnitTest) and iOS tests run in CI.__tests__/device/meshPairing.e2e.mjspairs an iPhone and an Android device on the real network and asserts each one shows the other, and that neither claims a relationship the other denies.srcat 80 on every metric,./proat 80 on statements/functions/lines and 79 on branches, which is where pro genuinely measures (79.44% of ~4,700 branches). Reaching 80 on branches needs about 78 more covered branches inttsService,mcp/oauth metadataandknowledgeDocumentSyncService; that is real work, not a rounding nudge, so the floor is pinned just under the measured value rather than at a number nothing satisfies.CI, and why it was red
Four separate causes, none of them a failing test:
npm run lintchained./gradlew :app:lintDebug, which cold-configures every React Native native module on a macOS runner. CI now runsnpx eslint .; Android Lint is a local pre-merge gate, the same call this workflow already documents for the Android build. Android unit tests still run here. Expect roughly 22 minutes instead of 90.jest.resetModules(). The harness now stops in-flight generation on teardown; the whole integration and rntl set (2,236 tests) then passes repeatedly with zero failures.One
cijob reports for this repo, matching the other three.Tests worth calling out
The doctrine here is integration over mocks, with fakes only at genuine device boundaries. Every mock in the sync test surface of this release is a real boundary: native TCP, native mDNS, the filesystem, the keychain, the document picker. There are no mocks of our own code in the new sync tests.
Where older suites did mock our own code, they were deleted rather than repaired, and the journeys they claimed were rewritten against the real thing:
generationFlow.test.tsfedonStreamitself, so the test was the model. 12 of its 15 cases were already covered by rendered suites; the two that were not are now real, asserted at the native engine.imageGenerationFlow.test.tswas 60 tests over a stubbed image generator, six of them named after line numbers. What it never covered is the window a user actually sits in: STOP reaching the native generator, progress moving on the card, and a second send not starting a second diffusion.ragFlow.test.tsmocked the DATABASE by matching SQL strings. Retrieval "found" whatever the matcher returned. Prompt-budget truncation and project scoping are now asserted over a real in-memory SQLite, including that a search never returns another project's documents.Three sync modules that had no test at all are now covered: mesh residency policy (a refused foreground service must not fail sync start),
availableSyncIds, andforgetDeviceRules.Known gaps, recorded not hidden
docs/GAPS_BACKLOG.mdcarries the open items, including: ejecting a model mid-reply unloads the engine without stopping the generation (measured: nativeunloadModel1, nativestopGeneration0); the ChatScreen journeys left uncovered by deleting a 155-case mockist suite, with the measured 8-point drop and the four named journeys; and the image-generation journeys not yet rewritten.Greptile Summary
This release substantially expands cross-device synchronization, pairing, receiving controls, licensing, model transfer, clipboard sharing, and chat convergence while consolidating CI verification.
Confidence Score: 5/5
The PR appears safe to merge because no eligible blocking failure or outstanding prior finding is established.
No blocking failure remains.
Important Files Changed
Sequence Diagram
sequenceDiagram participant A as Sending device participant D as Discovery and pairing participant R as Receiving rules participant T as Encrypted transfer participant B as Receiving device A->>D: Advertise stable identity B->>D: Discover and confirm pairing code D-->>A: Persist trusted peer D-->>B: Persist trusted peer A->>R: Announce clipboard, file, chat, or model R->>R: Apply peer and content-specific policy alt Receiving allowed R->>T: Authorize transfer T->>B: Send encrypted payload B->>B: Verify checksum and materialize B-->>A: Record completion else Ask or off R-->>B: Prompt or suppress transfer endReviews (3): Last reviewed commit: "fix(sync): make a failed receive discard..." | Re-trigger Greptile