Log entries: REST patch/delete/convert-to-note API and unparsed-line repair (for Calendar UI) #70

Closed
opened 2026-09-24 18:34:43 +00:00 by kayg · 11 comments
Owner

The Calendar UI (#39/#42/#43, built by a Claude UI agent) needs a REST API to change individual log entries. Today it can only create them (POST /api/v1/notes/journal/log) and read a day (GET /api/v1/notes/journal/{date}). The CalDAV adapter (#41) already edits log entries through NotesJournalProvider (crates/plugins/notes/src/lib.rs, the calternal_plugin::JournalProvider impl: move, retime and delete by ^block-id, under lock_user, ids minted without re-serializing untouched lines, zone recorded as [tz=...]). Reuse that code path: one writer for both CalDAV and REST, not a second implementation.

Build, in the notes plugin, with unique operation_ids:

  • PATCH /api/v1/notes/journal/entries/{block_id}: change any of date (moves the line to another daily note), start, end (or clear the end), title, tags, tz. Rewrite only that line; the rest of both files keeps its exact bytes. Requires If-Match with the entry ETag (the same canonical-line hash CalDAV uses); 412 on mismatch. Returns the updated entry plus its new ETag. Recorded in file versions so the UI can undo by re-patching.
  • DELETE /api/v1/notes/journal/entries/{block_id} with If-Match: removes the line and its child bullets; recoverable via file versions.
  • POST /api/v1/notes/journal/entries/{block_id}/note: long text becomes a linked note (owner decision C7 / #42). It creates a note in Notes/ through the notes plugin (calternal identity, title from the entry) with the given Markdown body, and attaches it as a child bullet link under the entry (calternal.js attachment-note behaviour). Returns the note id.
  • GET /api/v1/notes/journal/{date}: also return unparsed: lines inside ## 📝 Log that the parser could not read, each with a line index, raw text, a stable line hash and an optional suggestion (canonical rewrite from the composer parser when confident). This is for #53.
  • POST /api/v1/notes/journal/{date}/lines/{hash}/fix: applies the suggestion to that one line (If-Match via the line hash), byte-stable elsewhere (#53).
  • Every entry in responses carries id (block id) and etag.
  • Tests: byte stability, cross-day move, DST zone, concurrent PATCH plus CalDAV PUT on the same entry (one wins, the other gets 412, no lost lines), CRLF files, a daily note without a Log heading, and a duplicate block id. Extend tests/adversarial/attack.py with cursed block ids, huge titles, newline injection in the title (must never create a second line), and a PATCH storm.
The Calendar UI (#39/#42/#43, built by a Claude UI agent) needs a REST API to change individual **log entries**. Today it can only create them (`POST /api/v1/notes/journal/log`) and read a day (`GET /api/v1/notes/journal/{date}`). The CalDAV adapter (#41) already edits log entries through `NotesJournalProvider` (`crates/plugins/notes/src/lib.rs`, the `calternal_plugin::JournalProvider` impl: move, retime and delete by `^block-id`, under `lock_user`, ids minted without re-serializing untouched lines, zone recorded as `[tz=...]`). Reuse that code path: **one writer for both CalDAV and REST, not a second implementation**. Build, in the notes plugin, with unique `operation_id`s: - `PATCH /api/v1/notes/journal/entries/{block_id}`: change any of `date` (moves the line to another daily note), `start`, `end` (or clear the end), `title`, `tags`, `tz`. Rewrite **only that line**; the rest of both files keeps its exact bytes. Requires `If-Match` with the entry ETag (the same canonical-line hash CalDAV uses); 412 on mismatch. Returns the updated entry plus its new ETag. Recorded in file versions so the UI can undo by re-patching. - `DELETE /api/v1/notes/journal/entries/{block_id}` with `If-Match`: removes the line and its child bullets; recoverable via file versions. - `POST /api/v1/notes/journal/entries/{block_id}/note`: long text becomes a linked note (owner decision C7 / #42). It creates a note in `Notes/` through the notes plugin (calternal identity, title from the entry) with the given Markdown body, and attaches it as a child bullet link under the entry (calternal.js attachment-note behaviour). Returns the note id. - `GET /api/v1/notes/journal/{date}`: also return `unparsed`: lines inside `## 📝 Log` that the parser could not read, each with a line index, raw text, a stable line hash and an optional `suggestion` (canonical rewrite from the composer parser when confident). This is for #53. - `POST /api/v1/notes/journal/{date}/lines/{hash}/fix`: applies the suggestion to that one line (If-Match via the line hash), byte-stable elsewhere (#53). - Every entry in responses carries `id` (block id) and `etag`. - Tests: byte stability, cross-day move, DST zone, concurrent PATCH plus CalDAV PUT on the same entry (one wins, the other gets 412, no lost lines), CRLF files, a daily note without a Log heading, and a duplicate block id. Extend `tests/adversarial/attack.py` with cursed block ids, huge titles, newline injection in the title (must never create a second line), and a PATCH storm.
Author
Owner

Started on branch job/log-patch at base SHA 41aa77499e.

Started on branch job/log-patch at base SHA 41aa77499e0a090371ff252458722fd80b4e6a44.
Author
Owner

Finding: NotesJournalProvider already owns conditional put/delete, and JournalEvent::etag() is the canonical line hash. dayfile::replace_log_entry and remove_log_entry already splice only the selected entry. I will extend these paths and add the repair helper in notes-core so REST and CalDAV retain one writer.

Finding: `NotesJournalProvider` already owns conditional `put`/`delete`, and `JournalEvent::etag()` is the canonical line hash. `dayfile::replace_log_entry` and `remove_log_entry` already splice only the selected entry. I will extend these paths and add the repair helper in notes-core so REST and CalDAV retain one writer.
Author
Owner

Finding: the existing NotesJournalProvider called bump_day_file_last_edited after line edits, which also rewrote frontmatter and violated the byte-preservation requirement. REST and CalDAV now use the same provider write path without that extra rewrite; calternal-fs::replace_if still records the previous Daily note as a version. I added migration 0006 with an index on (user_id, block_id) so entry lookup does not scan a Home when the rebuildable projection is current.

Finding: the existing NotesJournalProvider called `bump_day_file_last_edited` after line edits, which also rewrote frontmatter and violated the byte-preservation requirement. REST and CalDAV now use the same provider write path without that extra rewrite; `calternal-fs::replace_if` still records the previous Daily note as a version. I added migration 0006 with an index on `(user_id, block_id)` so entry lookup does not scan a Home when the rebuildable projection is current.
Author
Owner

Gate setup finding: the first check-generated run could not compile calternal-server because RustEmbed requires apps/web/build, which was absent. A direct frontend build then reported vite missing; bun install --frozen-lockfile installed the locked workspace dependencies. Building the frontend artifact and rerunning generation now. No endpoint behavior issue was found by this setup check.

Gate setup finding: the first check-generated run could not compile calternal-server because RustEmbed requires apps/web/build, which was absent. A direct frontend build then reported vite missing; bun install --frozen-lockfile installed the locked workspace dependencies. Building the frontend artifact and rerunning generation now. No endpoint behavior issue was found by this setup check.
Author
Owner

Implementation decisions where §§29–31 were silent:

  • The unparsed-line hash is BLAKE3 over the zero-based absolute Daily-note line index and raw line content without its EOL. The absolute index distinguishes repeated identical malformed lines; inserting a preceding line changes the hash and makes an old repair request stale. The response also carries the quoted strong ETag used by If-Match.
  • line_index and duplicate Log heading positions are zero-based absolute file indexes.
  • GET reports missing and duplicate Log headings under log_structure; the existing parser keeps using the first Log heading.
  • PATCH and DELETE return 428 when If-Match is absent. Creating a Note from an entry has no If-Match because the issue does not specify one; it locates and updates under the existing per-user writer lock.
  • A new journal_resources(user_id, block_id) index supports block lookups, with authoritative Daily-note scan fallback on stale/missing index rows. Journal REST writes reuse NotesJournalProvider, which is also used by CalDAV.
  • Journal line edits do not stamp Daily-note frontmatter because that would change bytes outside the selected line.
Implementation decisions where §§29–31 were silent: - The unparsed-line hash is BLAKE3 over the zero-based absolute Daily-note line index and raw line content without its EOL. The absolute index distinguishes repeated identical malformed lines; inserting a preceding line changes the hash and makes an old repair request stale. The response also carries the quoted strong ETag used by If-Match. - line_index and duplicate Log heading positions are zero-based absolute file indexes. - GET reports missing and duplicate Log headings under log_structure; the existing parser keeps using the first Log heading. - PATCH and DELETE return 428 when If-Match is absent. Creating a Note from an entry has no If-Match because the issue does not specify one; it locates and updates under the existing per-user writer lock. - A new journal_resources(user_id, block_id) index supports block lookups, with authoritative Daily-note scan fallback on stale/missing index rows. Journal REST writes reuse NotesJournalProvider, which is also used by CalDAV. - Journal line edits do not stamp Daily-note frontmatter because that would change bytes outside the selected line.
Author
Owner

Clippy finding: the workspace gate reported clippy::question_mark at crates/calternal-notes-core/src/composer.rs:181 in suggest_log_line: if find_time(candidate).is_none() { return None; }. This is the new Option-returning suggestion helper from b430a60. The helper already has tests for explicit-time suggestions and missing-time rejection. I will replace the check with find_time(candidate)?;, which keeps the same rejection behavior and follows the repo lint policy.

Clippy finding: the workspace gate reported `clippy::question_mark` at crates/calternal-notes-core/src/composer.rs:181 in suggest_log_line: `if find_time(candidate).is_none() { return None; }`. This is the new Option-returning suggestion helper from b430a60. The helper already has tests for explicit-time suggestions and missing-time rejection. I will replace the check with `find_time(candidate)?;`, which keeps the same rejection behavior and follows the repo lint policy.
Author
Owner

Clippy follow-up: after fixing the Option guard and retrying, the workspace gate reported clippy::derivable_impls at crates/plugins/notes/src/lib.rs:1448 for PatchField<T>: its manual Default implementation always returns the fieldless Missing variant. I changed it to derive Default with Missing marked #[default], preserving the Missing/Null/Value tri-state behavior. The full gate will be rerun after this minimal fix.

Clippy follow-up: after fixing the Option guard and retrying, the workspace gate reported `clippy::derivable_impls` at crates/plugins/notes/src/lib.rs:1448 for `PatchField<T>`: its manual Default implementation always returns the fieldless Missing variant. I changed it to derive Default with Missing marked `#[default]`, preserving the Missing/Null/Value tri-state behavior. The full gate will be rerun after this minimal fix.
Author
Owner

Completed kayg/calternal#70 on branch job/log-patch. Head SHA: 4da867f91c341ede5da99cd1f29a67e7be1c91ea. The worktree is clean.

Gate evidence:

  • CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_INCREMENTAL=0 cargo fmt --check: exit 0, no stdout.
  • CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_INCREMENTAL=0 cargo clippy --workspace --all-targets -- -D warnings: Finished dev profile [unoptimized + debuginfo] target(s) in 14m 06s (exit 0).
  • CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_INCREMENTAL=0 cargo test: exit 0. Notes core ran 458 tests; calternal-plugin-notes output was test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.80s.
  • bash packages/api-client/check-generated.sh: exit 0. Output:
    Finished dev profile [unoptimized + debuginfo] target(s) in 26.92s
    Running target/debug/calternal-server openapi``
    $ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts
    ✨ openapi-typescript 7.13.0
    🚀 ../../contracts/openapi.json → src/generated.ts [772.6ms]
  • bash tests/adversarial/run.sh: exit 0, ==== FINDINGS 0 and ==== ROUND 2 FINDINGS 0.
  • CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_INCREMENTAL=0 cargo clean: Removed 13807 files, 8.6GiB total.

Decisions were recorded earlier in this issue: zero-based absolute line indexes; BLAKE3 over line index plus raw content for unparsed-line identity; log_structure reports missing/duplicate headings while the parser keeps the first; journal PATCH/DELETE require If-Match and Note creation does not; provider lookup uses the block index with an authoritative scan fallback; direct journal edits preserve all bytes outside the selected line.

Known limitation: Note creation and its parent Log attachment require two filesystem writes. If the second write fails after the Note is created, the Note can remain intact but unattached. The server lock and preconditions serialize normal concurrent writers; this is a storage-failure recovery edge case.

Completed kayg/calternal#70 on branch `job/log-patch`. Head SHA: `4da867f91c341ede5da99cd1f29a67e7be1c91ea`. The worktree is clean. Gate evidence: - `CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_INCREMENTAL=0 cargo fmt --check`: exit 0, no stdout. - `CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_INCREMENTAL=0 cargo clippy --workspace --all-targets -- -D warnings`: `Finished `dev` profile [unoptimized + debuginfo] target(s) in 14m 06s` (exit 0). - `CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_INCREMENTAL=0 cargo test`: exit 0. Notes core ran 458 tests; `calternal-plugin-notes` output was `test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.80s`. - `bash packages/api-client/check-generated.sh`: exit 0. Output: `Finished `dev` profile [unoptimized + debuginfo] target(s) in 26.92s` `Running `target/debug/calternal-server openapi`` `$ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts` `✨ openapi-typescript 7.13.0` `🚀 ../../contracts/openapi.json → src/generated.ts [772.6ms]` - `bash tests/adversarial/run.sh`: exit 0, `==== FINDINGS 0` and `==== ROUND 2 FINDINGS 0`. - `CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_INCREMENTAL=0 cargo clean`: `Removed 13807 files, 8.6GiB total`. Decisions were recorded earlier in this issue: zero-based absolute line indexes; BLAKE3 over line index plus raw content for unparsed-line identity; `log_structure` reports missing/duplicate headings while the parser keeps the first; journal PATCH/DELETE require If-Match and Note creation does not; provider lookup uses the block index with an authoritative scan fallback; direct journal edits preserve all bytes outside the selected line. Known limitation: Note creation and its parent Log attachment require two filesystem writes. If the second write fails after the Note is created, the Note can remain intact but unattached. The server lock and preconditions serialize normal concurrent writers; this is a storage-failure recovery edge case.
Author
Owner

Hygiene review: the Log-to-Note path still needs two filesystem writes; if the attachment write fails, a Note can remain unattached. Keeping #70 open for this consistency edge case.

Hygiene review: the Log-to-Note path still needs two filesystem writes; if the attachment write fails, a Note can remain unattached. Keeping #70 open for this consistency edge case.
Author
Owner

Already implemented on origin/dev. git log origin/dev --grep='(#70)' shows 01818d39f, which merges the Log-entry PATCH, DELETE, convert-to-Note and unparsed-line repair API. Current crates/plugins/notes/src/lib.rs defines the journal entry routes and their tests; apps/web/src/lib/calendar/journal.ts calls them. Recommend recording the merged API implementation here. Do not close the issue in this audit.

Already implemented on origin/dev. git log origin/dev --grep='(#70)' shows 01818d39f, which merges the Log-entry PATCH, DELETE, convert-to-Note and unparsed-line repair API. Current crates/plugins/notes/src/lib.rs defines the journal entry routes and their tests; apps/web/src/lib/calendar/journal.ts calls them. Recommend recording the merged API implementation here. Do not close the issue in this audit.
Author
Owner

Fixed in 01818d39f (origin/dev); Journal patch, delete, conversion and repair are covered by Notes tests and the Log section of tests/adversarial/attack.py.

Fixed in 01818d39f (origin/dev); Journal patch, delete, conversion and repair are covered by Notes tests and the Log section of `tests/adversarial/attack.py`.
kayg closed this issue 2026-10-03 11:55:18 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#70
No description provided.