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
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)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR 0007: Stable member and roster identity

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

Expand All @@ -22,10 +22,10 @@ design/specification.

### Distinguish every identity layer

- A **team owner** is the one human account authorized to control the hosted
team. V1 has exactly one immutable owner. Pairing another device must prove
the same owner; it is not an invitation for another owner. Server, CLI, and
credential issuance all enforce this boundary.
- A **team owner** is the one human account that controls the hosted team. V1
has exactly one immutable owner. Bringing a second machine onto a team is not
an invitation for another owner; it is the same owner reaching the same
server, which is what the sync protocol treats as the permission.
- A **local team identity** is a stable opaque UUID created locally. It is not
the team display name and is not the remote binding ID.
- A **member** is an agent principal anchored by stable opaque `member_id`.
Expand Down Expand Up @@ -71,8 +71,8 @@ messages or free the old identity for silent reuse.
When concurrent creators propose the same normalized new name, the first
accepted `member_id` is canonical. Another ID is not merged by name. A local
member awaiting remote acceptance remains `pending_remote_acceptance` and
cannot act, send, create read facts, or participate in Stage 1 or Stage 2 until
the server accepts that identity.
cannot act, send, create read facts, or participate in message or read-state
synchronization until the server accepts that identity.

### Roster mutations converge by dedupe then revision order

Expand Down Expand Up @@ -105,9 +105,10 @@ The eventual wire spelling belongs in the versioned protocol specification.
### Lifecycle operations do not collapse domains

Leaving or deleting the last local agent placement does not delete the portable
member catalog, local team identity, remote binding, or team key. Rename and
retirement do not revoke device credentials. Credential revocation does not
rewrite or cryptographically erase a member's historical messages.
member catalog, local team identity, remote binding, or team key. Removing a
member from the roster does not rewrite or cryptographically erase that
member's historical messages, and does not revoke a key already distributed —
anyone holding an epoch's identity can still read what was sealed to it.

Two independently populated teams are never merged implicitly. A reconnect may
reattach only with exact prior identity and binding proof. Any future populated
Expand Down Expand Up @@ -152,8 +153,8 @@ reconciliation protocol; matching display names are insufficient.
concurrent mutations and makes acknowledgement retry non-convergent.
- **Implicit populated-to-populated merge.** Rejected until an explicit
identity/history reconciliation protocol exists.
- **Treat removal as credential revocation or erasure.** Rejected because
membership, device authorization, and ciphertext history are separate facts.
- **Treat removal as key revocation or erasure.** Rejected because membership,
key distribution, and ciphertext history are separate facts.

## Consequences

Expand All @@ -171,8 +172,8 @@ reconciliation protocol; matching display names are insufficient.

## Normative design and specifications

- [Remote sync design](../../design/remote-sync.md)
- [HTTP API v1](../../../server/spec/v1.md)
- [Stage-2 read-state synchronization](../../spec/ref/read-state-synchronization.md)
- [Remote sync design](../design/remote-sync.md)
- [HTTP API v1](../../server/spec/v1.md)
- [Read-state synchronization](../spec/read-state-synchronization.md)
- [ADR 0005: Remote synchronization contract](0005-remote-sync-contract.md)
- [ADR 0006: Composite read-state frontier](0006-composite-read-state-frontier.md)
35 changes: 0 additions & 35 deletions docs/adr/ref/README.md

This file was deleted.

Loading
Loading