Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,18 @@ cargo check --workspace
cargo test --workspace
```

### Swift ↔ Rust Bridges — landed on `develop` (Slice 2)

Each bridge `dlopen`s `libskilly_core_ffi.dylib` (built from `core/ffi`) when present and falls back to the existing Swift logic when absent — so the app keeps working with or without the Rust dylib. All wiring is additive + `// MARK: - Skilly`.

| File | Lines | Purpose |
|------|-------|---------|
| `RustPolicyBridge.swift` | ~200 | Dynamic FFI loader for policy. `EntitlementManager.canStartTurn()` + `TrialTracker`/`UsageTracker` call Rust first, Swift fallback otherwise. |
| `RustSkillsBridge.swift` | ~220 | Dynamic FFI loader for skill prompt composition; falls back to `SkillPromptComposer`. |
| `RustRealtimeBridge.swift` | ~180 | Dynamic FFI loader for realtime replay/lifecycle; Swift fallback otherwise. |

> ⚠ The new `leanring-buddy/*.swift` files auto-compile via the project's `PBXFileSystemSynchronizedRootGroup` (Xcode 16, objectVersion 77) — no `project.pbxproj` edits needed. Validate with an Xcode build (trial/active/capped/admin turn-start + Rust-absent fallback); agents cannot run `xcodebuild` (TCC).

### Mobile SDK Bindings (`sdk/`) — landed on `develop` (Slice 3)

UniFFI-generated iOS (Swift) and Android (Kotlin) bindings over `core/mobile-sdk`, plus sample consumers and packaging scripts. The `sdk/*/generated/**` files are machine-generated — **regenerate, never hand-edit** (`scripts/generate-mobile-sdk-bindings.sh`; output is byte-reproducible from the crate).
Expand Down
76 changes: 76 additions & 0 deletions docs/architecture/rust-dylib-packaging-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Rust Dylib Packaging Strategy (macOS Shell)

## Purpose
Define a deterministic strategy for building and loading `libskilly_core_ffi.dylib` in development and release workflows.

## Current State
- Bridges dynamically try env-var paths first, then local workspace build outputs:
- `target/debug/libskilly_core_ffi.dylib`
- `target/release/libskilly_core_ffi.dylib`
- Fallback to Swift logic is intentional when dylib is unavailable.

## Goals
1. Keep macOS app functional even when Rust library is missing.
2. Make Rust-enabled runs deterministic in Xcode and CI.
3. Avoid ad-hoc dylib path drift across developer machines.

## Development Strategy

### Local Build Command
From repo root:
```bash
cargo build -p skilly-core-ffi
```

### Xcode Scheme Environment
Prefer one canonical variable:
- `SKILLY_RUST_CORE_DYLIB_PATH=/absolute/path/to/target/debug/libskilly_core_ffi.dylib`

Backward-compatible vars remain supported:
- `SKILLY_RUST_POLICY_DYLIB_PATH`
- `SKILLY_RUST_SKILLS_DYLIB_PATH`
- `SKILLY_RUST_REALTIME_DYLIB_PATH`

### Expected Behavior
- If dylib exists at configured path: Rust bridge path is active.
- If dylib missing: bridge logs fallback and Swift path remains active.

## CI Strategy
CI should always validate Rust build artifacts separately from Xcode runtime:
1. `cargo build -p skilly-core-ffi`
2. `cargo test -p skilly-core-ffi`
3. `cargo check --workspace`

This guarantees ABI surfaces compile while keeping macOS GUI runtime tests outside terminal `xcodebuild`.

## Release Strategy (Current)
For release builds, keep Swift fallback as hard safety net.
Do not block app release solely on Rust dylib packaging until runtime parity is fully proven.

Implemented packaging automation:
1. `scripts/package-rust-ffi-dylib.sh` builds `skilly-core-ffi --release` and publishes host-specific tarballs in `dist/rust-ffi/`.
2. `.github/workflows/mobile-sdk-artifacts.yml` builds and uploads Rust FFI artifacts for macOS and Linux and attaches them to GitHub release assets.

## Release Strategy (Target)
After parity is proven:
1. Add pre-release check that builds `skilly-core-ffi`.
2. Bundle dylib in app resources (or deterministic sidecar location).
3. Set runtime lookup path to bundled location first.
4. Keep fallback path behind runtime flag for rollback.

## Proposed Build-Phase Hook (Future)
Add optional Xcode "Run Script" phase for debug builds:
1. Run `cargo build -p skilly-core-ffi`.
2. Copy dylib to derived-data deterministic location.
3. Export `SKILLY_RUST_CORE_DYLIB_PATH` in scheme.

