@kokuin/controller 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/state.js CHANGED
@@ -1,34 +1,29 @@
1
- import { foldLog, foldLogAsync } from './fold.js';
2
- /**
3
- * Fold results to states, turning a fold failure into a throw. The identity and the resolver both
4
- * need the same fold-reject-take-last sequence, sync and async, so it lives here once. `context`
5
- * names the caller so its diagnostics survive the factoring.
6
- */ function foldedStates(did, context, result) {
1
+ import { foldLog } from './fold.js';
2
+ import { isControllerLog, MALFORMED_LOG } from './log.js';
3
+ /** The fold's last state, turning a fold failure into a throw. `context` names the caller. */ function lastState(did, context, result) {
7
4
  if (!result.ok) {
8
5
  throw new Error(`${context}: invalid log for ${did}: ${result.reason} at event ${result.index}`);
9
6
  }
10
- return result.states;
11
- }
12
- function lastState(did, context, result) {
13
- const states = foldedStates(did, context, result);
14
- return states[states.length - 1];
15
- }
16
- /**
17
- * Current state via the synchronous {@link foldLog}. A capability-authorised revoke fails closed
18
- * here — see {@link currentStateAsync}.
19
- */ export function currentState(did, events, context) {
20
- return lastState(did, context, foldLog(did, events));
21
- }
22
- /**
23
- * Every per-position state, via {@link foldLogAsync}. What the resolver needs and the identity does
24
- * not: a verifier has to answer about keys the log has since rotated away, since a token outlives the
25
- * state that gave its `kid` meaning.
26
- */ export async function allStatesAsync({ did, events, context, options }) {
27
- return foldedStates(did, context, await foldLogAsync(did, events, options));
7
+ const last = result.states[result.states.length - 1];
8
+ if (last === undefined) {
9
+ throw new Error(`${context}: no state produced for ${did}`);
10
+ }
11
+ return last;
28
12
  }
29
13
  /**
30
- * Current state via {@link foldLogAsync}, which can await `options.verifyCapability` for a
31
- * capability-authorised revoke instead of failing closed on it.
32
- */ export async function currentStateAsync({ did, events, context, options }) {
33
- return lastState(did, context, await foldLogAsync(did, events, options));
14
+ * The root key state a log establishes: the chain's last state.
15
+ *
16
+ * The chain alone, deliberately. Epoch events never change a root key (`k`, `ka`, `n` and the
17
+ * derivation indices live on the chain only), so folding them would buy an identity nothing but up
18
+ * to `MAX_EPOCH_EVENTS` signature checks on a path that must stay synchronous and cheap. The returned
19
+ * state's `deny`, `members` and `saturated` are therefore the chain's, not the live ones; nothing
20
+ * that builds an identity reads them.
21
+ */ export function currentState(did, log, context) {
22
+ if (!isControllerLog(log)) {
23
+ throw new Error(`${context}: invalid log for ${did}: ${MALFORMED_LOG} at event 0`);
24
+ }
25
+ return lastState(did, context, foldLog(did, {
26
+ chain: log.chain,
27
+ events: []
28
+ }));
34
29
  }
@@ -1,5 +1,4 @@
1
- import { type SignedEvent } from './events.js';
2
- import { type FoldOptions, type FoldResult } from './fold.js';
1
+ import type { SignedEvent } from './events.js';
3
2
  export type Duplicity = {
4
3
  /** Position of the divergence, from the folded state of the first conflicting event. */
5
4
  gen: number;
@@ -16,82 +15,42 @@ export type Duplicity = {
16
15
  * - `duplicity` — two events the controller cannot both have authored at one position. The only one
17
16
  * that says anything is wrong with the log.
18
17
  * - `no-valid-branch` — nothing presented folded at all.
19
- * - `needs-capability-verification` — **every** branch carries a capability-authorised revoke this
20
- * call was not equipped to check, so there is nothing left to compare. The log is fine; use
21
- * {@link resolveBranchesAsync} with a `verifyCapability`. A call where only *some* branches are
22
- * unverifiable answers with the rest and reports the count as {@link ResolveResult.unverified}.
23
18
  *
24
- * Added rather than swapped in: `duplicity` still carries the `gen: -1` sentinel for the two
25
- * non-duplicity arms, so a caller written against the previous shape still tells them apart.
26
- */
27
- export type ResolveFailure = 'duplicity' | 'no-valid-branch' | 'needs-capability-verification';
28
- /**
29
- * How many presented branches this call could not check, and therefore did not compare.
30
- *
31
- * Non-zero means the answer is **provisional**: an unfoldable branch may have superseded the one
32
- * reported, so a caller acting on a winner must re-resolve through {@link resolveBranchesAsync} with
33
- * a `verifyCapability` or treat the result as unconfirmed. Zero for every correctly configured call.
34
- *
35
- * Reported rather than fatal because the alternative is an unauthenticated DoS on duplicity
36
- * detection. A cap-bearing revoke reaches the verifier path *before* any signature check (the
37
- * capability names the signer), so a keyless peer can copy the public inception, append a `rev` with
38
- * `sigs: []` and arbitrary `cap` bytes, and present it — and refusing the whole resolution on that
39
- * would turn a genuine duplicity report between two honest branches into a refusal, for every
40
- * profile. Dropping such a branch cannot hand an attacker a win (a branch that does not fold is not a
41
- * contender); the only exposure left is a *stale* answer, which this count marks.
19
+ * `duplicity` still carries the `gen: -1` sentinel for the non-duplicity arm, so a caller tells them
20
+ * apart.
42
21
  */
22
+ export type ResolveFailure = 'duplicity' | 'no-valid-branch';
43
23
  export type ResolveResult = {
44
24
  ok: true;
45
25
  winner: Array<SignedEvent>;
46
26
  superseded: number;
47
- unverified: number;
48
27
  } | {
49
28
  ok: false;
50
29
  failure: ResolveFailure;
51
30
  duplicity: Duplicity;
52
- unverified: number;
53
31
  };
54
32
  /**
55
- * Whether a branch failed to fold only because this call could not check a capability. Filtering such
56
- * a branch out as invalid is what switched duplicity detection off for the management tier — a
57
- * `cap`-bearing revoke is the tier working as designed, not a forgery. Matched by prefix against the
58
- * fold's exported reasons, the contract those constants exist for.
59
- */
60
- export declare function needsCapabilityVerification(result: FoldResult): boolean;
61
- /**
62
- * Pick the authoritative branch. Two rules, in order:
33
+ * Pick the authoritative root chain among `chains` — chains only: epoch events never fork the log, so
34
+ * they are never a branch to choose between (see `mergeLogs` in `log.ts`). Two rules, in order:
63
35
  *
64
36
  * 1. The highest folded generation wins outright. Only a reset raises the generation, and only the
65
37
  * committed recovery key can author a reset — the root's override, unreachable by a thief.
66
- * 2. Within that generation, branches are compared **at the point they diverge**, not at their heads:
67
- * a rotate signed by the pre-committed next keys supersedes the current-key event it forked from
68
- * and everything that branch accumulated. Two diverging events with no rotate to settle them are
69
- * duplicity, surfaced rather than merged.
70
- *
71
- * Comparing heads instead would make branch *length* decisive below the top generation — the one
72
- * resource a thief has in abundance (a stolen current key cannot rotate but can sign unlimited
73
- * revokes). A longer branch still wins when it is the same history carried further, which the
74
- * divergence walk sees as a prefix.
75
- *
76
- * `superseded` counts the state-advancing events discarded from losing branches (what a cache must
38
+ * 2. Within that generation, chains are compared **at the point they diverge**, not at their heads. A
39
+ * chain that ends at the divergence point is a prefix of the other and loses to it; two chains
40
+ * that each carry an event there are duplicity, surfaced rather than merged.
41
+ *
42
+ * With root adds and root revokes moved into epochs, the chain holds only `icp` and `rot`, so the
43
+ * second case is always two rotates, or two resets at one generation — both signed by keys only the
44
+ * controller holds, neither outranking the other, and settled only by a reset at a higher generation.
45
+ * The current-key event a cold rotate outranks still exists, but in the epoch that rotate closes,
46
+ * where the fold gives it no effect; branch selection never sees it. That is also why chain length
47
+ * never decides anything here: the one resource a thief holding a current key has in abundance is
48
+ * current-key events, and none of them is on the chain. A longer chain still wins when it is the same
49
+ * history carried further, which the divergence walk sees as a prefix.
50
+ *
51
+ * `superseded` counts the state-advancing events discarded from losing chains (what a cache must
77
52
  * invalidate — folded state is not append-only under supersession); skipped events are not counted.
78
- * Branches that do not fold are filtered out before comparison, so a thief who cannot produce a valid
53
+ * Chains that do not fold are filtered out before comparison, so a thief who cannot produce a valid
79
54
  * event cannot create a duplicity report.
80
- *
81
- * This form folds synchronously and cannot check a capability-authorised revoke: such a branch is
82
- * counted in {@link ResolveResult.unverified} and left out, and only a call where *every* branch is
83
- * one answers `failure: 'needs-capability-verification'`. See {@link ResolveResult.unverified} for
84
- * why the count is reported rather than fatal; the remedy is {@link resolveBranchesAsync} with a
85
- * verifier.
86
- */
87
- export declare function resolveBranches(did: string, branches: Array<Array<SignedEvent>>): ResolveResult;
88
- /**
89
- * Async counterpart of {@link resolveBranches}, and the form the management tier needs — the sync
90
- * fold fails closed on a capability-authorised revoke, so resolving through it would filter every
91
- * branch of such a log away as invalid and report "no valid history" for a healthy profile.
92
- *
93
- * Branches are folded one at a time: `verifyCapability` is caller-supplied code that may do I/O, and
94
- * branch counts in a duplicity report are small, so there is nothing to win against calling a
95
- * caller's verifier concurrently.
96
55
  */
97
- export declare function resolveBranchesAsync(did: string, branches: Array<Array<SignedEvent>>, options?: FoldOptions): Promise<ResolveResult>;
56
+ export declare function resolveBranches(did: string, chains: Array<Array<SignedEvent>>): ResolveResult;
package/lib/supersede.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { digestOf } from './canonical.js';
2
- import { verifyRotate } from './events.js';
3
- import { CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD, CAPABILITY_REVOKE_NEEDS_VERIFIER, foldLog, foldLogAsync } from './fold.js';
2
+ import { foldLog } from './fold.js';
3
+ /** Index into an array whose element is a branch-resolution invariant, throwing rather than reading undefined. */ function required(items, index, what) {
4
+ const item = items[index];
5
+ if (item === undefined) {
6
+ throw new Error(`supersede: ${what}`);
7
+ }
8
+ return item;
9
+ }
4
10
  /**
5
11
  * The position reported for a failure that is not a fork. Real comparisons never reach `gen < 0` and
6
12
  * a real digest is never the empty string — how a caller told these from duplicity before
@@ -14,26 +20,11 @@ import { CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD, CAPABILITY_REVOKE_NEEDS_VERIFIER, f
14
20
  ]
15
21
  };
16
22
  function headState({ states }) {
17
- return states[states.length - 1];
23
+ return required(states, states.length - 1, 'folded branch has no state');
18
24
  }
19
25
  /** The digest of the `j`th event on the spine — the whole history up to it, chained. */ function spineDigest(folded, j) {
20
- return folded.states[folded.spine[j]].digest;
21
- }
22
- /**
23
- * Whether a branch failed to fold only because this call could not check a capability. Filtering such
24
- * a branch out as invalid is what switched duplicity detection off for the management tier — a
25
- * `cap`-bearing revoke is the tier working as designed, not a forgery. Matched by prefix against the
26
- * fold's exported reasons, the contract those constants exist for.
27
- */ export function needsCapabilityVerification(result) {
28
- return !result.ok && (result.reason.startsWith(CAPABILITY_REVOKE_NEEDS_ASYNC_FOLD) || result.reason.startsWith(CAPABILITY_REVOKE_NEEDS_VERIFIER));
29
- }
30
- function allUnverified(unverified) {
31
- return {
32
- ok: false,
33
- failure: 'needs-capability-verification',
34
- duplicity: NO_POSITION,
35
- unverified
36
- };
26
+ const index = required(folded.spine, j, 'spine index out of range');
27
+ return required(folded.states, index, 'spine points past folded state').digest;
37
28
  }
38
29
  function branchFrom(branch, result) {
39
30
  if (!result.ok) {
@@ -46,7 +37,7 @@ function branchFrom(branch, result) {
46
37
  for(let i = 1; i < states.length; i++){
47
38
  // A skipped event is the only one that leaves the digest where it was; every other chains its
48
39
  // predecessor's digest into its body, so no two can agree.
49
- if (states[i].digest !== states[i - 1].digest) {
40
+ if (required(states, i, 'state gap').digest !== required(states, i - 1, 'state gap').digest) {
50
41
  spine.push(i);
51
42
  }
52
43
  }
@@ -63,11 +54,11 @@ function branchFrom(branch, result) {
63
54
  if (candidate.branch.length !== incumbent.branch.length) {
64
55
  return candidate.branch.length < incumbent.branch.length;
65
56
  }
66
- return digestOf(candidate.branch[candidate.branch.length - 1].event) < digestOf(incumbent.branch[incumbent.branch.length - 1].event);
57
+ return digestOf(required(candidate.branch, candidate.branch.length - 1, 'empty branch').event) < digestOf(required(incumbent.branch, incumbent.branch.length - 1, 'empty branch').event);
67
58
  }
68
59
  /**
69
- * How far two branches agree, in spine entries. Zero is unreachable for branches of one log (they
70
- * share an inception), so a common prefix of 1 means they forked immediately after it.
60
+ * How far two branches agree, in spine entries. Zero means they disagree on their first entry: on
61
+ * spines trimmed to the top generation, two different resets opening it.
71
62
  */ function commonSpine(a, b) {
72
63
  const shortest = Math.min(a.spine.length, b.spine.length);
73
64
  let j = 0;
@@ -77,36 +68,23 @@ function branchFrom(branch, result) {
77
68
  return j;
78
69
  }
79
70
  /**
80
- * Does `candidate` supersede `incumbent` at the point their branches diverge? Per KERI, a rotate
81
- * signed by the pre-committed next keys outranks any current-key operation — at the diverging
82
- * position *and* over everything after it on the losing branch. That is what makes the owner win the
83
- * race against a thief holding a current key regardless of who published first or how far ahead: a
84
- * stolen current key cannot rotate but can sign revokes all day, so length must not decide this.
85
- */ function supersedes({ candidate, incumbent, priorDigest, priorNext }) {
86
- if (candidate.event.t !== 'rot') {
87
- return false;
88
- }
89
- if (incumbent.event.t === 'rot') {
90
- return false;
91
- }
92
- return verifyRotate(candidate, {
93
- digest: priorDigest,
94
- n: priorNext
95
- });
96
- }
97
- /**
98
- * Walk the contenders down to one, deciding at each divergence point rather than at the heads. Every
99
- * step resolves the *whole* surviving group at once: with three or more contenders, a sequential
100
- * pairwise reduction could compare two losing branches before reaching the one that supersedes both,
101
- * reporting duplicity between the losers and making the outcome depend on presentation order. The
102
- * group shrinks strictly each iteration, so this terminates.
71
+ * Walk the contenders down to one, deciding at each divergence point rather than at the heads. A
72
+ * contender ending at the divergence point is a prefix of the others and drops out; once every
73
+ * remaining contender carries an event there, they disagree on it, and nothing on the root chain can
74
+ * settle that within a generation — the chain holds only `icp` and `rot`, and two rotates at one
75
+ * position (or two resets at one generation) are both signed by keys only the controller holds,
76
+ * neither outranking the other. That is duplicity, for a reset at a higher generation to resolve.
77
+ *
78
+ * The whole surviving group is judged at once rather than pairwise, so the report is the same in every
79
+ * presentation order. The group shrinks strictly each iteration, so this terminates.
103
80
  */ function resolveContenders(contenders) {
104
81
  let group = contenders;
105
82
  while(group.length > 1){
83
+ const pivot = required(group, 0, 'empty contender group');
106
84
  // Where they first disagree, against the group as a whole — the point every contender agrees to.
107
- let divergence = group[0].spine.length;
85
+ let divergence = pivot.spine.length;
108
86
  for (const folded of group.slice(1)){
109
- divergence = Math.min(divergence, commonSpine(group[0], folded));
87
+ divergence = Math.min(divergence, commonSpine(pivot, folded));
110
88
  }
111
89
  // A branch ending at the divergence point is a prefix of the others: extended, not contradicted,
112
90
  // so it loses without any event discarded. The ordinary "one peer is behind" case, and the only
@@ -119,155 +97,76 @@ function branchFrom(branch, result) {
119
97
  group = extended;
120
98
  continue;
121
99
  }
122
- // Group the contenders by the event they diverge on. Branches sharing one are decided further
123
- // down.
124
- const rivals = new Map();
125
- for (const folded of group){
126
- const digest = spineDigest(folded, divergence);
127
- const bucket = rivals.get(digest);
128
- if (bucket == null) {
129
- rivals.set(digest, [
130
- folded
131
- ]);
132
- } else {
133
- bucket.push(folded);
134
- }
135
- }
136
- const groups = [
137
- ...rivals.values()
138
- ];
139
- // The state the diverging events all chain to. Identical across contenders, so any can answer.
140
- const first = groups[0][0];
141
- const prior = first.states[first.spine[divergence] - 1];
142
- let dominant = -1;
143
- for(let i = 0; i < groups.length; i++){
144
- const candidate = groups[i][0].branch[groups[i][0].spine[divergence]];
145
- const dominatesAll = groups.every((other, j)=>{
146
- if (j === i) {
147
- return true;
148
- }
149
- return supersedes({
150
- candidate,
151
- incumbent: other[0].branch[other[0].spine[divergence]],
152
- priorDigest: prior.digest,
153
- priorNext: prior.next
154
- });
155
- });
156
- if (dominatesAll) {
157
- if (dominant !== -1) {
158
- // More than one branch dominates every other — impossible for a legitimate rotate (two
159
- // distinct rotates never both supersede one another), but fall through to duplicity
160
- // defensively rather than pick arbitrarily.
161
- dominant = -2;
162
- break;
163
- }
164
- dominant = i;
100
+ // Every contender carries an event at the divergence point and at least two differ: two events the
101
+ // controller cannot both have authored. Deterministic regardless of arrival order: sort the
102
+ // conflicting digests, report from the branch sorted first.
103
+ const digests = [
104
+ ...new Set(group.map((folded)=>spineDigest(folded, divergence)))
105
+ ].sort();
106
+ const firstDigest = required(digests, 0, 'no diverging digests');
107
+ const secondDigest = required(digests, 1, 'duplicity needs two digests');
108
+ const reporter = group.find((folded)=>spineDigest(folded, divergence) === firstDigest);
109
+ const at = required(reporter.states, required(reporter.spine, divergence, 'spine index out of range'), 'divergence points past folded state');
110
+ return {
111
+ ok: false,
112
+ failure: 'duplicity',
113
+ duplicity: {
114
+ gen: at.gen,
115
+ seq: at.seq,
116
+ digests: [
117
+ firstDigest,
118
+ secondDigest
119
+ ]
165
120
  }
166
- }
167
- if (dominant < 0) {
168
- // Two events the controller cannot both have authored, no rotate to settle it. Deterministic
169
- // regardless of arrival order: sort the conflicting digests, report from the branch sorted
170
- // first.
171
- const digests = groups.map((members)=>spineDigest(members[0], divergence)).sort();
172
- const reporter = groups.find((members)=>spineDigest(members[0], divergence) === digests[0]);
173
- const at = reporter[0].states[reporter[0].spine[divergence]];
174
- return {
175
- ok: false,
176
- failure: 'duplicity',
177
- duplicity: {
178
- gen: at.gen,
179
- seq: at.seq,
180
- digests: [
181
- digests[0],
182
- digests[1]
183
- ]
184
- }
185
- };
186
- }
187
- group = groups[dominant];
121
+ };
188
122
  }
189
123
  return {
190
124
  ok: true,
191
- winner: group[0]
125
+ winner: required(group, 0, 'empty contender group')
192
126
  };
193
127
  }
194
128
  /**
195
- * Pick the authoritative branch. Two rules, in order:
129
+ * Pick the authoritative root chain among `chains` — chains only: epoch events never fork the log, so
130
+ * they are never a branch to choose between (see `mergeLogs` in `log.ts`). Two rules, in order:
196
131
  *
197
132
  * 1. The highest folded generation wins outright. Only a reset raises the generation, and only the
198
133
  * committed recovery key can author a reset — the root's override, unreachable by a thief.
199
- * 2. Within that generation, branches are compared **at the point they diverge**, not at their heads:
200
- * a rotate signed by the pre-committed next keys supersedes the current-key event it forked from
201
- * and everything that branch accumulated. Two diverging events with no rotate to settle them are
202
- * duplicity, surfaced rather than merged.
134
+ * 2. Within that generation, chains are compared **at the point they diverge**, not at their heads. A
135
+ * chain that ends at the divergence point is a prefix of the other and loses to it; two chains
136
+ * that each carry an event there are duplicity, surfaced rather than merged.
203
137
  *
204
- * Comparing heads instead would make branch *length* decisive below the top generation — the one
205
- * resource a thief has in abundance (a stolen current key cannot rotate but can sign unlimited
206
- * revokes). A longer branch still wins when it is the same history carried further, which the
207
- * divergence walk sees as a prefix.
138
+ * With root adds and root revokes moved into epochs, the chain holds only `icp` and `rot`, so the
139
+ * second case is always two rotates, or two resets at one generation — both signed by keys only the
140
+ * controller holds, neither outranking the other, and settled only by a reset at a higher generation.
141
+ * The current-key event a cold rotate outranks still exists, but in the epoch that rotate closes,
142
+ * where the fold gives it no effect; branch selection never sees it. That is also why chain length
143
+ * never decides anything here: the one resource a thief holding a current key has in abundance is
144
+ * current-key events, and none of them is on the chain. A longer chain still wins when it is the same
145
+ * history carried further, which the divergence walk sees as a prefix.
208
146
  *
209
- * `superseded` counts the state-advancing events discarded from losing branches (what a cache must
147
+ * `superseded` counts the state-advancing events discarded from losing chains (what a cache must
210
148
  * invalidate — folded state is not append-only under supersession); skipped events are not counted.
211
- * Branches that do not fold are filtered out before comparison, so a thief who cannot produce a valid
149
+ * Chains that do not fold are filtered out before comparison, so a thief who cannot produce a valid
212
150
  * event cannot create a duplicity report.
213
- *
214
- * This form folds synchronously and cannot check a capability-authorised revoke: such a branch is
215
- * counted in {@link ResolveResult.unverified} and left out, and only a call where *every* branch is
216
- * one answers `failure: 'needs-capability-verification'`. See {@link ResolveResult.unverified} for
217
- * why the count is reported rather than fatal; the remedy is {@link resolveBranchesAsync} with a
218
- * verifier.
219
- */ export function resolveBranches(did, branches) {
151
+ */ export function resolveBranches(did, chains) {
220
152
  const valid = [];
221
- let unverified = 0;
222
- for (const branch of branches){
223
- const result = foldLog(did, branch);
224
- if (needsCapabilityVerification(result)) {
225
- unverified++;
226
- continue;
227
- }
228
- const folded = branchFrom(branch, result);
153
+ for (const branch of chains){
154
+ const folded = branchFrom(branch, foldLog(did, {
155
+ chain: branch,
156
+ events: []
157
+ }));
229
158
  if (folded != null) {
230
159
  valid.push(folded);
231
160
  }
232
161
  }
233
- return selectWinner(valid, unverified);
162
+ return selectWinner(valid);
234
163
  }
235
- /**
236
- * Async counterpart of {@link resolveBranches}, and the form the management tier needs — the sync
237
- * fold fails closed on a capability-authorised revoke, so resolving through it would filter every
238
- * branch of such a log away as invalid and report "no valid history" for a healthy profile.
239
- *
240
- * Branches are folded one at a time: `verifyCapability` is caller-supplied code that may do I/O, and
241
- * branch counts in a duplicity report are small, so there is nothing to win against calling a
242
- * caller's verifier concurrently.
243
- */ export async function resolveBranchesAsync(did, branches, options = {}) {
244
- const valid = [];
245
- let unverified = 0;
246
- for (const branch of branches){
247
- const result = await foldLogAsync(did, branch, options);
248
- // Reachable only with no `verifyCapability` configured — with one, the verifier's answer decides
249
- // and a rejection is an invalid branch. So a correctly configured call always reports
250
- // `unverified: 0`.
251
- if (needsCapabilityVerification(result)) {
252
- unverified++;
253
- continue;
254
- }
255
- const folded = branchFrom(branch, result);
256
- if (folded != null) {
257
- valid.push(folded);
258
- }
259
- }
260
- return selectWinner(valid, unverified);
261
- }
262
- function selectWinner(valid, unverified) {
164
+ function selectWinner(valid) {
263
165
  if (valid.length === 0) {
264
- // Nothing left to compare, and which answer that is depends on *why*: an unchecked branch is not
265
- // evidence the log has no history.
266
- return unverified > 0 ? allUnverified(unverified) : {
166
+ return {
267
167
  ok: false,
268
168
  failure: 'no-valid-branch',
269
- duplicity: NO_POSITION,
270
- unverified
169
+ duplicity: NO_POSITION
271
170
  };
272
171
  }
273
172
  // Re-derivation is idempotent: branches folding to the same head are one history presented twice.
@@ -287,26 +186,41 @@ function selectWinner(valid, unverified) {
287
186
  ...byHead.values()
288
187
  ];
289
188
  const topGen = Math.max(...distinct.map((folded)=>headState(folded).gen));
290
- const resolved = resolveContenders(distinct.filter((f)=>headState(f).gen === topGen));
291
- if (!resolved.ok) {
292
- // A duplicity report is provisional the same way a winner is: an unchecked branch might have
293
- // superseded both conflicting events.
294
- return {
295
- ...resolved,
296
- unverified
189
+ // Compared from the top generation's first event on, not from the inception. A reset chains to the
190
+ // inception rather than to what preceded it, and its state is a function of the event alone, so one
191
+ // reset can sit on different prefixes — appended to a generation-0 chain by one party, straight
192
+ // after the inception in another's pruned copy. Once both contain it, what came before cannot change
193
+ // the winner; compared from the inception, the two would diverge at position 1 and read as a fork.
194
+ // At generation 0 the generation starts at the inception, so nothing is dropped.
195
+ const original = new Map();
196
+ const contenders = distinct.filter((folded)=>headState(folded).gen === topGen).map((folded)=>{
197
+ const start = folded.spine.findIndex((index)=>required(folded.states, index, 'spine points past folded state').gen === topGen);
198
+ const trimmed = {
199
+ ...folded,
200
+ spine: folded.spine.slice(start)
297
201
  };
202
+ original.set(trimmed, folded);
203
+ return trimmed;
204
+ });
205
+ const resolved = resolveContenders(contenders);
206
+ if (!resolved.ok) {
207
+ return resolved;
298
208
  }
299
- const { winner } = resolved;
209
+ const winner = original.get(resolved.winner) ?? resolved.winner;
210
+ // Discarded: what a loser's spine holds that the winner's does not. Counted by digest rather than
211
+ // from the common prefix, so a reset both carry on different prefixes is not counted as lost.
212
+ const kept = new Set(winner.spine.map((_, j)=>spineDigest(winner, j)));
300
213
  let superseded = 0;
301
214
  for (const loser of distinct){
302
215
  if (loser !== winner) {
303
- superseded += loser.spine.length - commonSpine(winner, loser);
216
+ for(let j = 0; j < loser.spine.length; j++){
217
+ if (!kept.has(spineDigest(loser, j))) superseded++;
218
+ }
304
219
  }
305
220
  }
306
221
  return {
307
222
  ok: true,
308
223
  winner: winner.branch,
309
- superseded,
310
- unverified
224
+ superseded
311
225
  };
312
226
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kokuin/controller",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "did:kokuin controller key event log",
5
5
  "keywords": [
6
6
  "did",
@@ -25,26 +25,26 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@noble/curves": "^2.3.0",
29
- "@noble/hashes": "^2.3.0",
30
- "@scure/base": "^2.3.0",
31
- "@sozai/json": "^0.1.1",
32
- "micro-key-producer": "^0.10.0",
33
- "@kokuin/token": "^0.5.0"
28
+ "@kokuin/token": "^0.6.0",
29
+ "@noble/curves": "^2.4.0",
30
+ "@noble/hashes": "^2.4.0",
31
+ "@scure/base": "^2.4.0",
32
+ "@sozai/json": "^0.3.0",
33
+ "micro-key-producer": "^0.10.2"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@kokuin/controller-conformance": "^0.0.0",
37
- "@kokuin/deterministic": "^0.2.2",
38
- "@kokuin/jwe": "^0.1.0"
37
+ "@kokuin/deterministic": "^0.2.3",
38
+ "@kokuin/jwe": "^0.1.1"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
45
- "build:clean": "del lib",
46
- "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
47
- "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
45
+ "build:clean": "del-cli lib",
46
+ "build:js": "del-cli 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
47
+ "build:types": "del-cli 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
48
48
  "test": "pnpm run test:types && pnpm run test:unit",
49
49
  "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
50
50
  "test:unit": "vitest run"