Skip to content

Commit aa58d88

Browse files
Merge plain-entries: plain journal entries + crisis-scan policy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents c9304ec + 3eba9f2 commit aa58d88

7 files changed

Lines changed: 431 additions & 69 deletions

File tree

ROADMAP.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,12 @@ installer).
5858
wired to the dose-reference interaction data; DoseWiki's dangerous/unsafe/caution
5959
tiers map onto our danger/caution/note severities (with the reason text), and
6060
inline dose-range + interaction warnings appear while logging a dose.
61-
- **Distribution** — cross-platform signed installers (macOS universal `.dmg` +
62-
Linux `.AppImage`/`.deb`/`.rpm`) via `tauri-action` CI on `v*` tags, plus
63-
**in-app auto-update** (Tauri updater; "Install & restart" banner). macOS is
64-
currently **unsigned** (right-click → Open on first launch) pending an Apple
65-
Developer ID.
61+
- **Distribution** — cross-platform installers (macOS universal `.dmg` +
62+
Linux `.AppImage`/`.deb`/`.rpm` + **Windows NSIS `.exe`/`.msi` from v0.5.0**)
63+
via `tauri-action` CI on `v*` tags, plus **in-app auto-update** (Tauri updater;
64+
"Install & restart" banner). macOS is currently **unsigned** (right-click →
65+
Open on first launch) pending an Apple Developer ID; Windows is unsigned too
66+
(SmartScreen "More info → Run anyway").
6667

6768
---
6869

@@ -382,7 +383,7 @@ emotional presence.
382383
**v0.4.0 and v0.4.1 are shipped and public** — the knowledge corpus, contribution
383384
drafts, the phone portal (Phase 3a), the combo-checker fix, and the phone Companion fix.
384385

385-
### Plain journal entries (not a drug session) — proposed
386+
### Plain journal entries (not a drug session) — ✅ shipped in v0.5.0
386387

387388
Today an entry **has to be a session**. If you just want to write about your day, the
388389
app has nowhere to put it, which quietly narrows a journal into a drug log. It should
@@ -393,10 +394,15 @@ be possible to write a plain text entry, in the same journal, alongside the sess
393394
*yet* looks exactly like a note, and it would flip type under you mid-session. Add a
394395
`kind` column (`'session' | 'note'`, defaulting to `'session'` so every existing row
395396
keeps its meaning) and branch on it.
396-
- **The crisis scan still runs.** A plain entry is *more* likely to be where someone
397-
writes that they're not okay, not less. `crisis.rs` must fire on these exactly as it
398-
does on session notes. The interaction checker is simply irrelevant here — that's
399-
fine, and different from being switched off.
397+
- **The crisis scan does NOT run on journal prose — owner's decision (2026-07-14).**
398+
The journal is private; the app must not read over the user's shoulder. `crisis.rs`
399+
fires in exactly two places: (1) what the user *says to* the Companion in an active
400+
chat (self-harm / harm-to-others intent), and (2) the deterministic combo checker
401+
when a dangerous interaction is flagged. Journal entries — session notes, timeline
402+
notes, and plain entries — are saved as written and never scanned. (The phone's
403+
timeline-note scan was removed for the same reason.) Don't relitigate this by
404+
"adding safety"; the guardrails live where the user is talking to something, not
405+
where they're talking to themselves.
400406
- **The UI should get quieter, not just different.** A plain entry has no doses, no
401407
timeline, no combo warnings, no elapsed-time header. It's a title, a body, a date.
402408
Resist re-using the session layout with the drug parts hidden.

