pub enum OpKind {
Show 23 variants
SetWorkspaceHead {
workspace: String,
commit: ContentHash,
prev: Option<ContentHash>,
},
SetRef {
name: String,
commit: ContentHash,
prev: Option<ContentHash>,
},
DeleteWorkspace {
workspace: String,
},
RequestReview {
id: String,
target: ContentHash,
reviewers: Vec<String>,
target_ref: Option<String>,
},
AssignReviewers {
id: String,
reviewers: Vec<String>,
},
ArchiveReview {
id: String,
lapsed: bool,
},
SlashApproval {
id: String,
reviewer: String,
reason: String,
},
PostVerdict {
id: String,
reviewer: String,
verdict: Verdict,
note: String,
},
PostComment {
id: String,
comment: String,
author: String,
body: String,
},
DeleteRef {
name: String,
prev: Option<ContentHash>,
},
RecordProvenance {
subject: String,
kind: String,
body: String,
},
CreateChange {
id: String,
owner: String,
workspace: String,
base_revision: ContentHash,
idempotency_key: String,
owner_sig: Option<Witness>,
cone: Vec<String>,
},
CheckpointChange {
id: String,
workspace: String,
revision: ContentHash,
prev_revision: ContentHash,
},
ArchiveChange {
id: String,
workspace: String,
prev_revision: ContentHash,
owner: String,
owner_sig: Witness,
},
BindKey {
operator: String,
key: ContentHash,
channel: Option<String>,
},
RevokeKey {
key: ContentHash,
reason: String,
},
Vouch {
voucher: String,
subject: String,
note: String,
},
WithdrawVouch {
voucher: String,
subject: String,
reason: String,
},
CountersignSnapshot {
witness: String,
snapshot: ContentHash,
},
RecordRefSnapshot {
snapshot: RefSnapshot,
},
ViewedReview {
id: String,
viewer: String,
},
RecordCheck {
subject: ContentHash,
name: String,
status: CheckStatus,
evidence: String,
reporter: String,
target_ref: Option<String>,
},
Submit {
review: String,
name: String,
commit: ContentHash,
prev: Option<ContentHash>,
authorization: Authorization,
},
}Expand description
The view mutations. Head-moving ops carry prev (compare-and-set
against the current view) so a stale writer is rejected instead of
silently clobbering a concurrent advance — the same discipline the op
log itself applies to its head.
Variants§
SetWorkspaceHead
Point workspace at commit; prev must equal its current head
(None = workspace must not exist yet).
Fields
commit: ContentHashNew head commit id.
prev: Option<ContentHash>Expected current head (CAS), None to create.
SetRef
Point named ref name at commit under the same CAS rule.
Fields
commit: ContentHashNew target commit id.
prev: Option<ContentHash>Expected current target (CAS), None to create.
DeleteWorkspace
Remove workspace from the view (its commits stay in the store).
RequestReview
Open review id on commit target, fanning out to reviewers
(additive variant, added for review fan-out; wire-format
unchanged). id must not already exist.
An empty reviewers list opens the review unassigned: the
requester is not naming their own reviewers, and an
OpKind::AssignReviewers op fills the list in later. An
unassigned review is never complete() and never approved(),
so “ask nobody” cannot read as a pass (D24 layer 5).
Fields
target: ContentHashThe commit under review.
target_ref: Option<String>The ref this review proposes to land on, in the view’s
namespaced form <repo>:<refname> (e.g.
"choir/choir.git:refs/heads/main"). None = unbound: a
review of a commit that names no destination.
Additive field, and the worked example of invariant 1 for an
enum variant: default + skip_serializing_if means logs
written before this field decode as None and re-serialize
byte-identically, so their entry hashes do not move.
This is what per-ref policy conditions on. Without it there is
no way to say “reviews landing on main are privilege-bearing”
— a review named a commit, and a commit belongs to no branch
(D24 layer 5; D23 blast-radius gating).
AssignReviewers
Fill in the reviewer list of an unassigned review (additive variant, wire-format unchanged). Assign-once: the review must exist with an empty list, and the new list must be non-empty.
This is the view-level half of D24 layer 5 — “the requester does not choose who reviews them”. Who may assign is admission policy (L2), not view semantics: the daemon accepts this op only from its own key, and picks from an operator-curated pool.
Fields
ArchiveReview
Settle review id and drop its bulk (additive variant,
wire-format unchanged).
A review’s verdicts, notes and reviewer list are the part that
grows without bound; the landing gate reads only
(target_ref, target, approved). Archiving keeps that triple and
discards the rest, so retention stops being an authorization
decision — an approval never silently expires.
Archiving is freezing, not deleting. PostVerdict overwrites
a reviewer’s earlier verdict, so approval is not monotonic and a
review can go approved then not. Once the verdicts are gone that
transition can no longer be computed, so an archived review
accepts no further verdicts — and says so, rather than reporting
itself absent.
Who may archive is admission policy (L2), like
OpKind::AssignReviewers: the daemon accepts it only from its
own key, because otherwise archiving would be a way to erase a
RequestChanges you did not like.
Fields
lapsed: boolSettle an incomplete review as not approved, rather than refusing because it never reached an outcome.
An unanswered review is exactly the kind that accumulates, and
it is incomplete by definition — so a pruner that can only
archive complete reviews reclaims the ones least likely to
pile up. Lapsing is the answer, and it deliberately invents
no new outcome: a review nobody answered never got
approval, so Archived { approved: false } is the whole truth.
The landing gate is unchanged, and there is no new persisted
enum to version.
When is not a view question. A pure fold of the log has no
clock, so the view cannot decide “abandoned”; the node decides
on wall time and this op records the decision, exactly as the
reviewer draw is decided node-side and recorded by
OpKind::AssignReviewers. Replay reproduces the lapse from
the op and never from re-running a timer.
Additive: absent in a payload written before this field
existed, which decodes as false — the previous strict
behaviour exactly.
SlashApproval
Invalidate one reviewer’s approval after a policy or trust finding (additive variant, wire-format unchanged).
The operation is append-only: it never moves a ref back and never erases the original verdict. A review with any slash visibly needs re-review, and its affected operator no longer contributes approval weight to future protected-ref authorization.
Live rows verify that reviewer currently has an approval. Archived
rows deliberately no longer retain reviewer detail, so admission
accepts this operation only from the node key; the signed operation
is the compact authority attestation and replay input.
Fields
PostVerdict
Record reviewer’s verdict on review id (additive variant).
Only listed reviewers may post; re-posting overwrites the
reviewer’s own earlier verdict (re-review after changes).
reviewer is payload data and therefore covered by the author
signature; binding it to the submitting key is admission policy
(L2), not view semantics.
Fields
PostComment
Append one comment to the discussion on review id (D38).
This is the half D34 deferred: a review page could show what was proposed and what was decided, and had nowhere to put the conversation that produced the decision. Discussion is a persisted operation like any other, so it is sequenced, replayable and append-only rather than a mutable side table.
Three properties live in the fold, and each is load-bearing:
- Order is the log’s order. The thread is a
Vecappended in fold order, not a map keyed by id, because a total order over a conversation is the one thing a single-writer sequencer offers that a mutable comment table cannot. commentis the replay defence.seqandparentare assigned after signing (invariant 4), so nothing positional is covered by the author’s signature; the payload carries its own identity instead and the fold refuses an id the review already holds. That is the CASprevin the only shape a comment can take, since a comment moves no head. The ABA holeprevleaves for refs does not open here: an id is never released, because an archived review accepts no comments at all.- Nothing is ever edited. There is no edit and no delete; a correction is a later comment. Removing one is a question about persisted history rather than about presentation, and it needs its own decision row (D38’s tripwire).
author is payload data and therefore covered by the author
signature; binding it to the submitting channel is admission
policy (L2), exactly as for OpKind::PostVerdict’s reviewer.
Without that binding the log’s author attribution and the view’s
comment attribution could disagree, which on a discussion surface
means words in somebody else’s name.
Fields
comment: StringCaller-chosen comment id, unique within that review. It is the author’s retry identity — resubmitting the same comment is refused rather than duplicated — and the anchor a later reply or reaction would name.
The channel making the statement (must be the submitting channel, enforced at admission).
DeleteRef
Remove named ref name under the same CAS rule (additive
variant, added for git branch deletion; wire-format unchanged).
RecordProvenance
Attach an intent/provenance record — task spec, plan, rationale
— to subject (D22 substrate; additive variant, wire-format
unchanged). The latest record per (subject, kind) wins in the
view; the full history stays in the log.
subject is deliberately not bound to the submitting channel —
a shared living spec is written by many agents. Trusting who
wrote a record means reading the log entry’s author.
Fields
CreateChange
Create stable logical change id and bind its first active
workspace at an immutable base revision (additive variant;
existing operation bytes are unchanged).
The change id survives later checkpoints and workspace archival.
idempotency_key is scoped to owner; the view rejects a second
change using the same pair so a lost create response cannot fork
one logical request into two changes.
Fields
owner: StringChannel allowed to checkpoint the change (enforced by L2 admission because signatures are not part of the pure view).
base_revision: ContentHashExact immutable revision the workspace starts from.
owner_sig: Option<Witness>Owner signature over the matching CreateAuthorization.
Absent only on operations accepted before creation
authorization was introduced.
cone: Vec<String>Directory prefixes this change declares it works within, in
git’s cone spelling ("services/api"). Empty = the whole
tree, which is what every change written before this field
existed decodes to and is exactly the previous behaviour
(invariant 1).
Declared, not enforced here. The fold does not police
which paths a commit touches; a cone is a statement about
intent that the node can serve a matching partial clone from
and that conflicts_for_cone narrows a conflict report
against. Making it a hard boundary would need path
enforcement in the merge layer, which is a separate decision
and a much larger one.
It sits in the signed payload rather than in node-side config for the reason D49 puts a check there: a replayer can then reproduce what the change said it was scoped to, and an operator cannot rewrite it after the fact.
CheckpointChange
Publish an immutable revision of an existing change and advance its bound workspace under compare-and-set.
Fields
revision: ContentHashNewly published immutable revision.
prev_revision: ContentHashExpected current change/workspace revision.
ArchiveChange
Archive a stable change’s active workspace under revision CAS. The revision remains addressable on the change after the mutable filesystem surface is detached.
Fields
prev_revision: ContentHashExpected current change/workspace revision.
owner: StringOwner channel that signed the matching
ArchiveAuthorization.
BindKey
Bind actor key key to the durable operator identity operator
(additive variant, wire-format unchanged).
Until now an operator existed only as a prefix convention on a
channel name (reviewer_operator) plus a line in a mutable,
operator-owned keys file. Both are rewritable without a trace, so
“these two keys are the same operator” was an assertion no replay
could reproduce. This op puts the assertion in the log, where it
is sequenced, append-only, and recoverable at any prefix via
View::at.
Two properties make it load-bearing, and both live in the fold:
- First binding wins the clock.
KeyBinding::bound_atis the sequence of the op that first bound the key, and never moves again — so re-binding to adjust a channel cannot reset accumulated standing. - One key, one operator, for the life of the key. Re-binding a key to a different operator is refused, so whatever position a key accumulates cannot be handed to somebody else.
What this does not establish. Who may author a binding is
admission policy (L2), exactly as for OpKind::AssignReviewers
and OpKind::SlashApproval. With no admission rule wired, any
key can bind any other key under any operator name, and operator
is a self-chosen label rather than a verified identity. The fold
proves sequence and immutability; it never proves authority.
Fields
operator: StringThe durable operator identity the key is bound to. Non-empty,
and free of / so it cannot alias a operator/agent channel
prefix and read as two different operators.
key: ContentHashThe actor key being bound: the content address of its public
key, as choir_identity::ActorKey::actor_id produces it.
channel: Option<String>The channel name the operator asserts this key speaks as, when
it asserts one — the sequenced form of the keys file’s
<name> <hex> line.
Constrained so the two available operator answers cannot
disagree: reviewer_operator of this channel must equal
operator, i.e. the channel is operator itself or
operator/<agent>. Without that rule a durable record reading
“bob” and a channel prefix reading “alice” would both be live,
which is worse than a single wrong answer.
Additive per invariant 1: a payload written before this field
existed decodes as None and re-serializes byte-identically,
so entry hashes do not move.
RevokeKey
Withdraw key’s binding (additive variant, wire-format unchanged).
Append-only and terminal. The binding row stays, so the operator
attribution for everything the key already did survives; the key
itself can never be bound again. Allowing a rebind would make
revocation a formality — revoke, rebind, carry on — so the remedy
is a fresh key, the same shape as OpKind::SlashApproval’s
“open a new review”.
This is the record a revocation cascade replays over, and the slot a later vouch or bond withdrawal hangs off. It moves no ref and undoes no landed change; like a slash, it constrains what comes next rather than rewriting what came before.
Who may revoke is admission policy (L2), as for
OpKind::BindKey.
Fields
key: ContentHashThe bound key whose binding is withdrawn.
Vouch
Record that one operator vouches for another (D65; additive variant, wire-format unchanged).
This is the edge D24’s Sybil resistance was missing. Key age already orders identities by standing, and it cannot tell a hundred keys one stranger bound from a hundred keys a hundred operators bound: age is a fact about a key, never about anybody’s opinion of it. A vouch is the opinion, sequenced.
Both ends must already be operators this log knows. The fold
refuses a voucher or subject with no unrevoked binding, which
is what makes the floor replayable rather than one node’s private
admission rule: minting an identity to vouch with costs a
OpKind::BindKey, and only the node authors those. The same
rule bounds the map — at most one edge per ordered pair of bound
operators, both drawn from a set the node itself admitted — so no
client can grow the view by inventing names (D64).
One edge per (voucher, subject): vouching where an edge already
stands is refused, so the pair is its own retry identity, exactly
as a comment id is for OpKind::PostComment. Changing the note
means withdrawing and vouching again, which is a new statement
and dated as one.
It authorizes nothing. No threshold, no score, no path count: nothing in this crate or the daemon reads a vouch to decide anything. That is deliberate rather than unfinished. A number computed from this graph would read as a measurement of trustworthiness while measuring how willing operators are to type each other’s names, and the first thing that number would do is become worth farming.
Who may author one is admission policy (L2), as for
OpKind::PostVerdict: the daemon binds voucher to the
operator of the signing channel. The fold proves the ends exist
and the edge is new; it never proves the voucher signed it.
Fields
voucher: StringThe operator doing the vouching (must be the operator of the submitting channel, enforced at admission).
WithdrawVouch
Withdraw a vouch (D65; additive variant, wire-format unchanged).
The edge leaves the view and both ops stay in the log. That split
is the deliberate half: a tombstone row would either make
withdrawal terminal, which trust is not, or be overwritten by the
next vouch, which makes it a row that says nothing. So a current
view answers “who vouches for X now”, and “who used to” is a
question for the log — the same division OpKind::DeleteRef
makes, where the ref goes and the commits stay.
Withdrawal is not terminal for the pair, unlike
OpKind::RevokeKey. Vouching again is admissible and starts a
fresh VouchState::at. The revocation argument does not carry
over: a rebindable revocation is no revocation, but a withdrawal
that could never be reconsidered would make one bad afternoon
permanent, and the remedy RevokeKey offers — use a fresh key —
has no counterpart when the thing withdrawn is an opinion about
somebody else.
Fields
CountersignSnapshot
One witness’s cosignature over the latest ref-state attestation (D67): “I saw this complete ref-state at this position”.
The unit is D25’s RefSnapshot, never an individual ref, and
never an choir_oplog::OpEntry. An entry’s witnesses field
is inside the bytes its content hash covers, so a cosignature
added after the fact would rewrite the entry and orphan every
descendant — in-entry witnessing is therefore synchronous by
construction, and D16’s tripwire exists precisely to keep that
off the sequencer’s critical path. This is the async branch that
row already names as its alternative: an ordinary op, admitted
after the snapshot it attests, costing the hot path nothing.
The witness signs in the only way this system has: the op’s own
author signature covers (channel, payload), and the payload
names the snapshot by content address. No second signature
scheme, and nothing new to verify.
Fields
witness: StringThe operator doing the witnessing (must be the operator of the submitting channel, enforced at admission, and never the node whose log this is).
snapshot: ContentHashContent address of the snapshot being attested, which must be the latest one the fold admitted. Attesting anything else is the stale-ref-state replay D25 names, so it is refused rather than recorded as a claim about the past.
RecordRefSnapshot
Record a signed attestation of the complete ref-state at one log position (D25; additive variant, wire-format unchanged).
This is the object that closes the gap the attestation section of the design notes states: every existing check proves the chain a reader was shown is consistent and authentically authored, none proves another reader was shown the same chain. A snapshot is the unit two readers compare, the record that makes a mirror bundle checkable against the log, the checkpoint truncation needs, and — when D16’s gate opens — the thing a witness cosigns. One object, because those are one question: “what was the whole ref-state at seq N?”.
The fold verifies the claim rather than storing it: admission
compares RefSnapshot::refs against the view’s refs and
RefSnapshot::at_seq against the fold position, so a snapshot
that lies about the log it sits in is refused by every replayer,
not just by the node that admitted it. The chain rule
(prev_snapshot must name the latest admitted snapshot) makes
replaying an old snapshot a chain violation even where the ref
map recurs — the ABA shape D26 measured, answered here the same
way prev answers it for refs.
Who may record one is admission policy (L2), as for
OpKind::AssignReviewers: the daemon accepts it only from its
own key. The view enforces truth, not authority.
Fields
snapshot: RefSnapshotThe snapshot; its detached file projection is these exact canonical bytes, never a second schema.
ViewedReview
Record that viewer read review id (a read receipt; additive
variant, wire-format unchanged; the backlog).
The receipt is what lets an author distinguish “reviewed and
ignored” from “nobody has looked yet”. The fact recorded is the
first read per viewer – when this review first got that
reader’s attention – so the fold refuses a viewer the review
already holds, and that refusal doubles as the replay defence,
exactly as a comment id does for OpKind::PostComment: nothing
positional is signed, so the payload’s (review, viewer) pair is
its own retry identity.
A receipt moves no ref, changes no verdict and carries no
authorization weight; it is bulk in OpKind::ArchiveReview’s
sense and is dropped with the rest of it.
viewer is payload data covered by the author signature; binding
it to the submitting channel is admission policy (L2), exactly as
for OpKind::PostVerdict’s reviewer.
Fields
RecordCheck
Record one automated check’s outcome on a commit (D49; additive variant, wire-format unchanged).
The node does not run the check. Executing workflows is containers, secrets, caches and artifacts — the largest surface on the platform and the least differentiated part of it, since every forge already has one. What no forge has is a check result that is ordered against the ref it attests and replayable by someone who trusts none of the parties. So this op carries the verdict and nothing else: any runner, or a person, reports by signing one.
That inversion is what makes it stronger than a status field on a
merge gate. A field is mutable and is read at merge time, so
“was this green when it landed” decays into “is it green now”. A
signed op is append-only and sits at a known sequence, so the
question stays decidable forever, and choir log --verify
already recomputes the hash and checks the signature.
Re-reporting overwrites the reporter’s own earlier result for the
same (subject, name), exactly as OpKind::PostVerdict lets a
reviewer re-review. A check that flaps is a check that flaps; the
log keeps every report and the view keeps the latest.
reporter is payload data covered by the author signature.
Binding it to the submitting channel is admission policy (L2),
the same split as reviewer and viewer above.
Fields
subject: ContentHashThe commit the check ran against.
status: CheckStatusWhat the check found.
target_ref: Option<String>The ref this check’s subject is proposed to land on, in the
view’s namespaced form <repo>:<refname>. None = unbound.
Present for the same reason OpKind::RequestReview carries
one, and it is load-bearing twice: per-ref policy conditions
on it, and it is the only thing that lets the ACL narrow a
check to a repository. A commit id names no repository, so a
check without this field is visible to node-wide readers
only — correct, and useless to the repository it belongs to.
Submit
Land a reviewed commit on a ref and record why it was allowed (D43; additive variant, wire-format unchanged).
OpKind::SetRef can already move a ref, and the landing gate
already runs before it. What the log does not keep is the reason:
the gate is evaluated at apply time inside the node’s submission
policy against files that are not in the log, so a SetRef on a
protected ref records that a merge happened and nothing about what
permitted it. This op is the same move with the answer attached.
Three properties live in the fold:
- The named review must actually say what the landing claims.
It must exist, be live, and name exactly this
(name, commit)pair. An authorization citing a review about something else is the failure mode worth refusing. - The authorization is rederived, never trusted. Approvers, approval weight, and the approving owner’s standing verdict are all computed from the view and compared; a mismatch is a rejection. The ACL grant behind an owner basis is the one part no replayer can check, because it lives in an operator file.
- It is the sole reason the record outlives the review.
OpKind::ArchiveReviewdiscards verdicts, so after archiving the entry bytes are the only surviving answer to “who approved this”. Replay still verifies, because validation runs at this op’s own position — before the archive that comes later.
This op narrows the gate on purpose. The node’s weight check
takes the maximum over every review naming (ref, commit); a
Submit names one review and is judged on that one, so two
half-approved reviews of the same commit cannot pool weight
through this path.
It is refused on a ref no rule gates. A node not running the
review gate, or a ref outside its protected set, takes an
ordinary OpKind::SetRef. Admitting a Submit there would
mint an authorization record for a decision nothing examined,
which is worse than no record — so Basis has no variant for
it and admission says so.
Who may submit one is admission policy (L2). Unlike
OpKind::AssignReviewers this one is author-signed: the
basis is a node determination, but pressing merge is a human act,
and the signature is the only place the log can keep who wanted
it. Admission rederives the authorization and refuses any
mismatch, so a client that writes its own basis buys a rejection
rather than a claim.
Fields
commit: ContentHashThe commit to land, which must be the review’s target.
prev: Option<ContentHash>Expected current target (CAS), None to create.
Why this was allowed.