Skip to content

Let the application supply the auto-merge policy; add Repo::merge_heads - #45

Merged
somasekimoto merged 3 commits into
mainfrom
feat/injectable-merge-policy
Sep 22, 2026
Merged

somasekimoto merged 3 commits into
mainfrom
feat/injectable-merge-policy

Conversation

@somasekimoto

Copy link
Copy Markdown
Contributor

Why

Auto-merge resolves concurrent heads with the policy named in the genesis metadata, and the only one is last-writer-wins over the whole payload. That is right when the payload is one value. It is wrong when it is several with different convergence rules — Monas's payload is a content body next to an access policy. A head that only advanced the policy still carries the body it inherited; a concurrent head that only changed the body still carries the policy it inherited. Whichever is newer wins both fields, and the other head's change is lost although nothing competed with it. In Monas this is a revoke being erased by a concurrent write (Monas-project/monas, docs/revoke-write-bypass-investigation.md on feat/policy-aware-merge).

The library cannot merge such a payload field by field — it does not know the fields — so it now lets the application say how.

What

  • Repo::with_merge_policy(Box<dyn MergePolicy<P>>): installs a policy used for every auto-merge in place of the genesis-named one. Nothing changes for callers that do not install one.
  • ResolveInput::parent_payloads: the head's parents' payloads, so a field-aware policy can tell what a head changed. LwwMergePolicy ignores it; a parent missing from storage is skipped.
  • Repo::merge_heads(genesis): run the auto-merge now and return the single head. The lazy merge inside the next commit is too late for a caller that wants to read the converged state first — latest() alone picks one concurrent head by timestamp. Repo::heads(genesis) exposes the current head set.

The policy is process-local and never travels with the data; every replica must install the same one (documented on the method).

Tests

  • installed policy is used over the genesis's "lww" and sees each head's parents
  • resolver attaches parent payloads
  • named policy still applies when nothing is installed
  • merge_heads converges two heads, is idempotent, returns None for unknown content

110 tests pass, clippy clean.

somasekimoto and others added 2 commits September 13, 2026 22:26
Auto-merge resolves concurrent heads with the policy named in the genesis
metadata, and the only one that exists is last-writer-wins over the whole
payload: the newest head's payload is copied into the merge node. That is
right when the payload is one value. It is wrong when the payload is
several values with different convergence rules — a content body next to an
access policy, say. A head that only advanced the policy still carries the
body it inherited; a concurrent head that only changed the body still
carries the policy it inherited. Whichever is newer wins both fields, and
the other head's change is lost even though nothing competed with it.

The library cannot merge such a payload field by field: it does not know
the fields. So it now lets the application say how.

- `Repo::with_merge_policy(Box<dyn MergePolicy<P>>)` installs a policy that
  is used for every auto-merge in place of the named one. Nothing changes
  for callers that do not install one.
- `ResolveInput` gains `parent_payloads`: the payloads of the head's
  parents. A field-aware policy needs to know what a head changed, not just
  what it holds, and only the parent can tell it. `LwwMergePolicy` ignores
  the field; a parent missing from storage (ancestry not yet synced) is
  skipped rather than an error.

The policy is process-local and never travels with the data, so every
replica must install the same one — replicas merging the same heads under
different rules produce different merge nodes and keep re-merging. That is
documented on the method.

Tests: the installed policy is used over the genesis's "lww" and sees each
head's parents (repo); the resolver attaches parent payloads (resolver);
the named policy still applies when nothing is installed (repo).

Co-Authored-By: Claude Code <noreply@anthropic.com>
Auto-merge runs lazily, inside the next commit. A caller that wants to
*read* the converged state before committing — to copy the current payload
into a new version, or to evaluate a policy it carries — gets nothing from
`latest()` but one of the concurrent heads picked by timestamp. Any value
it copies from there is one branch's, and the merge that follows inside its
commit resolves the *old* heads, not the value it just wrote on top of one
of them. With a field-wise merge policy that is exactly the case that
matters: the reader sees the branch that lacks the other branch's field.

`merge_heads(genesis)` runs the same merge the lazy path would, commits it
in its own batch, and returns the single head (or the existing one when
there was nothing to merge). `heads(genesis)` exposes the current head set
for callers that want to know whether a merge is pending. Both are
idempotent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@Yu-da-1

Yu-da-1 commented Sep 20, 2026

Copy link
Copy Markdown
Member

元々がそうなっていたってのがあるけど、現状はnodeを作成するタイミングにマージポリシーを適用させる状態になっていると思うのですが、こちらについてどう思いますか?
今回修正してもらって気づいたんですが、そうなるとマージポリシーは途中から変わりうる = 「過去と現在で形が変わる可能性がある」ってことだと思っていて、それって自分たちが保証したいこととはずれそうだなとは思っています。一方でそれはライブラリを使う方側が考えれば良いとも思い、どっちに責務を置くべきなのかが気になりました。

@somasekimoto

Copy link
Copy Markdown
Contributor Author

指摘のとおり、今回の実装では注入したpolicyがgenesisのpolicy_typeを無視するため、同じ履歴に別の規則を黙って適用できる状態になっていました。

ノード作成時にマージ結果を保存すること自体よりも、「その履歴で使うと記録された規則」と「実際に使う実装」が結び付いていない点が問題だと考え、2528d75で修正しました。

責務は次のように分けています。

  • ライブラリ:genesisのpolicy_typeに対応する実装を選び、対応実装がなければエラーにする。
  • アプリケーション:独自policyのマージ処理を実装し、各state nodeへ互換性のある実装を配布する。

新規作成時には選択したpolicyをgenesisへ記録し、以後のマージはその指定に従います。既存LWW履歴は、独自policyを注入してもLWWのまま扱います。また、同期先でも同じmetadata・CIDを復元できるよう、operationにその情報を保持させました。

ただし、ライブラリが検証するのは規則の名前と実装の対応であり、同じ名前で提供される実装の中身まで同一かは検証しません。そのため、挙動を変える場合は別のpolicy名にするなど、実装の互換性はアプリ側で管理する、という分担です。

@somasekimoto
somasekimoto merged commit 2b0d930 into main Sep 22, 2026
4 checks passed
@somasekimoto
somasekimoto deleted the feat/injectable-merge-policy branch September 22, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants