implement six-skill gstack 2 runtime

This commit is contained in:
Sinabina
2026-07-17 11:08:14 -07:00
parent ce37bd36a9
commit b6572ebbb7
455 changed files with 108945 additions and 2622 deletions
@@ -0,0 +1,157 @@
# Tailscale ACL example for the iOS QA daemon
The Mac-side daemon binds the Tailscale interface only when you pass
`--tailnet`. By default the daemon is local-USB-only. This doc walks through
the steps to expose your iPhone to remote agents safely so they can run iOS QA over the tailnet.
## Threat model recap
- **iOS app StateServer:** loopback-only always. Reachable from the Mac via
the CoreDevice IPv6 tunnel. Never directly bound to tailnet.
- **Mac daemon:** owns the tailnet interface. Binds two listeners — loopback
(full surface, never forwarded) and tailnet (locked allowlist with
capability tiers).
- **Auth:** Tailscale identity validation via the local `tailscaled` socket
(`/var/run/tailscale.sock` LocalAPI WhoIs). Allowlist file at
`~/.gstack/ios-qa-allowlist.json` is the single source of truth for who can
do what.
## Step 1: Install and run Tailscale
```bash
brew install --cask tailscale
# Login + start tailscaled, then verify:
tailscale status
```
Confirm the daemon can read the LocalAPI socket:
```bash
test -S /var/run/tailscale.sock && echo "socket present" || echo "MISSING"
```
If missing, the daemon will refuse to open the tailnet listener (fail-closed).
## Step 2: Set up the daemon's ACL
The daemon needs to know which Tailscale identities are allowed to control
which devices at which capability tier. The allowlist file is JSON:
```json
{
"version": 1,
"entries": [
{
"identity": "you@example.com",
"capabilities": ["restore"],
"expires_at": null,
"note": "Owner — full access"
},
{
"identity": "ci@example.com",
"capabilities": ["mutate"],
"expires_at": "2026-12-31T00:00:00Z",
"note": "CI runner — can write state but not full restore"
},
{
"identity": "tag:claude-readonly",
"capabilities": ["observe"],
"expires_at": null,
"note": "Agents that should only read"
}
]
}
```
Identities are canonicalized via WhoIs:
- **User OAuth:** `user@example.com` (no `acct:`, no domain rewriting).
- **Tagged nodes:** `tag:<tagname>` (lowercased).
- **Node keys:** `node:<nodekey-hex>` (rare; use tags instead).
Capability tiers are ordered: `observe` < `interact` < `mutate` < `restore`.
Granting `restore` implies all lower tiers.
## Step 3: Mint a session token for a remote agent
You can let agents self-mint (if their identity is allowlisted) or you can
mint server-side for them:
```bash
# Server-side mint (owner-only, runs locally on the Mac with the device):
gstack-ios-qa-mint --remote ci@example.com --capability mutate --ttl 1h
# Self-service mint (agent over tailnet):
curl -X POST http://<mac-tailnet-ip>:9999/auth/mint \
-H "Content-Type: application/json" \
-d '{"capability": "interact"}'
# → {"session_token": "...", "expires_at": "...", "capability": "interact"}
```
## Step 4: Tighten the Tailscale ACL (defense in depth)
The daemon's allowlist is the primary access control. Belt-and-suspenders:
restrict the tailnet ACL to limit who can even *reach* the daemon port.
```jsonc
// In your tailscale admin console:
{
"acls": [
// Allow CI runner to reach the iOS QA Mac on port 9999 only.
{
"action": "accept",
"src": ["ci@example.com"],
"dst": ["ios-qa-mac:9999"]
},
// Tagged Claude agents — observe tier only (enforced by daemon, not ACL).
{
"action": "accept",
"src": ["tag:claude-readonly"],
"dst": ["ios-qa-mac:9999"]
},
// Default deny.
{
"action": "drop",
"src": ["*"],
"dst": ["ios-qa-mac:9999"]
}
]
}
```
## Step 5: Audit trail
Every authenticated mutating request through the tailnet listener writes a
row to `~/.gstack/security/ios-qa-audit.jsonl`:
```jsonl
{"ts":"2026-05-18T14:23:00Z","identity":"ci@example.com","device_udid":"00008101-XXXX","endpoint":"/tap","session_id":"abc...","capability":"interact","request_id":"req_001","status":200}
```
Rejections (no token, expired token, capability-insufficient, identity not
allowlisted, rate limit hit) write to `~/.gstack/security/attempts.jsonl`.
## Rate limits
- `/auth/mint`: 10 mints / 60s per identity. 11th returns 429.
- Per-tailnet-request body: 1MB hard cap (413 above).
- Screenshot response: 10MB hard cap (500 above with sanitized error).
## Token lifetime
- Daemon-minted session tokens: default 1h TTL, max 24h via
`--tailnet-session-ttl`.
- Refreshable via `POST /session/heartbeat` (extends by `ttl_seconds`, capped
at the original max).
- Boot token (between iOS app launch and daemon rotation): ~5s lifetime —
daemon rotates immediately on first scrape.
## Failure modes
| Symptom | Cause | Action |
|---|---|---|
| Daemon refuses to open tailnet listener | `/var/run/tailscale.sock` missing or permission-denied | Install Tailscale; verify `tailscale status` works as the user running daemon |
| `403 identity_not_allowed` | identity missing from allowlist | Owner mint: `gstack-ios-qa-mint --remote <identity>` |
| `403 capability_insufficient` | token tier below endpoint requirement | Owner mint with higher `--capability` tier |
| `429 rate_limited` | >10 mints/min from one identity | Wait 60s; investigate why the agent is re-minting so often |
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture from current app build |
@@ -0,0 +1,308 @@
// AUTO-GENERATED from gstack/ios-qa/templates/Bridges.swift.template
//
// Real UIKit-backed implementations of the three bridges StateServer
// declares: ScreenshotBridge (PNG capture), ElementsBridge (accessibility
// tree), MutationBridge (tap/swipe/type via accessibility actions + hit
// testing). Everything #if DEBUG && canImport(UIKit) so Release builds
// don't link UIKit or carry any of this code.
//
// Wire from the consuming app:
//
// #if DEBUG && canImport(UIKit)
// import DebugBridgeUI
// DebugBridgeUIWiring.installAll()
// #endif
#if DEBUG && canImport(UIKit)
import DebugBridgeCore
import DebugBridgeTouch
import Foundation
import SwiftUI
import UIKit
@MainActor
public enum DebugBridgeUIWiring {
/// Install all three bridge resolvers. Idempotent — calling multiple
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
// MARK: - ScreenshotBridge implementation
@MainActor
enum ScreenshotBridgeImpl {
/// Capture a PNG of the active window. Uses UIGraphicsImageRenderer
/// (modern API, replaces UIGraphicsBeginImageContext). Returns nil if
/// no key window is available (e.g., app backgrounded).
static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
// because we want the CURRENT visible state, not a forced layout.
window.drawHierarchy(in: bounds, afterScreenUpdates: false)
}
return image.pngData()
}
private static func activeScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}
// MARK: - ElementsBridge implementation
@MainActor
enum ElementsBridgeImpl {
/// Walk the accessibility hierarchy + emit a flat list of elements.
/// Each entry has frame (in window coords), accessibility label,
/// identifier, traits as a bitmask, and a parent path. Skips
/// non-accessible / hidden views.
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
return elements
}
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
// Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return }
let frameInWindow = view.convert(view.bounds, to: nil)
if !windowBounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
let traits = Int(view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
// Emit if any of:
// - Marked accessible (covers UIKit-native widgets)
// - Has explicit AX label / identifier
// - Is a known interactive type (UIControl, UITextField, UIScrollView)
// - Hosts a SwiftUI view (UIHostingController's view class)
let isInteractive = view is UIControl || view is UIScrollView || view is UITextInput
let isHosting = className.contains("Hosting") || className.contains("SwiftUI")
if isAccessible || !label.isEmpty || !identifier.isEmpty || isInteractive || isHosting {
elements.append([
"path": path,
"class": className,
"label": label,
"identifier": identifier,
"value": value,
"traits": traits,
"frame": [
"x": Int(frameInWindow.origin.x),
"y": Int(frameInWindow.origin.y),
"w": Int(frameInWindow.size.width),
"h": Int(frameInWindow.size.height),
],
"is_user_interaction_enabled": view.isUserInteractionEnabled,
])
}
// Recurse into accessibility-elements first (some custom views vend
// synthetic children), then UIView subviews. SwiftUI's host views
// populate accessibilityElements lazily — many return nil before
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
_ = view.accessibilityElementCount()
if let axElements = view.accessibilityElements {
for case let element as NSObject in axElements {
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <synthetic>",
"class": "AccessibilityElement",
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
"frame": [
"x": Int(af.origin.x),
"y": Int(af.origin.y),
"w": Int(af.size.width),
"h": Int(af.size.height),
],
"is_user_interaction_enabled": true,
])
}
}
} else {
// accessibilityElements is nil — iterate by index. SwiftUI uses
// this dynamic protocol pattern; many AX elements only respond
// to accessibilityElementCount + accessibilityElement(at:).
let count = view.accessibilityElementCount()
for i in 0..<count {
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <ax\(i)>",
"class": String(describing: type(of: element)),
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
"frame": [
"x": Int(af.origin.x),
"y": Int(af.origin.y),
"w": Int(af.size.width),
"h": Int(af.size.height),
],
"is_user_interaction_enabled": true,
])
}
}
}
for sub in view.subviews {
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
}
}
private static func activeScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}
// MARK: - MutationBridge implementation
@MainActor
enum MutationBridgeImpl {
/// Route a mutation op to the right handler. Returns true on success,
/// false on failure (which the StateServer surfaces as 400 to the agent).
static func dispatch(op: String, payload: JSONDict) -> Bool {
switch op {
case "tap": return handleTap(payload)
case "type": return handleType(payload)
case "swipe": return handleSwipe(payload)
default: return false
}
}
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
/// This works for UIControl, SwiftUI Button (via iOS 18+
/// `_UIHitTestContext`), gesture recognizers, and anything else that
/// listens to the real event-dispatch path.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
return DebugBridgeTouch.sendTap(at: point, in: window)
}
/// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
guard let responder = findFirstResponder(in: window) else { return false }
if let field = responder as? UITextField {
field.text = text
field.sendActions(for: .editingChanged)
return true
}
if let view = responder as? UITextView {
view.text = text
view.delegate?.textViewDidChange?(view)
return true
}
return false
}
/// Swipe via UIScrollView programmatic scroll OR via setContentOffset on
/// the deepest UIScrollView in the hit-tested ancestor chain. Less
/// faithful than synthesized touches but covers common scroll scenarios.
private static func handleSwipe(_ payload: JSONDict) -> Bool {
guard let fx = payload["from_x"] as? NSNumber,
let fy = payload["from_y"] as? NSNumber,
let tx = payload["to_x"] as? NSNumber,
let ty = payload["to_y"] as? NSNumber else { return false }
let from = CGPoint(x: fx.doubleValue, y: fy.doubleValue)
let to = CGPoint(x: tx.doubleValue, y: ty.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
guard let hit = window.hitTest(from, with: nil) else { return false }
// Find the nearest enclosing UIScrollView.
var node: UIView? = hit
while let cur = node {
if let scroll = cur as? UIScrollView {
let dx = from.x - to.x
let dy = from.y - to.y
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
scroll.setContentOffset(off, animated: true)
return true
}
node = cur.superview
}
return false
}
// MARK: helpers
private static func walkUp(_ view: UIView) -> UIView? {
var node: UIView? = view
while let cur = node {
if cur is UIControl { return cur }
node = cur.superview
}
return view
}
private static func findFirstResponder(in view: UIView) -> UIResponder? {
if view.isFirstResponder { return view }
for sub in view.subviews {
if let found = findFirstResponder(in: sub) { return found }
}
return nil
}
private static func activeScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}
#endif // DEBUG && canImport(UIKit)
@@ -0,0 +1,49 @@
// AUTO-GENERATED from gstack/ios-qa/templates/DebugBridgeManager.swift.template
//
// Bootstraps StateServer on app launch. Lives in DebugBridgeCore (no UIKit
// dependency). The DebugOverlay install is wired separately by the consuming
// app — it lives in DebugBridgeUI which depends on DebugBridgeCore (not the
// other way around). Everything is #if DEBUG-gated; this file does not exist
// in Release builds.
#if DEBUG
import Foundation
@MainActor
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
public func start(appState: AppState) {
// 1. Register the canonical AppState struct + accessor wiring.
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
AppStateAccessor.register(appState)
// 2. Boot the StateServer.
StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See
// the example in DebugBridgeWiring.swift.template:
//
// #if canImport(UIKit)
// DebugOverlayWindow.shared.install(recording: recording)
// #endif
}
}
// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
// to the app's canonical state struct. Apps that haven't run codegen get a
// stub that registers no accessors (snapshot is empty, restore returns
// missing-key for every key).
@MainActor
public enum AppStateAccessor {
public static var register: (Any) -> Void = { _ in }
}
// Apps declare their canonical state struct; codegen reads it and emits
// AppStateAccessor.register. The app's struct must be `@Observable` and
// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
@MainActor
public protocol AppState: AnyObject {}
#endif // DEBUG
@@ -0,0 +1,34 @@
//
// DebugBridgeTouch.h — public Objective-C interface for in-process touch
// synthesis. Implementation derived from KIF (https://github.com/kif-framework/KIF),
// MIT-licensed. The minimal subset needed to deliver a real UITouch to a
// point on the key window, including SwiftUI Buttons via iOS 18+
// _UIHitTestContext. DEBUG-only — never link in Release.
#import <Foundation/Foundation.h>
#import <CoreGraphics/CoreGraphics.h>
#import <TargetConditionals.h>
#if TARGET_OS_IOS
#import <UIKit/UIKit.h>
#else
// macOS build: forward-declare UIWindow so the module compiles without UIKit.
// The host CI runs swift build on macOS to validate the cross-platform Swift
// surface; DebugBridgeTouch's implementation is a no-op there. On iOS the
// real UIWindow comes from UIKit and the implementation is active.
@class UIWindow;
#endif
NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).
/// On non-iOS platforms returns NO unconditionally.
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,301 @@
//
// DebugBridgeTouch.m — minimal port of KIF's in-process touch synthesis.
// Original code: https://github.com/kif-framework/KIF — MIT-licensed
// (Square, Inc. + KIF contributors). Adapted to a single-file, tap-only,
// iOS 18+ aware subset for the gstack/ios-qa DebugBridge.
//
// Uses these private UIKit selectors (DEBUG-only; never shipped to App Store):
// UITouch: _setLocationInWindow:resetPrevious:, _setIsFirstTouchForView:,
// setPhase:, setTimestamp:, setView:, setWindow:, setTapCount:,
// _setHidEvent:
// UIEvent: _clearTouches, _addTouch:forDelayedDelivery:, _setHIDEvent:
// UIApplication: _touchesEvent
// UIView: _hitTestWithContext: (iOS 18+ for SwiftUI hit-testing)
// NSObject: _UIHitTestContext contextWithPoint:radius: (iOS 18+)
//
// IOKit private symbols (linked dynamically via the IOKit framework on iOS):
// IOHIDEventCreateDigitizerEvent, IOHIDEventCreateDigitizerFingerEventWithQuality,
// IOHIDEventSetIntegerValue, IOHIDEventAppendEvent.
#import "DebugBridgeTouch.h"
#import <TargetConditionals.h>
#if TARGET_OS_IOS
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import <objc/message.h>
#import <mach/mach_time.h>
#pragma mark - IOHIDEvent (private symbols from IOKit)
typedef struct __IOHIDEvent * IOHIDEventRef;
#define IOHIDEventFieldBase(type) (type << 16)
#ifdef __LP64__
typedef double IOHIDFloat;
#else
typedef float IOHIDFloat;
#endif
typedef UInt32 IOOptionBits;
typedef uint32_t IOHIDDigitizerTransducerType;
typedef uint32_t IOHIDEventField;
enum {
kIOHIDDigitizerTransducerTypeStylus = 0,
kIOHIDDigitizerTransducerTypePuck,
kIOHIDDigitizerTransducerTypeFinger,
kIOHIDDigitizerTransducerTypeHand
};
enum {
kIOHIDEventTypeDigitizer = 11,
};
enum {
kIOHIDDigitizerEventRange = 0x00000001,
kIOHIDDigitizerEventTouch = 0x00000002,
kIOHIDDigitizerEventPosition = 0x00000004,
};
enum {
kIOHIDEventFieldDigitizerX = IOHIDEventFieldBase(kIOHIDEventTypeDigitizer),
kIOHIDEventFieldDigitizerY,
kIOHIDEventFieldDigitizerZ,
kIOHIDEventFieldDigitizerButtonMask,
kIOHIDEventFieldDigitizerType,
kIOHIDEventFieldDigitizerIndex,
kIOHIDEventFieldDigitizerIdentity,
kIOHIDEventFieldDigitizerEventMask,
kIOHIDEventFieldDigitizerRange,
kIOHIDEventFieldDigitizerTouch,
kIOHIDEventFieldDigitizerPressure,
kIOHIDEventFieldDigitizerAuxiliaryPressure,
kIOHIDEventFieldDigitizerTwist,
kIOHIDEventFieldDigitizerTiltX,
kIOHIDEventFieldDigitizerTiltY,
kIOHIDEventFieldDigitizerAltitude,
kIOHIDEventFieldDigitizerAzimuth,
kIOHIDEventFieldDigitizerQuality,
kIOHIDEventFieldDigitizerDensity,
kIOHIDEventFieldDigitizerIrregularity,
kIOHIDEventFieldDigitizerMajorRadius,
kIOHIDEventFieldDigitizerMinorRadius,
kIOHIDEventFieldDigitizerCollection,
kIOHIDEventFieldDigitizerCollectionChord,
kIOHIDEventFieldDigitizerChildEventMask,
kIOHIDEventFieldDigitizerIsDisplayIntegrated,
};
// IOKit is a PRIVATE framework on iOS — we can't link it via -framework. Load
// at runtime via dlopen/dlsym. This is the standard approach for KIF-style
// touch synthesis on iOS, including in DEBUG-only test harnesses.
#import <dlfcn.h>
typedef IOHIDEventRef (*IOHIDEventCreateDigitizerEventFn)(CFAllocatorRef, AbsoluteTime,
IOHIDDigitizerTransducerType, uint32_t, uint32_t, uint32_t, uint32_t,
IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, Boolean, Boolean, IOOptionBits);
typedef IOHIDEventRef (*IOHIDEventCreateDigitizerFingerEventWithQualityFn)(CFAllocatorRef,
AbsoluteTime, uint32_t, uint32_t, uint32_t, IOHIDFloat, IOHIDFloat, IOHIDFloat,
IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat,
IOHIDFloat, Boolean, Boolean, IOOptionBits);
typedef void (*IOHIDEventSetIntegerValueFn)(IOHIDEventRef, IOHIDEventField, int);
typedef void (*IOHIDEventAppendEventFn)(IOHIDEventRef, IOHIDEventRef);
static IOHIDEventCreateDigitizerEventFn _IOHIDEventCreateDigitizerEvent;
static IOHIDEventCreateDigitizerFingerEventWithQualityFn _IOHIDEventCreateDigitizerFingerEventWithQuality;
static IOHIDEventSetIntegerValueFn _IOHIDEventSetIntegerValue;
static IOHIDEventAppendEventFn _IOHIDEventAppendEvent;
static BOOL _IOKitLoaded = NO;
static BOOL DBT_LoadIOKit(void) {
if (_IOKitLoaded) return YES;
void *handle = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW);
if (!handle) {
handle = dlopen("/System/Library/PrivateFrameworks/IOKit.framework/IOKit", RTLD_NOW);
}
if (!handle) return NO;
_IOHIDEventCreateDigitizerEvent = (IOHIDEventCreateDigitizerEventFn)dlsym(handle, "IOHIDEventCreateDigitizerEvent");
_IOHIDEventCreateDigitizerFingerEventWithQuality = (IOHIDEventCreateDigitizerFingerEventWithQualityFn)dlsym(handle, "IOHIDEventCreateDigitizerFingerEventWithQuality");
_IOHIDEventSetIntegerValue = (IOHIDEventSetIntegerValueFn)dlsym(handle, "IOHIDEventSetIntegerValue");
_IOHIDEventAppendEvent = (IOHIDEventAppendEventFn)dlsym(handle, "IOHIDEventAppendEvent");
_IOKitLoaded = (_IOHIDEventCreateDigitizerEvent && _IOHIDEventCreateDigitizerFingerEventWithQuality &&
_IOHIDEventSetIntegerValue && _IOHIDEventAppendEvent);
return _IOKitLoaded;
}
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
uint64_t abTime = mach_absolute_time();
AbsoluteTime timeStamp;
timeStamp.hi = (UInt32)(abTime >> 32);
timeStamp.lo = (UInt32)(abTime);
IOHIDEventRef handEvent = _IOHIDEventCreateDigitizerEvent(kCFAllocatorDefault,
timeStamp, kIOHIDDigitizerTransducerTypeHand,
0, 0, kIOHIDDigitizerEventTouch, 0,
0, 0, 0, 0, 0,
0, true, 0);
_IOHIDEventSetIntegerValue(handEvent, kIOHIDEventFieldDigitizerIsDisplayIntegrated, 1);
uint32_t eventMask = (touch.phase == UITouchPhaseMoved)
? kIOHIDDigitizerEventPosition
: (kIOHIDDigitizerEventRange | kIOHIDDigitizerEventTouch);
uint32_t isTouching = (touch.phase == UITouchPhaseEnded) ? 0 : 1;
CGPoint loc = [touch locationInView:touch.window];
IOHIDEventRef fingerEvent = _IOHIDEventCreateDigitizerFingerEventWithQuality(kCFAllocatorDefault,
timeStamp, 1, 2, eventMask,
(IOHIDFloat)loc.x, (IOHIDFloat)loc.y, 0.0,
0, 0, 5.0, 5.0, 1.0, 1.0, 1.0,
(IOHIDFloat)isTouching, (IOHIDFloat)isTouching, 0);
_IOHIDEventSetIntegerValue(fingerEvent, kIOHIDEventFieldDigitizerIsDisplayIntegrated, 1);
_IOHIDEventAppendEvent(handEvent, fingerEvent);
CFRelease(fingerEvent);
return handEvent;
}
#pragma mark - Private selectors
@interface UITouch ()
- (void)setWindow:(UIWindow *)window;
- (void)setView:(UIView *)view;
- (void)setTapCount:(NSUInteger)tapCount;
- (void)setTimestamp:(NSTimeInterval)timestamp;
- (void)setPhase:(UITouchPhase)touchPhase;
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@interface UIEvent (DBTPrivate)
- (void)_clearTouches;
- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed;
- (void)_setHIDEvent:(IOHIDEventRef)event;
- (void)_setTimestamp:(NSTimeInterval)timestamp;
@end
@interface UIApplication (DBTPrivate)
- (UIEvent *)_touchesEvent;
@end
@interface UIView (DBTPrivate)
- (id)_hitTestWithContext:(id)context;
@end
#pragma mark - SwiftUI-aware hit test (iOS 18+)
// Returns `id` because iOS 18's _hitTestWithContext: can return either a UIView
// OR a SwiftUI.UIKitGestureContainer (a plain UIResponder, NOT a UIView).
// The latter is the case for SwiftUI Buttons. KIF's observation: the returned
// responder is still compatible with UITouch.setView: even when it isn't a
// UIView — so we pass it through as-is. Filtering by isKindOfClass:UIView
// here would drop every SwiftUI Button tap silently. Mirrors KIF PR #1323.
static id DBT_HitTestView(UIWindow *window, CGPoint point) {
UIView *fallback = [window hitTest:point withEvent:nil];
if (@available(iOS 18.0, *)) {
Class ctxClass = NSClassFromString(@"_UIHitTestContext");
SEL ctxSel = NSSelectorFromString(@"contextWithPoint:radius:");
if (ctxClass && [ctxClass respondsToSelector:ctxSel] &&
[UIView instancesRespondToSelector:@selector(_hitTestWithContext:)]) {
id (*sendCtx)(id, SEL, CGPoint, CGFloat) =
(id (*)(id, SEL, CGPoint, CGFloat))objc_msgSend;
id ctx = sendCtx(ctxClass, ctxSel, point, 0);
if (ctx) {
id found = nil;
UIView *current = fallback;
while (found == nil && current != nil) {
found = [current _hitTestWithContext:ctx];
current = current.superview;
}
if (found) {
return found;
}
}
}
}
return fallback;
}
#pragma mark - Public API
@implementation DebugBridgeTouch
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
id hit = DBT_HitTestView(window, point);
if (!hit) return NO;
// Build a single synthetic UITouch via private setters. Order matters —
// setWindow: clears internal state and must come first.
UITouch *touch = [[UITouch alloc] init];
[touch setWindow:window];
[touch setTapCount:1];
[touch _setLocationInWindow:point resetPrevious:YES];
// setView: typed UIView * but accepts SwiftUI.UIKitGestureContainer
// (UIResponder) too — that's how SwiftUI Buttons get routed on iOS 18+.
[touch setView:(UIView *)hit];
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
if ([touch respondsToSelector:@selector(setGestureView:)] &&
[hit isKindOfClass:[UIView class]]) {
[touch setGestureView:(UIView *)hit];
}
// Attach a real IOHIDEvent (required iOS 9+).
IOHIDEventRef hidEventBegan = DBT_IOHIDEventWithTouch(touch);
[touch _setHidEvent:hidEventBegan];
UIEvent *event = [[UIApplication sharedApplication] _touchesEvent];
if (!event) {
CFRelease(hidEventBegan);
return NO;
}
[event _clearTouches];
[event _setHIDEvent:hidEventBegan];
[event _addTouch:touch forDelayedDelivery:NO];
[[UIApplication sharedApplication] sendEvent:event];
CFRelease(hidEventBegan);
// Ended phase
[touch setPhase:UITouchPhaseEnded];
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
IOHIDEventRef hidEventEnded = DBT_IOHIDEventWithTouch(touch);
[touch _setHidEvent:hidEventEnded];
[event _clearTouches];
[event _setHIDEvent:hidEventEnded];
[event _addTouch:touch forDelayedDelivery:NO];
[[UIApplication sharedApplication] sendEvent:event];
CFRelease(hidEventEnded);
return YES;
}
@end
#else // !TARGET_OS_IOS
// macOS / Catalyst / other non-iOS host build: no-op stub so the module
// resolves cleanly without UIKit or IOKit. The Swift cross-platform tests
// don't exercise touch synthesis; that's iOS-only by definition.
@implementation DebugBridgeTouch
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
(void)point; (void)window;
return NO;
}
@end
#endif // TARGET_OS_IOS
@@ -0,0 +1,43 @@
// AUTO-GENERATED from gstack/ios-qa/templates/DebugBridgeWiring.swift.template
//
// Wiring snippet for the app's @main entry. Users paste this into their
// App.swift inside the `init()` of the SwiftUI App struct, gated by
// #if DEBUG. The wiring is intentionally tiny; everything heavy lives in
// the DebugBridge target.
#if DEBUG
import DebugBridge
@MainActor
func startGstackDebugBridge(appState: AppState) {
// Read --recording flag from launch arguments
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
// Install accessibility + screenshot + mutation bridges before starting
// the server so the first authenticated request can use them.
ElementsBridge.resolver = { AccessibilityScanner.snapshot() }
ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() }
MutationBridge.resolver = { op, payload in
MutationDispatcher.shared.run(op: op, payload: payload)
}
DebugBridgeManager.shared.start(appState: appState, recording: recording)
}
#endif
// Example usage in the app's @main entry (paste this into App.swift):
//
// @main
// struct MyApp: App {
// @State private var appState = MyAppState()
//
// init() {
// #if DEBUG
// startGstackDebugBridge(appState: appState)
// #endif
// }
//
// var body: some Scene {
// WindowGroup { ContentView() }
// }
// }
@@ -0,0 +1,137 @@
// AUTO-GENERATED from gstack/ios-qa/templates/DebugOverlay.swift.template
//
// DebugOverlay — on-device visual presence. Animated brand-colored border +
// agent attribution chip + (optional) recording watermark. Renders above
// sheets, alerts, and modals via a dedicated UIWindow with high windowLevel.
//
// Everything in this file is gated #if DEBUG and gone in Release.
#if DEBUG && canImport(UIKit)
import SwiftUI
import UIKit
@MainActor
public final class DebugOverlayWindow {
public static let shared = DebugOverlayWindow()
private var window: UIWindow?
public func install(recording: Bool = false) {
guard window == nil else { return }
guard let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first else { return }
let w = PassThroughWindow(windowScene: scene)
w.windowLevel = .alert + 1
w.backgroundColor = .clear
w.isUserInteractionEnabled = false
let host = UIHostingController(rootView: OverlayRoot(recording: recording))
host.view.backgroundColor = .clear
w.rootViewController = host
w.isHidden = false
window = w
}
public func setAttribution(_ identity: String) {
OverlayAttributionState.shared.identity = identity
}
}
/// A window that lets touches pass through to underlying windows.
private final class PassThroughWindow: UIWindow {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
return view == rootViewController?.view ? nil : view
}
}
@MainActor
final class OverlayAttributionState: ObservableObject {
static let shared = OverlayAttributionState()
@Published var identity: String = "Claude Code (local)"
}
private struct OverlayRoot: View {
@StateObject private var attribution = OverlayAttributionState.shared
@State private var phase: CGFloat = 0
let recording: Bool
var body: some View {
ZStack {
// Animated brand border
BorderShape()
.stroke(
AngularGradient(
gradient: Gradient(colors: [
BrandColor.accent.opacity(0.0),
BrandColor.accent.opacity(0.8),
BrandColor.accent.opacity(0.0),
]),
center: .center,
angle: .degrees(phase * 360)
),
lineWidth: 4
)
.ignoresSafeArea()
.onAppear {
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
phase = 1.0
}
}
// Attribution chip (top safe area)
VStack {
HStack {
Spacer()
Text("Driven by \(attribution.identity)")
.font(.caption2.weight(.semibold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
Capsule().fill(BrandColor.accent.opacity(0.85))
)
.padding(.trailing, 12)
.padding(.top, 8)
Spacer().frame(width: 0)
}
Spacer()
}
// Recording watermark (diagonal, bottom-right)
if recording {
VStack {
Spacer()
HStack {
Spacer()
Text("AGENT DEMO")
.font(.system(size: 10, weight: .heavy, design: .monospaced))
.foregroundColor(.red.opacity(0.7))
.rotationEffect(.degrees(-30))
.padding(.trailing, 16)
.padding(.bottom, 30)
}
}
}
}
.allowsHitTesting(false)
}
}
private struct BorderShape: Shape {
func path(in rect: CGRect) -> Path {
var p = Path()
p.addRoundedRect(in: rect.insetBy(dx: 2, dy: 2), cornerSize: CGSize(width: 16, height: 16))
return p
}
}
private enum BrandColor {
// gstack brand color — resolved from DESIGN.md when codegen runs.
// Default falls back to a deep blue.
static let accent = Color(red: 0.0, green: 0.46, blue: 1.0)
}
#endif // DEBUG && canImport(UIKit)
@@ -0,0 +1,67 @@
// AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template
//
// Drop-in SPM package definition for the DebugBridge stack. Three targets:
//
// - DebugBridgeCore Swift, cross-platform (Foundation + Network).
// Hosts the StateServer + bridge protocols.
// - DebugBridgeTouch Objective-C, iOS-only. KIF-derived in-process touch
// synthesis (UITouch + IOHIDEvent + iOS 18
// _UIHitTestContext for SwiftUI Buttons).
// - DebugBridgeUI Swift, iOS-only. ScreenshotBridge, ElementsBridge,
// MutationBridge implementations. Depends on the other
// two.
//
// The structural Release-build guard is the `.when(configuration: .debug)`
// conditional on every consuming target's dependency. SwiftPM refuses to link
// DebugBridge* in Release config.
//
// CI invariant: `swift build -c release` + `nm -j build/Release/<binary>
// | grep -q DebugBridge && exit 1`.
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "DebugBridge",
platforms: [.iOS(.v16), .macOS(.v13)],
products: [
.library(name: "DebugBridgeCore", targets: ["DebugBridgeCore"]),
.library(name: "DebugBridgeUI", targets: ["DebugBridgeUI"]),
.library(name: "DebugBridgeTouch", targets: ["DebugBridgeTouch"]),
],
targets: [
.target(
name: "DebugBridgeCore",
dependencies: [],
path: "Sources/DebugBridgeCore",
swiftSettings: [
.define("DEBUG", .when(configuration: .debug)),
]
),
.target(
name: "DebugBridgeTouch",
dependencies: [],
path: "Sources/DebugBridgeTouch",
publicHeadersPath: "include",
linkerSettings: [
// IOKit is loaded dynamically via dlopen at runtime (it's a
// private framework on iOS and can't be linked statically).
// UIKit links normally.
.linkedFramework("UIKit", .when(platforms: [.iOS])),
]
),
.target(
name: "DebugBridgeUI",
dependencies: ["DebugBridgeCore", "DebugBridgeTouch"],
path: "Sources/DebugBridgeUI",
swiftSettings: [
.define("DEBUG", .when(configuration: .debug)),
]
),
.testTarget(
name: "DebugBridgeCoreTests",
dependencies: ["DebugBridgeCore"],
path: "Tests/DebugBridgeCoreTests"
),
]
)
@@ -0,0 +1,38 @@
// AUTO-GENERATED from gstack/ios-qa/templates/StateAccessor.swift.template
// Regenerated by `swift run gen-accessors`. DO NOT EDIT.
//
// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders
// are filled per-class from swift-syntax AST inspection of the app's
// @Observable types. Only properties marked with @Snapshotable are emitted.
//
// {{CLASS_NAME}} — the canonical AppState struct name
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field
#if DEBUG
import Foundation
@MainActor
public enum {{CLASS_NAME}}Accessor {
public static func register(_ state: {{CLASS_NAME}}) {
StateServer.shared.register(
buildId: "{{APP_BUILD_ID}}",
accessorHash: "{{ACCESSOR_HASH}}",
atomicRestore: { keys in
// Validate every key + type FIRST, then apply in one struct
// assignment so SwiftUI observers see exactly one change.
var snapshot = state.snapshotable
{{VALIDATION_BLOCK}}
// Apply atomically.
state.snapshotable = snapshot
return .ok
}
)
{{REGISTER_BLOCK}}
}
}
#endif // DEBUG
@@ -0,0 +1,569 @@
// AUTO-GENERATED from gstack/ios-qa/templates/StateServer.swift.template
// Regenerate with: /ios-sync
//
// StateServer — HTTP server embedded in the iOS app under test. Loopback-only.
// All tailnet ingress is the responsibility of the Mac-side daemon.
//
// Threat model: this surface is reachable from the local Mac via the CoreDevice
// IPv6 tunnel. It MUST refuse any caller without a current bearer token. The
// boot token is rotated within ~5 seconds of daemon spawn so anything scraping
// os_log past that window sees a dead credential.
import Foundation
import Network
import os.log
#if DEBUG
public typealias JSONDict = [String: Any]
@MainActor
public final class StateServer {
// MARK: Public surface
public static let shared = StateServer()
// MARK: Configuration
private let logger = Logger(subsystem: "gstack.ios-qa", category: "StateServer")
private let port: UInt16
private let bootTokenPath: String
// Two listeners for dual-stack loopback. The fork's single-listener IPv6-only
// binding was caught in eng + outside-voice review as incomplete.
private var ipv6Listener: NWListener?
private var ipv4Listener: NWListener?
// Auth state. The boot token is what we wrote to os_log on first launch.
// It exists ONLY long enough for the daemon to call /auth/rotate.
private var bootToken: String
private var rotatedToken: String? // set after first /auth/rotate
private var bootTokenValid: Bool = true
// MARK: Session lock (per-device, sliding window on mutations only)
private struct Session {
let id: String
var lastMutationAt: Date
}
private var activeSession: Session?
private let sessionTtlSeconds: TimeInterval = 300 // 5 min orphan timeout
// MARK: Accessor registry (populated by codegen)
public typealias ReadHandler = () -> Any?
public typealias WriteHandler = (Any) -> Bool
public typealias TypeName = String
private var readHandlers: [String: ReadHandler] = [:]
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
// pipeline observes exactly one change notification — true observable
// atomicity. @MainActor alone doesn't guarantee that.
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
private var atomicRestore: AtomicRestoreFn?
// Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures.
private var accessorHash: String = "uninitialized"
private var appBuildId: String = "uninitialized"
// Agent identity for the DebugOverlay attribution chip. Display-only,
// never used for auth.
public private(set) var lastAgentIdentity: String = "Claude Code (local)"
// MARK: Lifecycle
private init(port: UInt16 = 9999) {
self.port = port
self.bootToken = UUID().uuidString
self.bootTokenPath = NSTemporaryDirectory() + "gstack-ios-qa.token"
}
public func start() {
// 1. Persist boot token to a 0600 file (best-effort fallback for the
// daemon if os_log scrape misses).
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
// The daemon will rotate immediately; this log line is dead within
// seconds.
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
startListener(family: .ipv6)
startListener(family: .ipv4)
}
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
self.atomicRestore = atomicRestore
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
readHandlers[key] = read
writeHandlers[key] = write
typeNames[key] = type
}
// MARK: Listener setup
private enum AddressFamily {
case ipv4
case ipv6
var host: NWEndpoint.Host {
switch self {
case .ipv4: return NWEndpoint.Host("127.0.0.1")
case .ipv6: return NWEndpoint.Host("::1")
}
}
}
private func startListener(family: AddressFamily) {
do {
// Binding strategy: accept connections from the device's loopback
// AND from the CoreDevice tunnel (the USB-mounted tunnel the Mac
// daemon uses to reach this app — appears as a non-loopback
// utun-style interface on the device with the peer's source
// address in the fd*/fc* ULA range). We can't use
// params.acceptLocalOnly — Network.framework's definition of
// "local" is strictly loopback and silently drops CoreDevice
// tunnel peers. Instead we accept on the wildcard interface and
// do a per-connection peer-address check below: loopback OR
// RFC 4193 ULA (fc00::/7) → accept, everything else → cancel.
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .ready = state {
self?.logger.notice("StateServer listening on \(String(describing: family))")
} else if case .failed(let err) = state {
self?.logger.error("StateServer listener failed: \(err.localizedDescription, privacy: .public)")
}
}
}
listener.newConnectionHandler = { [weak self] connection in
Task { @MainActor in
// Defense-in-depth: even with .loopback interface gate, double-check
// the peer is loopback. Reject otherwise.
if let self, self.isLoopbackPeer(connection) {
self.handle(connection)
} else {
connection.cancel()
}
}
}
listener.start(queue: .global(qos: .userInitiated))
switch family {
case .ipv6: ipv6Listener = listener
case .ipv4: ipv4Listener = listener
}
} catch {
logger.error("Listener bind failed (\(String(describing: family))): \(error.localizedDescription, privacy: .public)")
}
}
private func isLoopbackPeer(_ connection: NWConnection) -> Bool {
switch connection.endpoint {
case .hostPort(let host, _):
switch host {
case .ipv4(let addr):
return addr == .loopback
case .ipv6(let addr):
// Loopback (::1) — local same-device traffic
if addr.isLoopback { return true }
// CoreDevice ULA range (fd00::/8 unique-local addresses) —
// the USB tunnel that the Mac daemon uses to reach this app.
// Apple's CoreDevice tunnel uses fd-prefixed ULAs like
// fd72:8347:2ead::1 (Mac-facing) and fd72:8347:2ead::2
// (device-facing). We accept the entire ULA range since
// the prefix is regenerated per session.
let bytes = addr.rawValue
if bytes.count >= 1 && (bytes[0] & 0xFE) == 0xFC {
// RFC 4193 ULA range (fc00::/7) — fc* or fd* prefix.
return true
}
return false
case .name(let name, _):
return name == "localhost"
@unknown default: return false
}
default: return false
}
}
// MARK: Request handling
private func handle(_ connection: NWConnection) {
connection.start(queue: .global(qos: .userInitiated))
receive(connection: connection, buffer: Data())
}
private static let maxBodyBytes = 1_048_576 // 1MB hard cap
private func receive(connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
guard let self else { return }
Task { @MainActor in
var current = buffer
if let data = data { current.append(data) }
if current.count > Self.maxBodyBytes {
self.send(connection: connection, status: 413, body: ["error": "body_too_large"])
return
}
if let req = self.tryParseRequest(current) {
self.route(connection: connection, request: req)
} else if isComplete || error != nil {
self.send(connection: connection, status: 400, body: ["error": "bad_request"])
} else {
self.receive(connection: connection, buffer: current)
}
}
}
}
struct ParsedRequest {
let method: String
let path: String
let headers: [String: String]
let body: Data
}
private func tryParseRequest(_ data: Data) -> ParsedRequest? {
guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else { return nil }
let headerData = data.subdata(in: 0..<headerEnd.lowerBound)
let body = data.subdata(in: headerEnd.upperBound..<data.count)
guard let headerStr = String(data: headerData, encoding: .utf8) else { return nil }
let lines = headerStr.components(separatedBy: "\r\n")
guard let requestLine = lines.first else { return nil }
let parts = requestLine.components(separatedBy: " ")
guard parts.count >= 2 else { return nil }
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else { continue }
let key = String(line[..<colon]).lowercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
headers[key] = value
}
if let lenStr = headers["content-length"], let len = Int(lenStr), body.count < len {
return nil // need more bytes
}
return ParsedRequest(method: parts[0], path: parts[1], headers: headers, body: body)
}
private func route(connection: NWConnection, request: ParsedRequest) {
// Update display attribution from header (display only — never trusted
// for auth).
if let agent = request.headers["x-agent-identity"], !agent.isEmpty, agent.count < 200 {
lastAgentIdentity = agent
}
let path = request.path
// 1. Public on loopback: /healthz.
if request.method == "GET" && path == "/healthz" {
send(connection: connection, status: 200, body: [
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
])
return
}
// 2. Auth bootstrap: /auth/rotate is the ONLY endpoint that accepts the
// boot token. Everything else requires the rotated token.
if request.method == "POST" && path == "/auth/rotate" {
handleAuthRotate(connection: connection, request: request)
return
}
// 3. All other endpoints require Bearer auth with the rotated token.
guard authorize(request: request) else {
send(connection: connection, status: 401, body: ["error": "unauthorized"])
return
}
switch (request.method, path) {
case ("POST", "/session/acquire"): handleSessionAcquire(connection: connection)
case ("POST", "/session/release"): handleSessionRelease(connection: connection)
case ("POST", "/session/heartbeat"): handleSessionHeartbeat(connection: connection, request: request)
case ("GET", "/state/snapshot"): handleSnapshotGet(connection: connection)
case ("POST", "/state/restore"): handleSnapshotRestore(connection: connection, request: request)
case ("GET", "/elements"): handleElements(connection: connection)
case ("GET", "/screenshot"): handleScreenshot(connection: connection)
case ("POST", "/tap"): handleMutation(connection: connection, request: request, op: "tap")
case ("POST", "/swipe"): handleMutation(connection: connection, request: request, op: "swipe")
case ("POST", "/type"): handleMutation(connection: connection, request: request, op: "type")
case ("GET", let p) where p.hasPrefix("/state/"):
let key = String(p.dropFirst("/state/".count))
handleStateGet(connection: connection, key: key)
case ("POST", let p) where p.hasPrefix("/state/"):
let key = String(p.dropFirst("/state/".count))
handleStateWrite(connection: connection, request: request, key: key)
default:
send(connection: connection, status: 404, body: ["error": "not_found", "path": path])
}
}
// MARK: Auth
private func authorize(request: ParsedRequest) -> Bool {
guard let auth = request.headers["authorization"], auth.hasPrefix("Bearer ") else { return false }
let token = String(auth.dropFirst("Bearer ".count))
return token == rotatedToken
}
private func handleAuthRotate(connection: NWConnection, request: ParsedRequest) {
// Validate boot token (still alive AND used only once).
guard bootTokenValid,
let auth = request.headers["authorization"],
auth.hasPrefix("Bearer "),
String(auth.dropFirst("Bearer ".count)) == bootToken else {
send(connection: connection, status: 401, body: ["error": "boot_token_invalid"])
return
}
guard let dict = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict,
let newToken = dict["new_token"] as? String,
newToken.count >= 16 else {
send(connection: connection, status: 400, body: ["error": "invalid_rotate_payload"])
return
}
rotatedToken = newToken
bootTokenValid = false
// Best-effort scrub of on-disk boot token file.
try? FileManager.default.removeItem(atPath: bootTokenPath)
logger.notice("Boot token rotated; original now invalid")
send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Session lock
private static let mutatingPaths: Set<String> = ["/tap", "/swipe", "/type", "/state/restore"]
private func mutatingPathRequiresSession(_ path: String, method: String) -> Bool {
if method != "POST" { return false }
if path.hasPrefix("/state/") && path != "/state/restore" { return true } // /state/<key> writes
return Self.mutatingPaths.contains(path)
}
private func requireSession(in request: ParsedRequest, connection: NWConnection) -> Bool {
guard let id = request.headers["x-session-id"] else {
send(connection: connection, status: 409, body: ["error": "session_required"])
return false
}
guard let current = activeSession, current.id == id else {
send(connection: connection, status: 409, body: ["error": "session_invalid_or_expired"])
return false
}
// Mutation slides the lock; reads do not.
activeSession?.lastMutationAt = Date()
return true
}
private func handleSessionAcquire(connection: NWConnection) {
// Reap orphaned session.
if let s = activeSession, Date().timeIntervalSince(s.lastMutationAt) > sessionTtlSeconds {
activeSession = nil
}
if activeSession != nil {
send(connection: connection, status: 423, body: ["error": "device_locked"])
return
}
let id = UUID().uuidString
activeSession = Session(id: id, lastMutationAt: Date())
send(connection: connection, status: 200, body: [
"session_id": id,
"ttl_seconds": Int(sessionTtlSeconds),
])
}
private func handleSessionRelease(connection: NWConnection) {
activeSession = nil
send(connection: connection, status: 200, body: ["ok": true])
}
private func handleSessionHeartbeat(connection: NWConnection, request: ParsedRequest) {
guard let id = request.headers["x-session-id"],
activeSession?.id == id else {
send(connection: connection, status: 409, body: ["error": "session_invalid_or_expired"])
return
}
activeSession?.lastMutationAt = Date()
send(connection: connection, status: 200, body: ["ok": true, "ttl_seconds": Int(sessionTtlSeconds)])
}
// MARK: State handlers
private func handleStateGet(connection: NWConnection, key: String) {
guard let handler = readHandlers[key] else {
send(connection: connection, status: 404, body: ["error": "unknown_key", "key": key])
return
}
let value = handler() ?? NSNull()
send(connection: connection, status: 200, body: ["key": key, "value": value])
}
private func handleStateWrite(connection: NWConnection, request: ParsedRequest, key: String) {
guard requireSession(in: request, connection: connection) else { return }
guard let handler = writeHandlers[key] else {
send(connection: connection, status: 404, body: ["error": "unknown_key", "key": key])
return
}
guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict,
let value = payload["value"] else {
send(connection: connection, status: 400, body: ["error": "missing_value"])
return
}
let ok = handler(value)
if ok {
send(connection: connection, status: 200, body: ["ok": true])
} else {
send(connection: connection, status: 400, body: ["error": "type_mismatch", "expected": typeNames[key] ?? "?"])
}
}
private func handleSnapshotGet(connection: NWConnection) {
var keys: JSONDict = [:]
for (k, read) in readHandlers {
keys[k] = read() ?? NSNull()
}
let envelope: JSONDict = [
"_schema_version": 1,
"_app_build_id": appBuildId,
"_accessor_hash": accessorHash,
"keys": keys,
]
send(connection: connection, status: 200, body: envelope)
}
private func handleSnapshotRestore(connection: NWConnection, request: ParsedRequest) {
guard requireSession(in: request, connection: connection) else { return }
guard let envelope = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else {
send(connection: connection, status: 400, body: ["error": "invalid_json"])
return
}
// Schema gate.
if let hash = envelope["_accessor_hash"] as? String, hash != accessorHash {
send(connection: connection, status: 409, body: [
"error": "schema_mismatch",
"expected_hash": accessorHash,
"got_hash": hash,
])
return
}
guard let keys = envelope["keys"] as? JSONDict else {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
guard let restore = atomicRestore else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
// Validate-then-apply via the codegen-supplied closure. The closure does
// a single struct-assignment so SwiftUI sees one change notification.
switch restore(keys) {
case .ok:
send(connection: connection, status: 200, body: ["ok": true])
case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
case .typeMismatch(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
}
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
private func handleElements(connection: NWConnection) {
let tree = ElementsBridge.snapshot()
send(connection: connection, status: 200, body: ["elements": tree])
}
private func handleScreenshot(connection: NWConnection) {
if let png = ScreenshotBridge.capturePNG() {
send(connection: connection, status: 200, body: ["png_base64": png.base64EncodedString()])
} else {
send(connection: connection, status: 500, body: ["error": "screenshot_unavailable"])
}
}
private func handleMutation(connection: NWConnection, request: ParsedRequest, op: String) {
guard requireSession(in: request, connection: connection) else { return }
guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else {
send(connection: connection, status: 400, body: ["error": "invalid_json"])
return
}
let ok = MutationBridge.dispatch(op: op, payload: payload)
send(connection: connection, status: ok ? 200 : 400, body: ["op": op, "ok": ok])
}
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
let statusText: String
switch status {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
case 404: statusText = "Not Found"
case 409: statusText = "Conflict"
case 413: statusText = "Payload Too Large"
case 423: statusText = "Locked"
case 429: statusText = "Too Many Requests"
case 500: statusText = "Internal Server Error"
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in
connection.cancel()
})
}
}
// MARK: - Bridges (implementation provided by DebugBridgeManager)
@MainActor
public enum ElementsBridge {
public static var resolver: () -> [JSONDict] = { [] }
static func snapshot() -> [JSONDict] { resolver() }
}
@MainActor
public enum ScreenshotBridge {
public static var resolver: () -> Data? = { nil }
static func capturePNG() -> Data? { resolver() }
}
@MainActor
public enum MutationBridge {
public static var resolver: (String, JSONDict) -> Bool = { _, _ in false }
static func dispatch(op: String, payload: JSONDict) -> Bool { resolver(op, payload) }
}
#endif // DEBUG
@@ -0,0 +1,127 @@
# DX Hall of Fame Reference
Read ONLY the section for the current review pass. Do NOT load the entire file.
## Pass 1: Getting Started
**Gold standards:**
- **Stripe**: 7 lines of code to charge a card. Docs pre-fill YOUR test API keys when logged in. Stripe Shell runs CLI inside docs page. No local install needed.
- **Vercel**: `git push` = live site on global CDN with HTTPS. Every PR gets preview URL. One CLI command: `vercel`.
- **Clerk**: `<SignIn />`, `<SignUp />`, `<UserButton />`. 3 JSX components, working auth with email, social, MFA out of the box.
- **Supabase**: Create a Postgres table, auto-generates REST API + Realtime + self-documenting docs instantly.
- **Firebase**: `onSnapshot()`. 3 lines for real-time sync across all clients with offline persistence built-in.
- **Twilio**: Virtual Phone in console. Send/receive SMS without buying a number, no credit card. Result: 62% improvement in activation.
**Anti-patterns:**
- Email verification before any value (breaks flow)
- Credit card required before sandbox
- "Choose your own adventure" with multiple paths (decision fatigue; one golden path wins)
- API keys hidden in settings (Stripe pre-fills them into code examples)
- Static code examples without language switching
- Separate docs site from dashboard (context switching)
## Pass 2: API/CLI/SDK Design
**Gold standards:**
- **Stripe prefixed IDs**: `ch_` for charges, `cus_` for customers. Self-documenting. Impossible to pass wrong ID type.
- **Stripe expandable objects**: Default returns ID strings. `expand[]` gets full objects inline. Nested expansion up to 4 levels.
- **Stripe idempotency keys**: Pass `Idempotency-Key` header on mutations. Safe retries. No "did I double-charge?" anxiety.
- **Stripe API versioning**: First call pins account to that day's version. Test new versions per-request via `Stripe-Version` header.
- **GitHub CLI**: Auto-detects terminal vs pipe. Human-readable in terminal, tab-delimited when piped. `gh pr <tab>` shows all PR actions.
- **SwiftUI progressive disclosure**: `Button("Save") { save() }` to full customization, same API at every level.
- **htmx**: HTML attributes replace JS. 14KB total. `hx-get="/search" hx-trigger="keyup changed delay:300ms"`. Zero build step.
- **shadcn/ui**: Copy source code into your project. You own every line. No dependency, no version conflicts.
**Anti-patterns:**
- Chatty API: requiring 5 calls for one user-visible action
- Inconsistent naming: `/users` (plural) vs `/user/123` (singular) vs `/create-order` (verb in URL)
- Implicit failure: 200 OK with error nested in response body
- God endpoint: 47 parameter combinations with different behavior per subset
- Documentation-required API: 3 pages of docs before first call = too much ceremony
## Pass 3: Error Messages & Debugging
**Three tiers of error quality:**
**Tier 1, Elm (Conversational Compiler):**
```
-- TYPE MISMATCH ---- src/Main.elm
I cannot do addition with String values like this one:
42| "hello" + 1
^^^^^^^
Hint: To put strings together, use the (++) operator instead.
```
First person, complete sentences, exact location, suggested fix, further reading.
**Tier 2, Rust (Annotated Source):**
```
error[E0308]: mismatched types
--> src/main.rs:4:20
help: consider borrowing here
|
4 | let name: &str = &get_name();
| +
```
Error code links to tutorial. Primary + secondary labels. Help section shows exact edit.
**Tier 3, Stripe API (Structured with doc_url):**
```json
{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such customer: 'cus_nonexistent'","param":"customer","doc_url":"https://stripe.com/docs/error-codes/resource-missing"}}
```
Five fields, zero ambiguity.
**The formula:** What happened + Why + How to fix + Where to learn more + Actual values that caused it.
**Anti-pattern:** TypeScript buries "Did you mean?" at the BOTTOM of long error chains. Most actionable info should appear FIRST.
## Pass 4: Documentation & Learning
**Gold standards:**
- **Stripe docs**: Three-column layout (nav / content / live code). API keys injected when logged in. Language switcher persists across ALL pages. Hover-to-highlight. Stripe Shell for in-browser API calls. Built and open-sourced Markdoc. Features don't ship until docs are finalized. Docs contributions affect performance reviews.
- 52% of developers blocked by lack of documentation (Postman 2023)
- Companies with world-class docs see 2.5x increase in adoption
- "Docs as product": ships with the feature or the feature doesn't ship
## Pass 5: Upgrade & Migration Path
**Gold standards:**
- **Next.js**: `npx @next/codemod upgrade major`. One command upgrades Next.js, React, React DOM, runs all relevant codemods.
- **AG Grid**: Every release from v31+ includes a codemod.
- **Stripe API versioning**: One codebase internally. Version pinning per account. Breaking changes never surprise you.
- **Martin Fowler's pipeline pattern**: Compose small, testable transformations rather than one monolithic codemod.
- 21.9% of breaking changes in Maven Central were undocumented (Ochoa et al., 2021)
## Pass 6: Developer Environment & Tooling
**Gold standards:**
- **Bun**: 100x faster than npm install, 4x faster than Node.js runtime. Speed IS DX.
- 87 interruptions per day average; 25 minutes to recover from each. Devs code only 2-4 hours/day.
- Each 1-point DXI improvement = 13 minutes saved per developer per week.
- **GitHub Copilot**: 55.8% faster task completion. PR time from 9.6 days to 2.4 days.
## Pass 7: Community & Ecosystem
- Dev tools require ~14 exposures before purchase (Matt Biilmann, Netlify). Incompatible with quarterly OKR cycles.
- 4-5x performance multiplier for teams with strong developer experience (DevEx framework).
## Pass 8: DX Measurement
**Three academic frameworks:**
1. **SPACE** (Microsoft Research, 2021): Satisfaction, Performance, Activity, Communication, Efficiency. Measure at least 3 dimensions.
2. **DevEx** (ACM Queue, 2023): Feedback Loops, Cognitive Load, Flow State. Combine perceptual + workflow data.
3. **Fagerholm & Munch** (IEEE, 2012): Cognition, Affect, Conation. The psychological "trilogy of mind."
## Claude Code Skill DX Checklist
Use when reviewing plans for Claude Code skills, MCP servers, or AI agent tools.
- [ ] **AskUserQuestion design**: One issue per call. Re-ground context (project, branch, task). Browser handoff for visual feedback.
- [ ] **State storage**: Global (~/.tool/) vs per-project ($SLUG/) vs per-session. Append-only JSONL for audit trails.
- [ ] **Progressive consent**: One-time prompts with marker files. Never re-ask. Reversible.
- [ ] **Auto-upgrade**: Version check with cache + snooze backoff. Migration scripts. Inline offer.
- [ ] **Skill composition**: Benefits-from chains. Review chaining. Inline invocation with section skipping.
- [ ] **Error recovery**: Resume from failure. Partial results preserved. Checkpoint-safe.
- [ ] **Session continuity**: Timeline events. Compaction recovery. Cross-session learnings.
- [ ] **Bounded autonomy**: Clear operational limits. Mandatory escalation for destructive actions. Audit trails.
Reference implementations: gstack's design-shotgun loop, auto-upgrade flow, progressive consent, hierarchical storage.
@@ -0,0 +1,85 @@
# QA Issue Taxonomy
## Severity Levels
| Severity | Definition | Examples |
|----------|------------|----------|
| **critical** | Blocks a core workflow, causes data loss, or crashes the app | Form submit causes error page, checkout flow broken, data deleted without confirmation |
| **high** | Major feature broken or unusable, no workaround | Search returns wrong results, file upload silently fails, auth redirect loop |
| **medium** | Feature works but with noticeable problems, workaround exists | Slow page load (>5s), form validation missing but submit still works, layout broken on mobile only |
| **low** | Minor cosmetic or polish issue | Typo in footer, 1px alignment issue, hover state inconsistent |
## Categories
### 1. Visual/UI
- Layout breaks (overlapping elements, clipped text, horizontal scrollbar)
- Broken or missing images
- Incorrect z-index (elements appearing behind others)
- Font/color inconsistencies
- Animation glitches (jank, incomplete transitions)
- Alignment issues (off-grid, uneven spacing)
- Dark mode / theme issues
### 2. Functional
- Broken links (404, wrong destination)
- Dead buttons (click does nothing)
- Form validation (missing, wrong, bypassed)
- Incorrect redirects
- State not persisting (data lost on refresh, back button)
- Race conditions (double-submit, stale data)
- Search returning wrong or no results
### 3. UX
- Confusing navigation (no breadcrumbs, dead ends)
- Missing loading indicators (user doesn't know something is happening)
- Slow interactions (>500ms with no feedback)
- Unclear error messages ("Something went wrong" with no detail)
- No confirmation before destructive actions
- Inconsistent interaction patterns across pages
- Dead ends (no way back, no next action)
### 4. Content
- Typos and grammar errors
- Outdated or incorrect text
- Placeholder / lorem ipsum text left in
- Truncated text (cut off without ellipsis or "more")
- Wrong labels on buttons or form fields
- Missing or unhelpful empty states
### 5. Performance
- Slow page loads (>3 seconds)
- Janky scrolling (dropped frames)
- Layout shifts (content jumping after load)
- Excessive network requests (>50 on a single page)
- Large unoptimized images
- Blocking JavaScript (page unresponsive during load)
### 6. Console/Errors
- JavaScript exceptions (uncaught errors)
- Failed network requests (4xx, 5xx)
- Deprecation warnings (upcoming breakage)
- CORS errors
- Mixed content warnings (HTTP resources on HTTPS)
- CSP violations
### 7. Accessibility
- Missing alt text on images
- Unlabeled form inputs
- Keyboard navigation broken (can't tab to elements)
- Focus traps (can't escape a modal or dropdown)
- Missing or incorrect ARIA attributes
- Insufficient color contrast
- Content not reachable by screen reader
## Per-Page Exploration Checklist
For each page visited during a QA session:
1. **Visual scan** — Take annotated screenshot (`snapshot -i -a -o`). Look for layout issues, broken images, alignment.
2. **Interactive elements** — Click every button, link, and control. Does each do what it says?
3. **Forms** — Fill and submit. Test empty submission, invalid data, edge cases (long text, special characters).
4. **Navigation** — Check all paths in/out. Breadcrumbs, back button, deep links, mobile menu.
5. **States** — Check empty state, loading state, error state, full/overflow state.
6. **Console** — Run `console --errors` after interactions. Any new JS errors or failed requests?
7. **Responsiveness** — If relevant, check mobile and tablet viewports.
8. **Auth boundaries** — What happens when logged out? Different user roles?
@@ -0,0 +1,126 @@
# QA Report: {APP_NAME}
| Field | Value |
|-------|-------|
| **Date** | {DATE} |
| **URL** | {URL} |
| **Branch** | {BRANCH} |
| **Commit** | {COMMIT_SHA} ({COMMIT_DATE}) |
| **PR** | {PR_NUMBER} ({PR_URL}) or "—" |
| **Tier** | Quick / Standard / Exhaustive |
| **Scope** | {SCOPE or "Full app"} |
| **Duration** | {DURATION} |
| **Pages visited** | {COUNT} |
| **Screenshots** | {COUNT} |
| **Framework** | {DETECTED or "Unknown"} |
| **Index** | [All QA runs](./index.md) |
## Health Score: {SCORE}/100
| Category | Score |
|----------|-------|
| Console | {0-100} |
| Links | {0-100} |
| Visual | {0-100} |
| Functional | {0-100} |
| UX | {0-100} |
| Performance | {0-100} |
| Accessibility | {0-100} |
## Top 3 Things to Fix
1. **{ISSUE-NNN}: {title}** — {one-line description}
2. **{ISSUE-NNN}: {title}** — {one-line description}
3. **{ISSUE-NNN}: {title}** — {one-line description}
## Console Health
| Error | Count | First seen |
|-------|-------|------------|
| {error message} | {N} | {URL} |
## Summary
| Severity | Count |
|----------|-------|
| Critical | 0 |
| High | 0 |
| Medium | 0 |
| Low | 0 |
| **Total** | **0** |
## Issues
### ISSUE-001: {Short title}
| Field | Value |
|-------|-------|
| **Severity** | critical / high / medium / low |
| **Category** | visual / functional / ux / content / performance / console / accessibility |
| **URL** | {page URL} |
**Description:** {What is wrong, expected vs actual.}
**Repro Steps:**
1. Navigate to {URL}
![Step 1](screenshots/issue-001-step-1.png)
2. {Action}
![Step 2](screenshots/issue-001-step-2.png)
3. **Observe:** {what goes wrong}
![Result](screenshots/issue-001-result.png)
---
## Fixes Applied (if applicable)
| Issue | Fix Status | Commit | Files Changed |
|-------|-----------|--------|---------------|
| ISSUE-NNN | verified / best-effort / reverted / deferred | {SHA} | {files} |
### Before/After Evidence
#### ISSUE-NNN: {title}
**Before:** ![Before](screenshots/issue-NNN-before.png)
**After:** ![After](screenshots/issue-NNN-after.png)
---
## Regression Tests
| Issue | Test File | Status | Description |
|-------|-----------|--------|-------------|
| ISSUE-NNN | path/to/test | committed / deferred / skipped | description |
### Deferred Tests
#### ISSUE-NNN: {title}
**Precondition:** {setup state that triggers the bug}
**Action:** {what the user does}
**Expected:** {correct behavior}
**Why deferred:** {reason}
---
## Ship Readiness
| Metric | Value |
|--------|-------|
| Health score | {before} → {after} ({delta}) |
| Issues found | N |
| Fixes applied | N (verified: X, best-effort: Y, reverted: Z) |
| Deferred | N |
**PR Summary:** "QA found N issues, fixed M, health score X → Y."
---
## Regression (if applicable)
| Metric | Baseline | Current | Delta |
|--------|----------|---------|-------|
| Health score | {N} | {N} | {+/-N} |
| Issues | {N} | {N} | {+/-N} |
**Fixed since baseline:** {list}
**New since baseline:** {list}