hachure 0.6.0 → 0.7.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/README.md CHANGED
@@ -104,8 +104,14 @@ Plain-language definition (ADR 0002):
104
104
  > the producer played by — packed so it can cross a product boundary without the
105
105
  > receiver needing access to the producer's internals.
106
106
 
107
- The `source` field identifies the producer. Bundles from multiple producers can be
108
- merged; conflicts surface as `disputed` status (never last-write-wins).
107
+ The `source` field identifies the producer (free-text, may vary per run); an optional
108
+ `producerId` field carries a stable, unsigned identifier for the producing system,
109
+ consistent across every bundle it emits. When present, `producerId` MUST be a
110
+ non-empty string. Bundles from multiple producers can be merged
111
+ into one ledger without last-write-wins and without deleting losing evidence; conflicts
112
+ between claims are surfaced as `contradiction` transparency gaps, never silently
113
+ resolved or used to flip a claim's status. The full specification of identifier
114
+ conventions and the merge algorithm is in [merge.md](merge.md).
109
115
 
110
116
  An optional `identityLinks` array declares co-referent subjects — real-world entities
111
117
  known under more than one identifier. Each link carries a stable optional `id`, a
@@ -32,3 +32,39 @@ implementation derives the expected statuses.
32
32
  }
33
33
  }
