Skip to content

Test Centre: attach Oura ring diagnostics to the report bundle, and guard the review sheet against large streams - #570

Merged
ryanbr merged 4 commits into
ryanbr:mainfrom
pipiche38:feature/oura-report-diagnostics
Jul 19, 2026
Merged

ryanbr merged 4 commits into
ryanbr:mainfrom
pipiche38:feature/oura-report-diagnostics

Conversation

@pipiche38

Copy link
Copy Markdown

Summary

Two related, deliberately-bundled changes to the Test Centre report bundle. They
ship together because the second exists to make the first safe — separating them
would merge a feature that can hang the app, then fix it. Each is its own commit.

  1. Attach Oura ring diagnostics to the report bundle — when the ring's
    Tier-B JSONL sidecars (raw notifications / IBI-HR / activity MET) are present
    in <App Support>/OpenWhoop/Diagnostics, they're added to the bundle
    (mirroring raw-capture.jsonl), redacted by the existing whole-bundle scrub
    (the ring UUID → <device>), and trimmed to the 20 MB cap alongside
    raw-capture. Attached when present — no separate brand gate: a run with no
    ring produces no files, so nothing is attached. Entry names are normalized to
    drop the ring id.

  2. Don't inline large raw streams in the review sheet (fixes a hang) — the
    review-before-share sheet rendered every text entry as a single SwiftUI
    Text. A ~12 MB oura-raw.jsonl made SwiftUI/CoreText try to lay out
    megabytes as one Text, allocating >1 GB of glyph storage and pinning the
    main thread indefinitely, so generating a report appeared to hang.
    ReportReviewGate.previewText now excludes the large raw streams by name
    (screenshot + raw-capture + the Oura sidecars) plus a 1 MiB per-entry size
    guard, naming them in the "attached" note instead. report.txt / meta.json
    / last-crash.txt still show in full, and the excluded streams remain in the
    .zip — the review still shows exactly what's shared.

How the hang was found

Main-thread sample: 100% of samples in one synchronous layout pass at a 1.1 GB
footprint —
SheetContentRoot.sizeThatFits → StyledTextLayoutEngine.sizeThatFits → CTLineCreateWithAttributedString → TGlyphEncoder::EncodeChars.
Read, redaction, and zip were all ruled out; this is purely UI text layout.

Scope

  • Report-bundle half only. The producers that write the Oura sidecars
    (OuraRawDump / OuraIbiHrDump / OuraActivityDump) are not on main, so the
    attach logic is dormant until they land — it safely attaches "when present"
    and no-ops otherwise. The review-sheet guard is self-contained and correct
    regardless.
  • Cross-platform: Apple-only. This is the experimental Oura path on the
    Apple Test Centre; Android's report bundle does not attach these sidecars and
    has no SwiftUI review sheet, so there is no Kotlin twin to change here.

Verification

