Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c3dc7d8
feat(pads): add opt-in context ownership and association routing
delano Oct 2, 2026
0d1a8a3
feat(pads): expose the picker and independent timeline ordering
delano Oct 2, 2026
65f391d
feat(identity): mint UUIDv7 for pads and core items
delano Oct 2, 2026
b8f94d4
feat(icons): add Composer artwork with internal cast shadow
delano Oct 2, 2026
c7344b9
feat(packaging): compile and bundle the glass app icon
delano Oct 2, 2026
06b9f40
fix(icons): give actool stable stdin in CI
delano Oct 2, 2026
a8a4911
fix(ci): package refractivity artwork with Xcode 27
delano Oct 2, 2026
1599c74
fix(icons): validate glass compilation and packaging outputs
delano Oct 2, 2026
239ee2c
docs(icons): clarify Composer artwork and shadow assumptions
delano Oct 2, 2026
e655057
Merge pull request #230 from onetimesecret/codex/icon-composer-cast-s…
delano Oct 2, 2026
5075cad
Merge branch 'main' into codex/pad-context-implementation
delano Oct 2, 2026
6e31b98
fix(identity): clamp UUIDv7 clocks and document stable tab IDs
delano Oct 2, 2026
1f05bed
fix(pads): reconcile metadata and retain per-pad file context
delano Oct 2, 2026
874dbc2
fix(pads): expose lifecycle controls and clarify picker accessibility
delano Oct 2, 2026
349dd41
docs(pads): record every PR review resolution and final evidence
delano Oct 2, 2026
8bd5b71
fix(pads): rank app hints by manual visit order
delano Oct 2, 2026
3f878b1
fix(pads): retain file ownership and visible confirmation context
delano Oct 2, 2026
a7210c0
docs(pads): record Greptile fixes and focused review findings
delano Oct 2, 2026
4a0e43b
fix(pads): preserve checkpoint preferences across midnight reads
delano Oct 2, 2026
7e75bc6
fix(pads): yield shortcuts to sheets in every app window
delano Oct 2, 2026
53891ac
docs(pads): record the final Greptile findings and checks
delano Oct 2, 2026
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
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,21 @@ jobs:
# enough for every assertion here.
package-release:
name: release packaging (macos)
runs-on: macos-latest
# GitHub-hosted preview image, not a self-hosted runner label:
# https://github.com/actions/runner-images/issues/14404
# The saved Composer document enables Xcode 27's refractivity feature.
runs-on: xcode-27
steps:
- uses: actions/checkout@v4
- name: Verify release Xcode toolchain
run: |
version="$(xcodebuild -version | awk '$1 == "Xcode" {print $2}')" || version=
major="${version%%.*}"
if [[ ! "$major" =~ ^[0-9]+$ ]] || ((major < 27)); then
echo "release packaging requires Xcode 27 or later (selected: ${version:-unknown})" >&2
exit 1
fi
xcodebuild -version
- name: Install pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ Two entry points, both in `scripts/`:
Follow [Distributing OnetimePad through TestFlight](docs/development/testflight-distribution.md)
for account setup, upload, and tester qualification.

Release packaging compiles [artwork/OnetimePad-Glass.icon](artwork/OnetimePad-Glass.icon)
with Xcode's `actool` and bundles both `Assets.car` and the generated `.icns`.
The document enables refractivity; Apple's
[Xcode 27 release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-27-release-notes)
introduce Icon Composer 2.0 with “support for refractivity”. Release packaging
requires Xcode 27 or later; set `DEVELOPER_DIR` or `xcode-select` to select it.
Edit the document in Icon Composer, then run
`scripts/build-icons.sh --glass` to compile just the icon, or use the release
commands above to package it. Debug builds keep the black development icon.
Ad-hoc icons rendered with `build-icons.sh` no longer select the release icon.
See the [exported glass icon preview](artwork/OnetimePad-Glass-CastShadow.png)
for the current composition.

