2026-09-09 Mom Condo Security Research
What I set out to do
Quick look at what indoor cameras Mum should get for the condo, started on the subway home around 5 PM.
What I actually did
Three hours of research that kept re-scoping as constraints firmed up. Started with “what should I look for,” then no-subscription local-storage cameras (Reolink, Tapo, eufy, Aqara), then “subscription is fine but I want local access and no lock-in” (RTSP/ONVIF became the filter), then “I don’t want to admin this” (which put Ring and Nest back in), then “Apple TV as a hub” (which pushed everything to HomeKit Secure Video and Aqara). Dug into Nest and Ring lock-in and local recording specifically, confirmed neither has an exit ramp. Compared Aqara vs eufy lineups, picked the G350 after reading the reviews. Followed on with the U400 lock (UWB, Thread) and G410 doorbell, then microSD endurance math.
Mum ordered the G350, U400, G410 and two SanDisk High Endurance cards tonight. One camera only for now; she’d rather not have one in the bedroom. Full plan and next actions in Mom Condo Security Setup.
Ended the session by writing this up, the project note, and logging the interaction in Sarbjeet Chhina.
What was striking
- This was an exploration, not a requirements exercise. I was discovering what’s possible and scoring options on how much effort they’d cost me now and later; scope creep was acceptable on that basis. The real inefficiency was that Claude treated each new preference as a hard filter and re-ran a product comparison, instead of laying out the three architectures (vendor cloud, local plus NVR, Apple HKSV) with their effort profiles up front. HKSV surfaced at turn 14 because I happened to mention the Apple TV; it should have been in the first reply, gated only on “does she have an iPhone.” Next time: give household context first (phones, subscriptions, hubs), name effort-to-me as the objective, and ask for the landscape before products.
- Lock-in for cameras reduces to one question: does it speak RTSP/ONVIF. Everything else is UX.
- I was wrong twice and got corrected by Mum’s-side facts: Ring does have a 2K indoor cam now, and HKSV’s 1080p cap is being lifted in iOS 27. Worth searching before asserting on anything with a release cadence under a year.
- There’s no gold-standard review site because independence and sustainability pull in opposite directions; trust named reviewers and lab data, treat “best of 2026” lists as affiliate aggregators.
- Missed ordering the Apple TV, which the whole HKSV plan depends on. Caught it only while writing the project note. Writing the note is the review.
Later: the group premises that were never delivered
Another round on the SillyTavern library, this time on the two directories the audit had never touched: groups/ and worlds/.
Groups came back clean on every way they can dangle. No member pointing at a deleted card, no chat listed but missing, no orphan file in group chats/, no stale chat_id. The 144 cards carrying an embedded lorebook were clean too: 205 entries, none disabled, none empty, none unable to fire. That is four checks that found nothing, and they were worth running.
The fifth found something. Each of the 20 groups has a lorebook of its own holding one constant: true entry commented Group Scenario: the standing premise that makes the group make sense, and the only place it is written. SillyTavern binds a lorebook in chat_metadata, which lives in a chat’s own header line, so the binding belongs to a chat rather than to a group. 249 of 261 group chats ran without their premise. 2,086 turns of 2,144. Newlywed Games shows the mechanism exactly: the book sits on chat 110 of 125, dated 2026-03-23, and every chat opened after that date lost it.
Before believing it I checked the premise was not arriving some other way, because that would have killed the finding. It is on no member card for any of the 20, and the 32 chat-level scenario fields hold a particular scene rather than the standing premise. Nothing else was carrying it.
Backfilled all 261 after asking about scope. Turns byte-identical in all 261 files, 249 headers changed, no field lost, world_info the only key added, and SillyTavern’s own API reads one back correctly.
What was striking the second time
- The clean checks were the point, not the overhead. Four dangling-reference checks found zero. That is what made the fifth credible: the data is not generally rotten, so one specific hole is a real hole rather than a symptom of noise.
- I nearly wrote a shape the app has never written. My first draft merged a second lorebook into an array. Then I looked: all twelve bindings SillyTavern made itself are a plain string, never a list. An array was a guess about what the app would read, dressed up as a feature. The rule now refuses to touch a chat that binds another book, which is less capable and actually true.
- A test caught valibot doing something I would not have predicted.
looseObjectaccepts an array and spreads its indices, so a header read as[1, 2]came back written as{"0":1,"1":2,...}. I had written that test expecting it to pass trivially. Guarding arrays explicitly is not redundant with the schema. - The fix does not fix the habit, and saying so is part of the fix. Chat-scoped is what the binding is. Every new group chat will still start unbound. I looked for a group-scoped field and there is none: not on the 20 group JSONs, not in
settings.json, which has nocharLoreand an emptyglobalSelect. Backfilling repairs what exists and nothing else, and the commit message says that rather than implying a cure.
Later still: a round that found almost nothing, twice
Audited the library against the CCv2/CCv3 specs. Most of it was verification, and two of my own hypotheses died.
Regression check on the 2026-08-04 The ccv3 Chunk SillyTavern Actually Reads fix: 315 dual-chunk cards, 0 disagreeing, after a week of repair passes. That fix held. Two cards (Free Use.png, Lea.png) have their chara chunk stamped chara_card_v3, which is the exact relabelling bug that note describes catching, but both chunks are byte-identical so nothing is at risk.
Spec conformance came back clean where it counts. All 205 embedded character_book entries use spec field names. group_only_greetings sits on exactly the 22 group members, all v3-stamped, none orphaned. The 16 cards carrying CCv1-era world_scenario and example_dialogue hold empty strings in them.
The lesson was a number I nearly believed. Looking for fields emptied by tooling, a literal 12-word probe said 521 fields had text with no copy anywhere on the card, headlined by 40,000-character system prompts. Measured properly, with macros stripped and word shingles instead of literal matching, it is 35, and nearly all date from April 2026. The 2026-09-04 rebuild is clean: 970 system prompts and 127 example blocks survive whole, so the merge fix held too. The naive probe failed because that day’s commits macroise names and hoist sections, so the same text no longer matches itself literally.
Then I built a detector for “cards whose identity was replaced” and it found 37. I read two before reporting them. Tilly_Paul_Sally is the same character with rewritten prose; Sienna_Emma is the same pair in a different scene. Low word overlap measures rewriting, not replacement. Dropped the claim.
One genuine case survived reading: Celeste.png was a college friend meeting {{user}} at a cafe until 2026-04-05, and is now a 32-year-old CEO. Its scenario still held the cafe text until a pass removed it on 2026-09-05, which was a correct cleanup of five-month-old text belonging to the previous character, not a loss.
Real and small: 4 cards duplicate their entire scenario into their description, 10,566 characters sent twice. The other 796 non-empty scenarios are their own text.
Fixed both afterwards, as one repair pass each (a9e9596): the four scenarios cleared with the text recoverable from previous_values, the two chunks restamped. Every dual-chunk card now reads chara=V2 / ccv3=V3 with nothing disagreeing.
The threshold for the scenario rule came off the library rather than out of my head: at six-word runs the four sit between 97.3% and 100% repeated and the next card down is 87.9%, and that gap holds at every run length from four to eight. Worth measuring before writing the constant, not after.
The restamp needed no new write code. writePNGWithUpdatedMetadata has always stamped each chunk as it goes; the reconcile pass simply returned early whenever the two chunks agreed, and agreeing is not the same as being labelled right. Handing back the card already there was the whole repair.
What was striking the third time
- Two alarming numbers in one session, both mine, both artifacts. 521 and 37. The first came from matching text literally against text I knew had been rewritten; the second from treating a similarity threshold as an identity test. Each looked like a finding and each dissolved on contact with the actual content. Reading two cards cost minutes and saved a false report.
- A negative result is worth the run. Four dangling-reference checks on groups, the chunk-agreement regression, the merge-fix regression: all zero. That is what makes the small real findings legible as real.
Closing the 189
The loose end I had flagged twice and never explained: 189 of 915 attached chats whose opening rendered no card’s greeting. It was my own measurement artifact. I compared eight-word runs of the chat opening against cardSpeech’s twenty-word shingles, and an eight-word string never equals a twenty-word one, so the overlap was structurally zero for all 189. Reading four examples showed it in seconds: Alice, Alyson and Asami’s openings are plainly their card’s greeting with the macros filled in.
Measured with matching run lengths, the 915 attached chats come out as 726 rendering the greeting exactly, 111 lightly edited, 41 heavily edited, 30 written by the reader instead of the greeting, and 7 too short to judge.
The 30 are a habit, not a defect: 14 Evie chats open on scenes the reader typed, and the branches inherit them. greetingRendersAs also correctly refuses Claire’s chats, where the opening is the greeting with the reader’s continuation appended, and the shingle fallback catches those, which is the two-tier design working as intended.
Then the check that mattered, over every attached chat: 854 name their own card, 61 decline to answer, 0 name another card. The refiling holds.
The duplicates the identity rule cannot see
Turned to the card images, which had never been audited. Two things came out.
The library is 2,099 MB across 1,291 cards, mean 1.6 MB each, and 40 cards taller than 3:1 hold 244 MB of it: 12% of the bytes in 3% of the cards. Irene.png is 1200x12603 and 18.8 MB. Cropping the top showed a proper portrait, and a slice from halfway down showed the same portrait again at larger scale with more panels below, so these are stacked image strips used as a single avatar rather than page captures.
Then the real finding. Six pairs of cards share a byte-identical image, and five of the six are plainly the same bot under two filenames. Widening the search to cards that open on the same greeting or the same description found 31 pairs at 95%+ similarity, and dedupe links none of them. The mechanism is visible in the data: most pairs read ids=ny or ids=yn, meaning one side carries a JanitorAI id and the other does not, so the id: signal cannot fire; and identitySignals requires the description to match exactly, which 99% does not.
The cleanest case is a pair whose filenames differ in two characters: She's Changing For Them — Corinne.png and She’s Changing For Them - Corinne.png, a straight apostrophe against a curly one and an em-dash against a hyphen. One of the two carries the empty description I had already noted separately.
Not all 31 should merge. Nene and Nene Pt2 are a deliberate sequel pair, and Asami and Misa share 99% of a description while being two different group members. The 12 pairs matching on both description and greeting are the safe subset.
I had to correct myself on the (n) files. I reported all eight as different bots, judging on content-hash and image-hash equality plus shared identity signals, which are all exact-match tests that call anything with a byte of drift “different”. Measured by shared word runs, Diana(1)/Diana are 100% identical on both fields and WIFE NTR(1)/WIFE NTR are 96% on the greeting. Six of eight stand; two were wrong, and they were wrong because I used the very rule whose blind spot I was supposed to be testing.
Teaching dedupe the greeting
Built the near-match signal and it works: --shared-greeting takes the library from 0 groups to 24, 25 files to drop, where the existing signals find nothing at all (81bc484, off by default).
Three choices the data forced rather than taste:
- The description plays no part.
AsamiandMisaare members of two different groups whose descriptions are 97% the same text, because their creator reuses a 4,000-character instruction preamble across bots. On greetings they share nothing. Every pair a description rule would wrongly take is of that kind. - Symmetric, not containment. Containment reads any greeting as a copy of a longer one beginning with it, which is how a sequel reads against its predecessor.
- Threshold 0.8, read off the library. Copies of one bot score 0.83 to 1.00, everything else 0.00. The bar sits in a gap with nothing in it.
Candidates come from a run at the start, middle and end of the greeting. Start alone fails when one copy writes {{char}} said and the other spells the name out, because a macro shifts every run after it. A test caught that.
Two merges I flagged as risky both checked out: Your GF is Fucking Him / ...Her are two captures of one Yui Tanaka scene, and Paul_Whitford.png is a misnamed file whose card is named Serena, so the merge fixes the filename too.
Nene and Nene Pt2 are one bot, and “Pt2” is a misnomer. Comparing the descriptions block by block, with the literal name read as the macro it stands for, all 14 of Pt2’s blocks are in Nene.png, which carries two more: the jailbreak preamble and a **Personality:** section. Same fiancée, same university backstory, same encounter with Kevin half a year ago. Pt2 is a thinner second capture that spells the name out instead of macroising it, not a continuation. That settles the last blocker on applying the merge, and it means the three chats I refiled into Nene Pt2/ this morning belong in one folder with the rest either way.
The first pass at that comparison normalised every macro to ~, so “Nene” against {{char}} made all fourteen blocks look unique and only four matched. Same failure mode as the rest of the session, caught in one read this time.
What the 9.7 GB actually is
Audited disk, never looked at before. The data directory is 9.7 GB: 2.1 GB of live cards, 6.5 GB across 38 backup directories holding 3,117 card copies, 733 MB in SillyTavern’s own backups/ (9,784 chat snapshots back to 2025-05-25), and the rest small.
6,350 of the 6,510 MB is a PNG whose image is byte-identical to the live card’s. The backup mechanism copies the whole multi-megabyte picture to preserve a metadata change of a few kilobytes. Extracting just the card chunks would hold the same information in 1.34 GB, and most of that is the embedded-avatar data URL the repair passes exist to strip.
The number that matters for deleting any of it: only 56 card copies, across 11 directories, are of a card no longer in the library. Those are the merge losers, concentrated in characters-merge-backup-2026-09-07T0147 (28 of its 28) and ...0251 (8 of 9). Everything else is a prior version of a card still on disk, whose image is identical and whose text deltas are largely in its revision_history. The catch: the two largest directories, 1,864 MB and 1,546 MB, hold 2 gone cards each, so neither can be dropped wholesale without lifting those out first.
Also confirmed from 50 settings snapshots spanning 2026-08-23 to 2026-09-08: charLore is absent from every one, so per-character lorebook bindings never existed in this install. Independent support for the group-lorebook finding earlier today.
Which of the 38 can go
Tested it exactly rather than fuzzily: is every text value in a backup still somewhere in the live library, either as a current field or recorded in some card’s revision_history?
36 directories, 6,399 MB, hold nothing the library does not still say. Two directories, 111 MB, must stay.
The two keepers are characters-merge-backup-2026-09-07T0147 and ...0251, and what they hold is specific: roughly 83,000 characters of alternate greetings from 12 cards that dedupe dropped on 2026-09-07, scoring 0% against the live library. The losers’ cards went and their alternate greetings were not folded into the winners. Those two directories are the only copy.
Two things had to be measured properly to get there. First, an exact-string test called 6,375 MB unrecoverable, almost all of it alternate_greeting; that is the macroise passes rewriting names to {{char}}, so the pre-pass text is a different string carrying identical content. Second, previous_values is typed Record<string, string>, so an array field can only be recorded by stringifying it: descriptions appear 2,128 times in the histories and alternate_greetings only 49, which is why greetings are the field the backups are actually protecting.
The prettiest result: characters-macroise-backup-2026-09-06T2306 is 821 MB and was held back by exactly one value, a 1,094-character personality on Rara.png. That string turns out to be byte-identical to a copy inside characters-merge-backup-2026-09-07T0251, which is being kept anyway, so the 821 MB frees with nothing rescued first.
Deleted the 36 after checking the list read right: 9.7 GB down to 3.4 GB, 6.3 GB back. The two merge backups stay with their 37 cards. Library unchanged after: 1,293 cards, 926 chats, 261 group chats, 27 worlds, 0 errors, SillyTavern serving. Nothing prunes these directories, so the pile restarts on the next --apply; retention in backupDirFor’s caller is the standing fix and is not written yet.
That is the third measurement artifact of the session, after 521 and 37. All three had the same shape: a number produced by comparing two things that were never comparable, believed for as long as I did not look at an instance. Reading one example is faster than refining the metric, and it is what ended all three.
Related
scripts/lib/group-lorebook.ts,scripts/bind-group-lorebooks.ts, commit5c4bec0- Backup:
group-chats-lorebook-backup-2026-09-09T2016.tar.gz - Earlier rounds: 2026-09-08 The Picture Was Inside the Picture
Later still: automatic-mouse-mover on Apple Silicon
Wanted a local build of automatic-mouse-mover on the M4, and framed it to Claude as “add a nix flake.” That framing was wrong, and the build proved it in one command: the 2019 dependency set fails on arm64 because gopsutil@v0.0.0-20190131 only defines KinfoProc for darwin/amd64, so robotgo and gohook never compile. A flake around unbuildable code is still unbuildable code. Upstream is dormant at v1.1.0 (Oct 2022).
Two commits on a local local/aarch64-nix branch. First the dependency bump: robotgo v1.0.2, systray v1.2.2, activity-tracker v1.0.1, logrus v1.9.3, with ShowAlert → Alert and MoveMouse → Move. Notably prashantgupta24/activity-tracker v1.0.1 (2024) is already on robotgo v1 and gopsutil/v3, so the whole thing stays on upstream module paths. Then the flake: buildGoModule assembling the amm.app bundle, a dev shell, nixfmt-tree as formatter. Tests pass, nix build produces an arm64 bundle.
Also compared upstream against Resousse/automatic-mouse-mover, which someone had suggested in a PR. It is the live fork, 36 commits ahead at v1.4: same dependency modernization (that is the arm64 fix), plus 1456 lines of generated Go icon data replaced by four PNGs with an icon/colour picker persisted to ~/Library/Application Support/amm/settings.json, error alerts throttled to one per 24h, a quit-idempotency fix, GitHub Actions on macos-14, and DMG packaging. It renames the module to github.com/Resousse/... and depends on its own resousse/activity-tracker fork, so it is a hard switch, not a merge.
What was striking the third time
- The user’s stated task was not the task. “Add a nix flake” was a plausible-sounding diagnosis of a build failure I had not actually read. Running the build first is what turned the request into the right two commits. Worth doing before accepting any framing that names a solution.
go mod vendorstrips C headers. robotgo and gohook keep.h/.mfiles in subdirectories that are not Go packages, so vendoring drops them and cgo fails on'event/goEvent.h' file not found.proxyVendor = trueis the fix. The trap after that: changing to proxyVendor without changingvendorHashsilently reuses the old vendored fixed-output derivation, and the error you get talks about missing.zipfiles rather than about staleness.- robotgo’s version line is not monotonic in the obvious way. It tags both
v0.110.xandv1.0.x; v1.0.2 is March 2026 and v0.110.8 is May 2025, so the local branch is on a newer robotgo than the fork despite the fork looking more advanced. - A fork worth reading is not automatically a fork worth taking. Resousse’s arm64 story is the dependency bump, which is portable. The rest is features and a renamed module. Reading it was how I learned the upgrade path was safe; adopting it was never the point.
Postscript: TCC pins a cdhash, and nix store paths churn
Installing the arm64 amm needed a remove-and-re-add in Accessibility before it took. Reading TCC.db explained it. The row is com.pg.amm, client_type=0, keyed by bundle ID — but TCC also stores a csreq blob next to the key, and for an ad-hoc signature that requirement pins the binary’s cdhash. The 2023 x86_64 binary’s hash was baked into a row that outlived it. Swap the binary underneath and the row still reads as toggled on while the app is not actually trusted, and no prompt appears because a decision is already on file. I had predicted the re-grant but described it as “you’ll get a dialog.” The real failure mode is silence. tccutil reset Accessibility com.pg.amm before installing would have been the clean move.
Same session, same root cause, different key: ten kTCCServiceAccessibility rows for JankyBorders, one per nix store path, eight of them pointing at paths already garbage-collected. borders is a bare Mach-O with no bundle, so TCC has no bundle ID to key on and falls back to client_type=1, the absolute path. Every rebuild mints a fresh identity and nothing reaps the old rows.
The joke is that the live row is auth_value=0, denied, and it has never mattered. nm shows the binary links AXIsProcessTrusted and AXUIElement*, but its own error string scopes that to ax_focus=on, and the launchd agent runs borders with no arguments. It has been running unauthorized the whole time because it never asks for anything that needs the grant.
What was striking in the postscript
-
A permission row is not a permission. The System Settings toggle renders TCC’s key, not its requirement. An entry can look granted and be inert. Anything that reads a checkbox and infers trust is reading the wrong field.
-
Ad-hoc signing and content-addressed stores are a bad pair for TCC. Both make identity a function of content, so every rebuild is a new principal. The fix for amm is not to re-grant more carefully but to sign with a stable self-signed certificate, which gives a Designated Requirement anchored to the cert rather than a hash. For
bordersthere is no fix and none is needed.Checked this against the web afterwards and it holds, with one correction to something I told Adam too confidently. I had said wrapping a bare binary in a
.appwould be worse than leaving it pathwise, because bundle-ID keying inherits the cdhash problem. That is only true when the bundle is ad-hoc signed. home-manager #8179 is the same failure for espanso, and the proposed fix there is exactly to run from inside the package bundle, because that bundle is properly signed. The honest rule is that bundling and stable signing fix this together and neither fixes it alone. Also worth noting for later: self-signed is not the same as a self-signed Team ID, which macOS genuinely does ignore for TCC. What persists iscsreqmatching oncertificate leaf, and it needs Code Signing set to Always Trust in Keychain Access. -
Deciding not to clean is a decision worth writing down. Ten dead rows are cosmetic;
sudo tccutil reset Accessibilitywould take aerospace, BetterTouchTool, ghostty and Claude Desktop with them. Leaving clutter beats re-granting the whole machine.
Top 3 tomorrow
- Get an Apple TV 4K (Ethernet) ordered for Mum.
- Ask her to email the property manager about the doorbell camera and the lock key.
- Have her measure the door and check for an outlet by the entrance.