App-target Swift (CI doesn't cover it): built Strand (macOS) on origin/main;
TestBundleAssemblerTests + ReportReviewGateTests 12/12 pass, including a
new test proving a 2 MB stream never reaches the preview. Hang fix confirmed
on-device — the report sheet now opens instantly.

pipiche38 and others added 2 commits July 18, 2026 16:22
The Test Centre report bundle gathered report.txt + raw-capture.jsonl (the
WHOOP frame capture) + meta.json, but never the Oura ring's Tier-B JSONL
sidecars (oura-raw / oura-ibihr / oura-activity), so a maintainer could only
get the redacted log lines, not the raw corpora needed to reframe/validate.

Attach them WHEN PRESENT (mirroring raw-capture.jsonl), not behind a test
domain: they cut across Sleep / HRV / Connection / Sources, and file presence
is the honest gate (no ring used -> no files -> nothing attached, so an
Experimental ring needs no separate brand check). The ring UUID inside each
line is masked to <device> by the existing redactEntries pass; entry names are
normalized (id dropped) since redaction never touches names.

- ouraDiagnosticEntries()/normalizedOuraEntryName(): enumerate
  <App Support>/OpenWhoop/Diagnostics for oura-{raw,ibihr,activity}-*.jsonl,
  map to id-free names, keep the largest file per name (read-only; never
  creates the dir so a bundle build has no side effects).
- capEntries(): generalize the raw-capture-only tail-trim to a trimmableNames
  set and split the budget across trimmable files in proportion to size. With a
  single trimmable file this is byte-identical to the prior behaviour (its share
  is the whole remainder), so the existing cap test still passes.
- whoop.sqlite is deliberately NOT attached (large, binary, unredactable;
  meta.json already reports db_bytes + row counts).

App-target Swift (CI doesn't cover it): built Strand (macOS) + NOOPiOS, and
TestBundleAssemblerTests green 8/8 (5 existing + 3 new).

Verified on test/oura-persist-activity-combined; to be retrofitted onto the
per-feature diagnostics branches (oura-sleep-stage-timeline-persist, the
activity/ibihr branches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…t (fixes hang)

The review-before-share sheet renders every text bundle entry as a single
SwiftUI Text. When a large research stream is attached — the ~12 MB Oura
oura-raw.jsonl sidecar, or the WHOOP raw-capture — SwiftUI/CoreText tries to lay
out megabytes of text as one Text, allocating >1 GB of glyph storage and pinning
the main thread indefinitely, so generating a report bundle appears to hang.

Diagnosed from a main-thread sample: 100% of samples in
SheetContentRoot.sizeThatFits -> StyledTextLayoutEngine.sizeThatFits ->
CTLineCreateWithAttributedString -> TGlyphEncoder::EncodeChars, at a 1.1 GB
footprint.

ReportReviewGate.previewText now excludes the large raw streams by name
(screenshot.png + raw-capture.jsonl + the oura-{raw,ibihr,activity}.jsonl
sidecars) AND anything over a 1 MiB per-entry guard, so any oversized stream
(a future one, or a pathologically large report.txt) is named in the "attached"
note rather than laid out. report.txt / meta.json / last-crash.txt still show in
full, so the review still shows exactly the report body the user shares; the
excluded streams stay in the .zip and are named so the review stays honest.

Self-contained (no dependency on the Test Centre assembler), so it applies to
main and every feature branch that attaches diagnostics. App-target Swift (CI
doesn't cover it): built Strand (macOS), ReportReviewGateTests 4/4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
@ryanbr

ryanbr commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Reviewed both commits. This is careful work — the two-commit split is right (the guard exists to make the attach safe), the scope is honestly stated, and the tests are real rather than decorative. The proportional cap-split in particular is a clean generalization: reserve non-trimmable whole, share the remainder by size, floor-round so the sum can't breach the cap, keep each tail. testCapSharesBudgetAcrossOuraAndRawCaptureKeepingTails checks all of that (sum ≤ cap, both trimmed, newest byte kept, bigger stream bigger share).

Caveat on my verification: this is app-target Swift (Strand/StrandTests), which has no Linux/CI path, so I could not build or run it — I reviewed statically and trust your macOS 12/12. One thing I could check empirically is below.

The one substantive thing: the redaction guarantee is conditional and untested

The commit message states "the ring UUID inside each line is masked to <device> by the existing redactEntries pass." That holds only if the (off-main) producer writes the id as a canonical dashed uuidString. I ran the actual redactPii device-UUID regex against the ring id your own test uses:

MASKED   canonical dashed  5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148   -> <device>
MASKED   lowercased dashed                                        -> <device>
LEAKS    dashless          5C4C0BF82DF61B3A18D03DF0B3590148        -> unchanged
LEAKS    truncated (first 8 hex)                                   -> unchanged

CBPeripheral.identifier.uuidString is dashed-uppercase, so the happy path is covered. But the regex requires the 8-4-4-4-12 dash shape — a dashless or truncated id in the JSONL leaks verbatim into a bundle a user is about to share. Since the producers (OuraRawDump et al.) aren't on main, nothing here pins the format, and there's no test feeding a representative Oura line through redactEntries and asserting the id is gone (the existing normalizedOuraEntryName test uses a dashed UUID, but that's the filename, which is normalized away, not redacted).

Not blocking — the attach path is dormant until the producers land, so nothing leaks today. But this PR is where the "redaction covers it" claim is made, so I'd either (a) add a redaction test now with a line shaped the way the producer will actually write it, or (b) make it a landing gate on the producer PRs: the ring id MUST be emitted as uuidString, pinned by a test. Privacy-path guarantees that rest on an un-landed sibling's formatting are exactly the kind that rot silently.

Minor (neither blocking)

  • The size guard is per-entry, not aggregate. maxInlineBytes caps each inlined file at 1 MiB, but the preview joins all of them — report.txt + meta.json + last-crash.txt. In practice those are small so it's fine; just noting the guard bounds N×1 MiB, not the total. The name-based exclusion carries the real weight; the size guard is the belt to that suspenders, which is the right design.
  • meta.json doesn't reflect the sidecars. storage.rawCaptureBytes is still raw-capture-specific and truncated is a single bool, so a maintainer can't tell from meta which stream(s) got trimmed once several are trimmable. Observability nit, not correctness.

One reframing

The hang isn't reproducible on main today — previewText already excludes raw-capture.jsonl by name, and the other inline files are bounded, so there's no large stream to choke on until oura-raw.jsonl is attached. Your description says exactly this, so no correction needed — but it means the durable value of commit 2 is the size guard, which closes the whole class: any future stream added to the bundle without being added to the exclude list can no longer hang the sheet. That's worth more than the specific Oura case and is a good reason to want commit 2 on main regardless of when the producers land.

Solid PR. The redaction contract is the only thing I'd want addressed before this class of file starts carrying real ring ids.

… canonical uuidString)

Addresses the PR review (ryanbr): redactEntries masks a ring id to <device> only
because the producer writes it as a canonical dashed uuidString — the shape the
dash-anchored UUID rule in LiveState.redactPii matches. A dashless or truncated id
would leak verbatim into a bundle the user shares, silently.

Broadening the regex to dashless hex is NOT the fix: the raw sidecar's `hex` field
is a long raw-byte run that a greedier rule would shred. So the contract is pinned
with a test instead of a wider scrub.

testRedactionMasksOuraRingIdButKeepsRawHexIntact feeds a representative line in the
exact OuraRawDumpLine.encode shape (schema/deviceId/utc/iso/hex) through
redactEntries and asserts the id is masked to <device> AND the raw hex bytes survive
byte-for-byte. A future producer PR that emits a non-canonical id fails here rather
than shipping the id.

Verified: xcodebuild StrandTests — TestBundleAssemblerTests + ReportReviewGateTests
13/13 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DN6cU2ERWZUS9tWYCjPvi
@pipiche38

pipiche38 commented Jul 19, 2026 •

Copy link
Copy Markdown
Author

@ryanbr Thanks for the careful review — the redaction point is a good catch. Addressed in 9a16e7d.

Redaction contract (the blocking concern). You're right that the mask holds only for a canonical dashed uuidString — I confirmed the dash-anchored rule in LiveState.redactPii masks canonical (and lowercased) ids but leaks a dashless or truncated one. Today's producer writes the id in canonical form, so it's masked, but nothing pinned that.

I went with your first option — a redaction test rather than a wider regex — because broadening the rule to dashless hex would actively corrupt these files: the raw sidecar's hex field is a long raw-byte run, and a greedier hex rule would shred exactly the capture the diagnostic exists to preserve. So the safer fix is to hold the producer to canonical uuidString and lock it with a test.

testRedactionMasksOuraRingIdButKeepsRawHexIntact feeds a representative line in the exact OuraRawDumpLine.encode shape (schema/deviceId/utc/iso/hex) through redactEntries and asserts both halves: the id is masked to , and the raw hex bytes survive byte-for-byte. A future producer PR that emits a non-canonical id fails here instead of silently shipping the id in a bundle a user shares — the landing gate, made executable.

Verified: xcodebuild StrandTests → TestBundleAssemblerTests + ReportReviewGateTests, 13/13 pass.

On the two minor notes — both accurate, leaving as-is unless you'd prefer otherwise:

  • Per-entry vs aggregate guard: the 1 MiB guard is per-entry, but the inlineable set is just the small text files (report.txt,
    meta.json, last-crash.txt); the large streams are excluded by name regardless, so the preview's aggregate stays bounded well below the layout-choke point. Happy to switch it to a running total if you'd rather have the belt-and-braces.
  • Which streams were trimmed: meta.truncated is a single flag today, not per-stream. Easy follow-up to record the trimmed names/sizes in meta.json if that'd help triage — say the word and I'll add it.

@ryanbr
ryanbr merged commit b3ec5ca into ryanbr:main Jul 19, 2026
1 check passed
@pipiche38
pipiche38 deleted the feature/oura-report-diagnostics branch July 19, 2026 19:52
ryanbr added a commit that referenced this pull request Jul 19, 2026
…arity) (#624)

Port the two defense-in-depth pieces of the iOS #570 hang fix that Android's
ReportReviewGate was missing:

1. A ~1 MiB per-entry size guard. Android excluded the large streams by NAME
   only (raw-capture + the screenshot), so a pathologically large report.txt —
   or any future attached stream — would still be inlined into one Compose Text,
   the same class of layout choke #570 hardened iOS against. Now ANY entry over
   MAX_INLINE_BYTES is excluded regardless of name.
2. Name EVERY excluded stream in the "attached (not shown above)" note. Android
   previously listed only binaries, so raw-capture was excluded from the inline
   preview but never named — the review was slightly less honest than iOS. Now
   raw-capture + any oversized entry are named, so the user sees the whole bundle.

Mirrors Strand/System/ReportReviewGate.swift (notShownInline + maxInlineBytes).
Android-only; no iOS change. Three tests: raw-capture excluded-but-named, an
oversized entry caught by the guard and named, and normal small files still
shown in full with no note. compileFullDebugKotlin + ReportReviewGateTest (6/6)
+ i18n audit pass locally.
ryanbr added a commit that referenced this pull request Jul 19, 2026
… follow-up) (#621)

Follow-up to #570. That PR closed the ring-id leak with a *contract* — the
whole-bundle UUID scrub (LiveState.redactPii) masks the id only when the producer
writes it as a canonical dashed uuidString; a dashless or truncated id would leak
verbatim, and a hardcoded test could only document that, not enforce it. #570
correctly rejected broadening the UUID regex to dashless hex, because a greedier
rule would shred the raw `hex` capture field.

Close the leak by construction instead: in redactEntries, for the Oura sidecars
only, also mask the `deviceId` JSON VALUE by field name
("deviceId"\s*:\s*")[^"]*(") -> <device>. This is:
- format-independent — any id shape (dashless, truncated) is masked, so the
  producer no longer has to get the id format exactly right;
- safe for the capture — key-anchored to "deviceId", so it never touches `hex`
  (the whole reason #570 didn't broaden the scrub);
- scoped to the sidecars, so a non-PII logical deviceId ("my-whoop") elsewhere
  stays readable.

Runs after redactPii (no-op on an already-canonical id it masked; catches the
non-canonical ones it can't). Two tests: a dashless id is masked while `hex`
survives byte-for-byte (the direction #570's contract test couldn't enforce),
and a non-sidecar deviceId is left readable.

Apple-only (Android has no SwiftUI review sheet / doesn't attach these sidecars).
App-target Swift, so not CI-covered — NOT compile-verified here (no local Xcode;
GitHub Actions incident active). Logic reviewed; needs an xcodebuild StrandTests
pass once Actions/Xcode is available.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…uard the review sheet against large streams (ryanbr#570)

* Test Centre: attach Oura ring diagnostics JSONL to the report bundle

The Test Centre report bundle gathered report.txt + raw-capture.jsonl (the
WHOOP frame capture) + meta.json, but never the Oura ring's Tier-B JSONL
sidecars (oura-raw / oura-ibihr / oura-activity), so a maintainer could only
get the redacted log lines, not the raw corpora needed to reframe/validate.

Attach them WHEN PRESENT (mirroring raw-capture.jsonl), not behind a test
domain: they cut across Sleep / HRV / Connection / Sources, and file presence
is the honest gate (no ring used -> no files -> nothing attached, so an
Experimental ring needs no separate brand check). The ring UUID inside each
line is masked to <device> by the existing redactEntries pass; entry names are
normalized (id dropped) since redaction never touches names.

- ouraDiagnosticEntries()/normalizedOuraEntryName(): enumerate
  <App Support>/OpenWhoop/Diagnostics for oura-{raw,ibihr,activity}-*.jsonl,
  map to id-free names, keep the largest file per name (read-only; never
  creates the dir so a bundle build has no side effects).
- capEntries(): generalize the raw-capture-only tail-trim to a trimmableNames
  set and split the budget across trimmable files in proportion to size. With a
  single trimmable file this is byte-identical to the prior behaviour (its share
  is the whole remainder), so the existing cap test still passes.
- whoop.sqlite is deliberately NOT attached (large, binary, unredactable;
  meta.json already reports db_bytes + row counts).

App-target Swift (CI doesn't cover it): built Strand (macOS) + NOOPiOS, and
TestBundleAssemblerTests green 8/8 (5 existing + 3 new).

Verified on test/oura-persist-activity-combined; to be retrofitted onto the
per-feature diagnostics branches (oura-sleep-stage-timeline-persist, the
activity/ibihr branches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6

* Test Centre: don't inline large raw streams in the report review sheet (fixes hang)

The review-before-share sheet renders every text bundle entry as a single
SwiftUI Text. When a large research stream is attached — the ~12 MB Oura
oura-raw.jsonl sidecar, or the WHOOP raw-capture — SwiftUI/CoreText tries to lay
out megabytes of text as one Text, allocating >1 GB of glyph storage and pinning
the main thread indefinitely, so generating a report bundle appears to hang.

Diagnosed from a main-thread sample: 100% of samples in
SheetContentRoot.sizeThatFits -> StyledTextLayoutEngine.sizeThatFits ->
CTLineCreateWithAttributedString -> TGlyphEncoder::EncodeChars, at a 1.1 GB
footprint.

ReportReviewGate.previewText now excludes the large raw streams by name
(screenshot.png + raw-capture.jsonl + the oura-{raw,ibihr,activity}.jsonl
sidecars) AND anything over a 1 MiB per-entry guard, so any oversized stream
(a future one, or a pathologically large report.txt) is named in the "attached"
note rather than laid out. report.txt / meta.json / last-crash.txt still show in
full, so the review still shows exactly the report body the user shares; the
excluded streams stay in the .zip and are named so the review stays honest.

Self-contained (no dependency on the Test Centre assembler), so it applies to
main and every feature branch that attaches diagnostics. App-target Swift (CI
doesn't cover it): built Strand (macOS), ReportReviewGateTests 4/4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6

* Test Centre: pin the Oura sidecar redaction contract (ring id must be canonical uuidString)

Addresses the PR review (ryanbr): redactEntries masks a ring id to <device> only
because the producer writes it as a canonical dashed uuidString — the shape the
dash-anchored UUID rule in LiveState.redactPii matches. A dashless or truncated id
would leak verbatim into a bundle the user shares, silently.

Broadening the regex to dashless hex is NOT the fix: the raw sidecar's `hex` field
is a long raw-byte run that a greedier rule would shred. So the contract is pinned
with a test instead of a wider scrub.

testRedactionMasksOuraRingIdButKeepsRawHexIntact feeds a representative line in the
exact OuraRawDumpLine.encode shape (schema/deviceId/utc/iso/hex) through
redactEntries and asserts the id is masked to <device> AND the raw hex bytes survive
byte-for-byte. A future producer PR that emits a non-canonical id fails here rather
than shipping the id.

Verified: xcodebuild StrandTests — TestBundleAssemblerTests + ReportReviewGateTests
13/13 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DN6cU2ERWZUS9tWYCjPvi

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…#570 parity) (ryanbr#624)

Port the two defense-in-depth pieces of the iOS ryanbr#570 hang fix that Android's
ReportReviewGate was missing:

1. A ~1 MiB per-entry size guard. Android excluded the large streams by NAME
   only (raw-capture + the screenshot), so a pathologically large report.txt —
   or any future attached stream — would still be inlined into one Compose Text,
   the same class of layout choke ryanbr#570 hardened iOS against. Now ANY entry over
   MAX_INLINE_BYTES is excluded regardless of name.
2. Name EVERY excluded stream in the "attached (not shown above)" note. Android
   previously listed only binaries, so raw-capture was excluded from the inline
   preview but never named — the review was slightly less honest than iOS. Now
   raw-capture + any oversized entry are named, so the user sees the whole bundle.

Mirrors Strand/System/ReportReviewGate.swift (notShownInline + maxInlineBytes).
Android-only; no iOS change. Three tests: raw-capture excluded-but-named, an
oversized entry caught by the guard and named, and normal small files still
shown in full with no note. compileFullDebugKotlin + ReportReviewGateTest (6/6)
+ i18n audit pass locally.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…anbr#572 follow-up) (ryanbr#621)

Follow-up to ryanbr#570. That PR closed the ring-id leak with a *contract* — the
whole-bundle UUID scrub (LiveState.redactPii) masks the id only when the producer
writes it as a canonical dashed uuidString; a dashless or truncated id would leak
verbatim, and a hardcoded test could only document that, not enforce it. ryanbr#570
correctly rejected broadening the UUID regex to dashless hex, because a greedier
rule would shred the raw `hex` capture field.

Close the leak by construction instead: in redactEntries, for the Oura sidecars
only, also mask the `deviceId` JSON VALUE by field name
("deviceId"\s*:\s*")[^"]*(") -> <device>. This is:
- format-independent — any id shape (dashless, truncated) is masked, so the
  producer no longer has to get the id format exactly right;
- safe for the capture — key-anchored to "deviceId", so it never touches `hex`
  (the whole reason ryanbr#570 didn't broaden the scrub);
- scoped to the sidecars, so a non-PII logical deviceId ("my-whoop") elsewhere
  stays readable.

Runs after redactPii (no-op on an already-canonical id it masked; catches the
non-canonical ones it can't). Two tests: a dashless id is masked while `hex`
survives byte-for-byte (the direction ryanbr#570's contract test couldn't enforce),
and a non-sidecar deviceId is left readable.

Apple-only (Android has no SwiftUI review sheet / doesn't attach these sidecars).
App-target Swift, so not CI-covered — NOT compile-verified here (no local Xcode;
GitHub Actions incident active). Logic reviewed; needs an xcodebuild StrandTests
pass once Actions/Xcode is available.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…uard the review sheet against large streams (ryanbr#570)

* Test Centre: attach Oura ring diagnostics JSONL to the report bundle

The Test Centre report bundle gathered report.txt + raw-capture.jsonl (the
WHOOP frame capture) + meta.json, but never the Oura ring's Tier-B JSONL
sidecars (oura-raw / oura-ibihr / oura-activity), so a maintainer could only
get the redacted log lines, not the raw corpora needed to reframe/validate.

Attach them WHEN PRESENT (mirroring raw-capture.jsonl), not behind a test
domain: they cut across Sleep / HRV / Connection / Sources, and file presence
is the honest gate (no ring used -> no files -> nothing attached, so an
Experimental ring needs no separate brand check). The ring UUID inside each
line is masked to <device> by the existing redactEntries pass; entry names are
normalized (id dropped) since redaction never touches names.

- ouraDiagnosticEntries()/normalizedOuraEntryName(): enumerate
  <App Support>/OpenWhoop/Diagnostics for oura-{raw,ibihr,activity}-*.jsonl,
  map to id-free names, keep the largest file per name (read-only; never
  creates the dir so a bundle build has no side effects).
- capEntries(): generalize the raw-capture-only tail-trim to a trimmableNames
  set and split the budget across trimmable files in proportion to size. With a
  single trimmable file this is byte-identical to the prior behaviour (its share
  is the whole remainder), so the existing cap test still passes.
- whoop.sqlite is deliberately NOT attached (large, binary, unredactable;
  meta.json already reports db_bytes + row counts).

App-target Swift (CI doesn't cover it): built Strand (macOS) + NOOPiOS, and
TestBundleAssemblerTests green 8/8 (5 existing + 3 new).

Verified on test/oura-persist-activity-combined; to be retrofitted onto the
per-feature diagnostics branches (oura-sleep-stage-timeline-persist, the
activity/ibihr branches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6

* Test Centre: don't inline large raw streams in the report review sheet (fixes hang)

The review-before-share sheet renders every text bundle entry as a single
SwiftUI Text. When a large research stream is attached — the ~12 MB Oura
oura-raw.jsonl sidecar, or the WHOOP raw-capture — SwiftUI/CoreText tries to lay
out megabytes of text as one Text, allocating >1 GB of glyph storage and pinning
the main thread indefinitely, so generating a report bundle appears to hang.

Diagnosed from a main-thread sample: 100% of samples in
SheetContentRoot.sizeThatFits -> StyledTextLayoutEngine.sizeThatFits ->
CTLineCreateWithAttributedString -> TGlyphEncoder::EncodeChars, at a 1.1 GB
footprint.

ReportReviewGate.previewText now excludes the large raw streams by name
(screenshot.png + raw-capture.jsonl + the oura-{raw,ibihr,activity}.jsonl
sidecars) AND anything over a 1 MiB per-entry guard, so any oversized stream
(a future one, or a pathologically large report.txt) is named in the "attached"
note rather than laid out. report.txt / meta.json / last-crash.txt still show in
full, so the review still shows exactly the report body the user shares; the
excluded streams stay in the .zip and are named so the review stays honest.

Self-contained (no dependency on the Test Centre assembler), so it applies to
main and every feature branch that attaches diagnostics. App-target Swift (CI
doesn't cover it): built Strand (macOS), ReportReviewGateTests 4/4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6

* Test Centre: pin the Oura sidecar redaction contract (ring id must be canonical uuidString)

Addresses the PR review (ryanbr): redactEntries masks a ring id to <device> only
because the producer writes it as a canonical dashed uuidString — the shape the
dash-anchored UUID rule in LiveState.redactPii matches. A dashless or truncated id
would leak verbatim into a bundle the user shares, silently.

Broadening the regex to dashless hex is NOT the fix: the raw sidecar's `hex` field
is a long raw-byte run that a greedier rule would shred. So the contract is pinned
with a test instead of a wider scrub.

testRedactionMasksOuraRingIdButKeepsRawHexIntact feeds a representative line in the
exact OuraRawDumpLine.encode shape (schema/deviceId/utc/iso/hex) through
redactEntries and asserts the id is masked to <device> AND the raw hex bytes survive
byte-for-byte. A future producer PR that emits a non-canonical id fails here rather
than shipping the id.

Verified: xcodebuild StrandTests — TestBundleAssemblerTests + ReportReviewGateTests
13/13 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DN6cU2ERWZUS9tWYCjPvi

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…#570 parity) (ryanbr#624)

Port the two defense-in-depth pieces of the iOS ryanbr#570 hang fix that Android's
ReportReviewGate was missing:

1. A ~1 MiB per-entry size guard. Android excluded the large streams by NAME
   only (raw-capture + the screenshot), so a pathologically large report.txt —
   or any future attached stream — would still be inlined into one Compose Text,
   the same class of layout choke ryanbr#570 hardened iOS against. Now ANY entry over
   MAX_INLINE_BYTES is excluded regardless of name.
2. Name EVERY excluded stream in the "attached (not shown above)" note. Android
   previously listed only binaries, so raw-capture was excluded from the inline
   preview but never named — the review was slightly less honest than iOS. Now
   raw-capture + any oversized entry are named, so the user sees the whole bundle.

Mirrors Strand/System/ReportReviewGate.swift (notShownInline + maxInlineBytes).
Android-only; no iOS change. Three tests: raw-capture excluded-but-named, an
oversized entry caught by the guard and named, and normal small files still
shown in full with no note. compileFullDebugKotlin + ReportReviewGateTest (6/6)
+ i18n audit pass locally.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…anbr#572 follow-up) (ryanbr#621)

Follow-up to ryanbr#570. That PR closed the ring-id leak with a *contract* — the
whole-bundle UUID scrub (LiveState.redactPii) masks the id only when the producer
writes it as a canonical dashed uuidString; a dashless or truncated id would leak
verbatim, and a hardcoded test could only document that, not enforce it. ryanbr#570
correctly rejected broadening the UUID regex to dashless hex, because a greedier
rule would shred the raw `hex` capture field.

Close the leak by construction instead: in redactEntries, for the Oura sidecars
only, also mask the `deviceId` JSON VALUE by field name
("deviceId"\s*:\s*")[^"]*(") -> <device>. This is:
- format-independent — any id shape (dashless, truncated) is masked, so the
  producer no longer has to get the id format exactly right;
- safe for the capture — key-anchored to "deviceId", so it never touches `hex`
  (the whole reason ryanbr#570 didn't broaden the scrub);
- scoped to the sidecars, so a non-PII logical deviceId ("my-whoop") elsewhere
  stays readable.

Runs after redactPii (no-op on an already-canonical id it masked; catches the
non-canonical ones it can't). Two tests: a dashless id is masked while `hex`
survives byte-for-byte (the direction ryanbr#570's contract test couldn't enforce),
and a non-sidecar deviceId is left readable.

Apple-only (Android has no SwiftUI review sheet / doesn't attach these sidecars).
App-target Swift, so not CI-covered — NOT compile-verified here (no local Xcode;
GitHub Actions incident active). Logic reviewed; needs an xcodebuild StrandTests
pass once Actions/Xcode is available.
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