diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md new file mode 100644 index 0000000..0ecf860 --- /dev/null +++ b/docs/wiki/Architecture.md @@ -0,0 +1,139 @@ +# Architecture + +## Two targets + +- **`LULLKit/`** — a standalone Swift package (`swift-tools-version:5.9`, + iOS 17 / macOS 14). Domain models and the consent foundation: `Sensor`, + `ConsentLedger`, `EyeSession`, `Atmosphere`. No SwiftUI, no AVFoundation, no + UIKit — pure Swift, fully unit-testable without a simulator or device. +- **`app/`** — the SwiftUI iPhone app (the vertical slice, `THE EYE`). Depends + on `LULLKit` as a local Swift package. Built via a generated Xcode project + (`app/project.yml`, XcodeGen) — no `.xcodeproj` is committed. +- **`server/`** — referenced in the README as a later Vapor "haunt" backend. + Not present in the repo yet; see [Roadmap / Ideas](Roadmap-Ideas). + +```mermaid +graph TD + subgraph LULLKit["LULLKit — pure Swift package"] + Sensor["Sensor (enum)"] + ConsentLedger["ConsentLedger (struct)"] + EyeSession["EyeSession (struct)"] + Atmosphere["Atmosphere (enum, pure fn)"] + end + + subgraph appTarget["app — SwiftUI + AVFoundation"] + GameModel["GameModel (ObservableObject)"] + CameraController["CameraController : CameraGate"] + ConsentView + EyeView + RootView + AtmosphereBackground["AtmosphereBackground (SpriteView bridge)"] + AtmosphereScene["AtmosphereScene : SKScene (experiment branch)"] + end + + GameModel --> EyeSession + GameModel --> ConsentLedger + GameModel --> CameraController + ConsentView --> GameModel + EyeView --> GameModel + EyeView -.reads narration.-> Atmosphere + ConsentView -.reads narration.-> Atmosphere + RootView --> GameModel + RootView --> ConsentView + RootView --> EyeView + RootView -.background layer.-> AtmosphereBackground + AtmosphereBackground --> AtmosphereScene +``` + +`AtmosphereScene`/`AtmosphereBackground` exist only on the +`experiment/spritekit-atmosphere` branch — see +[Atmosphere (experiment)](Atmosphere-Experiment). Everything else above is on +`main`. + +## `ConsentLedger` — the consent state + +From `LULLKit/Sources/LULLKit/Consent.swift`. A small value type, not a state +machine: a `Set` of what's currently granted. + +- `mayUse(_:)` — is this sensor usable right now. Default (empty set) is + deny for everything. +- `grant(_:)` / `revoke(_:)` — per-sensor, explicit. +- `revokeAll()` — the panic switch: clears every grant at once. +- `activeSensors` — for an always-visible "LULL can see/hear you" indicator. + +## `EyeSession` — the mechanic's state machine + +From `LULLKit/Sources/LULLKit/Eye.swift`. `THE EYE`'s entire behavior is this +one `struct`, driven by `begin()`, `consent(_:)`, `advance(by:)`, and +`release()` — no camera, no UI, so it's fully unit-testable with fake time. + +```mermaid +stateDiagram-v2 + [*] --> dormant + dormant --> seekingConsent: begin() + seekingConsent --> denied: consent(false) + seekingConsent --> watching: consent(true) + watching --> noticing: advance(by:) once elapsed ≥ calmSeconds + noticing --> awake: advance(by:) once elapsed ≥ calmSeconds + noticingSeconds + watching --> released: release() + noticing --> released: release() + awake --> released: release() + seekingConsent --> released: release() + denied --> [*] + released --> [*] +``` + +`release()` is callable from **any** phase — it's the panic switch made +concrete for this mechanic. `wantsCamera` is a derived, read-only property +(`true` only in `watching`/`noticing`/`awake`) that the app is meant to +consult before running the camera — see +[The Safety Invariant](The-Safety-Invariant) for exactly how that's wired +(and where the guarantee is weaker than it sounds). + +`advance(by:)` clamps `dt` to `[0, 5]` seconds per call specifically so a +backgrounded app resuming with a huge time delta can't skip straight to +`.awake` — pinned by `testBackgroundingCannotFastForwardTheHorror` in +`EyeSessionTests.swift`. + +## `Atmosphere` — the narration layer + +From `LULLKit/Sources/LULLKit/Atmosphere.swift`. A **pure function** from +`EyeSession.Phase` (+ an elapsed-time-derived "beat") to a `Line` of text +tagged with a `Voice` (`kafka` / `beckett` / `poe`). It holds no state, +touches no hardware, and reaches no sensor — the app renders whatever it +returns and adds nothing of its own. This is also what lets the narration be +unit-tested (register-per-phase, beat-wrapping, "denial is always merciful") +without any UI. + +## `CameraController` — the real camera + +From `app/Sources/CameraController.swift`. Wraps a single `AVCaptureSession` +on the front camera behind the `CameraGate` protocol +(`requestAccess() / start() / stop()`), so `EyeSession`'s logic never needs +to know AVFoundation exists — tests can substitute a fake `CameraGate`. +`CameraPreview` (a `UIViewRepresentable`) renders the live `AVCaptureSession` +into SwiftUI; `EyeView` then deliberately degrades it (desaturated, blurred, +opacity-limited, vignetted) — the raw feed is never shown clean. + +## `GameModel` — the glue + +From `app/Sources/GameModel.swift`. An `ObservableObject` that owns one +`EyeSession`, one `ConsentLedger`, and one `CameraController`, and wires them +to a real 0.5s `Timer`. It contains no decisions of its own — every branch of +logic (does the eye escalate, is a sensor allowed) lives in `LULLKit` and is +tested there; `GameModel` just calls into it with real time and a real +camera. + +## View layer + +- **`ConsentView`** — the in-app rationale (`Sensor.camera.rationale`) shown + *before* the OS permission prompt, with a real "Not tonight" decline. +- **`EyeView`** — the degraded camera preview plus narration, escalating + opacity/blur/vignette by `EyeSession.Phase`, and an always-present + "close the eye" control. +- **`RootView`** (in `LULLApp.swift`) — routes purely on `model.eye.phase`: + `dormant`/`seekingConsent` → `ConsentView`, `watching`/`noticing`/`awake` → + `EyeView`, `denied`/`released` → `EndingView`. The UI is a function of the + state machine, nothing more. +- **`EndingView`** (also in `LULLApp.swift`) — renders the terminal narration + line for `denied` or `released`. diff --git a/docs/wiki/Atmosphere-Experiment.md b/docs/wiki/Atmosphere-Experiment.md new file mode 100644 index 0000000..85da7f7 --- /dev/null +++ b/docs/wiki/Atmosphere-Experiment.md @@ -0,0 +1,77 @@ +# Atmosphere (experiment) + +**Branch:** [`experiment/spritekit-atmosphere`](https://github.com/testtest126/LULL/tree/experiment/spritekit-atmosphere) +**PR:** [#1 — "Experiment: SpriteKit atmosphere layer"](https://github.com/testtest126/LULL/pull/1) (draft) + +This is explicitly framed in the PR description as **"a prototype, not a +decision"** — evaluating whether SpriteKit earns its place as a dependency of +LULL's real fear mechanics, before committing to it. + +## What's in it + +- **`app/Sources/AtmosphereScene.swift`** — an `SKScene` that is pure + decoration: slow drifting fog (`SKEmitterNode`), faint upward dust motes, a + static vignette (a generated radial-gradient texture, no shader), a slow + breathing red glow ("pulse"), and a rare, brief flicker. It owns no state, + reads no sensor, and knows nothing about consent or `EyeSession` — per its + own doc comment, "delete this file and the app behaves exactly as it did + before." The palette is copied from `Theme.swift` by eye (not imported), so + the file stays independently liftable/deletable. +- **`app/Sources/AtmosphereBackground.swift`** — a `SpriteView` bridge that + puts `AtmosphereScene` into SwiftUI as a background layer, with no + reference to `GameModel`, consent, or the camera. +- **`app/Sources/LULLApp.swift`** — a one-line swap in `RootView`'s `ZStack`: + `Theme.ink.ignoresSafeArea()` → `AtmosphereBackground()` as the base layer, + sitting *behind* the existing consent/eye UI. +- **`app/project.yml`** — adds `INFOPLIST_KEY_UILaunchScreen_Generation: YES`. + Called out in the PR as unrelated to SpriteKit itself — a pre-existing gap + (the app was rendering letterboxed in the Simulator for lack of a launch + screen) that had to be fixed to see the atmosphere full-screen. + +## What it explicitly is *not* + +Per the PR description: + +- No touches to `LULLKit`, `ConsentLedger`, `EyeSession`, or the camera + pipeline. `AtmosphereScene` holds no state and reaches no sensor. +- No new dependencies — SpriteKit is first-party (part of the OS SDK). +- Nothing removed — `ConsentView`, `EyeView`, and `GameModel` are untouched; + this is strictly an additive background layer. + +## Verification claimed in the PR + +- `swift test` in `LULLKit`: 16/16 green, unchanged (consistent with what + [Building & Running](Building-and-Running) documents for `main`). +- `xcodegen generate` + `xcodebuild` for `iphonesimulator`: builds clean. +- Run in the iPhone 17 Pro simulator: the atmosphere layer renders + full-screen behind the consent title, with a visibly breathing glow and + vignette. + +## The honest take (from the PR author, in the PR itself) + +The PR's own "Honest read" section is worth preserving verbatim in spirit, +because it's a well-reasoned argument *against* over-adopting the framework +it just introduced: + +> Restrained particle/glow work like this is well within reach of +> `Canvas`/`TimelineView` + Core Animation, and SpriteKit's advantage would +> show up more once there's actual interactivity — things reacting to +> touch/face/time in real time, physics, sprite-sheet animation — rather than +> ambient decoration. Ambient-only, lean SwiftUI to keep the surface area +> small; but SpriteKit is a lot less awkward for this than expected, so if +> `THE EYE`'s escalation ever wants particle reactions tied to the camera +> signal, it's a reasonable investment. + +In short: for purely decorative atmosphere (fog, dust, vignette, ambient +glow), plain SwiftUI/Core Animation likely does the job with less framework +surface area to maintain. SpriteKit's case gets stronger the moment the dread +needs to *react* — to the camera signal, to touch, to time, in a way that +benefits from a real scene graph and physics/particle system rather than +declarative animation. + +## Status + +Draft PR, not merged. Reading it as a decision record: the prototype proves +SpriteKit *can* sit cleanly behind the existing UI without touching game +logic, but whether it's worth adopting is explicitly left open pending +whether future mechanics need reactive behavior, not just ambience. diff --git a/docs/wiki/Building-and-Running.md b/docs/wiki/Building-and-Running.md new file mode 100644 index 0000000..654e504 --- /dev/null +++ b/docs/wiki/Building-and-Running.md @@ -0,0 +1,66 @@ +# Building & Running + +## Requirements + +- Xcode 15+ (iOS 17 deployment target) +- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`) +- A **real iPhone**, not the Simulator, if you want to see `THE EYE` actually + work — the front camera does nothing in the Simulator. + +## Generate and open the app project + +The `.xcodeproj` is not committed — it's generated from `app/project.yml`, +the same way the rest of the repo treats derived artifacts: + +```sh +brew install xcodegen # once +cd app +xcodegen # generates LULL.xcodeproj +open LULL.xcodeproj # then build + run on a real device +``` + +No XcodeGen available? Per `app/README.md`: create a new iOS App target in +Xcode 15+, add the files under `app/Sources/`, and add the local `LULLKit` +package as a dependency. The sources are plain SwiftUI — nothing exotic. + +`app/project.yml` sets the important App-Review-facing bits directly: + +- `PRODUCT_BUNDLE_IDENTIFIER`: `io.github.testtest126.lull` +- `INFOPLIST_KEY_NSCameraUsageDescription`: the honest camera rationale + string shown by the OS +- Deployment target: iOS 17.0 + +## What running it does + +From `app/README.md`, in order: + +1. **Asks in-app first.** `Sensor.camera.rationale` is shown before the OS + prompt. A "no" at either level is final — nothing watches. +2. **Opens the front camera**, degraded and never shown clean. No frame is + recorded; nothing leaves the device. +3. **Runs the clock**: `EyeSession` moves `calm → noticing → awake` on a + timer. +4. **"Close the eye"** is always present — it revokes consent and stops the + camera immediately (the panic switch). + +## Testing `LULLKit` + +`LULLKit` is a standalone Swift package and builds/tests independent of the +iOS app or a simulator: + +```sh +cd LULLKit +swift test +``` + +As of this writing that's **16 tests** across three files — `ConsentTests`, +`EyeSessionTests`, `AtmosphereTests` — covering the consent invariants (see +[The Safety Invariant](The-Safety-Invariant)), the `EyeSession` phase machine +(including the anti-fast-forward clamp), and the `Atmosphere` narration +layer. Per the README and `CLAUDE.md`, this suite is meant to stay green from +commit one and every commit after — it's the project's "perft," the +executable proof that consent and the dread clock behave as designed. + +> `swift test` requires a full Swift toolchain with XCTest available (ships +> with Xcode's command-line tools). A bare Swift toolchain without Xcode may +> not have `XCTest` on its module search path. diff --git a/docs/wiki/Concept-and-Design.md b/docs/wiki/Concept-and-Design.md new file mode 100644 index 0000000..4d046ae --- /dev/null +++ b/docs/wiki/Concept-and-Design.md @@ -0,0 +1,94 @@ +# Concept & Design + +Source: [`README.md`](https://github.com/testtest126/LULL/blob/main/README.md), +[`docs/concept.md`](https://github.com/testtest126/LULL/blob/main/docs/concept.md), +[`CLAUDE.md`](https://github.com/testtest126/LULL/blob/main/CLAUDE.md). + +## The premise + +LULL poses as a sleep aid. You install it to help you fall asleep; it asks, +politely, for the usual permissions. For a few nights it behaves — soft +sounds, a breathing guide, a gentle log of your rest. Then it starts to +*remember things you didn't tell it*, to notice when you're watching, and to +forget that it's supposed to be a game. + +The one-line pitch: not "a scary game on a phone" — **the scary thing is the +phone.** It's in your hand, it has a camera on your face and a mic in your +room, it knows it's late, and it can reach you after you've closed it. + +## Why iPhone (the OD move) + +The concept brief frames this as Kojima's fourth-wall instinct (Psycho +Mantis "reading" your memory card, MGS2 faking a crash) applied to a device +that's *already personal* in a way a console across the room never is. That +intimacy isn't a limitation to design around — per `docs/concept.md`, "it is +the entire game." + +## Fear mechanics (iOS-native) + +From the README / concept brief. **Only `THE EYE` is implemented** as of v0.1 +— the rest are named design intent, not shipped features (see +[Roadmap / Ideas](Roadmap-Ideas)). + +| Mechanic | The device turned against you | Status | +|---|---|---| +| **THE EYE** | The front camera — it watches you, and reacts to your face | Implemented (`THE EYE`, v0.1) | +| **THE ROOM** | The microphone — it hears the silence, and what breaks it | Named only | +| **THE REACH** | A notification at 3am, while the app is closed | Named only | +| **THE HOUR** | It plays differently when it's late and the room is still | Named only | +| **THE PULSE** | A heartbeat in your palm, through haptics, not quite yours | Named only | +| **BEHIND YOU** | Spatial audio, something just over your shoulder | Named only | + +## Form + +Short. One sitting. Escalating, with one clean break in the fourth wall — not +a sprawling epic. Dread over gore: the phone doesn't need blood. The concept +brief argues restraint scares harder than spectacle, and restraint is what a +solo build can afford to do impeccably. + +## The voice: Kafka, Beckett, Poe + +LULL narrates in three literary registers, one per act of the experience: + +| Register | Governs | The feeling | +|---|---|---| +| **Franz Kafka** | The threshold — consent | A record opening in your name; a verdict withheld | +| **Samuel Beckett** | The lull — the calm & the endings | Waiting, the failing light, the nothing that is a mercy | +| **Edgar Allan Poe** | The watch — the escalation | The eye that will not blink; the heart beneath the floor | + +This is a design language, not a citation. Every line in +[`Atmosphere.swift`](https://github.com/testtest126/LULL/blob/main/LULLKit/Sources/LULLKit/Atmosphere.swift) +is original prose written *in* each register — nothing is quoted, and no +author's name appears on screen, so the spell isn't broken. The registers live +in a pure, tested, sensor-free layer over the same phase machine that drives +the dread (`EyeSession.Phase`), so the writing is provable and structurally +cannot widen what the game is permitted to touch — see +[The Safety Invariant](The-Safety-Invariant) for what "cannot widen" means +precisely. + +## The consent-first philosophy + +Also documented in [The Safety Invariant](The-Safety-Invariant), but the +design rationale is worth stating plainly, in the project's own words +(`CLAUDE.md` / README): + +- Every sensor is opt-in, explained, and revocable. Default is deny. +- Forbidden, forever: photos, contacts, location trails, health data, and + anything read without the player knowing. +- No genuine harm — uncanny, not traumatizing; unsettling, but never + deceptive about what's real. A fake "your data has leaked" is out; the + dread is fiction and stays legibly fiction. +- No PII in the repo, logs, or telemetry, ever. + +The fear is meant to come from what the player *knowingly* hands over, turned +uncanny — not from what is taken. Privacy-first is framed as both the decent +choice and the only one App Review will pass. + +## Lineage + +The project explicitly reuses lessons (not process) from an earlier project, +[MateMate (chess)](https://github.com/testtest126/chess): SwiftUI craft, a +Vapor server pattern, "verify, don't assume" rigor, and a privacy-audited +foundation. `CLAUDE.md` is explicit that the *ceremony* around that project +(multi-agent coordination protocols) doesn't apply here — LULL is small, and +keeps the rigor while dropping the ritual. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..662f0e4 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,50 @@ +# LULL + +*Working title — alts: HUSH / VIGIL / SOMNUS* + +**A psychological horror game for iPhone that stops behaving like software.** +Built to be played once. Alone. In the dark. + +Not "a scary game on a phone" — **the scary thing is the phone.** It's in your +hand, it has a camera on your face and a mic in your room, it knows it's late, +and it can reach you after you've closed it. + +Inspired by the *spirit* of Hideo Kojima's **OD**: fear as the subject, the +blur between game and film, dread over gore, and the unknown — at a scale a +small team can actually build. + +LULL is solo/small, open source (MIT), and currently at **v0.1 — the vertical +slice**: one fear mechanic, `THE EYE`, executed end to end, consent-gated, and +speaking in its own narrative voice. + +## The one rule + +> Horror by permission, not violation. + +Every sensor LULL touches is opt-in, explained, and revocable. Photos, +contacts, location, and health data are not just declined — they are designed +out of the codebase. See **[The Safety Invariant](The-Safety-Invariant)** for +exactly what that does and doesn't guarantee. + +## Wiki contents + +| Page | What's in it | +|---|---| +| **[Concept & Design](Concept-and-Design)** | The horror premise, why iPhone, the fear-mechanics list, and the consent-first philosophy | +| **[The Safety Invariant](The-Safety-Invariant)** | The honest account of what's compiler-enforced vs. what's convention | +| **[Architecture](Architecture)** | LULLKit vs. the app target, the state machines, how the pieces compose | +| **[Building & Running](Building-and-Running)** | XcodeGen setup, running `THE EYE` on device, `swift test` | +| **[Atmosphere (experiment)](Atmosphere-Experiment)** | The SpriteKit prototype on `experiment/spritekit-atmosphere` / PR #1 | +| **[Roadmap / Ideas](Roadmap-Ideas)** | What the repo's own status notes imply is next — marked tentative | + +## Status at a glance + +- `LULLKit` (the shared Swift package): consent ledger, the `EyeSession` state + machine, and the `Atmosphere` narration layer — 16 unit tests, green from + commit one. +- `app/`: a SwiftUI + AVFoundation iPhone app implementing `THE EYE`, + consent-gated end to end. +- `server/`: not started yet — the planned Vapor "haunt" backend is design-only + (see [Roadmap / Ideas](Roadmap-Ideas)). + +License: [MIT](https://github.com/testtest126/LULL/blob/main/LICENSE). diff --git a/docs/wiki/Roadmap-Ideas.md b/docs/wiki/Roadmap-Ideas.md new file mode 100644 index 0000000..67192d9 --- /dev/null +++ b/docs/wiki/Roadmap-Ideas.md @@ -0,0 +1,75 @@ +# Roadmap / Ideas + +> **Everything on this page is tentative.** LULL has no issue tracker or +> formal roadmap document in the repo. What follows is only what the repo's +> own status notes (README "Status" section, `CLAUDE.md`, the concept brief, +> and the fear-mechanics table) genuinely imply is next — not a commitment, +> not a schedule, and not anything beyond what's already written down +> somewhere in the project. + +## What v0.1 says is done + +Per the README's own "Status" section: + +> `LULLKit` (consent, the `EyeSession` mechanic, and the `Atmosphere` voice) +> is tested and green — 16 tests, green from commit one; the SwiftUI + +> AVFoundation app in `app/` implements `THE EYE`, consent-gated, speaking in +> the three registers above. [...] Not scary yet — but it watches, it has a +> voice, and it never breaks the rule. + +Two things worth noting precisely: the project's own words are **"not scary +yet"** — v0.1 is described as a proven mechanic and a working consent +foundation, not a finished horror experience. And the vertical slice's +purpose is explicitly a go/no-go test, per the concept brief: *"does it make +one person, alone at night, put the phone face-down? If yes, everything else +is worth building. If no, we learned it cheap."* No result of that test is +recorded in the repo. + +## Implied next mechanics + +The README's fear-mechanics table lists five more mechanics beyond `THE EYE` +that are named and designed but have no code yet (see +[Concept & Design](Concept-and-Design) for the full table): + +- **THE ROOM** — microphone, listening to silence and what breaks it +- **THE REACH** — a local notification at 3am, app closed +- **THE HOUR** — behavior that varies with how late/still it is +- **THE PULSE** — a haptic "heartbeat" that isn't quite the player's own +- **BEHIND YOU** — spatial audio placing something over the player's shoulder + +Notably, `LULLKit.Sensor` already has cases for `microphone`, `notifications`, +`haptics`, and `motion` — with honest rationale strings written for each — +even though only `.camera` is wired into any actual session logic today. That +reads as the allow-list having been designed ahead of the mechanics that will +use it, which fits the "horror by permission" rule: the sensor has to be +nameable and consent-gated *before* any mechanic can use it. + +## The haunt server + +The README describes a `server/` component, marked *(later)*: + +> the Vapor **haunt server**: content that shifts between sessions, seems to +> respond, and is quietly shared between players. + +There is no `server/` directory in the repo yet. The concept brief frames +this as reusing the Vapor/session/realtime stack from the MateMate chess +project, calling it "already a solved problem" — but that's a stated +intention to reuse prior work, not evidence of any LULL-specific server code +existing. + +## The SpriteKit question + +Whether the [Atmosphere experiment](Atmosphere-Experiment) (PR #1, draft) +gets adopted, replaced with a lighter SwiftUI/Core Animation approach, or +left as a prototype is explicitly undecided — the PR frames it as evidence to +look at, not a decision already made. + +## What's genuinely open, per the project's own framing + +- Whether `THE EYE` actually scares anyone (the go/no-go test the whole slice + was built to run) — no result recorded in the repo. +- Which, if any, of the other five mechanics get built next. +- Whether the SpriteKit prototype becomes the app's rendering approach for + ambience, or purely reactive future mechanics use it while ambience stays + in SwiftUI. +- The haunt server — design intent only, no code. diff --git a/docs/wiki/The-Safety-Invariant.md b/docs/wiki/The-Safety-Invariant.md new file mode 100644 index 0000000..c9b854c --- /dev/null +++ b/docs/wiki/The-Safety-Invariant.md @@ -0,0 +1,135 @@ +# The Safety Invariant + +LULL's README and `CLAUDE.md` both say the "one rule" — horror by permission, +not violation — is "enforced in code." That's true, but it's true of **one +half** of the guarantee and not the other. This page states precisely which +half is which, because overclaiming here is exactly the kind of thing App +Review (and any careful reader) will catch. + +## Two separate claims + +1. **"LULL cannot even name a forbidden sensor."** — Photos, contacts, + location, and health data. +2. **"The camera never runs without consent."** — The one sensor LULL *does* + use, gated correctly every time. + +These are different kinds of guarantee, verified differently. + +## Claim 1 — genuinely compiler-enforced + +In [`LULLKit/Sources/LULLKit/Consent.swift`](https://github.com/testtest126/LULL/blob/main/LULLKit/Sources/LULLKit/Consent.swift): + +```swift +public enum Sensor: String, CaseIterable, Sendable, Codable { + case camera + case microphone + case notifications + case haptics + case motion +} +``` + +`Sensor` is a closed Swift `enum`. There is no `case` for photos, contacts, +location, or health data. `ConsentLedger.grant(_:)`, `.mayUse(_:)`, and every +other consent API take a `Sensor` as their parameter type — so **no line of +Swift anywhere in this codebase can express "ask for contacts" or "read +location."** It isn't refused at runtime; it can't be written, full stop. To +add such a sensor, someone would have to edit `Consent.swift` itself, in a +diff any reviewer would immediately see for what it is. + +This is a real type-system guarantee, not a policy or a runtime check. It's +the strongest kind of promise a codebase can make. + +## Claim 2 — currently a disciplined call site, not a compiler guarantee + +The camera is *allowed* to exist (`Sensor.camera` is a valid case), so the +type system can't forbid using it — it can only help make sure it's used +correctly. Today, "correctly" is enforced by a hand-written sequence, not by +a type that makes the wrong order impossible. + +Trace the actual call path in +[`app/Sources/GameModel.swift`](https://github.com/testtest126/LULL/blob/main/app/Sources/GameModel.swift): + +```swift +func allowTheEye() async { + consent.grant(.camera) + guard await camera.requestAccess() else { + consent.revoke(.camera) + eye.consent(false) + return + } + eye.consent(true) + await camera.start() + startClock() +} +``` + +This is the *only* place in the app that calls `camera.start()`, and the +sequencing — grant consent, then ask the OS, then start — is correct as +written and matches the "in-app first, OS prompt second" rule from +`CLAUDE.md`. But look at +[`CameraController`](https://github.com/testtest126/LULL/blob/main/app/Sources/CameraController.swift) +and [`EyeSession`](https://github.com/testtest126/LULL/blob/main/LULLKit/Sources/LULLKit/Eye.swift) +themselves: + +- `CameraController.start()` has **no internal check** against + `ConsentLedger` or `EyeSession.wantsCamera`. It will start the capture + session if you call it — from anywhere, at any time. It trusts its caller. +- `EyeSession.wantsCamera` is a *derived, read-only property* the app is + expected to consult ("the app asks this and nothing else," per the doc + comment) — but nothing in the types forces `GameModel` to actually check it + before calling `camera.start()`. `GameModel` happens to only call `start()` + right after `eye.consent(true)`, but that's the current code being + well-behaved, not a constraint the compiler would catch if a future call + site skipped it. +- Nothing stops a hypothetical new call site (a new view, a debug button, a + future feature) from constructing its own `CameraController` and calling + `.start()` directly, bypassing `ConsentLedger` and `EyeSession` entirely. + The compiler would accept that code. + +So: the *shape* of the guarantee that exists today is "one reviewed function, +`GameModel.allowTheEye()`, does the right thing, and it's the only place that +touches the camera." That's a real, verifiable property of the *current* +codebase — but it's a convention upheld by there being one call site and a +small team, not an invariant the type system defends against a second call +site being added carelessly. + +`closeTheEye()` (the panic switch) has the same shape: it's correct today — +it cancels the clock, calls `eye.release()`, `consent.revokeAll()`, and +`camera.stop()` — but nothing types-check that *every* path capable of +starting the camera is paired with a path that can stop it. + +## What would close the gap + +Not implemented — noted here as the honest "what a stronger version would +look like," not as a roadmap commitment: + +- Have `CameraController.start()` (or a wrapper) take a `ConsentLedger` or an + `EyeSession` snapshot as a required argument, so the compiler refuses to + compile a call site that doesn't supply proof of consent. +- Make `CameraGate` conformances only obtainable through a factory that + requires a granted `ConsentLedger`, so `CameraController` can't be + constructed and started independently of the consent flow. + +## What's tested today + +The consent *logic* itself — independent of whether every call site respects +it — is unit-tested in +[`ConsentTests.swift`](https://github.com/testtest126/LULL/blob/main/LULLKit/Tests/LULLKitTests/ConsentTests.swift) +and +[`EyeSessionTests.swift`](https://github.com/testtest126/LULL/blob/main/LULLKit/Tests/LULLKitTests/EyeSessionTests.swift): +default-deny, per-sensor scoping, immediate revoke, the panic switch clearing +everything, denial being respected and terminal from any phase, and the +camera clock being unable to fast-forward past a backgrounded app. These are +real, green, and pinned. What they don't and can't test is "every future call +site in the app will remember to check consent first" — that's the part that +currently relies on the app being small enough that one person can review +every call to `camera.start()` by eye. + +## Bottom line + +- **Forbidden sensors:** genuinely impossible to name in code. Compiler-level. +- **Consent gates the camera:** correctly wired today, verified by reading + the single call site — but held up by code review and a small surface + area, not by the type system. Worth re-checking any time a new call site + touching `CameraController` is added.