34
34
  ```
35
+
36
+ ## Merge conformance vectors
37
+
38
+ `conformance/merge/` contains a second, distinct family of vectors that make
39
+ the [Identifier & Multi-Producer Merge Semantics specification](../merge.md)
40
+ executable. Each vector merges two or more input `TrustBundle`s and asserts
41
+ the merged claim-id set, any id collisions, and the per-claim status derived
42
+ independently on the merged bundle. This repo's `npm test` (`test/merge.test.mjs`)
43
+ validates vector *shape* and Ajv-validates every `inputs[]` entry against
44
+ `trust-bundle.schema.json`; it does not execute `mergeBundles`/
45
+ `mergeBundlesDetailed`/`deriveClaimStatus` (this repo carries no
46
+ `@kontourai/surface` dependency) — see `merge.md`'s "Reference implementation
47
+ notes" for the implementation-side conformance loop.
48
+
49
+ ### Merge test vector inventory
50
+
51
+ | File | Scenario | Now |
52
+ |---|---|---|
53
+ | `merge-agree-values.json` | Two producers' claims agree on the same canonical subject+field; both retained as distinct records, both derive their own status independently | 2026-06-10T00:00:00.000Z |
54
+ | `merge-conflict-value.json` | Two producers' claims disagree on value, governed by a shared `incompatibleValues` policy; both retained, statuses computed independently | 2026-06-10T00:00:00.000Z |
55
+ | `merge-conflict-status.json` | Producer A's claim reaches `disputed` via its own blocking evidence; producer B's claim independently reaches `verified` — merge does not let one overwrite or suppress the other | 2026-06-10T00:00:00.000Z |
56
+ | `merge-collision-order-independence.json` | Three bundles; one `Claim.id` shared by two with genuinely different content (accidental collision) plus one unrelated bundle; asserts the merge result (kept content + collisions) is identical for every permutation of `inputs` | 2026-06-10T00:00:00.000Z |
57
+
58
+ ### Merge test vector format
59
+
60
+ ```json
61
+ {
62
+ "now": "<ISO 8601 string>",
63
+ "inputs": [ /* TrustBundle, TrustBundle, ... */ ],
64
+ "expect": {
65
+ "mergedClaimIds": ["<id>", "..."],
66
+ "collisions": [{ "collection": "claims", "id": "<id>" }],
67
+ "statusByClaimId": { "<claimId>": "<TrustStatus>" }
68
+ }
69
+ }
70
+ ```
@@ -0,0 +1,59 @@
1
+ {
2
+ "$comment": "Hand-derivation (merge.md §4/§5/§7a; status-function.md Step 6). Bundle A (producer-a) and Bundle B (producer-b) each carry one claim with the same subjectType/subjectId ('repo'/'repo-1') and the same fieldOrBehavior ('coverage'), no qualifiers on either side -> canonicalClaimKey equal (§4) -> same logical claim. Values are deep-equal (91 === 91) -> agreement (§7a): both claims MUST be retained as distinct records (never collapsed), so mergedClaimIds contains both distinct ids. Neither claims/evidence/policies/events share an id across the two bundles, so collisions is empty. Per-claim status: neither bundle attaches any policy or evidence/events for its claim, so status-function.md Step 6 ('No policy') applies for both -> policy is undefined and evidence.length === 0 -> 'unknown' for both, independently of one another and of the agreement itself (merge does not synthesize a stronger status from agreement, §7a).",
3
+ "now": "2026-06-10T00:00:00.000Z",
4
+ "inputs": [
5
+ {
6
+ "schemaVersion": 4,
7
+ "source": "producer-a:run-1",
8
+ "producerId": "producer-a",
9
+ "claims": [
10
+ {
11
+ "id": "producer-a.claim.readiness.coverage",
12
+ "subjectType": "repo",
13
+ "subjectId": "repo-1",
14
+ "surface": "readiness",
15
+ "claimType": "coverage",
16
+ "fieldOrBehavior": "coverage",
17
+ "value": 91,
18
+ "createdAt": "2026-06-01T00:00:00.000Z",
19
+ "updatedAt": "2026-06-01T00:00:00.000Z"
20
+ }
21
+ ],
22
+ "evidence": [],
23
+ "policies": [],
24
+ "events": []
25
+ },
26
+ {
27
+ "schemaVersion": 4,
28
+ "source": "producer-b:run-7",
29
+ "producerId": "producer-b",
30
+ "claims": [
31
+ {
32
+ "id": "producer-b.claim.readiness.coverage",
33
+ "subjectType": "repo",
34
+ "subjectId": "repo-1",
35
+ "surface": "readiness",
36
+ "claimType": "coverage",
37
+ "fieldOrBehavior": "coverage",
38
+ "value": 91,
39
+ "createdAt": "2026-06-02T00:00:00.000Z",
40
+ "updatedAt": "2026-06-02T00:00:00.000Z"
41
+ }
42
+ ],
43
+ "evidence": [],
44
+ "policies": [],
45
+ "events": []
46
+ }
47
+ ],
48
+ "expect": {
49
+ "mergedClaimIds": [
50
+ "producer-a.claim.readiness.coverage",
51
+ "producer-b.claim.readiness.coverage"
52
+ ],
53
+ "collisions": [],
54
+ "statusByClaimId": {
55
+ "producer-a.claim.readiness.coverage": "unknown",
56
+ "producer-b.claim.readiness.coverage": "unknown"
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,85 @@
1
+ {
2
+ "$comment": "Hand-derivation (merge.md §5/§6/§8). This vector's inputs/expect are STRUCTURALLY identical to .kontourai/flow-agents/hachure-identifier-merge/design.md §11 'Worked example + independent hand-derivation' -- the bundle-level source/producerId values were genericized from design.md §11's worked example (survey -> producer-a, veritas -> producer-b, flow -> producer-c; mapping recorded in this session's deliver.md History) to keep this shipped, machine-checked conformance fixture vendor-neutral, consistent with the rest of this repo's conformance vectors and merge.md's own examples (plan.md AC7). This rename does NOT change expect: source/producerId are bundle-level fields, not part of the Claim record being compared, so the tie-break outcome below is unaffected -- re-derived by hand against the renamed inputs. shared.claim.x appears in Bundle A (subjectId r1, surface readiness, claimType coverage, value 91) and Bundle B (subjectId r2, surface governance, claimType policy-check, value true) -- content differs under the same id, so per merge.md §5 rule 2 this is a collision, reported as {collection: 'claims', id: 'shared.claim.x'}. Per merge.md §6's tie-break, compute a canonical (sorted-key) serialization of each variant: A's serialization starts '{\\\"claimType\\\":\\\"coverage\\\",...}', B's starts '{\\\"claimType\\\":\\\"policy-check\\\",...}' -- lexicographically 'coverage' < 'policy-check' ('c' < 'p'), so A's record is the deterministically-kept content regardless of whether the implementation is handed [A,B,C], [B,A,C], [C,B,A], or any other permutation of the 3 inputs -- this is the vector that exercises the order-independence MUST (test/merge.test.mjs asserts identical output across every permutation of inputs). Bundle C's claim ('unrelated.claim.y') never collides with anything, and its id is a distinct string never repeated. Status-function.md Step 6 ('no policy, no evidence') applies to both surviving claim ids ('shared.claim.x' from kept content A, and 'unrelated.claim.y') since no bundle attaches any policy or evidence/events -> both derive 'unknown'.",
3
+ "now": "2026-06-10T00:00:00.000Z",
4
+ "inputs": [
5
+ {
6
+ "schemaVersion": 4,
7
+ "source": "producer-a",
8
+ "producerId": "producer-a",
9
+ "claims": [
10
+ {
11
+ "id": "shared.claim.x",
12
+ "subjectType": "repo",
13
+ "subjectId": "r1",
14
+ "surface": "readiness",
15
+ "claimType": "coverage",
16
+ "fieldOrBehavior": "coverage",
17
+ "value": 91,
18
+ "createdAt": "2026-06-01T00:00:00.000Z",
19
+ "updatedAt": "2026-06-01T00:00:00.000Z"
20
+ }
21
+ ],
22
+ "evidence": [],
23
+ "policies": [],
24
+ "events": []
25
+ },
26
+ {
27
+ "schemaVersion": 4,
28
+ "source": "producer-b",
29
+ "producerId": "producer-b",
30
+ "claims": [
31
+ {
32
+ "id": "shared.claim.x",
33
+ "subjectType": "repo",
34
+ "subjectId": "r2",
35
+ "surface": "governance",
36
+ "claimType": "policy-check",
37
+ "fieldOrBehavior": "signed-off",
38
+ "value": true,
39
+ "createdAt": "2026-06-01T00:00:00.000Z",
40
+ "updatedAt": "2026-06-01T00:00:00.000Z"
41
+ }
42
+ ],
43
+ "evidence": [],
44
+ "policies": [],
45
+ "events": []
46
+ },
47
+ {
48
+ "schemaVersion": 4,
49
+ "source": "producer-c",
50
+ "producerId": "producer-c",
51
+ "claims": [
52
+ {
53
+ "id": "unrelated.claim.y",
54
+ "subjectType": "gate",
55
+ "subjectId": "g1",
56
+ "surface": "gates",
57
+ "claimType": "gate-status",
58
+ "fieldOrBehavior": "passed",
59
+ "value": true,
60
+ "createdAt": "2026-06-01T00:00:00.000Z",
61
+ "updatedAt": "2026-06-01T00:00:00.000Z"
62
+ }
63
+ ],
64
+ "evidence": [],
65
+ "policies": [],
66
+ "events": []
67
+ }
68
+ ],
69
+ "expect": {
70
+ "mergedClaimIds": [
71
+ "shared.claim.x",
72
+ "unrelated.claim.y"
73
+ ],
74
+ "collisions": [
75
+ {
76
+ "collection": "claims",
77
+ "id": "shared.claim.x"
78
+ }
79
+ ],
80
+ "statusByClaimId": {
81
+ "shared.claim.x": "unknown",
82
+ "unrelated.claim.y": "unknown"
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,149 @@
1
+ {
2
+ "$comment": "Hand-derivation (merge.md §7c; status-function.md Steps 1-4). Both claims share subjectType/subjectId ('resource'/'svc-1') and fieldOrBehavior ('granted') -> same canonical claim (§4). Both bundles carry the identical policy 'policy.access-check.basic' (byte-identical in both -> unioning it is not a collision), which additionally declares incompatibleStatuses covering ['verified','disputed'] purely as an illustration that this does NOT by itself flip either claim's status (§7c) -- neither this vector's expect block nor status-function.md's fold reads incompatibleStatuses at all; it only affects the report layer, out of scope here (§9). No ids collide across the two bundles -> collisions is empty. Fold for producer-a's claim: latestEvent is 'producer-a.event.access.verified' (status 'verified', not terminal, skip Steps 2-3) -> Step 4 applies. 4a: claim has no expiresAt/ttlSeconds, policy validityRule.kind='duration', durationDays=30, verifiedAt=2026-06-01T00:00:00Z, now=2026-06-10T00:00:00Z -> 9 days elapsed < 30 -> not stale. 4b: entailing evidence (supportStrength defaults to 'entails' on both evidence items) = [producer-a.evidence.access.pass, producer-a.evidence.access.fail]; their evidenceType set {source_excerpt} and method set {observation} both satisfy policy.requiredEvidence=['source_excerpt']/requiredMethods=['observation']; requiresCorroboration=false -> no gap. 4c: 'producer-a.evidence.access.fail' has passing=false and blocking=true, observedAt=2026-06-05 (after the verified event) -> blocking failure found -> return 'disputed'. Fold for producer-b's claim: latestEvent is 'producer-b.event.access.verified' (status 'verified') -> Step 4. 4a: verifiedAt=2026-06-02T00:00:00Z, now=2026-06-10T00:00:00Z -> 8 days < 30 -> not stale. 4b: entailing evidence = [producer-b.evidence.access.pass], evidenceType {source_excerpt}, method {observation} -> satisfies policy -> no gap. 4c: the one evidence item has no 'passing:false' entry (passing is absent) -> no blocking failure -> 4d: return 'verified'. This demonstrates merge does not let producer-b's 'verified' overwrite or suppress producer-a's independently-derived 'disputed', and vice versa.",
3
+ "now": "2026-06-10T00:00:00.000Z",
4
+ "inputs": [
5
+ {
6
+ "schemaVersion": 4,
7
+ "source": "producer-a:run-3",
8
+ "producerId": "producer-a",
9
+ "claims": [
10
+ {
11
+ "id": "producer-a.claim.access.grant",
12
+ "subjectType": "resource",
13
+ "subjectId": "svc-1",
14
+ "surface": "access",
15
+ "claimType": "access-check",
16
+ "fieldOrBehavior": "granted",
17
+ "value": true,
18
+ "createdAt": "2026-06-01T00:00:00.000Z",
19
+ "updatedAt": "2026-06-01T00:00:00.000Z"
20
+ }
21
+ ],
22
+ "evidence": [
23
+ {
24
+ "id": "producer-a.evidence.access.pass",
25
+ "claimId": "producer-a.claim.access.grant",
26
+ "evidenceType": "source_excerpt",
27
+ "method": "observation",
28
+ "sourceRef": "source A: access log",
29
+ "excerptOrSummary": "Access granted.",
30
+ "observedAt": "2026-06-01T00:00:00.000Z",
31
+ "collectedBy": "agent-a"
32
+ },
33
+ {
34
+ "id": "producer-a.evidence.access.fail",
35
+ "claimId": "producer-a.claim.access.grant",
36
+ "evidenceType": "source_excerpt",
37
+ "method": "observation",
38
+ "sourceRef": "source A2: revocation log",
39
+ "excerptOrSummary": "Access revocation observed after original grant.",
40
+ "observedAt": "2026-06-05T00:00:00.000Z",
41
+ "collectedBy": "agent-a",
42
+ "passing": false,
43
+ "blocking": true
44
+ }
45
+ ],
46
+ "policies": [
47
+ {
48
+ "id": "policy.access-check.basic",
49
+ "claimType": "access-check",
50
+ "requiredEvidence": ["source_excerpt"],
51
+ "requiredMethods": ["observation"],
52
+ "requiresCorroboration": false,
53
+ "acceptanceCriteria": ["access confirmed from source"],
54
+ "reviewAuthority": "operator",
55
+ "validityRule": { "kind": "duration", "durationDays": 30 },
56
+ "stalenessTriggers": [],
57
+ "conflictRules": [],
58
+ "impactLevel": "medium",
59
+ "incompatibleStatuses": [
60
+ { "statuses": ["verified", "disputed"], "message": "conflicting access status across producers" }
61
+ ]
62
+ }
63
+ ],
64
+ "events": [
65
+ {
66
+ "id": "producer-a.event.access.verified",
67
+ "claimId": "producer-a.claim.access.grant",
68
+ "status": "verified",
69
+ "actor": "operator-a",
70
+ "method": "attestation",
71
+ "evidenceIds": ["producer-a.evidence.access.pass"],
72
+ "createdAt": "2026-06-01T00:00:00.000Z",
73
+ "verifiedAt": "2026-06-01T00:00:00.000Z"
74
+ }
75
+ ]
76
+ },
77
+ {
78
+ "schemaVersion": 4,
79
+ "source": "producer-b:run-4",
80
+ "producerId": "producer-b",
81
+ "claims": [
82
+ {
83
+ "id": "producer-b.claim.access.grant",
84
+ "subjectType": "resource",
85
+ "subjectId": "svc-1",
86
+ "surface": "access",
87
+ "claimType": "access-check",
88
+ "fieldOrBehavior": "granted",
89
+ "value": true,
90
+ "createdAt": "2026-06-02T00:00:00.000Z",
91
+ "updatedAt": "2026-06-02T00:00:00.000Z"
92
+ }
93
+ ],
94
+ "evidence": [
95
+ {
96
+ "id": "producer-b.evidence.access.pass",
97
+ "claimId": "producer-b.claim.access.grant",
98
+ "evidenceType": "source_excerpt",
99
+ "method": "observation",
100
+ "sourceRef": "source B: access log",
101
+ "excerptOrSummary": "Access granted, confirmed.",
102
+ "observedAt": "2026-06-02T00:00:00.000Z",
103
+ "collectedBy": "agent-b"
104
+ }
105
+ ],
106
+ "policies": [
107
+ {
108
+ "id": "policy.access-check.basic",
109
+ "claimType": "access-check",
110
+ "requiredEvidence": ["source_excerpt"],
111
+ "requiredMethods": ["observation"],
112
+ "requiresCorroboration": false,
113
+ "acceptanceCriteria": ["access confirmed from source"],
114
+ "reviewAuthority": "operator",
115
+ "validityRule": { "kind": "duration", "durationDays": 30 },
116
+ "stalenessTriggers": [],
117
+ "conflictRules": [],
118
+ "impactLevel": "medium",
119
+ "incompatibleStatuses": [
120
+ { "statuses": ["verified", "disputed"], "message": "conflicting access status across producers" }
121
+ ]
122
+ }
123
+ ],
124
+ "events": [
125
+ {
126
+ "id": "producer-b.event.access.verified",
127
+ "claimId": "producer-b.claim.access.grant",
128
+ "status": "verified",
129
+ "actor": "operator-b",
130
+ "method": "attestation",
131
+ "evidenceIds": ["producer-b.evidence.access.pass"],
132
+ "createdAt": "2026-06-02T00:00:00.000Z",
133
+ "verifiedAt": "2026-06-02T00:00:00.000Z"
134
+ }
135
+ ]
136
+ }
137
+ ],
138
+ "expect": {
139
+ "mergedClaimIds": [
140
+ "producer-a.claim.access.grant",
141
+ "producer-b.claim.access.grant"
142
+ ],
143
+ "collisions": [],
144
+ "statusByClaimId": {
145
+ "producer-a.claim.access.grant": "disputed",
146
+ "producer-b.claim.access.grant": "verified"
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,100 @@
1
+ {
2
+ "$comment": "Hand-derivation (merge.md §4/§5/§7b; status-function.md Step 7). Both claims share subjectType/subjectId ('repo'/'repo-2') and fieldOrBehavior ('tier'), no qualifiers -> same canonical claim key (§4). Values 'gold' (producer-a) and 'silver' (producer-b) are not deep-equal, and both bundles reference the identical policy 'policy.pricing-field.tier-conflict', which declares incompatibleValues covering ['gold','silver'] -> this is a value conflict (§7b): both claims MUST be retained (mergedClaimIds has both distinct ids); the conflict itself surfaces only as a report-layer contradiction transparency gap, which is out of this vector format's scope (merge.md §9) and does NOT affect either claim's own status computation. The policy object is byte-identical in both bundles under the same id, so unioning it is not a collision (merge.md §5 rule 2) -> collisions is empty. Status-function.md fold, per claim: no verification event in either bundle (skip Steps 1-5); a policy IS resolved (claimType 'pricing-field' matches) so Step 6 ('no policy') does not apply; Step 7 applies ('policy present, no verification event'): producer-a's claim has one entailing evidence item with evidenceType 'source_excerpt', so its evidence-type set is {source_excerpt}, which is a superset of policy.requiredEvidence=['source_excerpt'] -> 'proposed'. producer-b's claim has no evidence at all, so its evidence-type set is {} which does NOT contain 'source_excerpt' -> 'unknown'. The two claims resolve to different statuses purely from their own attached evidence, independent of the value conflict between them.",
3
+ "now": "2026-06-10T00:00:00.000Z",
4
+ "inputs": [
5
+ {
6
+ "schemaVersion": 4,
7
+ "source": "producer-a:run-2",
8
+ "producerId": "producer-a",
9
+ "claims": [
10
+ {
11
+ "id": "producer-a.claim.pricing.tier",
12
+ "subjectType": "repo",
13
+ "subjectId": "repo-2",
14
+ "surface": "pricing",
15
+ "claimType": "pricing-field",
16
+ "fieldOrBehavior": "tier",
17
+ "value": "gold",
18
+ "createdAt": "2026-06-01T00:00:00.000Z",
19
+ "updatedAt": "2026-06-01T00:00:00.000Z"
20
+ }
21
+ ],
22
+ "evidence": [
23
+ {
24
+ "id": "producer-a.evidence.tier.source",
25
+ "claimId": "producer-a.claim.pricing.tier",
26
+ "evidenceType": "source_excerpt",
27
+ "method": "observation",
28
+ "sourceRef": "source A: internal pricing sheet",
29
+ "excerptOrSummary": "Tier is gold.",
30
+ "observedAt": "2026-06-01T00:00:00.000Z",
31
+ "collectedBy": "crawler-a"
32
+ }
33
+ ],
34
+ "policies": [
35
+ {
36
+ "id": "policy.pricing-field.tier-conflict",
37
+ "claimType": "pricing-field",
38
+ "requiredEvidence": ["source_excerpt"],
39
+ "acceptanceCriteria": ["tier confirmed by source"],
40
+ "reviewAuthority": "operator",
41
+ "validityRule": { "kind": "historical" },
42
+ "stalenessTriggers": [],
43
+ "conflictRules": [],
44
+ "impactLevel": "medium",
45
+ "incompatibleValues": [
46
+ { "values": ["gold", "silver"], "message": "tier values conflict across producers" }
47
+ ]
48
+ }
49
+ ],
50
+ "events": []
51
+ },
52
+ {
53
+ "schemaVersion": 4,
54
+ "source": "producer-b:run-9",
55
+ "producerId": "producer-b",
56
+ "claims": [
57
+ {
58
+ "id": "producer-b.claim.pricing.tier",
59
+ "subjectType": "repo",
60
+ "subjectId": "repo-2",
61
+ "surface": "pricing",
62
+ "claimType": "pricing-field",
63
+ "fieldOrBehavior": "tier",
64
+ "value": "silver",
65
+ "createdAt": "2026-06-02T00:00:00.000Z",
66
+ "updatedAt": "2026-06-02T00:00:00.000Z"
67
+ }
68
+ ],
69
+ "evidence": [],
70
+ "policies": [
71
+ {
72
+ "id": "policy.pricing-field.tier-conflict",
73
+ "claimType": "pricing-field",
74
+ "requiredEvidence": ["source_excerpt"],
75
+ "acceptanceCriteria": ["tier confirmed by source"],
76
+ "reviewAuthority": "operator",
77
+ "validityRule": { "kind": "historical" },
78
+ "stalenessTriggers": [],
79
+ "conflictRules": [],
80
+ "impactLevel": "medium",
81
+ "incompatibleValues": [
82
+ { "values": ["gold", "silver"], "message": "tier values conflict across producers" }
83
+ ]
84
+ }
85
+ ],
86
+ "events": []
87
+ }
88
+ ],
89
+ "expect": {
90
+ "mergedClaimIds": [
91
+ "producer-a.claim.pricing.tier",
92
+ "producer-b.claim.pricing.tier"
93
+ ],
94
+ "collisions": [],
95
+ "statusByClaimId": {
96
+ "producer-a.claim.pricing.tier": "proposed",
97
+ "producer-b.claim.pricing.tier": "unknown"
98
+ }
99
+ }
100
+ }
package/merge.md ADDED
@@ -0,0 +1,364 @@
1
+ # Identifier & Multi-Producer Merge Semantics — Specification
2
+
3
+ **Function:** `mergeBundles(bundles: TrustBundle[]) → TrustBundle` /
4
+ `mergeBundlesDetailed(bundles: TrustBundle[]) → { bundle: TrustBundle; collisions: MergeCollision[] }`
5
+ **Source of truth:** `src/merge.ts`, `src/identity.ts`, `src/canonical.ts` in `@kontourai/surface`
6
+
7
+ ---
8
+
9
+ ## 1. Principle
10
+
11
+ A Trust Bundle (README §"TrustBundle") is the supply side of the ledger, from
12
+ a single producer (ADR 0002). Multiple producers' bundles about overlapping
13
+ subjects MUST be combinable into one ledger without:
14
+
15
+ - silently overwriting one producer's claim with another's (never
16
+ last-write-wins),
17
+ - deleting losing evidence,
18
+ - requiring a shared identifier authority, key infrastructure, or
19
+ pre-registration between producers.
20
+
21
+ This document specifies: how a claim's identity is compared across producers
22
+ (§4), how bundles fold into one ledger (§5), the determinism guarantee that
23
+ folding MUST satisfy (§6), how agreement/conflict/dispute are represented
24
+ (§7), and how accidental id collisions between *unrelated* records are
25
+ detected (§8).
26
+
27
+ ---
28
+
29
+ ## 2. Producer identity
30
+
31
+ `TrustBundle.source` (`schemas/trust-bundle.schema.json`) is a free-text
32
+ string. Real producers use it inconsistently as a human-readable label, a
33
+ run-scoped value, or both (e.g. `source: 'producer-b:${run_id}'`,
34
+ `source: 'session-log'`, `source: 'filesystem-inferred'`). `source`
35
+ alone is not a stable, comparable producer identity — it changes per
36
+ run/session for the same producer.
37
+
38
+ `TrustBundle` carries one OPTIONAL field, `producerId` (string), a stable
39
+ identifier for the *system* that produced the bundle, distinct from
40
+ `source`'s run-scoped free text:
41
+
42
+ ```jsonc
43
+ {
44
+ "schemaVersion": 4,
45
+ "source": "producer-a:run-48213", // unchanged: free text, may vary per run
46
+ "producerId": "producer-a", // OPTIONAL, new: stable across runs
47
+ "claims": [ /* ... */ ]
48
+ }
49
+ ```
50
+
51
+ Rules:
52
+
53
+ - `producerId` is OPTIONAL. A bundle without it is exactly as valid as a
54
+ bundle that predates this field (additive; `trust-bundle.schema.json`'s
55
+ `required` array is unchanged).
56
+ - When present, `producerId` MUST be a non-empty string
57
+ (`trust-bundle.schema.json`'s `producerId` property carries `minLength: 1`)
58
+ — an empty string carries no identifying information, so it is
59
+ schema-invalid rather than treated as equivalent to omitting the field.
60
+ - When present, `producerId` SHOULD be stable across every bundle the same
61
+ system emits, and SHOULD be used (§3) as the leading segment of that
62
+ producer's record ids.
63
+ - `producerId` carries no cryptographic weight. It is an L0
64
+ (producer-asserted) fact in Assurance-profile terms (`assurance.md`).
65
+ Producers wanting a verifiable producer identity SHOULD present that
66
+ identity via the existing Assurance L1 (OIDC-backed) or L2 (held-key)
67
+ presentation (`assurance.md` §"Identity presentation"). This document does
68
+ not define, and MUST NOT be read to require, any DID or key-resolution
69
+ mechanism. Cryptographic identity is Assurance-profile territory;
70
+ `producerId` is the plain, unsigned, always-available floor underneath it.
71
+ - On merge (§5), a merged bundle represents more than one producer, so a
72
+ merged bundle's `producerId` MUST be omitted — it MUST NOT be synthesized
73
+ the way `source` is (`source` becomes `merged:<a>+<b>`; `producerId` has no
74
+ analogous synthesized form). Per-record producer attribution across a merge
75
+ is best-effort via the id convention in §3, not a schema-enforced field on
76
+ every record; `Claim`, `Evidence`, `VerificationPolicy`, and
77
+ `VerificationEvent` do not each carry their own `producerId` — the
78
+ bundle-level field plus the id convention is the complete mechanism.
79
+
80
+ ---
81
+
82
+ ## 3. Identifier format
83
+
84
+ `id` fields (`Claim.id`, `Evidence.id`, `VerificationPolicy.id`,
85
+ `VerificationEvent.id`, etc.) remain `{ "type": "string" }` with no `pattern`
86
+ constraint. This document introduces no schema change to any `id` field.
87
+
88
+ - Producers SHOULD mint ids as dot-separated, lowercase, URL-safe segments
89
+ (a stable helper that lowercases, collapses non-alphanumeric runs to `-`,
90
+ and joins segments with `.` is the recommended shape).
91
+ - Producers that set `producerId` (§2) SHOULD make the id's leading segment
92
+ equal to `producerId` (or a short slug derived from it), e.g.
93
+ `producerId: "producer-a"` → ids like `producer-a.recommendation.upgrade-node`.
94
+ - This is a SHOULD, not a MUST, and is never schema-enforced. A conforming
95
+ bundle with un-prefixed ids remains fully conformant.
96
+ - Rationale for SHOULD over MUST: enforcing a producer prefix would need a
97
+ `pattern` regex, which cannot be written today without either rejecting
98
+ real existing ids or being so permissive it adds no safety. The prefix
99
+ convention earns its value from making *accidental* id collisions between
100
+ unrelated producers vanishingly unlikely (§8), not from schema enforcement.
101
+
102
+ ---
103
+
104
+ ## 4. Claim identity across producers
105
+
106
+ Two claims from different producers are the same logical claim (candidates
107
+ for agreement/conflict comparison, §7) **if and only if:**
108
+
109
+ 1. Their subjects resolve to the same canonical key under the merged bundle's
110
+ identity index (`IdentityIndex.canonicalKeyForClaim`) — i.e. same subject,
111
+ or subjects declared co-referent via `identityLinks`/`subjectAliases`.
112
+ 2. `canonicalClaimKey({ subjectType, subjectId, fieldOrBehavior, qualifiers })`
113
+ is equal once (1) is applied (same `fieldOrBehavior`, same `qualifiers`
114
+ after the existing trim/lowercase/sort normalization).
115
+
116
+ **`claimType` and `surface` are explicitly excluded from the identity key —
117
+ this is a deliberate design decision, not an oversight:**
118
+
119
+ - `claimType` is excluded because the canonical claim key is defined over
120
+ *subject, predicate, value, qualifiers* — `fieldOrBehavior` is the
121
+ predicate; `claimType` is a taxonomy tag, not part of the matching grammar.
122
+ Two producers describing the same subject+field under different
123
+ `claimType` taxonomies are still the same logical claim for merge
124
+ purposes; reusing the canonical key means merge and Inquiry matching never
125
+ diverge on this point.
126
+ - `surface` is excluded because it is a producer-defined grouping or
127
+ namespace for related claims, not the primary thing users evaluate. Two
128
+ producers will pick unrelated `surface` values for logically identical
129
+ claims — there is no shared `surface` vocabulary across producers;
130
+ including it in the identity key would make cross-producer matches
131
+ essentially never fire. `surface` remains meaningful *within* one
132
+ producer's bundle (grouping, reporting `bySurface` counts) but plays no
133
+ role in cross-producer identity.
134
+
135
+ This means: **claims are never collapsed into one record by claim identity.**
136
+ Two producers' claims about the same canonical subject+field, even when they
137
+ fully agree, remain two distinct `Claim` records with two distinct ids in the
138
+ merged bundle (§5 unions by `id`, not by claim identity) — claim identity is
139
+ used only to decide *how to interpret* the pair (§7), never to deduplicate
140
+ them into one.
141
+
142
+ ---
143
+
144
+ ## 5. The merge algorithm
145
+
146
+ Given `bundles: TrustBundle[]` (all sharing one `schemaVersion` —
147
+ implementations MUST reject a merge across differing `schemaVersion` values
148
+ rather than guessing a coercion):
149
+
150
+ 1. **Union every collection by `id`**: `claims`, `evidence`, `policies`,
151
+ `events` (each item has a required `id`); `claimGroups`, `authorityTrace`
152
+ (each item has an optional `id`; items without an `id` are always kept,
153
+ never deduped). `identityLinks` are concatenated in full (they may omit
154
+ `id`; a union-find-based identity index dedupes them harmlessly even when
155
+ duplicated).
156
+ 2. **First-occurrence wins content, subject to the determinism rule in §6** —
157
+ when two records share an `id`:
158
+ - If their content is structurally identical (deep-equal), keep it; this
159
+ is not a collision (the same fact was reported by two bundles, e.g.
160
+ after a re-export round-trip).
161
+ - If their content differs, this is a **collision** (§8): the
162
+ implementation MUST record it (`MergeCollision`: `collection`, `id`, and
163
+ enough information to identify the contributing bundles) rather than
164
+ silently picking one. The throwing entry point (`mergeBundles`) MUST
165
+ throw when any **claim** collision (differing content, same `Claim.id`)
166
+ is detected — silent claim corruption is the one thing merge MUST NOT
167
+ ever do. The non-throwing entry point (`mergeBundlesDetailed`) MUST
168
+ return the collisions for the caller to inspect/reconcile instead of
169
+ throwing.
170
+ 3. **`source` becomes a synthesized combination** of the distinct `source`
171
+ values across the merged bundles (`merged:<a>+<b>`); **`producerId` MUST
172
+ be omitted** on a merged bundle (§2).
173
+ 4. The merged bundle is not itself a new producer assertion — it MUST be
174
+ accepted as input to the same, unmodified status derivation
175
+ (`status-function.md`) and to the merge function again (merge MUST be
176
+ re-appliable to an already-merged bundle, since a bundle is a bundle
177
+ regardless of how many producers contributed to it — no special "already
178
+ merged" flag is introduced).
179
+
180
+ ---
181
+
182
+ ## 6. Determinism (order independence)
183
+
184
+ **MUST:** for any fixed *set* of input bundles, the merge function's output
185
+ (both the retained record content and the `collisions[]` set, modulo list
186
+ ordering) MUST be identical regardless of the order the bundles are supplied
187
+ in. `merge([A, B, C])`, `merge([C, A, B])`, and every other permutation of the
188
+ same set MUST produce the same merged bundle.
189
+
190
+ **Normative tie-break rule:** when N ≥ 2 records share an id and are not all
191
+ content-identical, an implementation MUST:
192
+
193
+ 1. Compare **every** colliding record's content against every other's (not
194
+ just against the first-seen one), and report a collision for every
195
+ distinct-content pair.
196
+ 2. Choose the *kept* record deterministically from content alone — not from
197
+ array position — using the record whose RFC 8785 (JSON Canonicalization
198
+ Scheme) serialization sorts lexicographically first among the distinct
199
+ contents. (RFC 8785/JCS is the target canonicalization primitive; until it
200
+ is adopted bundle-wide, an implementation MAY substitute `JSON.stringify`
201
+ of each object with its keys sorted recursively, which is
202
+ order-independent for this purpose even though it is not RFC 8785-compliant
203
+ in general — this rule asks for convergence-under-permutation of *this*
204
+ function, not full JCS compliance.)
205
+
206
+ This makes the merged bundle a pure, order-independent function of the *set*
207
+ of input bundles — the same guarantee `status-function.md` already gives for
208
+ `now`-parameterized status derivation, extended to the merge step that
209
+ precedes it.
210
+
211
+ ---
212
+
213
+ ## 7. Agreement, conflict, and dispute mechanics
214
+
215
+ Given two claims that are the same logical claim under §4:
216
+
217
+ ### 7a. Agreement
218
+
219
+ If `deepEqual(a.value, b.value)`: the claims agree. They MUST NOT be
220
+ collapsed into one record (§4). Agreement is informational at the merge
221
+ layer; agreement alone does not synthesize a stronger status. A consumer that
222
+ wants "N producers agree" as an input to a decision already has the tool for
223
+ it without a new mechanism: an authored `DerivationRule` (ADR 0003 §5,
224
+ `derivation-rule.schema.json`) can require `acceptedStatuses` across both
225
+ claim ids explicitly. This document does not add corroboration-across-producers
226
+ as an automatic status input.
227
+
228
+ ### 7b. Value conflict
229
+
230
+ If the claims are governed by a `VerificationPolicy` with `incompatibleValues`
231
+ covering the pair (`verification-policy.schema.json`) and the values match an
232
+ `incompatibleValues` pair: **both claims MUST be retained** (never
233
+ last-write-wins) and the conflict is surfaced as a `contradiction`
234
+ transparency gap. This document does not add a normative JSON Schema for
235
+ `TransparencyGap` — that remains explicitly out of scope
236
+ (`schemas/trust-report.schema.json`'s own `$comment` already documents this;
237
+ see §9). The merge-layer guarantee this document DOES make is
238
+ schema-checkable without a `TransparencyGap` schema: neither claim is
239
+ dropped, mutated, or status-overridden by the presence of the other (§5 rule
240
+ 2).
241
+
242
+ ### 7c. Status conflict
243
+
244
+ A cross-producer `incompatibleStatuses` policy match (like a value conflict,
245
+ §7b) produces a `contradiction` transparency gap. It does not, by itself,
246
+ flip either claim's `status`.
247
+
248
+ A claim's `status` becomes `disputed` **only** through the existing,
249
+ single-claim mechanisms already in `status-function.md`: blocking
250
+ non-passing evidence (Step 4c), a terminal event with `status: "disputed"`
251
+ (Step 2), or an authority-gated resolution that is itself overridden by newer
252
+ blocking evidence (Step 1). Nothing in the cross-producer conflict path sets
253
+ a claim's status to `disputed`; `TrustReport.summary.disputedClaims` is
254
+ populated purely by scanning `claim.status === "disputed"` from each claim's
255
+ own single-claim fold output.
256
+
257
+ ### 7d. Dispute resolution — no new record type
258
+
259
+ When a human/authority needs to resolve a `disputed` status (from 7c's
260
+ existing mechanisms), the spec already has the shape (ADR 0003 §8): a
261
+ `VerificationEvent` with `resolvesDispute: true` and an optional
262
+ `authorityRef`, gated by an active `AuthorityTrace` at decision time
263
+ (`status-function.md` Step 1). This document does not introduce a new
264
+ "Dispute" resource. Reusing `VerificationEvent` + `AuthorityTrace` means a
265
+ cross-producer dispute is resolved exactly the same way a single-producer one
266
+ is — the resolving event just needs `claimId` pointed at whichever specific
267
+ claim the authority is ruling on (the fold is per-claim; resolving "the
268
+ subject+field disagreement" in general means issuing a resolution event on
269
+ each affected claim id, or issuing one and letting a `DerivationRule` compose
270
+ the pair — no new bulk-resolution primitive is added by this document).
271
+
272
+ ---
273
+
274
+ ## 8. Id collision handling for records that are NOT the same logical claim
275
+
276
+ This is the case where two producers, without coordinating, mint the
277
+ identical `id` string for two *unrelated* records (accidental collision —
278
+ distinct from §4's "same logical claim, different ids" case, and distinct
279
+ from §7's value/status conflict between claims that *are* the same logical
280
+ claim).
281
+
282
+ - **Detection:** compare content; identical content is not a problem
283
+ (idempotent re-merge); differing content under the same id is a collision
284
+ that MUST be surfaced (`mergeBundles` throws for claims;
285
+ `mergeBundlesDetailed` reports for every collection).
286
+ - **Mitigation is the id convention (§3), not a new mechanism.** A collision
287
+ between two truly unrelated records is only possible if both producers
288
+ independently chose the same opaque string. The producer-prefixed dotted
289
+ convention (e.g. `producer-a.recommendation.upgrade-node` vs.
290
+ `producer-b.candidate.upgrade-node`) makes this vanishingly unlikely
291
+ without any schema enforcement. This document does not add a registry,
292
+ reservation scheme, or uniqueness authority — that would introduce the
293
+ kind of cross-producer coordination infrastructure the "stand-alone,
294
+ vendor-neutral format" goal explicitly rules out.
295
+
296
+ ---
297
+
298
+ ## 9. Explicitly out of scope
299
+
300
+ - **`TransparencyGap` / `EvidenceRequirement` normative JSON Schemas.**
301
+ Referenced descriptively in §7b/§7c (the `contradiction` gap type already
302
+ exists informally, per `schemas/trust-report.schema.json`'s own
303
+ `$comment`), but this document does not add a schema for them.
304
+ - **RFC 8785 canonicalization as a general bundle-hashing primitive.** §6
305
+ depends on *a* canonicalization function existing for its tie-break rule
306
+ and names RFC 8785 (JCS) as the target, with a documented interim fallback
307
+ (sorted-key `JSON.stringify`) — it does not itself specify RFC 8785
308
+ adoption bundle-wide.
309
+ - **Cryptographic producer identity (DIDs, keys, transparency-log-anchored
310
+ identity).** `producerId` (§2) is deliberately unsigned and unverified.
311
+ Where verifiable producer identity is needed, use Assurance L1/L2
312
+ (`assurance.md`) — this document adds no new identity/signing mechanism.
313
+ - **Survey chains, Veritas standards, Flow gates** — unchanged; still
314
+ extension-profile territory per README's existing "Out of scope" section.
315
+
316
+ ---
317
+
318
+ ## Prior art
319
+
320
+ - **W3C Verifiable Credentials.** VC issuer identity is built on DIDs — a
321
+ resolvable, typically key-based identifier scheme. Requiring DIDs for
322
+ `producerId` would collapse the existing layered design (Assurance
323
+ L0/producer-asserted is the default; L1/L2 signing is opt-in) into "every
324
+ producer needs key infrastructure just to be namespaced for merge," a
325
+ strictly higher bar than merge needs. `producerId` is deliberately at the
326
+ same trust level as the existing `source` field (L0, free-text, unsigned).
327
+ - **in-toto.** `interop-in-toto.md` already wraps a whole `TrustBundle` as one
328
+ in-toto `Statement`'s `predicate` — in-toto's subject/predicate model is
329
+ single-attestation by design and defines no multi-producer merge algorithm
330
+ at the claim level. This document is compatible with that profile
331
+ unchanged: merge happens on `TrustBundle`s *before* DSSE wrapping, or a
332
+ verifier can independently wrap several signed Statements' predicates and
333
+ merge them after unwrapping — either order works because merge is a pure
334
+ function over `TrustBundle` values, not over signed envelopes.
335
+
336
+ ---
337
+
338
+ ## Reference implementation notes (for implementers)
339
+
340
+ | Normative rule (this document) | Where it lands in `@kontourai/surface` `src/` | Status |
341
+ |---|---|---|
342
+ | §2 `producerId` field | `src/types.ts` `TrustBundle` interface; `schemas/trust-bundle.schema.json` (this repo) | New — add optional field (not yet in the reference implementation) |
343
+ | §3 id convention | Prose-only; no code change (SHOULD, unenforced) | N/A |
344
+ | §4 claim identity across producers | `src/canonical.ts` `canonicalClaimKey` + `src/identity.ts` `buildIdentityIndex` | Reused unchanged |
345
+ | §5 rule 1–2 (union by id, first-occurrence-wins-if-identical, collision on differing content) | `src/merge.ts` `unionById` / `unionOptionalById` | Implemented |
346
+ | §5 rule 3 (`producerId` omitted on merge) | `src/merge.ts` `mergeBundlesDetailed` (the `source` synthesis block) | Implementer TODO: add omission of `producerId` |
347
+ | §6 determinism / order-independence | `src/merge.ts` `unionById` | **Known gap**: current `unionById` only compares against the first-seen record for a given id, not every colliding record — kept-content and the collision set are both order-dependent today for 3+-way collisions on one id. Not yet true; needs an implementation fix. |
348
+ | §6 tie-break (canonical-serialization ordering) | `src/merge.ts` `sameContent` | Implementer TODO: no multi-way tie-break exists yet, since there's no multi-way comparison yet |
349
+ | §7b value conflict → `contradiction` gap | `src/conflict-derivation.ts` `deriveConflictTransparencyGaps` | Implemented |
350
+ | §7c status conflict | `src/conflict-derivation.ts` (no code change needed — the code already matches this document's narrower rule) | Implemented; `docs/adr/0002-trust-bundle.md` in `@kontourai/surface` previously described a broader behavior and has a correction note |
351
+ | §7d dispute resolution | `src/dispute.ts` `buildDisputeResolutionEvent`; `status-function.md` Step 1 | Implemented |
352
+ | §8 collision detection | `src/merge.ts` `MergeCollision` (a TS type; not currently a normative wire schema, and stays that way per §9) | Implemented |
353
+ | Conformance vectors | `conformance/merge/*.json` (this repo) | Vector shape/schema validated by this repo's `npm test`; algorithmic correctness is proven by an implementation actually running `mergeBundlesDetailed`/`deriveClaimStatus` against them, not by this repo alone |
354
+
355
+ ---
356
+
357
+ ## Versioning
358
+
359
+ This document introduces no change to `statusFunctionVersion` (stays `"2"`)
360
+ and no change to `schemaVersion`'s meaning (stays `4` — the new
361
+ `TrustBundle.producerId` field is optional and ignored by the unchanged
362
+ status-derivation fold). A bundle merged under this document and fed to the
363
+ unchanged fold produces identical per-claim results to a bundle that was
364
+ never merged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hachure",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "statusFunctionVersion": "2",
5
5
  "description": "Hachure — canonical distribution of the open trust format: normative JSON schemas, conformance test vectors, and spec constants.",
6
6
  "type": "module",
@@ -16,6 +16,7 @@
16
16
  "index.mjs",
17
17
  "README.md",
18
18
  "status-function.md",
19
+ "merge.md",
19
20
  "interop-in-toto.md",
20
21
  "verification-endpoint.md",
21
22
  "assurance.md"
@@ -8,6 +8,11 @@
8
8
  "properties": {
9
9
  "schemaVersion": { "enum": [2, 3, 4] },
10
10
  "source": { "type": "string" },
11
+ "producerId": {
12
+ "type": "string",
13
+ "minLength": 1,
14
+ "description": "Optional stable identifier for the producing system, distinct from source (which is free-text and may vary per run). When present, MUST be a non-empty string (minLength: 1) -- see merge.md section 2. Omitted (never synthesized) on a merged bundle."
15
+ },
11
16
  "claims": {
12
17
  "type": "array",
13
18
  "items": { "$ref": "claim.schema.json" }