src-tauri/src/commands.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ pub fn import_experience(db: State<'_, Db>, parsed: ollama::ParsedExperience) ->
273273
let exp = db::create_experience(
274274
&conn,
275275
&ExperienceInput {
276+
kind: "session".into(),
276277
title: if parsed.title.is_empty() { "Imported experience".into() } else { parsed.title.clone() },
277278
intention: parsed.intention.clone(),
278279
setting: parsed.setting.clone(),

src-tauri/src/db.rs

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ pub fn open(path: &Path, key: Option<&str>) -> rusqlite::Result<Connection> {
3030
conn.pragma_update(None, "journal_mode", "WAL")?;
3131
conn.pragma_update(None, "foreign_keys", "ON")?;
3232
conn.execute_batch(SCHEMA)?;
33+
// Migration: `kind` postdates v0.5 journals. CREATE TABLE IF NOT EXISTS won't
34+
// touch an existing table, so add the column when it's missing.
35+
let has_kind: i64 = conn.query_row(
36+
"SELECT COUNT(*) FROM pragma_table_info('experiences') WHERE name = 'kind'",
37+
[],
38+
|r| r.get(0),
39+
)?;
40+
if has_kind == 0 {
41+
conn.execute_batch("ALTER TABLE experiences ADD COLUMN kind TEXT NOT NULL DEFAULT 'session'")?;
42+
}
3343
Ok(conn)
3444
}
3545

@@ -107,6 +117,10 @@ CREATE TABLE IF NOT EXISTS substances (
107117
108118
CREATE TABLE IF NOT EXISTS experiences (
109119
id INTEGER PRIMARY KEY,
120+
-- 'session' (a drug session: doses, timeline, the works) or 'note' (a plain
121+
-- journal entry: title, body, date — nothing else). Explicit, never inferred:
122+
-- a session with no doses logged *yet* is still a session.
123+
kind TEXT NOT NULL DEFAULT 'session',
110124
title TEXT NOT NULL DEFAULT '',
111125
intention TEXT NOT NULL DEFAULT '',
112126
setting TEXT NOT NULL DEFAULT '',
@@ -194,6 +208,8 @@ pub struct TimelineEvent {
194208
#[derive(Debug, Clone, Serialize)]
195209
pub struct Experience {
196210
pub id: i64,
211+
/// `'session'` or `'note'`. Set at creation and never changed by edits.
212+
pub kind: String,
197213
pub title: String,
198214
pub intention: String,
199215
pub setting: String,
@@ -240,6 +256,9 @@ pub struct SubstanceInput {
240256

241257
#[derive(Debug, Deserialize)]
242258
pub struct ExperienceInput {
259+
/// `'session'` (default) or `'note'` — anything else is treated as `'session'`.
260+
#[serde(default = "default_kind")]
261+
pub kind: String,
243262
#[serde(default)]
244263
pub title: String,
245264
#[serde(default)]
@@ -249,6 +268,10 @@ pub struct ExperienceInput {
249268
pub started_at: String,
250269
}
251270

271+
fn default_kind() -> String {
272+
"session".into()
273+
}
274+
252275
#[derive(Debug, Deserialize)]
253276
pub struct DoseInput {
254277
pub experience_id: i64,
@@ -342,6 +365,7 @@ fn classes_for(conn: &Connection, name: &str) -> Vec<String> {
342365
fn row_to_experience(r: &rusqlite::Row) -> rusqlite::Result<Experience> {
343366
Ok(Experience {
344367
id: r.get("id")?,
368+
kind: r.get("kind")?,
345369
title: r.get("title")?,
346370
intention: r.get("intention")?,
347371
setting: r.get("setting")?,
@@ -354,14 +378,33 @@ fn row_to_experience(r: &rusqlite::Row) -> rusqlite::Result<Experience> {
354378
}
355379

356380
pub fn create_experience(conn: &Connection, input: &ExperienceInput) -> rusqlite::Result<Experience> {
381+
// Normalize, don't validate-and-reject: unknown kinds mean an older client,
382+
// and an older client means a session (that was the only kind there was).
383+
let kind = if input.kind == "note" { "note" } else { "session" };
357384
conn.execute(
358-
"INSERT INTO experiences (title, intention, setting, started_at) VALUES (?1, ?2, ?3, ?4)",
359-
params![input.title, input.intention, input.setting, input.started_at],
385+
"INSERT INTO experiences (kind, title, intention, setting, started_at) VALUES (?1, ?2, ?3, ?4, ?5)",
386+
params![kind, input.title, input.intention, input.setting, input.started_at],
360387
)?;
361388
let id = conn.last_insert_rowid();
362389
get_experience_row(conn, id)
363390
}
364391

392+
/// Refuse a session-only operation against a plain note. Notes have no doses, no
393+
/// timeline, and no "end": that invariant is enforced here, not hoped for in the UI.
394+
fn require_session(conn: &Connection, experience_id: i64, what: &str) -> rusqlite::Result<()> {
395+
let kind: String =
396+
conn.query_row("SELECT kind FROM experiences WHERE id = ?1", [experience_id], |r| r.get(0))?;
397+
if kind == "note" {
398+
// SqliteFailure with a message Displays as just the message, which is what
399+
// `Db::with`'s to_string() hands the UI.
400+
return Err(rusqlite::Error::SqliteFailure(
401+
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
402+
Some(format!("This entry is a plain note, not a session — it can't have {what}.")),
403+
));
404+
}
405+
Ok(())
406+
}
407+
365408
fn get_experience_row(conn: &Connection, id: i64) -> rusqlite::Result<Experience> {
366409
conn.query_row("SELECT * FROM experiences WHERE id = ?1", [id], row_to_experience)
367410
}
@@ -422,6 +465,7 @@ pub fn get_experience(conn: &Connection, id: i64) -> rusqlite::Result<Experience
422465
}
423466

424467
pub fn end_experience(conn: &Connection, id: i64, ended_at: &str, rating: Option<i64>, notes: &str) -> rusqlite::Result<Experience> {
468+
require_session(conn, id, "an end time or rating")?;
425469
conn.execute(
426470
"UPDATE experiences SET ended_at = ?2, rating = ?3, notes = ?4 WHERE id = ?1",
427471
params![id, ended_at, rating, notes],
@@ -434,6 +478,7 @@ pub fn end_experience(conn: &Connection, id: i64, ended_at: &str, rating: Option
434478
/// Insert a dose and return it together with any interaction warnings against the
435479
/// other substances already logged in the same experience.
436480
pub fn log_dose(conn: &Connection, input: &DoseInput) -> rusqlite::Result<(Dose, Vec<crate::interactions::Warning>)> {
481+
require_session(conn, input.experience_id, "doses")?;
437482
let substance_id: Option<i64> = conn
438483
.query_row(
439484
"SELECT id FROM substances WHERE name = ?1 COLLATE NOCASE",
@@ -490,6 +535,7 @@ pub fn combo_warnings(conn: &Connection, names: &[String]) -> Vec<crate::interac
490535
}
491536

492537
pub fn add_timeline_event(conn: &Connection, input: &TimelineInput) -> rusqlite::Result<TimelineEvent> {
538+
require_session(conn, input.experience_id, "timeline events")?;
493539
conn.execute(
494540
"INSERT INTO timeline_events (experience_id, at, note, mood, intensity)
495541
VALUES (?1, ?2, ?3, ?4, ?5)",
@@ -781,6 +827,7 @@ mod tests {
781827
}).unwrap();
782828

783829
let exp = create_experience(&c, &ExperienceInput {
830+
kind: "session".into(),
784831
title: "test".into(), intention: String::new(), setting: String::new(),
785832
started_at: "2026-01-01T20:00:00Z".into(),
786833
}).unwrap();
@@ -813,6 +860,77 @@ mod tests {
813860
assert_eq!(detail.doses.len(), 2);
814861
}
815862

863+
#[test]
864+
fn plain_notes_are_explicit_and_session_only_ops_refuse_them() {
865+
let c = mem();
866+
867+
// Kind is normalized at creation: unknown values mean an older client.
868+
let weird = create_experience(&c, &ExperienceInput {
869+
kind: "diary".into(), title: "old client".into(),
870+
intention: String::new(), setting: String::new(),
871+
started_at: "2026-07-01T09:00:00Z".into(),
872+
}).unwrap();
873+
assert_eq!(weird.kind, "session");
874+
875+
let note = create_experience(&c, &ExperienceInput {
876+
kind: "note".into(), title: "just a day".into(),
877+
intention: String::new(), setting: String::new(),
878+
started_at: "2026-07-02T09:00:00Z".into(),
879+
}).unwrap();
880+
assert_eq!(note.kind, "note");
881+
882+
// A note is not a session: no doses, no timeline, no "end".
883+
let dose = log_dose(&c, &DoseInput {
884+
experience_id: note.id, substance_name: "MDMA".into(), amount: Some(100.0),
885+
unit: "mg".into(), route: "oral".into(), taken_at: "2026-07-02T10:00:00Z".into(),
886+
note: String::new(),
887+
});
888+
assert!(dose.is_err());
889+
let ev = add_timeline_event(&c, &TimelineInput {
890+
experience_id: note.id, at: "2026-07-02T10:00:00Z".into(),
891+
note: "hm".into(), mood: String::new(), intensity: None,
892+
});
893+
assert!(ev.is_err());
894+
assert!(end_experience(&c, note.id, "2026-07-02T11:00:00Z", Some(5), "").is_err());
895+
896+
// Editing the body doesn't flip the kind.
897+
let edited = update_experience(&c, note.id, &ExperienceUpdate {
898+
title: "just a day".into(), intention: String::new(), setting: String::new(),
899+
notes: "wrote some words".into(), rating: None,
900+
started_at: "2026-07-02T09:00:00Z".into(), ended_at: None,
901+
}).unwrap();
902+
assert_eq!(edited.kind, "note");
903+
}
904+
905+
#[test]
906+
fn kind_column_is_added_to_pre_v05_journals() {
907+
let dir = std::env::temp_dir().join(format!("fn-migrate-test-{}", std::process::id()));
908+
let _ = std::fs::remove_dir_all(&dir);
909+
std::fs::create_dir_all(&dir).unwrap();
910+
let path = dir.join("journal.db");
911+
912+
// A journal created before `kind` existed: same table minus the column.
913+
{
914+
let c = Connection::open(&path).unwrap();
915+
c.execute_batch(
916+
"CREATE TABLE experiences (
917+
id INTEGER PRIMARY KEY, title TEXT NOT NULL DEFAULT '',
918+
intention TEXT NOT NULL DEFAULT '', setting TEXT NOT NULL DEFAULT '',
919+
notes TEXT NOT NULL DEFAULT '', rating INTEGER,
920+
started_at TEXT NOT NULL, ended_at TEXT,
921+
created_at TEXT NOT NULL DEFAULT (datetime('now')));
922+
INSERT INTO experiences (title, started_at) VALUES ('old', '2026-01-01T00:00:00Z');",
923+
).unwrap();
924+
}
925+
926+
// Reopening through the front door migrates it.
927+
let c = open(&path, None).unwrap();
928+
let exp = get_experience_row(&c, 1).unwrap();
929+
assert_eq!(exp.kind, "session", "existing rows keep their meaning");
930+
drop(c);
931+
let _ = std::fs::remove_dir_all(&dir);
932+
}
933+
816934
// Uses the bundled DoseWiki snapshot (no network). MDMA + Tramadol is a
817935
// graded "dangerous" interaction on both sides, so it must surface at log time.
818936
#[test]
@@ -822,6 +940,7 @@ mod tests {
822940
pw_replace_all(&mut c, &all).unwrap();
823941

824942
let exp = create_experience(&c, &ExperienceInput {
943+
kind: "session".into(),
825944
title: "t".into(), intention: String::new(), setting: String::new(),
826945
started_at: "2026-01-01T00:00:00Z".into(),
827946
}).unwrap();

0 commit comments

Comments
 (0)