Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ jobs:
- name: Public CLI status consumes shared effective-state
run: ./scripts/test-cli-status.sh ./build/desktidy-sort

- name: R1B Phase 1A fake-substrate harness
run: |
chmod +x scripts/build-probe.sh scripts/test-phase1a-wiring.sh
./scripts/test-phase1a-wiring.sh
./build/desktidy-sort --phase1a-test
./build/desktidy-sort --phase1a1-test
chmod +x scripts/test-phase1a1-public-boundary.sh
./scripts/test-phase1a1-public-boundary.sh
./scripts/build-probe.sh build
test -x ./build/DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe
test -x ./build/DeskTidySacrificialProbe.app/Contents/MacOS/SacrificialHelper
test -f ./build/DeskTidySacrificialProbe.app/Contents/Library/LaunchAgents/com.desktidy.sacrificial.plist
codesign -dv ./build/DeskTidySacrificialProbe.app
./build/DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe --plan | grep -q 'read-only\|NON-PRODUCTION\|UNOBSERVED'

- name: R1A read-only confinement grep (app sources contain no mutation calls)
run: |
for f in app/DeskTidyApp.swift src/EffectiveState.swift; do
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## Unreleased (branch r1b/phase1a-smappservice-harness — stacked on Phase 0, non-final)

- **R1B Phase 1A.1 (measured evidence seal, still no live mutation):** the
sacrificial probe builds `InterlockContext` from measured executable
SHA-256, compiled 40-hex source commit, one-open authorization bytes,
two root/authority observations, and a durable `O_CREAT|O_EXCL` nonce.
A sealed `PreparedMutationGrant` is produced then the probe exits 4
(`STOP_BEFORE_PRODUCTION_ADAPTER`) with a zero construction/call ledger.
Production adapter methods exist but return fail-closed and do not call
`SMAppService.register` / `.unregister`. Phase 1B still requires a
reviewed connection patch plus separate architect authorization.
- **R1B Phase 1A (fake-substrate only):** sacrificial SMAppService harness —
migration state machine, injectable fake adapter, multi-factor mutation
interlock, simulated transaction/rollback matrix, non-production probe
bundle (ad-hoc, development evidence). No real `SMAppService.register`
or `.unregister` execution. Apple public distribution membership
unresolved. Phase 1B observation is separately authorized.

## Unreleased (branch r1b/phase0-unified-truth — stacked on R1A, non-final)

- **R1B Phase 0 (no live service migration):** one target resolver, one
Expand Down
139 changes: 139 additions & 0 deletions docs/R1B_PHASE1B_OPERATOR_RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Phase 1B Operator Runbook — Sacrificial SMAppService Observation

Phase 1A.1 seals measurement and grant preparation only. Authorization
alone is **not** the next step.

Phase 1B still requires:

1. a reviewed minimal patch connecting the sealed `PreparedMutationGrant` to
exactly one production adapter call;
2. separate architect authorization for that patch and the observation.

Do not run a real mutation path until both exist. Fake-substrate tests are
not that observation.

Token creation must use the **measured probe executable SHA-256** and the
**compiled 40-hex source commit** from the built probe (`--plan` prints the
commit). Nonce reservations are durable and non-reusable under the
sacrificial support root. Foreign/protected-root evidence is measured twice.

Runtime label, Login Items string, status semantics, FDA/TCC, reboot/login,
and Apple distribution remain **unknown**. No automatic production
self-label widening.

## Non-claims (Apple membership)

Developer ID, notarization, TestFlight/App Store, public DMG, and production
profiles are unavailable. Ad-hoc signing is development evidence only.

Compilation of `SMAppService` is capability, not proof of runtime registration.

## Identities (hypotheses until observed)

| Item | Value | Status |
|---|---|---|
| Probe bundle id | `com.desktidy.sacrificial-probe` | coded |
| Embedded plist | `Contents/Library/LaunchAgents/com.desktidy.sacrificial.plist` | coded |
| Hypothesized label | `com.desktidy.sacrificial` | **UNOBSERVED** |
| Production self labels | `com.desktidy.sort`, `com.desktidy.notify` | unchanged |

Do **not** add any observed label to `ProductIdentity.selfLabels` automatically.

## Prerequisites

1. Phase 1A PR reviewed; source commit recorded (40- or 64-hex SHA).
2. Sacrificial probe built with `scripts/build-probe.sh` into a `/tmp` output root.
3. Bundle SHA-256 of the built `.app` recorded.
4. Protected live labels remain `com.sicarii.desktop-autosort` and
`com.sicarii.desktop-autosort-notify`.
5. Architect is present for Login Items / FDA visual readback.

