Skip to content

A caller can make a write conditional on the state of the block it read - #124

Merged
muellerei merged 1 commit into
mainfrom
integration/035-vorbedingung
Oct 7, 2026
Merged

muellerei merged 1 commit into
mainfrom
integration/035-vorbedingung

Conversation

@muellerei

@muellerei muellerei commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Closes #123

Summary

get-block --json                      -> hash, tree_hash
find-block / get-page / get-todos /
get-journal-range  --json             -> hash on every block

update-block, set-todo-status, set-block-property, remove-property --id,
move-block, copy-block --remove, add-journal-block --upsert-heading
  --expect-hash H          remove-block / delete-block  --expect-tree-hash H
  set-todo-status          --expect-marker M

WriteCommand(owes=…)           the one place that asks "did the call say what it read?"
  guard_write()                read_only first
  guard_preconditions()        [safety] require_preconditions
  command body
    read past the cache
    check_precondition()       before --dry-run, the no-change answer and the first write
    update_block(expect=…)     the write itself refuses with block_changed if it moved again
 [safety]
 read_only = false
+require_preconditions = true     # also --require-preconditions, LOGSEQ_CLI_REQUIRE_PRECONDITIONS

Evidence

  • Before: an agent reads a block, the user edits it in Logseq, the agent's write goes through over the edit. Suite: 3793 tests.
    After: the same call refuses with reason: precondition_failed (shown from the test double), nothing written:
$ logseq-cli update-block --id 6650a1b2-0000-4000-8000-000000000001 \
    --content "TODO ship the parser, fixed" --expect-hash 3fa9c01b27de
Error: Block 6650a1b2.. changed since it was read (marker is TODO, first line:
"TODO ship the parser"). Read it again and decide again. Nothing was written.
$ echo $?
1
  • The hash ignores collapsed:: and CLOCK lines, so folding a block or a running clock does not fire it.
  • Suite: 4029 passed, 8 skipped, on Python 3.10, 3.11, 3.12 and 3.13; ruff check . clean; the wheel check passes.
  • Each new rule has a test that fails when the rule is removed (the check, the cache bypass, the owes mark, the always text, the order against read_only, the Upsert exception).
  • The hash adds about 30 bytes per block to --json reads.

Merge Danger

Door: two-way. The switch is off by default and the new options are optional; reverting the commit removes them.

Blast Radius: JSON output

hash (and tree_hash on get-block) are new fields in the JSON of five read commands, and --max-chars counts them. Scripts that compare whole JSON blocks will see the new key. Four calls that cannot use a precondition now say so (--expect-hash without --upsert-heading, with several --content, on remove-property --page, on copy-block without --remove) with a usage error instead of ignoring the option.

Known limits, stated in docs/safety.md: no compare-and-swap, the hash sees content and not position, write outputs carry no new hash yet, and copy-block --remove does not cover the window in which it copies.

… when the block has changed

An agent that reads a block, decides and writes minutes later could write
over an edit made in Logseq in between. Now the caller passes what it read
and the command refuses, writing nothing, when the block is another.

- Block JSON from get-block, find-block, get-page, get-journal-range and
  get-todos carries a `hash` (12 hex characters over the normalised content;
  folding a block and CLOCK lines do not change it). get-block also carries
  `tree_hash` over the block and everything under it.
- --expect-hash on update-block, set-todo-status, set-block-property,
  remove-property --id, move-block, copy-block --remove and
  add-journal-block --upsert-heading (checked on the block the upsert would
  replace; with no match there is nothing to check). --expect-tree-hash on
  remove-block and its alias, because a child added after the read would
  otherwise be deleted with the block. --expect-marker on set-todo-status.
- The check reads past the cache and runs before --dry-run, before the
  no-change answer and before the first write. A mismatch ends with
  `reason: precondition_failed` and names the current marker and first line
  of the block, never the current hash.
- `[safety] require_preconditions` (also --require-preconditions and
  LOGSEQ_CLI_REQUIRE_PRECONDITIONS) makes those commands refuse without a
  precondition (`reason: precondition_required`). It can only tighten, a
  broken config refuses writes, and `doctor` shows it like read_only. The
  gate sits once, behind the write gate, driven by `owes=` on WriteCommand.
- update-block and the upsert hold the write to the text the check saw
  (`block_changed` between the check and the write).
- The [safety] machinery works per key, add-journal-block --upsert-heading
  chooses its block before it creates anything, and examples/safe-update-block.sh
  shows the read, check and re-read habit.

Limits, stated in the docs: no compare-and-swap, the hash sees content and
not position, write outputs carry no new hash yet, and copy-block --remove
does not cover the window in which it copies.
@muellerei muellerei added the enhancement New feature or request label Oct 7, 2026
@muellerei
muellerei merged commit cd4c5c9 into main Oct 7, 2026
6 checks passed
@muellerei
muellerei deleted the integration/035-vorbedingung branch October 7, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A caller can make a write conditional on the state of the block it read

1 participant