This is not mandatory yet; current env-var path approach is adequate during migration.

## Risk Controls
1. Never remove Swift fallback while migration phases remain incomplete.
2. Keep bridge symbols additive to avoid breaking older dylibs in local caches.
3. Version ABI via `skilly_policy_ffi_version()` and add equivalent version entrypoints for new surfaces as needed.

## Decision
Adopt env-var-first deterministic loading for development and maintain Swift fallback.
Use packaged Rust FFI artifacts as release sidecars while host-app runtime parity validation continues.
88 changes: 88 additions & 0 deletions docs/architecture/swift-rust-fallback-parity-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Swift-Rust Fallback Parity Harness

## Purpose
Define how we validate behavior parity when a Rust bridge is available versus when Swift fallback logic is used.

This harness applies to:
- policy gating (`RustPolicyBridge`)
- skill prompt composition (`RustSkillsBridge`)
- realtime transition replay (`RustRealtimeBridge`)

## Core Principle
For each bridge-backed decision path, we verify two lanes:
1. Rust lane: bridge loaded and Rust result used.
2. Swift lane: bridge unavailable and Swift fallback used.

The expected outcome must match for equivalent inputs unless a migration ADR explicitly declares a behavior change.

## Runtime Toggle Strategy
Bridge load behavior is controlled by environment variables and dylib availability.

Recommended local toggles in Xcode scheme:
- Rust lane:
- set `SKILLY_RUST_CORE_DYLIB_PATH` to a valid `libskilly_core_ffi.dylib`
- Swift lane:
- unset all `SKILLY_RUST_*_DYLIB_PATH` vars (or point to a non-existent path)

## Parity Scenarios

### Policy Scenarios
1. Trial user under cap -> allowed.
2. Trial user exhausted -> blocked (`trialExhausted`).
3. Active user under cap -> allowed.
4. Active user over cap -> blocked (`capReached`).
5. Admin user over cap/expired -> allowed.
6. Canceled user with valid access under cap -> allowed.
7. Canceled user with valid access over cap -> blocked (`capReached`).

### Skill Prompt Scenarios
1. Active skill with full vocabulary budget.
2. Active skill where vocabulary trimming applies.
3. Active skill stage with completed-stage history.
4. Missing current stage fallback behavior.
5. Pointing mode variants (`always`, `when-relevant`, `minimal`).

### Realtime Transition Scenarios
1. Happy path turn:
- `turn_started -> audio_capture_committed -> audio_playback_started -> response_completed`
2. Error path:
- `turn_started -> audio_capture_committed -> session_error`
3. Reset path:
- `completed -> session_reset`
4. Invalid-order rejection:
- `turn_started -> response_completed` (without commit)

## Verification Procedure
1. Run Rust fixture/unit checks:
- `cargo test --workspace`
2. Run shell smoke checks:
- `cargo run -p skilly-linux-shell -- --smoke`
- `cargo run -p skilly-windows-shell -- --smoke`
3. Run manual macOS parity in Xcode:
- lane A (Rust enabled): perform each scenario and capture observed outcomes.
- lane B (Rust disabled): repeat scenarios.
4. Compare:
- decision values
- block reasons
- prompt output text shape
- lifecycle phase progression

## Evidence Capture
For each scenario, record:
- lane (`rust` or `swift`)
- input snapshot
- output snapshot
- parity result (`match` / `mismatch`)
- notes

Store evidence in:
- `docs/architecture/runtime-validation-report-YYYY-MM-DD.md` generated via:
- `./scripts/create-runtime-validation-report.sh`
- or PR body under "Parity Evidence" when a dedicated report is not required.

## Failure Policy
If a mismatch is found:
1. Treat it as a regression by default.
2. Add a focused fixture test reproducing mismatch.
3. Fix Rust or Swift path to restore parity.
4. If change is intentional, document it in an ADR and update expected fixtures.
4 changes: 2 additions & 2 deletions leanring-buddy/AdminAllowlist.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ enum AdminAllowlist {
)
}

private static var allAdminWorkOSUserIDs: Set<String> {
static var allConfiguredAdminWorkOSUserIDs: Set<String> {
adminWorkOSUserIDs.union(infoPlistAdminWorkOSUserIDs)
}

/// Whether the currently signed-in user is a Skilly admin.
@MainActor
static var isCurrentUserAdmin: Bool {
guard let userID = AuthManager.shared.currentUser?.id else { return false }
return allAdminWorkOSUserIDs.contains(userID)
return allConfiguredAdminWorkOSUserIDs.contains(userID)
}
}
Loading
Loading