## Sacrificial root

Create a directory **outside** `~/Desktop` and not symlink-equivalent to it,
e.g. `/tmp/desktidy-phase1b-<nonce>`. Never use the live Desktop.

## Pre-observation inventory

Record, read-only:

- `git rev-parse HEAD`
- `shasum -a 256` of the probe bundle executable
- `launchctl print` is **not** required until Phase 1B; if used, print only,
never bootstrap/bootout/kickstart/enable/disable
- confirm personal labels are not mutation targets

## Authorization

The architect writes **one** one-time authorization file (strict JSON, schema 1,
exact keys, no duplicate keys) binding:

- `operation` (`register` then a second file for `unregister`)
- `sacrificialRoot`
- `bundleSHA256`
- `sourceCommit`
- `expiry`
- `nonce` (unique; never reused)

Phase 1A must not create a valid live file. Phase 1B creates it by hand.

## Probe command

Default is read-only:

```text
DeskTidySacrificialProbe.app/Contents/MacOS/DeskTidySacrificialProbe --plan
```

Mutation (Phase 1B only):

```text
…/DeskTidySacrificialProbe --register --auth-file /path/to/auth.json
…/DeskTidySacrificialProbe --unregister --auth-file /path/to/unreg.json
```

Phase 1A's probe **refuses to invoke** the production mutator even if the
interlock would permit (exit 4). Phase 1B replaces that stop with the single
granted adapter call.

## Exact readbacks after a real grant

1. `SMAppService.status` for the embedded plist name
2. `launchctl print gui/<uid>/<observed-label>` — print only
3. Login Items visible string (human screenshot/notes)
4. Helper heartbeat under the sacrificial app-support root
5. Target confinement: heartbeat/target not on Desktop
6. Migration transaction record fields
7. FDA/TCC: observe whether a prompt appears; do **not** enter credentials
into an agent-driven prompt. Architect operates the UI.
8. Reboot/login is a **later subphase**, not performed here.

## PASS / BLOCKED / INDETERMINATE / rollback-required

| Verdict | Meaning |
|---|---|
| PASS | Granted operation ran; status and label readbacks agree; no live Desktop/personal-label involvement; unregister restored absence |
| BLOCKED | Interlock refused, or a protected root/label would have been touched |
| INDETERMINATE | API returned unknown / status unreadable; must not claim success |
| rollback-required | Dual presence or unknown post-state; unregister and stop |

## Rollback and timeout

If register is granted and anything is unknown or dual, run the unregister
authorization within a bounded window (architect-chosen, e.g. 15 minutes).
If unregister fails or is unknown, mark rollback-required and stop.

## Post-run absence proof

After unregister: status notRegistered/notFound, no Login Item string for the
probe, heartbeat file may remain as a sacrificial artifact.

## Deletion rules

Delete only probe-owned `/tmp` sacrificial roots and authorization files.
Do not delete `~/Desktop` contents or personal-mover state.

## What this runbook does not do

No merge, tag, release, notarize, Homebrew, self-label widening, reboot, or
FDA grant by an agent.
59 changes: 59 additions & 0 deletions docs/evidence/R1B_PHASE1A1_ABA_NONCE_EXCLUSIVITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# R1B Phase 1A.1 A→B→A — durable nonce exclusivity

Semantic mutation of `src/DurableNonceStore.swift`: drop `O_EXCL` so a
replay or concurrent duplicate can open the same reservation path.

No production ServiceManagement adapter invocation. Fake/hermetic roots only.

## A (green)

