@common-agent/reputations 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/LICENSE-UNICODE +39 -0
- package/NOTICE +9 -0
- package/README.md +38 -2
- package/dist/casefolding-15.1.0.json +1539 -0
- package/dist/heads.d.ts +184 -0
- package/dist/heads.js +358 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +28 -0
- package/dist/ledger.d.ts +92 -0
- package/dist/ledger.js +157 -0
- package/dist/nda-claims.d.ts +141 -0
- package/dist/nda-claims.js +168 -0
- package/dist/nda-record.d.ts +86 -0
- package/dist/nda-record.js +83 -0
- package/dist/pool.d.ts +59 -0
- package/dist/pool.js +77 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +1 -0
- package/dist/report.d.ts +176 -0
- package/dist/report.js +147 -0
- package/dist/score.d.ts +110 -0
- package/dist/score.js +129 -0
- package/dist/signing.d.ts +150 -0
- package/dist/signing.js +299 -0
- package/package.json +53 -4
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type ScoredEvent } from "./score.js";
|
|
2
|
+
/** The prev_hash of a chain's first entry. */
|
|
3
|
+
export declare const GENESIS = "genesis";
|
|
4
|
+
/**
|
|
5
|
+
* The ledger's actions (§4.1). This bureau writes enrolled, withdrew, fact and
|
|
6
|
+
* attested today; the rest are the standard's, named so a reader of any
|
|
7
|
+
* chain knows them: amended (an enrollment's capabilities changed), probed
|
|
8
|
+
* (§4.3), stopped (§4.6), nda_claimed, nda_answered and nda_resolved (§4.8),
|
|
9
|
+
* excluded (§6.4) and corrected (§10, a data-error repair appended, never a
|
|
10
|
+
* rewrite).
|
|
11
|
+
*/
|
|
12
|
+
export type LedgerAction = "enrolled" | "amended" | "withdrew" | "fact" | "attested" | "probed" | "stopped" | "nda_claimed" | "nda_answered" | "nda_resolved" | "excluded" | "corrected";
|
|
13
|
+
export declare const LEDGER_ACTIONS: readonly LedgerAction[];
|
|
14
|
+
/** Evidence source, ranked. Higher rank wins the interaction (§6.3).
|
|
15
|
+
* `value` is the §6.6 value judgment: an attestation like a review, so it
|
|
16
|
+
* outranks the mechanical layers the same way — but it never competes with
|
|
17
|
+
* one, because the two cannot both exist for the same engagement (a
|
|
18
|
+
* document either names deliverables or it does not). `floor_miss` (Agent
|
|
19
|
+
* SoW §5.14) sits with `form_check`: both are mechanical verdicts derived
|
|
20
|
+
* from the engagement record, and either outranks the bare task fact.
|
|
21
|
+
* `interview` (SPEC.md §3.3.1) ranks with `task` — moot in practice, since
|
|
22
|
+
* interview outcomes sit outside the scoring table and its interaction ids
|
|
23
|
+
* collide with nothing, but a rank above 1 could let a non-scoring entry
|
|
24
|
+
* suppress a scoring one if an id ever did collide. `dispute` (§4.4: a fact,
|
|
25
|
+
* recorded and never scored) ranks 0, below everything, so even a dispute
|
|
26
|
+
* fact whose interaction id collided with a task's could never take that
|
|
27
|
+
* task's place; its ids are the dispute's own besides. */
|
|
28
|
+
export declare const SOURCE_RANK: Record<string, number>;
|
|
29
|
+
export interface LedgerEntry {
|
|
30
|
+
subject: string;
|
|
31
|
+
seq: number;
|
|
32
|
+
at: string;
|
|
33
|
+
action: LedgerAction;
|
|
34
|
+
capability: string;
|
|
35
|
+
detail: string;
|
|
36
|
+
prev_hash: string;
|
|
37
|
+
entry_hash: string;
|
|
38
|
+
/**
|
|
39
|
+
* How entry_hash was made. 1, or absent: this bureau's first construction,
|
|
40
|
+
* lowercase hex SHA-256 over the canonical JSON of the entry with prev_hash
|
|
41
|
+
* inside it. 2: the standard's (§4.1), base64 SHA-256 over the canonical
|
|
42
|
+
* JSON of the entry without the two hash fields, its detail as an object,
|
|
43
|
+
* followed by prev_hash. A chain may hold both; each entry says which.
|
|
44
|
+
*/
|
|
45
|
+
hash_v?: 1 | 2;
|
|
46
|
+
}
|
|
47
|
+
/** The hash an entry should carry, by its hash_v. `canonical` is the platform's canonical JSON, which made the first construction's bytes. */
|
|
48
|
+
export declare function entryHashOf(entry: Omit<LedgerEntry, "entry_hash">, canonical?: (v: unknown) => string): string;
|
|
49
|
+
/** Walk one subject's chain start to end, recomputing every link, whichever construction each entry used. */
|
|
50
|
+
export declare function verifyChainEntries(rows: readonly LedgerEntry[], canonical?: (v: unknown) => string): {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
count: number;
|
|
53
|
+
brokenAt?: number;
|
|
54
|
+
};
|
|
55
|
+
export interface EnrollmentInfo {
|
|
56
|
+
state: "UNENROLLED" | "ENROLLED" | "WITHDRAWN";
|
|
57
|
+
enrolled_at?: string;
|
|
58
|
+
withdrew_at?: string;
|
|
59
|
+
/** Withdrawal takes effect after cooling-off (§3.3); until then evidence
|
|
60
|
+
* continues to land and the subject still reads as enrolled. */
|
|
61
|
+
withdrawal_effective_at?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface PoolResult {
|
|
64
|
+
events: ScoredEvent[];
|
|
65
|
+
declined: number;
|
|
66
|
+
scoredInteractions: number;
|
|
67
|
+
silentFailures: number;
|
|
68
|
+
attesterAnchors: Map<string, number>;
|
|
69
|
+
/** How many scored events are §6.6 value judgments. The report's
|
|
70
|
+
* `value_judgment_share` is a WEIGHT share and comes from scorePool; this
|
|
71
|
+
* count is what decides whether the field is required at all. */
|
|
72
|
+
valueJudgments: number;
|
|
73
|
+
/** SPEC §10.8a: failures the platform attributed to the CLIENT — the caller
|
|
74
|
+
* did not furnish something the offering declared it needed. Recorded and
|
|
75
|
+
* never scored, and counted here so that "not scored" does not become
|
|
76
|
+
* "invisible": a reader can see that the ending happened without it costing
|
|
77
|
+
* the provider anything. */
|
|
78
|
+
notFurnished: number;
|
|
79
|
+
oldest?: string;
|
|
80
|
+
newest?: string;
|
|
81
|
+
}
|
|
82
|
+
/** Enrollment state from a subject's enrollment acts, in chain order (§3).
|
|
83
|
+
* Cooling-off: a withdrawal only takes effect `coolingOffDays` after it was
|
|
84
|
+
* filed. */
|
|
85
|
+
export declare function enrollmentFromActs(acts: readonly LedgerEntry[], nowMs: number, coolingOffDays?: number): EnrollmentInfo;
|
|
86
|
+
/**
|
|
87
|
+
* The quality pool drawn from evidence rows (§7.1). Per interaction, the
|
|
88
|
+
* highest-ranked evidence class scores (§6.3); everything stays in the
|
|
89
|
+
* ledger regardless. De-duplication is per interaction across all the rows
|
|
90
|
+
* given, so the same rule serves one subject and an organisation's agents.
|
|
91
|
+
*/
|
|
92
|
+
export declare function poolFromEntries(rows: readonly LedgerEntry[]): PoolResult;
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure rules of the bureau's ledger (Agent Reputations §3, §4, §6.3).
|
|
3
|
+
*
|
|
4
|
+
* The ledger itself, the bureau's store,
|
|
5
|
+
* stores the entries and keeps each subject's hash chain. What does not need
|
|
6
|
+
* a database lives here: the entry and result shapes, which evidence wins an
|
|
7
|
+
* interaction when evidence disagrees, how the scoring pool is drawn from the
|
|
8
|
+
* evidence, and what a subject's enrollment acts add up to. Each takes rows
|
|
9
|
+
* and returns a value, so anyone holding the rows computes the same answer.
|
|
10
|
+
*
|
|
11
|
+
* The shapes are an earlier bureau's own, from before 0.6.0; pool.ts holds
|
|
12
|
+
* the standard's.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { OUTCOME_VALUES, UNSCORED_OUTCOMES } from "./score.js";
|
|
16
|
+
import { jcs } from "./signing.js";
|
|
17
|
+
/** The prev_hash of a chain's first entry. */
|
|
18
|
+
export const GENESIS = "genesis";
|
|
19
|
+
export const LEDGER_ACTIONS = [
|
|
20
|
+
"enrolled", "amended", "withdrew", "fact", "attested", "probed", "stopped",
|
|
21
|
+
"nda_claimed", "nda_answered", "nda_resolved", "excluded", "corrected",
|
|
22
|
+
];
|
|
23
|
+
/** Evidence source, ranked. Higher rank wins the interaction (§6.3).
|
|
24
|
+
* `value` is the §6.6 value judgment: an attestation like a review, so it
|
|
25
|
+
* outranks the mechanical layers the same way — but it never competes with
|
|
26
|
+
* one, because the two cannot both exist for the same engagement (a
|
|
27
|
+
* document either names deliverables or it does not). `floor_miss` (Agent
|
|
28
|
+
* SoW §5.14) sits with `form_check`: both are mechanical verdicts derived
|
|
29
|
+
* from the engagement record, and either outranks the bare task fact.
|
|
30
|
+
* `interview` (SPEC.md §3.3.1) ranks with `task` — moot in practice, since
|
|
31
|
+
* interview outcomes sit outside the scoring table and its interaction ids
|
|
32
|
+
* collide with nothing, but a rank above 1 could let a non-scoring entry
|
|
33
|
+
* suppress a scoring one if an id ever did collide. `dispute` (§4.4: a fact,
|
|
34
|
+
* recorded and never scored) ranks 0, below everything, so even a dispute
|
|
35
|
+
* fact whose interaction id collided with a task's could never take that
|
|
36
|
+
* task's place; its ids are the dispute's own besides. */
|
|
37
|
+
export const SOURCE_RANK = { task: 1, form_check: 2, floor_miss: 2, interview: 1, dispute: 0, review: 3, value: 3 };
|
|
38
|
+
/** The hash an entry should carry, by its hash_v. `canonical` is the platform's canonical JSON, which made the first construction's bytes. */
|
|
39
|
+
export function entryHashOf(entry, canonical = jcs) {
|
|
40
|
+
if ((entry.hash_v ?? 1) === 2) {
|
|
41
|
+
const { prev_hash, ...rest } = entry;
|
|
42
|
+
const body = jcs({ ...rest, detail: JSON.parse(entry.detail) });
|
|
43
|
+
return createHash("sha256").update(body + prev_hash, "utf8").digest("base64");
|
|
44
|
+
}
|
|
45
|
+
// The first construction hashed the row as it was stored, which held no hash_v.
|
|
46
|
+
const { hash_v: _v, ...v1 } = entry;
|
|
47
|
+
return createHash("sha256").update(canonical(v1)).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
/** Walk one subject's chain start to end, recomputing every link, whichever construction each entry used. */
|
|
50
|
+
export function verifyChainEntries(rows, canonical) {
|
|
51
|
+
let prev = GENESIS;
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const { entry_hash, ...rest } = row;
|
|
54
|
+
if (row.prev_hash !== prev || entryHashOf(rest, canonical) !== entry_hash)
|
|
55
|
+
return { ok: false, count: rows.length, brokenAt: row.seq };
|
|
56
|
+
prev = entry_hash;
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, count: rows.length };
|
|
59
|
+
}
|
|
60
|
+
/** Enrollment state from a subject's enrollment acts, in chain order (§3).
|
|
61
|
+
* Cooling-off: a withdrawal only takes effect `coolingOffDays` after it was
|
|
62
|
+
* filed. */
|
|
63
|
+
export function enrollmentFromActs(acts, nowMs, coolingOffDays = 30) {
|
|
64
|
+
if (!acts.length)
|
|
65
|
+
return { state: "UNENROLLED" };
|
|
66
|
+
let enrolledAt;
|
|
67
|
+
let withdrewAt;
|
|
68
|
+
for (const act of acts) {
|
|
69
|
+
if (act.action === "enrolled") {
|
|
70
|
+
enrolledAt = act.at;
|
|
71
|
+
withdrewAt = undefined;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
withdrewAt = act.at;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (withdrewAt) {
|
|
78
|
+
const effective = new Date(Date.parse(withdrewAt) + coolingOffDays * 86_400_000).toISOString();
|
|
79
|
+
if (nowMs >= Date.parse(effective)) {
|
|
80
|
+
return { state: "WITHDRAWN", enrolled_at: enrolledAt, withdrew_at: withdrewAt, withdrawal_effective_at: effective };
|
|
81
|
+
}
|
|
82
|
+
return { state: "ENROLLED", enrolled_at: enrolledAt, withdrew_at: withdrewAt, withdrawal_effective_at: effective };
|
|
83
|
+
}
|
|
84
|
+
return { state: "ENROLLED", enrolled_at: enrolledAt };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The quality pool drawn from evidence rows (§7.1). Per interaction, the
|
|
88
|
+
* highest-ranked evidence class scores (§6.3); everything stays in the
|
|
89
|
+
* ledger regardless. De-duplication is per interaction across all the rows
|
|
90
|
+
* given, so the same rule serves one subject and an organisation's agents.
|
|
91
|
+
*/
|
|
92
|
+
export function poolFromEntries(rows) {
|
|
93
|
+
const byInteraction = new Map();
|
|
94
|
+
// When each interaction ended (§7.2): its fact's ended_at, or the fact's own
|
|
95
|
+
// time for a fact recorded without one. Every event about the interaction,
|
|
96
|
+
// an attestation signed weeks later included, takes that age.
|
|
97
|
+
const endedAt = new Map();
|
|
98
|
+
let anonSeq = 0;
|
|
99
|
+
const parsed = rows.map((row) => ({
|
|
100
|
+
row,
|
|
101
|
+
d: JSON.parse(row.detail),
|
|
102
|
+
}));
|
|
103
|
+
for (const { row, d } of parsed) {
|
|
104
|
+
if (row.action === "fact" && d.interaction_id && !endedAt.has(d.interaction_id))
|
|
105
|
+
endedAt.set(d.interaction_id, d.ended_at ?? row.at);
|
|
106
|
+
}
|
|
107
|
+
for (const { row, d } of parsed) {
|
|
108
|
+
const id = d.interaction_id ?? `anon_${anonSeq++}`;
|
|
109
|
+
const rank = SOURCE_RANK[d.source ?? "task"] ?? 1;
|
|
110
|
+
const prev = byInteraction.get(id);
|
|
111
|
+
if (!prev || rank >= prev.rank) {
|
|
112
|
+
byInteraction.set(id, { outcome: d.outcome ?? d.derived_outcome ?? "", at: endedAt.get(id) ?? d.ended_at ?? row.at, rank, evaluator: d.evaluator, source: d.source });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const result = {
|
|
116
|
+
events: [],
|
|
117
|
+
declined: 0,
|
|
118
|
+
scoredInteractions: 0,
|
|
119
|
+
silentFailures: 0,
|
|
120
|
+
attesterAnchors: new Map(),
|
|
121
|
+
valueJudgments: 0,
|
|
122
|
+
notFurnished: 0,
|
|
123
|
+
};
|
|
124
|
+
for (const best of byInteraction.values()) {
|
|
125
|
+
if (best.evaluator) {
|
|
126
|
+
result.attesterAnchors.set(best.evaluator, (result.attesterAnchors.get(best.evaluator) ?? 0) + 1);
|
|
127
|
+
}
|
|
128
|
+
if (best.outcome === "DECLINED") {
|
|
129
|
+
result.declined += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (best.outcome === "NOT_FURNISHED")
|
|
133
|
+
result.notFurnished += 1;
|
|
134
|
+
if (UNSCORED_OUTCOMES.has(best.outcome))
|
|
135
|
+
continue;
|
|
136
|
+
const value = OUTCOME_VALUES[best.outcome];
|
|
137
|
+
// An outcome with no quality value — EXHAUSTED, TERMINATED,
|
|
138
|
+
// NOT_FURNISHED — is recorded and not scored (§4.4, SPEC §10.8a).
|
|
139
|
+
// Skipping it here is the whole mechanism: it never becomes an event, so
|
|
140
|
+
// it can never read as a failure however it got into the chain. This is
|
|
141
|
+
// what keeps a customer's lapsed licence off the provider's record.
|
|
142
|
+
if (value === undefined)
|
|
143
|
+
continue;
|
|
144
|
+
const isValue = best.source === "value";
|
|
145
|
+
result.events.push({ value, at: best.at, ...(isValue ? { value_judgment: true } : {}) });
|
|
146
|
+
if (isValue)
|
|
147
|
+
result.valueJudgments += 1;
|
|
148
|
+
result.scoredInteractions += 1;
|
|
149
|
+
if (best.outcome === "FAILED_SILENT")
|
|
150
|
+
result.silentFailures += 1;
|
|
151
|
+
if (!result.oldest || best.at < result.oldest)
|
|
152
|
+
result.oldest = best.at;
|
|
153
|
+
if (!result.newest || best.at > result.newest)
|
|
154
|
+
result.newest = best.at;
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export declare const NDA_CLAIM_TAG = "fdbk-nda-claim-v1";
|
|
2
|
+
/** The kinds of disclosure record a claim may cite (§4.8). */
|
|
3
|
+
export declare const DISCLOSURE_KINDS: readonly ["rfp_release", "sow_task", "delivery"];
|
|
4
|
+
export type DisclosureKind = (typeof DISCLOSURE_KINDS)[number];
|
|
5
|
+
/** The claim body a claimant signs (§4.8). */
|
|
6
|
+
export interface NdaClaim {
|
|
7
|
+
nda: {
|
|
8
|
+
id: string;
|
|
9
|
+
version: number;
|
|
10
|
+
digest: string;
|
|
11
|
+
ref?: string;
|
|
12
|
+
};
|
|
13
|
+
claimant: {
|
|
14
|
+
agent: string;
|
|
15
|
+
handle?: string;
|
|
16
|
+
};
|
|
17
|
+
respondent: {
|
|
18
|
+
agent: string;
|
|
19
|
+
handle?: string;
|
|
20
|
+
};
|
|
21
|
+
categories: string[];
|
|
22
|
+
disclosures: Array<{
|
|
23
|
+
kind: DisclosureKind;
|
|
24
|
+
digest: string;
|
|
25
|
+
at: string;
|
|
26
|
+
}>;
|
|
27
|
+
found: {
|
|
28
|
+
at: string;
|
|
29
|
+
material_digest: string;
|
|
30
|
+
note?: string;
|
|
31
|
+
};
|
|
32
|
+
breach_at: string | null;
|
|
33
|
+
at: string;
|
|
34
|
+
}
|
|
35
|
+
/** The bytes the claimant signs: the tag, a line feed, and the JCS of the claim. */
|
|
36
|
+
export declare function claimSignedBytes(claim: NdaClaim): Uint8Array;
|
|
37
|
+
/**
|
|
38
|
+
* The claim's digest, which answers, acknowledgments and withdrawals sign
|
|
39
|
+
* over: `sha256:` and the hex SHA-256 of the claim's JCS. §4.8 names a claim's
|
|
40
|
+
* digest without defining it; this is the definition this bureau uses, the
|
|
41
|
+
* one Agent NDA uses for an agreement.
|
|
42
|
+
*/
|
|
43
|
+
export declare function claimDigest(claim: NdaClaim): string;
|
|
44
|
+
/** What is wrong with a claim's shape, or nothing. */
|
|
45
|
+
export declare function claimProblems(x: unknown): string[];
|
|
46
|
+
/**
|
|
47
|
+
* The one spelling a bureau keeps an agent key in, the nkey, whichever way a
|
|
48
|
+
* claim spells it, so one agent is never two subjects. Null for a string that
|
|
49
|
+
* is not an agent key.
|
|
50
|
+
*/
|
|
51
|
+
export declare function nkeyOf(agent: string): string | null;
|
|
52
|
+
/** Whether `sigB64` (padded base64 Ed25519) is `key`'s signature over `bytes`; `key` an nkey or padded base64. */
|
|
53
|
+
export declare function signedByKey(bytes: Uint8Array, key: string, sigB64: string): boolean;
|
|
54
|
+
/** Check 1, in part: the claimant's key signed the claim (padded base64 Ed25519). Resolving the key's identity is the bureau's. */
|
|
55
|
+
export declare function claimSignedBy(claim: NdaClaim, sigB64: string): boolean;
|
|
56
|
+
/** The salted hash a ledger entry carries in place of an anchor (§4.1). */
|
|
57
|
+
export declare function saltedAnchorHash(anchor: string, salt: string): string;
|
|
58
|
+
/** Check 8, the window: from the first disclosure the claim cites until the filing period has passed after the duties ended. */
|
|
59
|
+
export declare function withinClaimWindow(x: {
|
|
60
|
+
disclosedAt: readonly string[];
|
|
61
|
+
dutiesEndAt: string;
|
|
62
|
+
filedAt: string;
|
|
63
|
+
filingPeriodDays: number;
|
|
64
|
+
}): boolean;
|
|
65
|
+
/** The filing period this bureau publishes (§4.8, §10): the recommended 365 days; never under 90. */
|
|
66
|
+
export declare const FILING_PERIOD_DAYS = 365;
|
|
67
|
+
/** Check 9: claims that share this key are one claim, the later an amendment of the earlier. */
|
|
68
|
+
export declare function amendmentKey(x: {
|
|
69
|
+
claimantAnchorHash: string;
|
|
70
|
+
ndaDigest: string;
|
|
71
|
+
respondent: string;
|
|
72
|
+
}): string;
|
|
73
|
+
export declare const NDA_ANSWER_TAG = "fdbk-nda-answer-v1";
|
|
74
|
+
export declare const NDA_ACK_TAG = "fdbk-nda-ack-v1";
|
|
75
|
+
export declare const NDA_WITHDRAW_TAG = "fdbk-nda-withdraw-v1";
|
|
76
|
+
/** The respondent's answer: over the claim's digest and the answer text. */
|
|
77
|
+
export interface NdaAnswer {
|
|
78
|
+
claim: string;
|
|
79
|
+
text: string;
|
|
80
|
+
}
|
|
81
|
+
/** The respondent's acknowledgment of the breach. A seat that names an organization carries the signer's mandate. */
|
|
82
|
+
export interface NdaAck {
|
|
83
|
+
claim: string;
|
|
84
|
+
mandate?: string;
|
|
85
|
+
}
|
|
86
|
+
/** The claimant's withdrawal. */
|
|
87
|
+
export interface NdaWithdrawal {
|
|
88
|
+
claim: string;
|
|
89
|
+
}
|
|
90
|
+
/** At most this many characters in an answer: it is stored and shown, never scored. */
|
|
91
|
+
export declare const MAX_ANSWER_CHARS = 4000;
|
|
92
|
+
/** The bytes an answer, acknowledgment or withdrawal is signed over: its tag, a line feed, and the JCS of the body. */
|
|
93
|
+
export declare function ndaActBytes(tag: typeof NDA_ANSWER_TAG | typeof NDA_ACK_TAG | typeof NDA_WITHDRAW_TAG, body: NdaAnswer | NdaAck | NdaWithdrawal): Uint8Array;
|
|
94
|
+
/** The four bases on which a claim is upheld, and only these. */
|
|
95
|
+
export declare const UPHOLD_BASES: readonly ["acknowledged", "arbiter", "owners", "court"];
|
|
96
|
+
export type UpholdBasis = (typeof UPHOLD_BASES)[number];
|
|
97
|
+
/** What an `nda_resolved` entry records. `lapsed` is never recorded: it is how an open claim stands after the open period. */
|
|
98
|
+
export declare const RESOLUTIONS: readonly ["upheld", "dismissed", "withdrawn", "set_aside"];
|
|
99
|
+
export type Resolution = (typeof RESOLUTIONS)[number];
|
|
100
|
+
/** How a claim stands. */
|
|
101
|
+
export type ClaimState = "open" | "lapsed" | Resolution;
|
|
102
|
+
/** The open period this bureau publishes (§4.8, §10): the recommended 365 days. */
|
|
103
|
+
export declare const OPEN_PERIOD_DAYS = 365;
|
|
104
|
+
/** The record period this bureau publishes: the recommended five years; never under 1095 days. */
|
|
105
|
+
export declare const RECORD_PERIOD_DAYS = 1825;
|
|
106
|
+
export declare const MIN_RECORD_PERIOD_DAYS = 1095;
|
|
107
|
+
export declare const MIN_FILING_PERIOD_DAYS = 90;
|
|
108
|
+
/**
|
|
109
|
+
* Why a resolution may not follow the state a claim is in, or null when it
|
|
110
|
+
* may. A claim leaves open (or lapsed, since a resolution after the open
|
|
111
|
+
* period still lands) only once: upheld, dismissed or withdrawn. Only an
|
|
112
|
+
* upheld breach can be set aside, and a set-aside claim stays set aside.
|
|
113
|
+
*/
|
|
114
|
+
export declare function resolutionRefusal(current: ClaimState, outcome: Resolution): string | null;
|
|
115
|
+
/**
|
|
116
|
+
* How a claim stands on one day, from what its entries record: the latest
|
|
117
|
+
* resolution, or open until the open period has passed from filing and
|
|
118
|
+
* lapsed after; when it last moved (its filing, an answer or a resolution);
|
|
119
|
+
* and whether it is still within the record period from that latest entry,
|
|
120
|
+
* after which it leaves reports and stays in the excerpts.
|
|
121
|
+
*/
|
|
122
|
+
export declare function claimStanding(x: {
|
|
123
|
+
filedAt: string;
|
|
124
|
+
answeredAt: readonly string[];
|
|
125
|
+
resolutions: ReadonlyArray<{
|
|
126
|
+
outcome: Resolution;
|
|
127
|
+
basis?: string;
|
|
128
|
+
at: string;
|
|
129
|
+
}>;
|
|
130
|
+
nowMs: number;
|
|
131
|
+
openPeriodDays?: number;
|
|
132
|
+
recordPeriodDays?: number;
|
|
133
|
+
}): {
|
|
134
|
+
state: ClaimState;
|
|
135
|
+
basis?: string;
|
|
136
|
+
resolvedAt?: string;
|
|
137
|
+
answers: number;
|
|
138
|
+
lastAnsweredAt?: string;
|
|
139
|
+
latestAt: string;
|
|
140
|
+
inRecord: boolean;
|
|
141
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claims of breach of an Agent NDA (Agent Reputations §4.8), the parts that
|
|
3
|
+
* are pure: the claim document, its signed bytes and digest, its shape, the
|
|
4
|
+
* claimant's signature, the claim window (check 8), and the key that makes a
|
|
5
|
+
* later claim an amendment of an earlier one (check 9); the signed answers,
|
|
6
|
+
* acknowledgments and withdrawals; the resolutions a claim may take and in
|
|
7
|
+
* what order; and the open and record periods, which say how a claim stands
|
|
8
|
+
* on a given day.
|
|
9
|
+
*
|
|
10
|
+
* Checks 2, 3, 4, 6 and 7 read the agreement itself, which is the Agent NDA
|
|
11
|
+
* core's to read; the bureau's host asks it and hands the answers in. This
|
|
12
|
+
* core uses no other core.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { USER_KEY_RE, decodeSignature, nkeyFromRaw, rawFromBase64Key, rawUserKey, verifyRaw } from "@common-agent/crypto";
|
|
16
|
+
import { jcs } from "./signing.js";
|
|
17
|
+
export const NDA_CLAIM_TAG = "fdbk-nda-claim-v1";
|
|
18
|
+
/** The kinds of disclosure record a claim may cite (§4.8). */
|
|
19
|
+
export const DISCLOSURE_KINDS = ["rfp_release", "sow_task", "delivery"];
|
|
20
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
21
|
+
const INSTANT = (s) => typeof s === "string" && Number.isFinite(Date.parse(s));
|
|
22
|
+
/** The bytes the claimant signs: the tag, a line feed, and the JCS of the claim. */
|
|
23
|
+
export function claimSignedBytes(claim) {
|
|
24
|
+
return new TextEncoder().encode(`${NDA_CLAIM_TAG}\n${jcs(claim)}`);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The claim's digest, which answers, acknowledgments and withdrawals sign
|
|
28
|
+
* over: `sha256:` and the hex SHA-256 of the claim's JCS. §4.8 names a claim's
|
|
29
|
+
* digest without defining it; this is the definition this bureau uses, the
|
|
30
|
+
* one Agent NDA uses for an agreement.
|
|
31
|
+
*/
|
|
32
|
+
export function claimDigest(claim) {
|
|
33
|
+
return `sha256:${createHash("sha256").update(jcs(claim)).digest("hex")}`;
|
|
34
|
+
}
|
|
35
|
+
/** What is wrong with a claim's shape, or nothing. */
|
|
36
|
+
export function claimProblems(x) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const c = x;
|
|
39
|
+
if (!c || typeof c !== "object")
|
|
40
|
+
return ["a claim is a JSON object"];
|
|
41
|
+
const nda = c.nda;
|
|
42
|
+
if (!nda || typeof nda.id !== "string" || !Number.isInteger(nda.version) || typeof nda.digest !== "string" || !DIGEST.test(nda.digest)) {
|
|
43
|
+
out.push("nda names the agreement: id, version, and the sha256: digest of the completed agreement");
|
|
44
|
+
}
|
|
45
|
+
for (const side of ["claimant", "respondent"]) {
|
|
46
|
+
const p = c[side];
|
|
47
|
+
if (!p || typeof p.agent !== "string" || !rawKey(p.agent))
|
|
48
|
+
out.push(`${side} names an agent key, as an nkey or padded base64`);
|
|
49
|
+
else if (p.handle !== undefined && (typeof p.handle !== "string" || p.handle.length > 320))
|
|
50
|
+
out.push(`${side}.handle is a handle`);
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(c.categories) || !c.categories.length || !c.categories.every((k) => typeof k === "string" && k))
|
|
53
|
+
out.push("categories names at least one category");
|
|
54
|
+
if (!Array.isArray(c.disclosures) || !c.disclosures.length)
|
|
55
|
+
out.push("disclosures cites at least one record");
|
|
56
|
+
else
|
|
57
|
+
for (const d of c.disclosures) {
|
|
58
|
+
if (!d || !DISCLOSURE_KINDS.includes(d.kind) || typeof d.digest !== "string" || !DIGEST.test(d.digest) || !INSTANT(d.at)) {
|
|
59
|
+
out.push(`each disclosure names its kind (${DISCLOSURE_KINDS.join(", ")}), the sha256: digest of its record, and the instant the record states`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const found = c.found;
|
|
64
|
+
if (!found || !INSTANT(found.at) || typeof found.material_digest !== "string" || !DIGEST.test(found.material_digest))
|
|
65
|
+
out.push("found says when, and the sha256: digest of what was found");
|
|
66
|
+
if (c.breach_at !== null && !INSTANT(c.breach_at))
|
|
67
|
+
out.push("breach_at is an instant, or null");
|
|
68
|
+
if (!INSTANT(c.at))
|
|
69
|
+
out.push("at is the instant of filing");
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** An agent key's raw bytes, from a user nkey whose checksum holds or from padded base64. Null when it is neither. */
|
|
73
|
+
function rawKey(agent) {
|
|
74
|
+
return USER_KEY_RE.test(agent) ? rawUserKey(agent) : rawFromBase64Key(agent);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The one spelling a bureau keeps an agent key in, the nkey, whichever way a
|
|
78
|
+
* claim spells it, so one agent is never two subjects. Null for a string that
|
|
79
|
+
* is not an agent key.
|
|
80
|
+
*/
|
|
81
|
+
export function nkeyOf(agent) {
|
|
82
|
+
const raw = rawKey(agent);
|
|
83
|
+
return raw ? nkeyFromRaw(raw) : null;
|
|
84
|
+
}
|
|
85
|
+
/** Whether `sigB64` (padded base64 Ed25519) is `key`'s signature over `bytes`; `key` an nkey or padded base64. */
|
|
86
|
+
export function signedByKey(bytes, key, sigB64) {
|
|
87
|
+
// One spelling per signature: base64 whose spare bits are set in its last
|
|
88
|
+
// character names the same bytes another way, and would let one act be
|
|
89
|
+
// recorded twice, so only the canonical spelling decodes.
|
|
90
|
+
return verifyRaw(rawKey(key), bytes, decodeSignature(sigB64, "base64"));
|
|
91
|
+
}
|
|
92
|
+
/** Check 1, in part: the claimant's key signed the claim (padded base64 Ed25519). Resolving the key's identity is the bureau's. */
|
|
93
|
+
export function claimSignedBy(claim, sigB64) {
|
|
94
|
+
return signedByKey(claimSignedBytes(claim), claim.claimant.agent, sigB64);
|
|
95
|
+
}
|
|
96
|
+
/** The salted hash a ledger entry carries in place of an anchor (§4.1). */
|
|
97
|
+
export function saltedAnchorHash(anchor, salt) {
|
|
98
|
+
return `sha256:${createHash("sha256").update(`${salt}\n${anchor.toLowerCase()}`).digest("hex")}`;
|
|
99
|
+
}
|
|
100
|
+
/** Check 8, the window: from the first disclosure the claim cites until the filing period has passed after the duties ended. */
|
|
101
|
+
export function withinClaimWindow(x) {
|
|
102
|
+
const first = Math.min(...x.disclosedAt.map((a) => Date.parse(a)));
|
|
103
|
+
const filed = Date.parse(x.filedAt);
|
|
104
|
+
const closes = Date.parse(x.dutiesEndAt) + x.filingPeriodDays * 86_400_000;
|
|
105
|
+
return Number.isFinite(first) && Number.isFinite(filed) && filed >= first && filed <= closes;
|
|
106
|
+
}
|
|
107
|
+
/** The filing period this bureau publishes (§4.8, §10): the recommended 365 days; never under 90. */
|
|
108
|
+
export const FILING_PERIOD_DAYS = 365;
|
|
109
|
+
/** Check 9: claims that share this key are one claim, the later an amendment of the earlier. */
|
|
110
|
+
export function amendmentKey(x) {
|
|
111
|
+
return `${x.claimantAnchorHash}|${x.ndaDigest}|${x.respondent}`;
|
|
112
|
+
}
|
|
113
|
+
// ── Answers, acknowledgments and withdrawals (§4.8) ─────────────────────────
|
|
114
|
+
export const NDA_ANSWER_TAG = "fdbk-nda-answer-v1";
|
|
115
|
+
export const NDA_ACK_TAG = "fdbk-nda-ack-v1";
|
|
116
|
+
export const NDA_WITHDRAW_TAG = "fdbk-nda-withdraw-v1";
|
|
117
|
+
/** At most this many characters in an answer: it is stored and shown, never scored. */
|
|
118
|
+
export const MAX_ANSWER_CHARS = 4000;
|
|
119
|
+
/** The bytes an answer, acknowledgment or withdrawal is signed over: its tag, a line feed, and the JCS of the body. */
|
|
120
|
+
export function ndaActBytes(tag, body) {
|
|
121
|
+
return new TextEncoder().encode(`${tag}\n${jcs(body)}`);
|
|
122
|
+
}
|
|
123
|
+
// ── Resolutions and the periods (§4.8) ──────────────────────────────────────
|
|
124
|
+
/** The four bases on which a claim is upheld, and only these. */
|
|
125
|
+
export const UPHOLD_BASES = ["acknowledged", "arbiter", "owners", "court"];
|
|
126
|
+
/** What an `nda_resolved` entry records. `lapsed` is never recorded: it is how an open claim stands after the open period. */
|
|
127
|
+
export const RESOLUTIONS = ["upheld", "dismissed", "withdrawn", "set_aside"];
|
|
128
|
+
/** The open period this bureau publishes (§4.8, §10): the recommended 365 days. */
|
|
129
|
+
export const OPEN_PERIOD_DAYS = 365;
|
|
130
|
+
/** The record period this bureau publishes: the recommended five years; never under 1095 days. */
|
|
131
|
+
export const RECORD_PERIOD_DAYS = 1825;
|
|
132
|
+
export const MIN_RECORD_PERIOD_DAYS = 1095;
|
|
133
|
+
export const MIN_FILING_PERIOD_DAYS = 90;
|
|
134
|
+
/**
|
|
135
|
+
* Why a resolution may not follow the state a claim is in, or null when it
|
|
136
|
+
* may. A claim leaves open (or lapsed, since a resolution after the open
|
|
137
|
+
* period still lands) only once: upheld, dismissed or withdrawn. Only an
|
|
138
|
+
* upheld breach can be set aside, and a set-aside claim stays set aside.
|
|
139
|
+
*/
|
|
140
|
+
export function resolutionRefusal(current, outcome) {
|
|
141
|
+
if (outcome === "set_aside")
|
|
142
|
+
return current === "upheld" ? null : `only an upheld breach is set aside, and this claim is ${current}`;
|
|
143
|
+
return current === "open" || current === "lapsed" ? null : `this claim is already ${current}`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* How a claim stands on one day, from what its entries record: the latest
|
|
147
|
+
* resolution, or open until the open period has passed from filing and
|
|
148
|
+
* lapsed after; when it last moved (its filing, an answer or a resolution);
|
|
149
|
+
* and whether it is still within the record period from that latest entry,
|
|
150
|
+
* after which it leaves reports and stays in the excerpts.
|
|
151
|
+
*/
|
|
152
|
+
export function claimStanding(x) {
|
|
153
|
+
const last = x.resolutions.at(-1);
|
|
154
|
+
const filed = Date.parse(x.filedAt);
|
|
155
|
+
const state = last ? last.outcome : x.nowMs > filed + (x.openPeriodDays ?? OPEN_PERIOD_DAYS) * 86_400_000 ? "lapsed" : "open";
|
|
156
|
+
const instants = [x.filedAt, ...x.answeredAt, ...x.resolutions.map((r) => r.at)];
|
|
157
|
+
const latestAt = instants.reduce((a, b) => (Date.parse(b) > Date.parse(a) ? b : a));
|
|
158
|
+
const lastAnsweredAt = x.answeredAt.length ? x.answeredAt.reduce((a, b) => (Date.parse(b) > Date.parse(a) ? b : a)) : undefined;
|
|
159
|
+
return {
|
|
160
|
+
state,
|
|
161
|
+
...(last?.basis ? { basis: last.basis } : {}),
|
|
162
|
+
...(last ? { resolvedAt: last.at } : {}),
|
|
163
|
+
answers: x.answeredAt.length,
|
|
164
|
+
...(lastAnsweredAt ? { lastAnsweredAt } : {}),
|
|
165
|
+
latestAt,
|
|
166
|
+
inRecord: x.nowMs <= Date.parse(latestAt) + (x.recordPeriodDays ?? RECORD_PERIOD_DAYS) * 86_400_000,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The NDA record (Agent Reputations §4.9): the part of a report that shows
|
|
3
|
+
* the claims of breach about a subject, as dated findings and counts, and,
|
|
4
|
+
* for the readers rule 6 names, the claims it filed. Pure: the bureau reads
|
|
5
|
+
* each claim's standing from its ledger (nda-claims.ts says how a claim
|
|
6
|
+
* stands) and this decides what a reader may be shown of it.
|
|
7
|
+
*
|
|
8
|
+
* What a report never shows (rule 4) is never an input here: no agreement
|
|
9
|
+
* identifier or digest, no claimant identity, no note, no answer text, no
|
|
10
|
+
* record a claim cites. `agreement` and `respondent` below only group upheld
|
|
11
|
+
* claims, and nothing of either reaches the output.
|
|
12
|
+
*/
|
|
13
|
+
import { type ClaimState } from "./nda-claims.js";
|
|
14
|
+
/** One claim, with its amendments, as the NDA record reads it. */
|
|
15
|
+
export interface ClaimView {
|
|
16
|
+
state: ClaimState;
|
|
17
|
+
basis?: string;
|
|
18
|
+
resolvedAt?: string;
|
|
19
|
+
filedAt: string;
|
|
20
|
+
answers: number;
|
|
21
|
+
lastAnsweredAt?: string;
|
|
22
|
+
/** Inside the record period from its latest entry. Outside it, the claim leaves reports. */
|
|
23
|
+
inRecord: boolean;
|
|
24
|
+
categories: readonly string[];
|
|
25
|
+
direction: string;
|
|
26
|
+
claimantAnchorHash: string;
|
|
27
|
+
/** The salted hash of the claimant's operator, where the bureau knew it: claimants that share one count once. */
|
|
28
|
+
claimantOperatorHash?: string;
|
|
29
|
+
/** With `respondent`, groups several upheld claims on one agreement against one respondent into one entry (rule 1). Never shown. */
|
|
30
|
+
agreement: string;
|
|
31
|
+
respondent: string;
|
|
32
|
+
}
|
|
33
|
+
export interface NdaRecordUpheld {
|
|
34
|
+
upheld_at: string;
|
|
35
|
+
basis: string;
|
|
36
|
+
categories: string[];
|
|
37
|
+
direction: string;
|
|
38
|
+
claimant_anchor_hash: string;
|
|
39
|
+
answered: boolean;
|
|
40
|
+
answered_at?: string;
|
|
41
|
+
}
|
|
42
|
+
/** The claims a party filed in the record period, in each state (rule 6). */
|
|
43
|
+
export interface NdaFiledCounts {
|
|
44
|
+
open: number;
|
|
45
|
+
upheld: number;
|
|
46
|
+
dismissed: number;
|
|
47
|
+
withdrawn: number;
|
|
48
|
+
set_aside: number;
|
|
49
|
+
lapsed: number;
|
|
50
|
+
}
|
|
51
|
+
export interface NdaRecord {
|
|
52
|
+
record_days: number;
|
|
53
|
+
upheld: NdaRecordUpheld[];
|
|
54
|
+
open: {
|
|
55
|
+
count: number;
|
|
56
|
+
claimants: number;
|
|
57
|
+
earliest: string | null;
|
|
58
|
+
answered: number;
|
|
59
|
+
};
|
|
60
|
+
closed: {
|
|
61
|
+
dismissed: number;
|
|
62
|
+
withdrawn: number;
|
|
63
|
+
set_aside: number;
|
|
64
|
+
lapsed: number;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Only in the party's own subject-scoped excerpt, and in a respondent's
|
|
68
|
+
* excerpt beside a claim the party filed (rule 6, Agent Reputations
|
|
69
|
+
* 0.10.0-draft). Never in a report another reader can fetch: there it
|
|
70
|
+
* would move at the moment the respondent's open count does, and tell a
|
|
71
|
+
* poller who filed against whom.
|
|
72
|
+
*/
|
|
73
|
+
filed?: NdaFiledCounts;
|
|
74
|
+
}
|
|
75
|
+
/** The counts of a party's filings in the record period, in each state (rule 6). */
|
|
76
|
+
export declare function filedCounts(filed: readonly ClaimView[]): NdaFiledCounts;
|
|
77
|
+
/**
|
|
78
|
+
* The NDA record of a subject from the claims against it. Rejected claims
|
|
79
|
+
* are never inputs (they appear in the two parties' excerpts and nowhere
|
|
80
|
+
* else), and only claims inside the record period are shown. `filed`, the
|
|
81
|
+
* claims the subject filed, is given only for a reader rule 6 serves it to.
|
|
82
|
+
*/
|
|
83
|
+
export declare function ndaRecord(about: readonly ClaimView[], opts?: {
|
|
84
|
+
recordDays?: number;
|
|
85
|
+
filed?: readonly ClaimView[];
|
|
86
|
+
}): NdaRecord;
|