Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions CONTRIBUTING.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ agmsg は重要な設計判断を記録するために ADR([Architecture Decisio

1. [`docs/adr/template.md`](docs/adr/template.md) を `docs/adr/NNNN-short-title.md` としてコピーする。`NNNN` は次に空いている番号。
2. 各セクションを埋める。*Alternatives considered*(検討した代替案)は正直に書くこと — ADR の価値の多くは、何を却下したか、なぜ却下したかを記録している点にある。
3. PR を開く。議論は PR 上で行う。ステータスは最初 `proposed` とし、マージされたら `accepted` に変更する。
4. 後のADRが以前のADRを置き換える(supersede)場合は、元の ADR はそのまま残し、前方リンクを張る(`Status: superseded by ADR-XXXX`)。ADR は不変の履歴であり、wiki ではない。
3. PR を開く。議論は PR 上で行う。ステータスは最初 `proposed` とし、実際に決定が下されるまで `proposed` のままにする — ファイルがマージされた時点は、その瞬間ではない。一部が未決・未実装のあいだ `main` 上に `proposed` のまま置かれることは正常な状態であり、放置ではない。
4. ツリーと突き合わせて確かめられる決定になった時点で `accepted` にする。一部しか成立していない場合は、どの部分かをステータス行に書くか、成立している部分を独立した ADR として accepted にし、残りを `proposed` のままにする。
5. `proposed` の ADR は、`main` 上かどうかに関わらず、その場で書き換えてよい。まだ何もその上に築かれていないので、直せば読み手に残る記録は2つではなく1つで済む。何を変えたかはコミットメッセージに書くこと。
6. accepted になった ADR は「下された決定」の記録である。誤字・リンク切れ・書式は自由に直してよいが、決定した内容を変えるのは、それを supersede する後の ADR を通してのみ行う。元の ADR はそのまま残し、前方リンクを張る(`Status: superseded by ADR-XXXX`)。

### ドキュメント化されていない決定を見つけたとき

Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ Small bug fixes, doc updates, dependency bumps, and new tests do not need ADRs.

1. Copy [`docs/adr/template.md`](docs/adr/template.md) to `docs/adr/NNNN-short-title.md` where `NNNN` is the next free number.
2. Fill in the sections. Be honest in *Alternatives considered* — the value of an ADR is largely in capturing what you rejected and why.
3. Open a PR. Discussion happens on the PR. Status starts as `proposed`; mark it `accepted` when merged.
4. When a later ADR supersedes an earlier one, leave the original in place and link forward (`Status: superseded by ADR-XXXX`). ADRs are immutable history, not a wiki.
3. Open a PR. Discussion happens on the PR. Status starts as `proposed` and stays `proposed` until the decision is actually taken — merging the file is not that moment. A record may sit on `main` as `proposed` for as long as parts of it are still undecided or unbuilt, and that is a normal state, not an oversight.
4. Mark it `accepted` when the decision is one a reader can check the tree against. If only part of it holds, say which part in the status line, or accept the part that holds as its own ADR and leave the rest `proposed`.
5. A `proposed` ADR may be edited in place, on `main` or off it. Nothing is built on it yet, so correcting it leaves a reader one record instead of two. Say what changed in the commit message.
6. Once accepted, an ADR records a decision that was made: correct typos, broken links, and formatting freely, but change what it decided only through a later ADR that supersedes it, leaving the original in place and linking forward (`Status: superseded by ADR-XXXX`).

### When you find an undocumented decision

Expand Down
112 changes: 48 additions & 64 deletions docs/adr/0003-storage-axis-driver-abi-and-scope.md
Original file line number Diff line number Diff line change
@@ -1,98 +1,82 @@
# ADR 0003: Storage axis — driver ABI, contract shape, and scope boundary

**Status:** proposed (draft — subject to change)
**Status:** proposed
**Date:** 2026-06-24
**Deciders:** @fujibee

## Context

1.1.0 shipped the axis-generic driver registry and external-plugin opt-in
([ADR 0002](../0002-driver-discovery-and-plugin-opt-in.md)). 1.1.1 implements the
**storage axis** — the message store, made pluggable — with drivers `sqlite`
(default), `jsonl`+`duckdb`, and `redis`. Before writing code, three
architectural questions needed locking. An independent design pass (codex, this
session) converged with the earlier Fugu-demo design (codex + gemini) and
corrected an initial lean toward a subcommand ABI; this ADR records the
converged decisions. ADRs are revisable, so it reaffirms or tightens
[ADR 0001](../0001-storage-driver-pluginization.md) where that is the better choice.
[ADR 0002](0002-driver-discovery-and-plugin-opt-in.md) established the
axis-generic driver registry and external-plugin opt-in. Making the message
store pluggable needs three architectural questions locked before any driver is
written, because each of them is expensive to reverse once a store exists:
what a driver is allowed to expose, what core is allowed to ask for, and where
the storage axis stops.

An independent design pass converged with an earlier one and corrected an
initial lean toward a subcommand ABI; this ADR records the converged decisions.
ADRs are revisable, so it reaffirms or tightens
[ADR 0001](0001-storage-driver-pluginization.md) where that is the better
choice.

## Decision

1. **Drivers stay sourced bash, behind a *locked* ABI.** Keep ADR 0001's
sourced-function model, but tighten it: a driver exposes only the `storage_*`
domain operations and must not leak SQL fragments, file paths, or backend
cursors to core. Non-bash backends are reached by a thin bash facade that
shells out (duckdb, redis-cli, a helper). A subcommand + JSONL-pipe protocol
is reserved as an *internal* form a facade may exec later if a driver truly
can't be bash — it is **not** promoted to the core ABI now.
cursors to core. A backend that is not bash is reached by a thin bash facade
that shells out to it. A subcommand + JSONL-pipe protocol is reserved as an
*internal* form a facade may exec later if a driver truly cannot be bash — it
is **not** promoted to the core ABI now.

2. **The contract abstracts use-cases, not queries.** Core calls domain
operations (send / list-unread / mark-read / watch-after / history), never a
query language. Two consequences: (a) the watch/check-inbox replay checkpoint
is an **opaque, driver-issued delivery cursor**, kept separate from read
state — core never compares it, so it absorbs sqlite int ids, UUIDv7, Redis
stream ids, and JSONL offsets; (b) read-marking is recipient-scoped and
idempotent. This removes the `id > watermark` (integer-id) assumption that
currently lives in core and would break on UUIDv7 / Redis streams. The
canonical export/import format is a JSONL event log.

3. **Redis (1.1.2) is a message store only.** The team registry and run-state
(pidfiles, watch watermarks, actas locks, ready sentinels) are *not* moved
onto the network with it — those carry distributed-lease / TTL / clock /
orphan-reclaim concerns beyond the storage axis. Multi-host coordination, if
wanted, becomes a separate **coordination axis** under its own ADR. Redis
enters as a remote message bus, not as shared everything.
state — core never compares it, so a backend may order by an integer, a
time-ordered id, or a byte offset in an append log without core knowing
which; (b) read-marking is recipient-scoped and idempotent. This removes any
`id > watermark` assumption from core, which would otherwise hold only for
backends whose ids happen to be comparable integers. The canonical
export/import format is a JSONL event log.

**Phasing:** 1.1.1 = storage facade + `sqlite` driver + `jsonl`(+`duckdb`)
driver (duckdb an opt-in query accelerator; default install stays bash +
sqlite). 1.1.2 = `redis` (message store).
3. **The storage axis is a message store, and stops there.** The team registry
and run-state — pidfiles, watch watermarks, actas locks, ready sentinels —
are not part of it, and a driver that moves the message store onto a network
does not carry them along. Those raise distributed-lease, TTL, clock, and
orphan-reclaim concerns that belong to a **coordination axis** under its own
ADR, if multi-host coordination is ever wanted.

## Alternatives considered

- **Subcommand + JSONL pipe as the core driver ABI.** Cleaner process boundary
and language-agnostic, but the inner sqlite3/duckdb/redis-cli process runs
inside the driver regardless, so piping the core↔driver boundary adds
call-path complexity (and an extra fork on the unread/insert hot path) before
any real benefit. Independent codex and the Fugu design both landed on sourced;
kept as a deferred internal-impl option.
and language-agnostic, but a non-bash backend runs its own process inside the
driver regardless, so piping the core↔driver boundary adds call-path
complexity (and an extra fork on the unread/insert hot path) before any real
benefit. Two independent design passes both landed on sourced; kept as a
deferred internal-implementation option.
- **Structured query params with a single core-comparable watermark.** Rejected
the comparable watermark — any cursor core can compare leaks the backend's id
scheme. The opaque-cursor decision generalizes it.
- **Redis as full shared state (messages + registry + coordination).** Rejected
for 1.1.2: balloons into distributed coordination; split to a future axis.
- **A networked store as full shared state (messages + registry +
coordination).** Rejected: it balloons into distributed coordination, which
decision 3 splits to a future axis.

## Consequences

- Positive: one ABI serves sqlite / jsonl-duckdb / redis with no core changes;
opaque cursor + use-case contract make a new backend a self-contained driver;
default install unchanged; aligns with ADR 0002's registry + opt-in.
- Negative: core code that assumes the integer `messages.id` / `read_at` column
must move behind the contract before any non-sqlite driver works (the bulk of
1.1.1 — #203/#204/#206). Sourced drivers keep ADR 0002's trust concern,
mitigated by opt-in + the `storage_*` prefix discipline.
- Known gap (not yet migrated, tracked for a follow-up): `rename.sh` /
`rename-team.sh` rewrite historical `from_agent`/`to_agent`/`team` values by
running `UPDATE` directly against the sqlite driver's own `messages` and
`events` tables — bypassing the contract entirely, guarded only by "does the
sqlite db file exist". A team/agent renamed while a non-sqlite driver is
active gets its live registry entry renamed correctly, but the driver's
historical message log is not — no contract function exists yet for
"rewrite a name across history" (`storage_send`/`storage_history`/etc. all
operate on messages, not identities). `api.sh`'s `get teams <team> messages`
read path has the same coupling for a different reason: it needs
`--before-id` cursor pagination `storage_history` does not expose, so it
runs its own event-log ∪ legacy-table query rather than the facade function
— correct today (it still reads driver-shaped tables, not different ones),
but it too would need a driver-contract addition (a paginated history op) to
stop assuming sqlite's schema shape under a non-sqlite driver.
- Positive: one ABI serves any backend with no core changes; the opaque cursor
and the use-case contract make a new backend a self-contained driver; the
default install is unchanged; it aligns with ADR 0002's registry and opt-in.
- Negative: core code that assumes one backend's own id column and read-marking
shape must move behind the contract before any second driver works. Sourced
drivers keep ADR 0002's trust concern, mitigated by opt-in and the `storage_*`
prefix discipline.
- Neutral: the subcommand+pipe protocol and multi-host coordination are
explicitly *deferred, not rejected* — each can land under a later ADR.

## References

- Builds on [ADR 0001](../0001-storage-driver-pluginization.md) and
[ADR 0002](../0002-driver-discovery-and-plugin-opt-in.md).
- Spec (where the `storage_*` signatures live, not this ADR):
[`docs/spec/driver-interface.md`](../../spec/driver-interface.md).
- Implementation: #51 (epic), #203 (contract), #204 (facade + sqlite),
#205 (event-log), #206 (call-site migration), #207 (jsonl+duckdb), #208 (redis).
- Builds on [ADR 0001](0001-storage-driver-pluginization.md) and
[ADR 0002](0002-driver-discovery-and-plugin-opt-in.md).
- The `storage_*` signatures live in the spec, not this ADR:
[`docs/spec/driver-interface.md`](../spec/driver-interface.md).
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR 0005: Remote synchronization contract

**Status:** proposed (dogfood architecture)
**Status:** accepted
**Date:** 2026-07-25
**Deciders:** @fujibee

Expand Down Expand Up @@ -35,16 +35,13 @@ quarantined, or terminally corrupt.
The immutable binding identity is
`(server_instance_id, remote_team_id, protocol_version)` plus the storage
driver's persistent generation for interpretation of local positions. Endpoint
location and credential rotation do not change stream identity. A different
server instance, remote team, protocol, or local position generation is a
different synchronization namespace.
location does not change stream identity. A different server instance, remote
team, protocol, or local position generation is a different synchronization
namespace.

A device credential is bound to its endpoint origin, server instance, remote
team, and non-secret `credential_id`. It cannot authorize a different binding,
and revocation targets `credential_id`, never the bearer secret as an object
identifier. Secret single-delivery and provisional activation/finalization are
pinned by the onboarding API specification; an acknowledged secret is never
reissued after response loss.
A binding is a local record, not an authorization. It carries no secret, and
every write that changes one advances a revision so a concurrent reconnect
cannot have its newer binding overwritten by a caller that never saw it.

### At-least-once transport has exact conflict semantics

Expand Down Expand Up @@ -78,8 +75,9 @@ per-agent wake decisions stay local.
The synchronization server never chooses team keys and never receives plaintext
private team or recovery keys. Sealing and opening happen client-side. This is
a content-confidentiality boundary, not an anonymity claim: team identity, wire
ID, sequence, server receipt time, envelope version, cipher, key epoch, digest,
size, timing, and traffic frequency remain visible as defined by the protocol.
ID, sequence, server receipt time, envelope version, cipher, whichever key the
scheme names for that envelope, digest, size, timing, and traffic frequency
remain visible as defined by the protocol.

### Progress layers remain independent

Expand All @@ -104,10 +102,11 @@ again.

### Onboarding cannot overstate history durability

A successful credential connection is not proof that local history is durable
on the server. When onboarding backfills an existing store, retention cannot
drop a range required by the promoted snapshot until the server has the
manifest's terminal durable acknowledgement.
A successful connect is not proof that local history is durable on the server.
Registering a team and replicating its history are separate operations with
separate crash boundaries, and only the acknowledgements of the second make a
range durable. A client MUST NOT treat connect as backfill, and MUST NOT retire
local state on the strength of it.

Proofs over local source identities and proofs over translated wire identities
use separate domains. They are never compared or reused as if translation
Expand Down Expand Up @@ -140,8 +139,8 @@ preserved identity bytes.
loss requiring explicit acknowledgement, not a successful pull.
- **Plaintext-only projections or a parallel table.** Rejected because E2EE
would become a feature downgrade and two implementations would diverge.
- **Implicit full-history durability at connect.** Rejected because credential
activation and history backfill have different crash boundaries.
- **Implicit full-history durability at connect.** Rejected because team
registration and history backfill have different crash boundaries.

## Consequences

Expand All @@ -156,9 +155,9 @@ preserved identity bytes.

## Normative specifications

- [HTTP API v1](../../../server/spec/v1.md)
- [Stage-1 local-first remote synchronization](../../spec/ref/stage-1-remote-sync.md)
- [Cipher-independent opaque-envelope server schema](../../spec/ref/server-opaque-envelope.md)
- [Retention-gap resynchronization](../../spec/ref/retention-gap-resynchronization.md)
- [Storage driver interface](../../spec/driver-interface.md)
- [age-v1 profile](../../spec/ref/age-v1-profile.md)
- [HTTP API v1](../../server/spec/v1.md)
- [Local-first message synchronization](../spec/message-synchronization.md)
- [Cipher-independent opaque-envelope server schema](../spec/server-opaque-envelope.md)
- [Retention-gap resynchronization](../spec/retention-gap-resynchronization.md)
- [Storage driver interface](../spec/driver-interface.md)
- [age-v1 profile](../spec/age-v1-profile.md)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR 0006: Composite read-state frontier

**Status:** proposed (dogfood architecture)
**Status:** accepted
**Date:** 2026-07-25
**Deciders:** @fujibee

Expand Down Expand Up @@ -128,7 +128,7 @@ fail-closed overflow are architecture requirements.

## Normative specifications

- [Stage-2 read-state synchronization](../../spec/ref/read-state-synchronization.md)
- [HTTP API v1](../../../server/spec/v1.md)
- [Storage driver interface](../../spec/driver-interface.md)
- [Read-state synchronization](../spec/read-state-synchronization.md)
- [HTTP API v1](../../server/spec/v1.md)
- [Storage driver interface](../spec/driver-interface.md)
- [ADR 0005: Remote synchronization contract](0005-remote-sync-contract.md)
Loading
Loading