- file: `src/DurableNonceStore.swift`
- SHA-256: `6200ce12ae8fd1e2d4cab5c0244a739acc657321d82ff32b22e3338457563bd1`
- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-phase1a1-aba/desktidy-sort-A && /tmp/desktidy-r1b-phase1a1-aba/desktidy-sort-A --phase1a1-test`
- exit: `0`
- excerpt:

```
PASS E08 nonce replay across store instances refused
PASS E09 concurrent nonce reservation has exactly one winner
PASS S11 retained second-precall foreign ID still refused at prepare
PASS S12 retained second-precall target-change ID still refused
PHASE1A1 GATES: 24 passed, 0 failed
```

## B (O_EXCL removed)

- SHA-256: `fba4f0ba4ec0ace5bc09c13262d4d9ed268f0ce63323e0ce41e0cec635962a54`
- rebuild after deleting the previous B binary
- command: `--phase1a1-test`
- exit: `1`
- failing IDs:

```
FAIL E08 nonce replay across store instances refused — reserved(...)
FAIL E09 concurrent nonce reservation has exactly one winner — wins=8 losses=0
PHASE1A1 GATES: 22 passed, 2 failed
```

Intended reason: without `O_CREAT|O_EXCL`, the second reservation of
`nonce-rep1` overwrites instead of refusing, and all eight concurrent
openers of `nonce-race1` win.

Diff (B vs A):

```diff
- let flags = disableExclusivityForMutationTest ? (O_CREAT | O_WRONLY) : (O_CREAT | O_EXCL | O_WRONLY)
+ let flags = (O_CREAT | O_WRONLY) // B-MUTATION: drop O_EXCL so replay/concurrent can pass
```

The test hook `disableExclusivityForMutationTest` is reset to `false` by
`Phase1A1Tests.runAll()`. Mutating only that default is not load-bearing;
the B mutation therefore changes the open flags themselves.

## Restore

- SHA-256: `6200ce12ae8fd1e2d4cab5c0244a739acc657321d82ff32b22e3338457563bd1` (equals A)
- `--phase1a1-test` exit 0 — 24/24; E08/E09/S11/S12 PASS

Retained second-precall transcript: `docs/evidence/R1B_PHASE1A_ABA_SECOND_PRECALL.md`.
S11/S12 re-run on this restore: PASS (phase1a 64/64 and phase1a1 24/24).
52 changes: 52 additions & 0 deletions docs/evidence/R1B_PHASE1A_ABA_SECOND_PRECALL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# R1B Phase 1A A→B→A — second pre-call authority/target check

Semantic mutation of `src/MigrationTransaction.swift`: skip the second
pre-call evidence check so a foreign mover or invalid target appearing
between preflight and mutation is ignored.

No production ServiceManagement adapter invocation. Fake adapter only.

## A (green)

- file: `src/MigrationTransaction.swift`
- SHA-256: `007cef67d03bffce6ac3d5ead179078d7fae25fd6ac0f0eb3c2f9079e87eabc3`
- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-phase1a-aba/desktidy-sort && /tmp/desktidy-r1b-phase1a-aba/desktidy-sort --phase1a-test`
- exit: `0`
- excerpt:

```
PASS S11 transaction refused
PASS S12 transaction refused
PHASE1A GATES: 64 passed, 0 failed
```

## B (second pre-call disabled)

- SHA-256: `f26672bde0cab8b77750c0fa53facb93d24913c4655af47d40f7d32320f2955e`
- rebuild after deleting the previous binary
- command: `--phase1a-test`
- exit: `1`
- failing IDs:

```
FAIL S11 transaction refused — got indeterminate regs=1 unregs=0
FAIL S12 transaction refused — got indeterminate regs=1 unregs=0
PHASE1A GATES: 62 passed, 2 failed
```

Intended reason: without the second check, `requestRegister` ran (`regs=1`)
after preflight had been clean. S11 plants a foreign mover at call time; S12
invalidates the target at call time.

Diff (B vs A):

```diff
- if !skipSecondPreCallCheck {
+ if false && !skipSecondPreCallCheck { // B-MUTATION: skip second pre-call authority/target check
```

## Restore

- SHA-256: `007cef67d03bffce6ac3d5ead179078d7fae25fd6ac0f0eb3c2f9079e87eabc3` (equals A)
- `--phase1a-test` exit 0 — 64/64; S11 PASS
- `--self-test` 17/17; `--state-test` 63/63; `--r0-test` 31/31
32 changes: 32 additions & 0 deletions probe/HelperMain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

// Sacrificial helper: no file movement, no network. Writes one heartbeat
// under an explicitly supplied sacrificial app-support root, then exits.
@main
struct SacrificialHelper {
static func main() {
let env = ProcessInfo.processInfo.environment
guard let app = env["DESKTIDY_PROBE_APP_DIR"], !app.isEmpty,
let target = env["DESKTIDY_PROBE_TARGET"], !target.isEmpty else {
fputs("sacrificial helper: refusing — DESKTIDY_PROBE_APP_DIR and DESKTIDY_PROBE_TARGET are required\n", stderr)
exit(2)
}
let desktop = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Desktop").path
if target == desktop || target.hasPrefix(desktop + "/") {
fputs("sacrificial helper: refusing — target must not be the live Desktop\n", stderr)
exit(2)
}
let dir = URL(fileURLWithPath: (app as NSString).expandingTildeInPath, isDirectory: true)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let beat = dir.appendingPathComponent("heartbeat.json")
let body = "{\"schema\":1,\"ok\":true,\"targetPresent\":true}\n"
try Data(body.utf8).write(to: beat)
} catch {
fputs("sacrificial helper: heartbeat write failed\n", stderr)
exit(1)
}
exit(0)
}
}
Loading
Loading