Signing values stay outside the checkout, one environment directory per lane,
so every worktree reads the same ones: `dev/.env` for the dev lane,
`local/.env` for the local lane, and `staging/.env` for the App Store lane,
Expand Down
Binary file added artwork/OnetimePad-Glass-CastShadow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 43 additions & 0 deletions artwork/OnetimePad-Glass.icon/icon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"features" : [
"refractivity"
],
"fill" : {
"automatic-gradient" : "extended-srgb:0.86275,0.29020,0.13333,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "01-logo-foreground.png",
"name" : "01-logo-foreground"
},
{
"glass" : false,
"image-name" : "00-logo-cast-shadow.png",
"name" : "Cast shadow"
}
],
"name" : "Group",
"refractivity" : {
"depth" : 0,
"enabled" : true,
"strength" : 0.15
},
"shadow" : {
"kind" : "neutral",
"opacity" : 0.18
},
"translucency" : {
"enabled" : true,
"value" : 0.18
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
103 changes: 90 additions & 13 deletions crates/core/src/sheet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,28 @@
//! The excerpt on a chip is mechanical; counts are counts; detection
//! never returns.

use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use crate::blocks::{BlockIndex, BlockMeta};
use crate::document::{DocRun, SheetDocument};
use crate::ledger::SizeClass;
use crate::secret::SecretBuffer;
use crate::ttl::{self, Ttl};

/// A random 128-bit item identifier (a version 4 UUID), minted at
/// creation. The sequential [`SheetId`] and [`ChipId`] counters stay for
/// A stable 128-bit item identifier. Newly minted values are version 7 UUIDs;
/// restored legacy version 4 values retain their original bytes. The
/// sequential [`SheetId`] and [`ChipId`] counters stay for
/// internal ordering; this is the only identifier that may appear in the
/// ledger or in any persisted artifact (ADR-0012).
/// ledger or in any persisted artifact (ADR-0012; `UUIDv7` follow-up in ADR-0039).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ItemId([u8; 16]);

impl ItemId {
/// Mint a fresh identity from the operating system CSPRNG.
/// Mint a `UUIDv7` from Unix milliseconds and the operating system CSPRNG.
/// The timestamp is visible in the ID; the remaining 74 bits are random.
/// Same-millisecond order and clock-rollback monotonicity are not promised.
/// A pre-epoch clock uses timestamp zero; a clock beyond the 48-bit field
/// uses its maximum value. Neither clock condition aborts identity minting.
///
/// Panicking when the CSPRNG is unavailable is deliberate: a fallback
/// identifier would be predictable, and an unpredictable identity is
Expand All @@ -46,9 +51,23 @@ impl ItemId {
pub fn random() -> Self {
let mut bytes = [0u8; 16];
getrandom::getrandom(&mut bytes).expect("the OS CSPRNG must be available");
// Version 4 in the high nibble of byte 6, RFC 4122 variant in the
// top two bits of byte 8.
bytes[6] = (bytes[6] & 0x0F) | 0x40;
Self::version_seven_at(SystemTime::now(), bytes)
}

fn version_seven_at(time: SystemTime, bytes: [u8; 16]) -> Self {
let millis = time
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
Self::version_seven(millis, bytes)
}

fn version_seven(millis: u128, mut bytes: [u8; 16]) -> Self {
let millis = millis.min((1_u128 << 48) - 1);
// RFC 9562 §5.7: 48-bit big-endian timestamp, version 7 and
// RFC variant, with all other bits drawn from the CSPRNG.
bytes[..6].copy_from_slice(&millis.to_be_bytes()[10..]);
bytes[6] = (bytes[6] & 0x0F) | 0x70;
bytes[8] = (bytes[8] & 0x3F) | 0x80;
Self(bytes)
}
Expand Down Expand Up @@ -256,7 +275,7 @@ impl SealedChip {
self.id
}

/// The random item identity, minted when this chip was sealed. The
/// The item identity, minted when this chip was sealed. The
/// only identifier of this chip that may leave the process.
#[must_use]
pub fn uuid(&self) -> ItemId {
Expand Down Expand Up @@ -401,7 +420,7 @@ impl Sheet {
self.id
}

/// The random item identity, minted when this page was created. The
/// The item identity, minted when this page was created. The
/// only identifier of this page that may leave the process.
#[must_use]
pub fn uuid(&self) -> ItemId {
Expand Down Expand Up @@ -730,7 +749,7 @@ impl Tab {
self.id
}

/// The random item identity, minted when this tab was opened.
/// The item identity, minted when this tab was opened.
#[must_use]
pub fn uuid(&self) -> ItemId {
self.uuid
Expand Down Expand Up @@ -1170,17 +1189,44 @@ mod tests {
}

#[test]
fn item_ids_are_random_version_four() {
fn item_ids_are_version_seven_with_current_unix_milliseconds() {
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
let before = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let id = ItemId::random();
let after = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let bytes = *id.as_bytes();
assert_eq!(bytes[6] >> 4, 0x4, "version nibble");
let mut timestamp = [0u8; 8];
timestamp[2..].copy_from_slice(&bytes[..6]);
let millis = u128::from(u64::from_be_bytes(timestamp));
assert!((before..=after).contains(&millis), "Unix milliseconds");
assert_eq!(bytes[6] >> 4, 0x7, "version nibble");
assert_eq!(bytes[8] >> 6, 0b10, "variant bits");
assert!(seen.insert(bytes), "a minted identity repeated");
}
}

#[test]
fn item_id_v7_matches_rfc_9562_vector_and_orders_distinct_milliseconds() {
let random = [
0, 0, 0, 0, 0, 0, 0xcc, 0xc3, 0x18, 0xc4, 0xdc, 0x0c, 0x0c, 0x07, 0x39, 0x8f,
];
let id = ItemId::version_seven(0x017f_22e2_79b0, random);
assert_eq!(id.to_string(), "017f22e2-79b0-7cc3-98c4-dc0c0c07398f");
let later = ItemId::version_seven(0x017f_22e2_79b1, [0; 16]);
assert!(id < later);
// Restoring an older UUIDv4 retains every original bit.
let mut legacy = [0x44; 16];
legacy[8] = 0x84;
assert_eq!(ItemId::from_bytes(legacy).as_bytes(), &legacy);
}

#[test]
fn item_id_renders_as_hyphenated_lowercase_hex() {
let id = ItemId::random();
Expand All @@ -1198,6 +1244,37 @@ mod tests {
assert_eq!(ItemId::from_bytes(*id.as_bytes()), id);
}

#[test]
fn item_id_clock_boundaries_clamp_without_changing_random_bits() {
let random = [0xAB; 16];
let earliest = ItemId::version_seven_at(UNIX_EPOCH - Duration::from_millis(1), random);
let epoch = ItemId::version_seven_at(UNIX_EPOCH, random);
assert_eq!(earliest, epoch);
assert_eq!(&earliest.as_bytes()[..6], &[0; 6]);
let submillisecond =
ItemId::version_seven_at(UNIX_EPOCH + Duration::from_nanos(999_999), random);
assert_eq!(submillisecond, epoch);
let one_millisecond =
ItemId::version_seven_at(UNIX_EPOCH + Duration::from_millis(1), random);
assert_eq!(&one_millisecond.as_bytes()[..6], &[0, 0, 0, 0, 0, 1]);

let maximum_millis = (1_u64 << 48) - 1;
let maximum =
ItemId::version_seven_at(UNIX_EPOCH + Duration::from_millis(maximum_millis), random);
let beyond = ItemId::version_seven_at(
UNIX_EPOCH + Duration::from_millis(maximum_millis + 1),
random,
);
assert_eq!(beyond, maximum);
assert_eq!(ItemId::version_seven(u128::MAX, random), maximum);
assert_eq!(&maximum.as_bytes()[..6], &[0xFF; 6]);
// Clamping changes only the timestamp; version/variant masking and
// the supplied random bits remain identical at both bounds.
assert_eq!(&earliest.as_bytes()[6..], &maximum.as_bytes()[6..]);
assert_eq!(earliest.as_bytes()[6] >> 4, 7);
assert_eq!(earliest.as_bytes()[8] >> 6, 2);
}

#[test]
fn a_sealed_chip_carries_its_own_uuid() {
// Identity is minted, not derived from content: identical text
Expand Down
6 changes: 6 additions & 0 deletions crates/ffi/include/companion_ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,12 @@ bool companion_tab_set_title(CompanionHandle *handle, uint64_t tab,
* entry per slot, whether or not it holds a page. Free with
* companion_string_free(). Fields per tab:
* id (the TAB's id, what the selection and the keyboard address),
* uuid (string: the TAB's stable ItemId in lowercase hyphenated UUID
* form. It survives restore unchanged while the dense numeric id is
* re-minted. Newly minted identities are UUIDv7: the first 48 bits
* encode Unix milliseconds at creation, clamped to zero for a
* pre-epoch clock or to 2^48-1 beyond that range. Restored legacy
* UUIDv4 identities coexist unchanged and carry no such timestamp),
* has_page (bool, false is a slot whose page expired or was never
* opened; every clock field below is meaningless then, and the
* strip draws the dashed empty treatment instead of a gauge),
Expand Down
28 changes: 27 additions & 1 deletion crates/ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3631,6 +3631,7 @@ fn summary_json(
let remaining = page.map_or(std::time::Duration::ZERO, |sheet| sheet.remaining(now));
serde_json::json!({
"id": tab.id().raw(),
"uuid": tab.uuid().to_string(),
"has_page": page.is_some(),
"page_id": page.map(|sheet| sheet.id().raw()),
"title": tab.label(utc_offset_seconds),
Expand Down Expand Up @@ -4022,6 +4023,30 @@ mod tests {
serde_json::from_str(&unsafe { take_json(companion_tabs_json(handle)) }).unwrap()
}

#[test]
fn tab_summary_uuid_survives_reorder_and_dense_restore_ids() {
unsafe {
let handle = handle();
let first = companion_tab_new(handle);
let second = companion_tab_new(handle);
let third = companion_tab_new(handle);
let before = strip(handle);
let second_uuid = before[1]["uuid"].clone();
let third_uuid = before[2]["uuid"].clone();
assert!(second_uuid.as_str().is_some_and(|uuid| uuid.len() == 36));
assert!(companion_tab_close(handle, first));
assert!(companion_tab_move(handle, third, 0));
age_by(handle, 0);
let restored = strip(handle);
assert_eq!(restored[0]["id"], 1);
assert_eq!(restored[0]["uuid"], third_uuid);
assert_eq!(restored[1]["id"], 2);
assert_eq!(restored[1]["uuid"], second_uuid);
assert_ne!(second, 0);
companion_free(handle);
}
}

/// The page a tab holds, read back through the summaries, or `None`
/// for an empty slot.
unsafe fn page_of(handle: *mut CompanionHandle, tab: u64) -> Option<u64> {
Expand Down Expand Up @@ -5533,7 +5558,7 @@ mod tests {
/// summary is decoded by name on the far side, so a rename here is
/// an empty window there.
#[test]
fn the_fifteen_existing_summary_keys_are_unchanged() {
fn existing_summary_fields_and_additive_stable_uuid_are_preserved() {
let handle = handle();
unsafe {
let (_tab, page) = new_page(handle);
Expand All @@ -5548,6 +5573,7 @@ mod tests {
keys.sort_unstable();
let mut expected = [
"id",
"uuid",
"has_page",
"page_id",
"title",
Expand Down
6 changes: 6 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ browser prototype, folder and application association proposals, and approaches
removed during review. Browser behavior and simulated native actions are
identified separately; a mockup is not evidence of a shipped contract.

The opt-in native attempt is defined in the [pad-context feature proposal](spec/feature/pad-context/README.md),
with [implementation notes](development/pad-context-experiment.md) and a
[native verification runbook](qa/pad-context-experiment.md). The
[PR #232 follow-up checklist](qa/pr232-review-followup.md) records each review
observation and its implementation resolution.

## plans/

Routes to a milestone: what has to happen, in what order, to get
Expand Down
31 changes: 31 additions & 0 deletions docs/adr/0039-pad-selection-and-associations.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,37 @@ Storage choice, migration, and metadata removal still require review.
- Stable ownership must survive native tab restoration. A restore-reminted
numeric handle is insufficient as the persistent catalog identity; the
implementation must use stable identity and verify restart behavior.
- The maintainer's follow-up instruction is: “re: UUIDs, use uuidv7.” Newly
minted pad IDs and core `ItemId` identities (tabs, pages, chips, and block
records) use UUIDv7 in this experiment.
Existing stored IDs and Scratch's nil sentinel are preserved. This departs
from the **proposed** [ADR-0012](0012-framing-threat-boundary-and-persistence-model.md),
whose item-identity wording is “Every sheet/chip gets a random 128-bit
identifier (UUIDv4) at creation, minted in the Rust core.” UUIDv7 encodes
Unix milliseconds plus 74 random bits ([RFC 9562 §5.7](https://www.rfc-editor.org/rfc/rfc9562.html#section-5.7));
the RFC defines `unix_ts_ms` as a “48-bit big-endian unsigned number of the
Unix Epoch timestamp in milliseconds”.
The earlier fully-random-identity rationale cannot be carried forward as
an unchanged privacy claim. This proposal records that scope explicitly.
UUIDv7 timestamps are not trusted evidence of real creation time: the
proposed generators clamp clocks before the Unix epoch to zero and clocks
beyond the 48-bit field to `2^48 - 1` milliseconds. The Swift generator also
treats NaN dates as zero and infinite dates as the corresponding bound.
These rules keep malformed wall clocks from aborting identity minting while
retaining OS CSPRNG randomness; they do not promise monotonic IDs across a
clock rollback. The FFI tab-summary `uuid` is the stable identity, while
`id` is a restore-reminted numeric handle. Restored UUIDv4 values coexist
with newly minted UUIDv7 values, so consumers must not interpret every
summary UUID as containing a creation timestamp.
- The revised implementation proposal compares explicitly supplied directory
aliases using resolved symlinks and volume/inode metadata when available,
and folds path spelling only when the volume reports case-insensitive
matching. It does not infer a universal case-folding rule. Inaccessible
roots fall back to resolved lexical comparison; that fallback cannot
establish that two unavailable spellings refer to different physical
directories. Thus one-owner-by-physical-identity remains a proposed
requirement with an explicit unavailable-metadata limitation. No directory
tree scan, watcher, or file-content read is part of this comparison.
- Path equality, nested roots, inaccessible associations, activation failures,
association editing, and bounded recency need tests and documented failure
behavior. Sample chooser exclusivity does not prove any of them.
Expand Down
Loading
Loading