@jinn-network/jinn-layer 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/dist/adapters/contribution-adapter.d.ts +18 -0
- package/dist/adapters/contribution-adapter.js +134 -0
- package/dist/adapters/corpus-adapter.d.ts +12 -0
- package/dist/adapters/corpus-adapter.js +212 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/json-map-store.d.ts +4 -0
- package/dist/adapters/json-map-store.js +31 -0
- package/dist/adapters/local-learning-adapter.d.ts +25 -0
- package/dist/adapters/local-learning-adapter.js +49 -0
- package/dist/adapters/skills-adapter.d.ts +8 -0
- package/dist/adapters/skills-adapter.js +55 -0
- package/dist/admission-policy.d.ts +38 -0
- package/dist/admission-policy.js +17 -0
- package/dist/bin/jinn-distill-mcp.d.ts +2 -0
- package/dist/bin/jinn-distill-mcp.js +10 -0
- package/dist/bin/jinn-layer.d.ts +7 -0
- package/dist/bin/jinn-layer.js +15 -0
- package/dist/bridge-fetch-evidence.d.ts +102 -0
- package/dist/bridge-fetch-evidence.js +677 -0
- package/dist/bridge-verdict-source.d.ts +47 -0
- package/dist/bridge-verdict-source.js +216 -0
- package/dist/bridge.d.ts +247 -0
- package/dist/bridge.js +439 -0
- package/dist/capture.d.ts +98 -0
- package/dist/capture.js +617 -0
- package/dist/cli.d.ts +148 -0
- package/dist/cli.js +2221 -0
- package/dist/cluster-selection.d.ts +34 -0
- package/dist/cluster-selection.js +135 -0
- package/dist/cluster.d.ts +89 -0
- package/dist/cluster.js +142 -0
- package/dist/consume.d.ts +139 -0
- package/dist/consume.js +280 -0
- package/dist/corpus-probes.d.ts +61 -0
- package/dist/corpus-probes.js +120 -0
- package/dist/corpus-store.d.ts +16 -0
- package/dist/corpus-store.js +101 -0
- package/dist/distill-captures.d.ts +56 -0
- package/dist/distill-captures.js +283 -0
- package/dist/distill-feedback.d.ts +20 -0
- package/dist/distill-feedback.js +30 -0
- package/dist/distill-llm.d.ts +138 -0
- package/dist/distill-llm.js +438 -0
- package/dist/distill-mcp-server.d.ts +30 -0
- package/dist/distill-mcp-server.js +281 -0
- package/dist/distill-mode.d.ts +62 -0
- package/dist/distill-mode.js +107 -0
- package/dist/distill-progress.d.ts +78 -0
- package/dist/distill-progress.js +69 -0
- package/dist/distill-prompt.d.ts +34 -0
- package/dist/distill-prompt.js +85 -0
- package/dist/distill-render.d.ts +123 -0
- package/dist/distill-render.js +303 -0
- package/dist/distill-runs.d.ts +28 -0
- package/dist/distill-runs.js +39 -0
- package/dist/distill-traces.d.ts +67 -0
- package/dist/distill-traces.js +211 -0
- package/dist/distill.d.ts +180 -0
- package/dist/distill.js +337 -0
- package/dist/distiller.d.ts +118 -0
- package/dist/distiller.js +142 -0
- package/dist/envelope.d.ts +9 -0
- package/dist/envelope.js +9 -0
- package/dist/eval-prep.d.ts +129 -0
- package/dist/eval-prep.js +523 -0
- package/dist/execution-publish.d.ts +62 -0
- package/dist/execution-publish.js +72 -0
- package/dist/gate.d.ts +39 -0
- package/dist/gate.js +106 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +50 -0
- package/dist/ipfs-cid.d.ts +18 -0
- package/dist/ipfs-cid.js +149 -0
- package/dist/layer-default.d.ts +3 -0
- package/dist/layer-default.js +10 -0
- package/dist/ledger.d.ts +62 -0
- package/dist/ledger.js +104 -0
- package/dist/measurement.d.ts +90 -0
- package/dist/measurement.js +134 -0
- package/dist/path-safety.d.ts +19 -0
- package/dist/path-safety.js +303 -0
- package/dist/pipeline.d.ts +94 -0
- package/dist/pipeline.js +124 -0
- package/dist/plugin-wiring.d.ts +4 -0
- package/dist/plugin-wiring.js +54 -0
- package/dist/preview.d.ts +31 -0
- package/dist/preview.js +28 -0
- package/dist/process-contract.d.ts +303 -0
- package/dist/process-contract.js +118 -0
- package/dist/publish-skill.d.ts +86 -0
- package/dist/publish-skill.js +116 -0
- package/dist/publish.d.ts +294 -0
- package/dist/publish.js +1090 -0
- package/dist/scripts/scan-skills-registry.d.ts +24 -0
- package/dist/scripts/scan-skills-registry.js +207 -0
- package/dist/seed-import/curated-batch.d.ts +36 -0
- package/dist/seed-import/curated-batch.js +103 -0
- package/dist/seed-import/episode-execute.d.ts +50 -0
- package/dist/seed-import/episode-execute.js +235 -0
- package/dist/seed-import/episode-fetch.d.ts +119 -0
- package/dist/seed-import/episode-fetch.js +137 -0
- package/dist/seed-import/episode-plan.d.ts +11 -0
- package/dist/seed-import/episode-plan.js +27 -0
- package/dist/seed-import/episode-report.d.ts +39 -0
- package/dist/seed-import/episode-report.js +36 -0
- package/dist/seed-import/execute.d.ts +61 -0
- package/dist/seed-import/execute.js +245 -0
- package/dist/seed-import/fetch.d.ts +62 -0
- package/dist/seed-import/fetch.js +82 -0
- package/dist/seed-import/licence.d.ts +17 -0
- package/dist/seed-import/licence.js +36 -0
- package/dist/seed-import/plan.d.ts +10 -0
- package/dist/seed-import/plan.js +21 -0
- package/dist/seed-import/report.d.ts +34 -0
- package/dist/seed-import/report.js +31 -0
- package/dist/seed-import/state.d.ts +72 -0
- package/dist/seed-import/state.js +120 -0
- package/dist/signal.d.ts +36 -0
- package/dist/signal.js +48 -0
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +21 -0
- package/dist/skill-package.d.ts +70 -0
- package/dist/skill-package.js +141 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +77 -0
- package/dist/snapshot-transcript.d.ts +54 -0
- package/dist/snapshot-transcript.js +117 -0
- package/dist/three-arm-run.d.ts +27 -0
- package/dist/three-arm-run.js +41 -0
- package/package.json +60 -0
package/dist/publish.js
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness-layer publish path (plan Task 4, issue #1311).
|
|
3
|
+
*
|
|
4
|
+
* `publish(pending)` performs the consent conversion (calling publish after
|
|
5
|
+
* preview IS the consent action), projects the scrubbed pending capture into
|
|
6
|
+
* a canonical EpisodeV1 artifact, and wraps it in the same signed
|
|
7
|
+
* `jinn.execution.v1` envelope the daemon capture path publishes (reusing
|
|
8
|
+
* `buildUnsignedCaptureEnvelope`), and anchors it via ERC-8004. Publishing
|
|
9
|
+
* through that wrapper is what makes the returned ref resolvable with
|
|
10
|
+
* `jinn-layer corpus get <ref>` — the corpus reads signed envelopes, so the
|
|
11
|
+
* contribute → anchored → discoverable round trip closes.
|
|
12
|
+
*
|
|
13
|
+
* No-bypass: only a `PendingEnvelope` produced by `capture()` (which ran the
|
|
14
|
+
* mandatory scrub, fail-closed) can be published — enforced by the parameter
|
|
15
|
+
* type and a runtime kind gate. There is no code path from raw spans to
|
|
16
|
+
* `publish()`.
|
|
17
|
+
*
|
|
18
|
+
* Per-task veto: `opts.veto` publishes nothing and records a ledger entry
|
|
19
|
+
* marked `vetoed (local only)` — the operator's receipt that the trace
|
|
20
|
+
* existed and was withheld.
|
|
21
|
+
*
|
|
22
|
+
* v0 anchors directly per publish (testnet); the batch relayer is v0.5 work
|
|
23
|
+
* alongside earning.
|
|
24
|
+
*/
|
|
25
|
+
import { buildUnsignedCaptureEnvelope, sha256Hex, } from './execution-publish.js';
|
|
26
|
+
import { SignedEnvelopeSchema } from '@jinn-network/core';
|
|
27
|
+
import { SKILL_ARTIFACT_TYPE, SkillArtifactV1Schema, } from '@jinn-network/core';
|
|
28
|
+
import { canonicalJson } from '@jinn-network/core';
|
|
29
|
+
import { signCanonical } from './signing.js';
|
|
30
|
+
import { EMPTY_BUNDLE_SHA256 } from '@jinn-network/core/trajectory';
|
|
31
|
+
import { EPISODE_SCHEMA_VERSION, EpisodeV1WriteSchema, hasRetrievalMark, } from '@jinn-network/plugin';
|
|
32
|
+
import { buildManifestMetadataKey, encodeManifestPayload, hashLeaf, MANIFEST_SCHEMA_VERSION, merkleRoot, parseManifestV0, } from '@jinn-network/core';
|
|
33
|
+
import { PENDING_ENVELOPE_KIND } from './capture.js';
|
|
34
|
+
import { parseTraceEnvelopeV0 } from './envelope.js';
|
|
35
|
+
import { assertRawSha256CidMatches, MAX_RAW_IPFS_BLOCK_BYTES, rawSha256Cid, } from './ipfs-cid.js';
|
|
36
|
+
/** Read-compat only: no new capture publication emits this payload. */
|
|
37
|
+
export const TRACE_ENVELOPE_ARTIFACT_TYPE = 'jinn.trace-envelope.v0';
|
|
38
|
+
/** Canonical local/public evidence payload emitted by new publications. */
|
|
39
|
+
export const EPISODE_ARTIFACT_TYPE = EPISODE_SCHEMA_VERSION;
|
|
40
|
+
const DONATION_ARTIFACT_ENCODING = 'jinn.artifact.donation.v1';
|
|
41
|
+
const DEFAULT_PRICE_USDC = '0';
|
|
42
|
+
/**
|
|
43
|
+
* The irreversible publish/anchor succeeded, but the local contribution
|
|
44
|
+
* ledger receipt could not be appended. Ordinary callers receive a failure;
|
|
45
|
+
* recovery-aware seed callers may persist `result` before stopping.
|
|
46
|
+
*/
|
|
47
|
+
export class PublishLedgerError extends Error {
|
|
48
|
+
result;
|
|
49
|
+
ledgerError;
|
|
50
|
+
name = 'PublishLedgerError';
|
|
51
|
+
constructor(result, ledgerError) {
|
|
52
|
+
const detail = ledgerError instanceof Error ? ledgerError.message : String(ledgerError);
|
|
53
|
+
super(`anchored ${result.envelopeRef}${result.anchorTx ? ` at ${result.anchorTx}` : ''}, ` +
|
|
54
|
+
`but local ledger append failed: ${detail}`);
|
|
55
|
+
this.result = result;
|
|
56
|
+
this.ledgerError = ledgerError;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export class ManifestBatchRecordingError extends Error {
|
|
60
|
+
result;
|
|
61
|
+
recordingError;
|
|
62
|
+
batchKey;
|
|
63
|
+
name = 'ManifestBatchRecordingError';
|
|
64
|
+
txHash;
|
|
65
|
+
constructor(result, recordingError, batchKey) {
|
|
66
|
+
const detail = recordingError instanceof Error ? recordingError.message : String(recordingError);
|
|
67
|
+
super(`manifest ${result.manifestCid} anchored at ${result.anchorTx}, ` +
|
|
68
|
+
`but local recording failed: ${detail}`);
|
|
69
|
+
this.result = result;
|
|
70
|
+
this.recordingError = recordingError;
|
|
71
|
+
this.batchKey = batchKey;
|
|
72
|
+
this.memberRefs = [...result.memberRefs];
|
|
73
|
+
const candidate = recordingError?.txHash;
|
|
74
|
+
this.txHash =
|
|
75
|
+
typeof candidate === 'string' && /^0x[0-9a-fA-F]{64}$/.test(candidate)
|
|
76
|
+
? candidate
|
|
77
|
+
: null;
|
|
78
|
+
}
|
|
79
|
+
memberRefs;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A partitioned manifest publish failed. Retain both any completed irreversible
|
|
83
|
+
* anchors and every uploaded member ref so the caller can reconcile without
|
|
84
|
+
* retrying successful work.
|
|
85
|
+
*/
|
|
86
|
+
export class ManifestBatchSetError extends Error {
|
|
87
|
+
completed;
|
|
88
|
+
failed;
|
|
89
|
+
memberRefs;
|
|
90
|
+
batchKey;
|
|
91
|
+
name = 'ManifestBatchSetError';
|
|
92
|
+
constructor(completed, failed, memberRefs, batchKey) {
|
|
93
|
+
const detail = failed instanceof Error ? failed.message : String(failed);
|
|
94
|
+
super(`manifest partition failed after ${completed.length} completed partition(s): ${detail}`);
|
|
95
|
+
this.completed = completed;
|
|
96
|
+
this.failed = failed;
|
|
97
|
+
this.memberRefs = memberRefs;
|
|
98
|
+
this.batchKey = batchKey;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export class ManifestBatchPreparationError extends Error {
|
|
102
|
+
stage;
|
|
103
|
+
memberRefs;
|
|
104
|
+
preparationError;
|
|
105
|
+
batchKey;
|
|
106
|
+
name = 'ManifestBatchPreparationError';
|
|
107
|
+
constructor(stage, memberRefs, preparationError, batchKey) {
|
|
108
|
+
const detail = preparationError instanceof Error
|
|
109
|
+
? preparationError.message
|
|
110
|
+
: String(preparationError);
|
|
111
|
+
super(`manifest ${stage} failed after ${memberRefs.length} member upload(s): ${detail}`);
|
|
112
|
+
this.stage = stage;
|
|
113
|
+
this.memberRefs = memberRefs;
|
|
114
|
+
this.preparationError = preparationError;
|
|
115
|
+
this.batchKey = batchKey;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The manifest body is already content-addressed, but the durable anchor
|
|
120
|
+
* state could not advance safely. A callback hash is retained when known;
|
|
121
|
+
* a hashless write-ahead intent deliberately remains closed to retry.
|
|
122
|
+
*/
|
|
123
|
+
export class ManifestBatchAnchorError extends Error {
|
|
124
|
+
manifestCid;
|
|
125
|
+
memberRefs;
|
|
126
|
+
root;
|
|
127
|
+
anchorError;
|
|
128
|
+
batchKey;
|
|
129
|
+
name = 'ManifestBatchAnchorError';
|
|
130
|
+
txHash;
|
|
131
|
+
constructor(manifestCid, memberRefs, root, anchorError, batchKey) {
|
|
132
|
+
const candidate = anchorError?.txHash;
|
|
133
|
+
const txHash = typeof candidate === 'string' && /^0x[0-9a-fA-F]{64}$/.test(candidate)
|
|
134
|
+
? candidate
|
|
135
|
+
: null;
|
|
136
|
+
const detail = anchorError instanceof Error ? anchorError.message : String(anchorError);
|
|
137
|
+
super(`manifest ${manifestCid} anchor confirmation failed` +
|
|
138
|
+
`${txHash ? ` for ${txHash}` : ''}: ${detail}`);
|
|
139
|
+
this.manifestCid = manifestCid;
|
|
140
|
+
this.memberRefs = memberRefs;
|
|
141
|
+
this.root = root;
|
|
142
|
+
this.anchorError = anchorError;
|
|
143
|
+
this.batchKey = batchKey;
|
|
144
|
+
this.txHash = txHash;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function assertPending(pending) {
|
|
148
|
+
if (pending === null ||
|
|
149
|
+
typeof pending !== 'object' ||
|
|
150
|
+
pending.kind !== PENDING_ENVELOPE_KIND) {
|
|
151
|
+
throw new TypeError('publish() accepts only a PendingEnvelope produced by capture() — raw traces cannot be published (no-bypass, spec §5)');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Consent conversion: the pre-consent draft becomes a full TraceEnvelopeV0
|
|
156
|
+
* with both consent flags literal `true`. Valid only for capture()-produced
|
|
157
|
+
* pending envelopes (runtime kind gate) and re-validated against the frozen
|
|
158
|
+
* schema on the way out.
|
|
159
|
+
*/
|
|
160
|
+
export function toTraceEnvelope(pending) {
|
|
161
|
+
assertPending(pending);
|
|
162
|
+
return parseTraceEnvelopeV0({
|
|
163
|
+
...pending.draft,
|
|
164
|
+
consent: { contributionConsent: true, scrubCompleted: true },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function publishedStepKind(step, declaredKind) {
|
|
168
|
+
if (declaredKind)
|
|
169
|
+
return declaredKind;
|
|
170
|
+
const role = step.attributes['role'];
|
|
171
|
+
return role === 'user' || role === 'assistant' || step.name.startsWith('turn:')
|
|
172
|
+
? 'jinn.agent_turn'
|
|
173
|
+
: 'jinn.tool_call';
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Project the scrubbed pending capture into the same canonical contract the
|
|
177
|
+
* local evidence store uses. Consent remains the publish action and is not a
|
|
178
|
+
* second payload field; trace-envelope.v0 remains available only to legacy
|
|
179
|
+
* readers.
|
|
180
|
+
*/
|
|
181
|
+
export function toPublishedEpisode(pending) {
|
|
182
|
+
const trace = toTraceEnvelope(pending);
|
|
183
|
+
if (trace.provenance === 'derived-from-history'
|
|
184
|
+
&& hasRetrievalMark(trace.task.distributionTags)) {
|
|
185
|
+
throw new Error('derived-from-history evidence must not carry the retrieval-visible mark');
|
|
186
|
+
}
|
|
187
|
+
return EpisodeV1WriteSchema.parse({
|
|
188
|
+
schemaVersion: EPISODE_SCHEMA_VERSION,
|
|
189
|
+
episodeId: trace.session.sessionId,
|
|
190
|
+
retrievalVisible: hasRetrievalMark(trace.task.distributionTags),
|
|
191
|
+
session: {
|
|
192
|
+
...trace.session,
|
|
193
|
+
kind: pending.episodeFacts.session.kind ?? 'user',
|
|
194
|
+
...(pending.episodeFacts.session.parentSessionId
|
|
195
|
+
? { parentSessionId: pending.episodeFacts.session.parentSessionId }
|
|
196
|
+
: {}),
|
|
197
|
+
},
|
|
198
|
+
origin: {
|
|
199
|
+
writer: trace.environment.harness.name,
|
|
200
|
+
build: trace.environment.harness.version,
|
|
201
|
+
},
|
|
202
|
+
task: {
|
|
203
|
+
...trace.task,
|
|
204
|
+
...pending.episodeFacts.task,
|
|
205
|
+
},
|
|
206
|
+
trajectory: trace.steps.map((step, index) => ({
|
|
207
|
+
spanId: step.spanId,
|
|
208
|
+
parentSpanId: step.parentSpanId,
|
|
209
|
+
kind: publishedStepKind(step, pending.episodeFacts.trajectoryKinds[index]),
|
|
210
|
+
name: step.name,
|
|
211
|
+
startTimeUnixNano: step.startTimeUnixNano,
|
|
212
|
+
endTimeUnixNano: step.endTimeUnixNano,
|
|
213
|
+
attributes: step.attributes,
|
|
214
|
+
redactedKeys: step.redactedKeys,
|
|
215
|
+
...(step.truncatedKeys ? { truncatedKeys: step.truncatedKeys } : {}),
|
|
216
|
+
...pending.episodeFacts.trajectoryObservations?.[index],
|
|
217
|
+
})),
|
|
218
|
+
environment: {
|
|
219
|
+
...trace.environment,
|
|
220
|
+
...pending.episodeFacts.environment,
|
|
221
|
+
skillsLoadout: pending.episodeFacts.environment.skillsLoadout ?? [],
|
|
222
|
+
},
|
|
223
|
+
outcome: {
|
|
224
|
+
status: trace.outcome.status,
|
|
225
|
+
verificationStrength: trace.outcome.verifiabilityTier,
|
|
226
|
+
...(trace.outcome.summary ? { summary: trace.outcome.summary } : {}),
|
|
227
|
+
},
|
|
228
|
+
cost: trace.cost,
|
|
229
|
+
retention: { policy: 'contribution-eligible' },
|
|
230
|
+
provenance: trace.provenance,
|
|
231
|
+
...(pending.episodeFacts.attemptGroup
|
|
232
|
+
? { attemptGroup: pending.episodeFacts.attemptGroup }
|
|
233
|
+
: {}),
|
|
234
|
+
...(pending.episodeFacts.lineage
|
|
235
|
+
? { lineage: pending.episodeFacts.lineage }
|
|
236
|
+
: {}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Synthesise the capture row `buildUnsignedCaptureEnvelope` reads. The
|
|
241
|
+
* harness layer has no CapturesStore — the pending envelope carries all the
|
|
242
|
+
* session facts. capturePath 'A' = native harness instrumentation (the
|
|
243
|
+
* four-path ingest taxonomy in spec/2026-05-07-telemetry-collector §4.2).
|
|
244
|
+
*/
|
|
245
|
+
function toCaptureRow(envelope) {
|
|
246
|
+
return {
|
|
247
|
+
sessionId: envelope.session.sessionId,
|
|
248
|
+
capturedAt: envelope.session.capturedAt,
|
|
249
|
+
originatingTool: {
|
|
250
|
+
name: envelope.environment.harness.name,
|
|
251
|
+
version: envelope.environment.harness.version,
|
|
252
|
+
},
|
|
253
|
+
capturePath: 'A',
|
|
254
|
+
spanCount: envelope.trajectory.length,
|
|
255
|
+
redactedSpanCount: envelope.trajectory.filter((s) => s.redactedKeys.length > 0 || (s.truncatedKeys?.length ?? 0) > 0).length,
|
|
256
|
+
durationMs: envelope.cost.durationMs,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Upload one payload as an artifact blob and build its wrapper-envelope
|
|
261
|
+
* Artifact entry (shared by the trace and the optional skill artifact).
|
|
262
|
+
*/
|
|
263
|
+
async function publishAsArtifact(deps, artifactType, payload, metadata) {
|
|
264
|
+
const blob = await deps.publishArtifact({ artifactType, payload });
|
|
265
|
+
const sha256 = blob.sha256 ?? sha256Hex(canonicalJson(payload));
|
|
266
|
+
const endpoint = blob.endpoint ?? deps.defaultArtifactEndpoint;
|
|
267
|
+
if (!endpoint) {
|
|
268
|
+
throw new Error(`published ${artifactType} artifact ${blob.cid} has no access endpoint (set defaultArtifactEndpoint)`);
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
artifactType,
|
|
272
|
+
sha256,
|
|
273
|
+
metadata,
|
|
274
|
+
access: { endpoint, priceUsdc: blob.priceUsdc ?? DEFAULT_PRICE_USDC },
|
|
275
|
+
sources: [{ kind: 'ipfs', cid: blob.cid, sha256, encoding: DONATION_ARTIFACT_ENCODING }],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Consent, upload, and sign one pending member without anchoring or writing the
|
|
280
|
+
* contribution ledger. Both the per-record and manifest paths share this exact
|
|
281
|
+
* byte-producing step so batch mode cannot drift into a second envelope format.
|
|
282
|
+
*/
|
|
283
|
+
export async function publishMemberEnvelope(pending, deps, opts = {}) {
|
|
284
|
+
assertPending(pending);
|
|
285
|
+
const now = opts.now ?? deps.now?.() ?? new Date();
|
|
286
|
+
const trace = toTraceEnvelope(pending);
|
|
287
|
+
const episode = toPublishedEpisode(pending);
|
|
288
|
+
const skill = opts.skill === undefined ? undefined : SkillArtifactV1Schema.parse(opts.skill);
|
|
289
|
+
const artifacts = [
|
|
290
|
+
await publishAsArtifact(deps, EPISODE_ARTIFACT_TYPE, episode, {
|
|
291
|
+
description: 'Layer-1 canonical evidence episode (scrubbed)',
|
|
292
|
+
tags: episode.task.distributionTags,
|
|
293
|
+
}),
|
|
294
|
+
];
|
|
295
|
+
if (skill) {
|
|
296
|
+
artifacts.push(await publishAsArtifact(deps, SKILL_ARTIFACT_TYPE, skill, {
|
|
297
|
+
description: `Skill: ${skill.skill.name}`,
|
|
298
|
+
tags: episode.task.distributionTags,
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
const unsigned = buildUnsignedCaptureEnvelope({
|
|
302
|
+
capture: toCaptureRow(episode),
|
|
303
|
+
now,
|
|
304
|
+
participant: deps.participant,
|
|
305
|
+
signerAddress: deps.signer.address,
|
|
306
|
+
clientGitSha: deps.clientGitSha,
|
|
307
|
+
artifacts,
|
|
308
|
+
harnessBundleSha: EMPTY_BUNDLE_SHA256,
|
|
309
|
+
});
|
|
310
|
+
const signature = deps.signEnvelope
|
|
311
|
+
? await deps.signEnvelope(unsigned)
|
|
312
|
+
: await signWithPrivateKey(unsigned, deps);
|
|
313
|
+
const envelope = SignedEnvelopeSchema.parse({ ...unsigned, signature });
|
|
314
|
+
const envelopeBlob = await deps.publishEnvelope(envelope);
|
|
315
|
+
const envelopeRef = envelopeBlob.cid;
|
|
316
|
+
const sha256 = envelopeBlob.sha256 ?? sha256Hex(canonicalJson(envelope));
|
|
317
|
+
return { envelopeRef, sha256, envelope, trace, episode };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Publish a pending envelope: consent conversion → artifact upload → signed
|
|
321
|
+
* wrapper envelope → ERC-8004 anchor → ledger entry. Returns the corpus ref
|
|
322
|
+
* (the wrapper envelope's CID) and the anchor tx.
|
|
323
|
+
*/
|
|
324
|
+
export async function publish(pending, deps, opts = {}) {
|
|
325
|
+
assertPending(pending);
|
|
326
|
+
const now = deps.now?.() ?? new Date();
|
|
327
|
+
if (opts.veto) {
|
|
328
|
+
deps.ledger.append({
|
|
329
|
+
ts: now.toISOString(),
|
|
330
|
+
taskSummary: pending.draft.task.summary,
|
|
331
|
+
envelopeRef: null,
|
|
332
|
+
anchorTx: null,
|
|
333
|
+
verifiabilityTier: pending.draft.outcome.verifiabilityTier,
|
|
334
|
+
status: 'vetoed (local only)',
|
|
335
|
+
});
|
|
336
|
+
return { vetoed: true };
|
|
337
|
+
}
|
|
338
|
+
const member = await publishMemberEnvelope(pending, deps, {
|
|
339
|
+
...(opts.skill === undefined ? {} : { skill: opts.skill }),
|
|
340
|
+
now,
|
|
341
|
+
});
|
|
342
|
+
const { envelopeRef, envelope, episode } = member;
|
|
343
|
+
const anchor = await deps.anchorEnvelope({
|
|
344
|
+
metadataKey: `capture:${envelopeRef}`,
|
|
345
|
+
envelopeCid: envelopeRef,
|
|
346
|
+
envelopeHash: envelope.signature.hash,
|
|
347
|
+
envelope,
|
|
348
|
+
});
|
|
349
|
+
const anchorTx = anchor.txHash ?? null;
|
|
350
|
+
const result = { vetoed: false, envelopeRef, anchorTx };
|
|
351
|
+
try {
|
|
352
|
+
deps.ledger.append({
|
|
353
|
+
ts: now.toISOString(),
|
|
354
|
+
taskSummary: episode.task.summary,
|
|
355
|
+
envelopeRef,
|
|
356
|
+
anchorTx,
|
|
357
|
+
verifiabilityTier: episode.outcome.verificationStrength,
|
|
358
|
+
status: 'published',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
throw new PublishLedgerError(result, err);
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
function safeJournalMember(member) {
|
|
367
|
+
return {
|
|
368
|
+
pending: {
|
|
369
|
+
kind: member.pending.kind,
|
|
370
|
+
draft: member.pending.draft,
|
|
371
|
+
episodeFacts: member.pending.episodeFacts,
|
|
372
|
+
// Redaction `before` values are display-only secrets and must never be
|
|
373
|
+
// persisted. Retain the non-secret redaction receipt as frozen input.
|
|
374
|
+
redactions: member.pending.redactions.map(({ before: _before, ...redaction }) => redaction),
|
|
375
|
+
},
|
|
376
|
+
...(member.polarity === undefined ? {} : { polarity: member.polarity }),
|
|
377
|
+
...(member.instanceId === undefined ? {} : { instanceId: member.instanceId }),
|
|
378
|
+
sourceId: member.sourceId,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function normalizePublicationScope(scope) {
|
|
382
|
+
if (!Number.isSafeInteger(scope.chainId) || scope.chainId < 1) {
|
|
383
|
+
throw new Error('manifest publication scope requires a positive integer chainId');
|
|
384
|
+
}
|
|
385
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(scope.identityRegistryAddress)) {
|
|
386
|
+
throw new Error('manifest publication scope requires an identity registry address');
|
|
387
|
+
}
|
|
388
|
+
if (typeof scope.agentId !== 'string' || scope.agentId.length === 0) {
|
|
389
|
+
throw new Error('manifest publication scope requires an agent identity');
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
chainId: scope.chainId,
|
|
393
|
+
identityRegistryAddress: scope.identityRegistryAddress.toLowerCase(),
|
|
394
|
+
agentId: scope.agentId,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function manifestBatchKey(batchKind, sourceIds, publicationScope) {
|
|
398
|
+
return sha256Hex(canonicalJson({
|
|
399
|
+
schemaVersion: 'jinn.manifest-batch-recovery.v2',
|
|
400
|
+
batchKind,
|
|
401
|
+
sourceIds,
|
|
402
|
+
publicationScope,
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
function memberInputsDigest(members) {
|
|
406
|
+
return sha256Hex(canonicalJson(members));
|
|
407
|
+
}
|
|
408
|
+
function isJournalTransaction(value) {
|
|
409
|
+
if (value === null || typeof value !== 'object')
|
|
410
|
+
return false;
|
|
411
|
+
const transaction = value;
|
|
412
|
+
if (transaction.status === 'intent') {
|
|
413
|
+
return transaction.txHash === undefined;
|
|
414
|
+
}
|
|
415
|
+
return ((transaction.status === 'broadcast' || transaction.status === 'confirmed') &&
|
|
416
|
+
typeof transaction.txHash === 'string' &&
|
|
417
|
+
/^0x[0-9a-fA-F]{64}$/.test(transaction.txHash));
|
|
418
|
+
}
|
|
419
|
+
function isJournalManifestUpload(value) {
|
|
420
|
+
if (value === null || typeof value !== 'object')
|
|
421
|
+
return false;
|
|
422
|
+
const upload = value;
|
|
423
|
+
return ((upload.status === 'intent' || upload.status === 'uploaded') &&
|
|
424
|
+
typeof upload.expectedCid === 'string');
|
|
425
|
+
}
|
|
426
|
+
function isJournalPartition(value) {
|
|
427
|
+
if (value === null || typeof value !== 'object')
|
|
428
|
+
return false;
|
|
429
|
+
const partition = value;
|
|
430
|
+
return (partition.body !== null &&
|
|
431
|
+
typeof partition.body === 'object' &&
|
|
432
|
+
(partition.manifestUpload === null ||
|
|
433
|
+
isJournalManifestUpload(partition.manifestUpload)) &&
|
|
434
|
+
(partition.transaction === null ||
|
|
435
|
+
isJournalTransaction(partition.transaction)) &&
|
|
436
|
+
typeof partition.anchorRecorded === 'boolean' &&
|
|
437
|
+
(partition.controlTransaction === undefined ||
|
|
438
|
+
partition.controlTransaction === null ||
|
|
439
|
+
isJournalTransaction(partition.controlTransaction)));
|
|
440
|
+
}
|
|
441
|
+
function parseJournalState(raw) {
|
|
442
|
+
const parsed = JSON.parse(raw);
|
|
443
|
+
if (parsed.version === 2) {
|
|
444
|
+
throw new Error('manifest journal conflict: legacy v2 state lacks write-ahead intents and cannot be recovered safely');
|
|
445
|
+
}
|
|
446
|
+
if (parsed.version !== 3 ||
|
|
447
|
+
typeof parsed.batchKey !== 'string' ||
|
|
448
|
+
typeof parsed.batchKind !== 'string' ||
|
|
449
|
+
!Array.isArray(parsed.sourceIds) ||
|
|
450
|
+
!parsed.sourceIds.every((value) => typeof value === 'string') ||
|
|
451
|
+
parsed.publicationScope === null ||
|
|
452
|
+
typeof parsed.publicationScope !== 'object' ||
|
|
453
|
+
typeof parsed.memberInputsDigest !== 'string' ||
|
|
454
|
+
typeof parsed.nowIso !== 'string' ||
|
|
455
|
+
!Number.isInteger(parsed.maxBodyBytes) ||
|
|
456
|
+
typeof parsed.measurePerRecordControl !== 'boolean' ||
|
|
457
|
+
!Array.isArray(parsed.members) ||
|
|
458
|
+
!Array.isArray(parsed.published) ||
|
|
459
|
+
(parsed.partitions !== null &&
|
|
460
|
+
(!Array.isArray(parsed.partitions) ||
|
|
461
|
+
!parsed.partitions.every(isJournalPartition)))) {
|
|
462
|
+
throw new Error('manifest journal conflict: invalid persisted state');
|
|
463
|
+
}
|
|
464
|
+
return parsed;
|
|
465
|
+
}
|
|
466
|
+
function openManifestJournal(members, deps, opts, now, maxBodyBytes) {
|
|
467
|
+
const journal = deps.manifestJournal;
|
|
468
|
+
const suppliedSourceIds = members
|
|
469
|
+
.map((member) => member.sourceId)
|
|
470
|
+
.filter((sourceId) => sourceId !== undefined);
|
|
471
|
+
const duplicateSourceId = suppliedSourceIds.find((sourceId, index) => suppliedSourceIds.indexOf(sourceId) !== index);
|
|
472
|
+
if (duplicateSourceId !== undefined) {
|
|
473
|
+
throw new Error(`duplicate manifest sourceId: ${duplicateSourceId}`);
|
|
474
|
+
}
|
|
475
|
+
if (!journal) {
|
|
476
|
+
const sourceIds = members.map((member, index) => member.sourceId ?? `ephemeral:${index}`);
|
|
477
|
+
const frozenMembers = members.map((member, index) => safeJournalMember({ ...member, sourceId: sourceIds[index] }));
|
|
478
|
+
return {
|
|
479
|
+
batchKey: null,
|
|
480
|
+
state: {
|
|
481
|
+
version: 3,
|
|
482
|
+
batchKey: '',
|
|
483
|
+
batchKind: opts.batchKind,
|
|
484
|
+
sourceIds,
|
|
485
|
+
publicationScope: deps.manifestPublicationScope
|
|
486
|
+
? normalizePublicationScope(deps.manifestPublicationScope)
|
|
487
|
+
: null,
|
|
488
|
+
memberInputsDigest: memberInputsDigest(frozenMembers),
|
|
489
|
+
nowIso: now.toISOString(),
|
|
490
|
+
maxBodyBytes,
|
|
491
|
+
measurePerRecordControl: Boolean(opts.measurePerRecordControl),
|
|
492
|
+
members: frozenMembers,
|
|
493
|
+
published: [],
|
|
494
|
+
partitions: null,
|
|
495
|
+
},
|
|
496
|
+
save: () => undefined,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (members.some((member) => typeof member.sourceId !== 'string' || member.sourceId.length === 0)) {
|
|
500
|
+
throw new Error('durable manifest recovery requires a non-empty immutable sourceId for every member');
|
|
501
|
+
}
|
|
502
|
+
if (!deps.manifestPublicationScope) {
|
|
503
|
+
throw new Error('durable manifest recovery requires an immutable publication scope');
|
|
504
|
+
}
|
|
505
|
+
const sourceIds = members.map((member) => member.sourceId);
|
|
506
|
+
const publicationScope = normalizePublicationScope(deps.manifestPublicationScope);
|
|
507
|
+
const frozenMembers = members.map(safeJournalMember);
|
|
508
|
+
const frozenMemberInputsDigest = memberInputsDigest(frozenMembers);
|
|
509
|
+
const batchKey = manifestBatchKey(opts.batchKind, sourceIds, publicationScope);
|
|
510
|
+
const persisted = journal.loadManifestBatchJournal(batchKey);
|
|
511
|
+
const state = persisted === null
|
|
512
|
+
? {
|
|
513
|
+
version: 3,
|
|
514
|
+
batchKey,
|
|
515
|
+
batchKind: opts.batchKind,
|
|
516
|
+
sourceIds,
|
|
517
|
+
publicationScope,
|
|
518
|
+
memberInputsDigest: frozenMemberInputsDigest,
|
|
519
|
+
nowIso: now.toISOString(),
|
|
520
|
+
maxBodyBytes,
|
|
521
|
+
measurePerRecordControl: Boolean(opts.measurePerRecordControl),
|
|
522
|
+
members: frozenMembers,
|
|
523
|
+
published: [],
|
|
524
|
+
partitions: null,
|
|
525
|
+
}
|
|
526
|
+
: parseJournalState(persisted);
|
|
527
|
+
if (state.batchKey !== batchKey ||
|
|
528
|
+
state.batchKind !== opts.batchKind ||
|
|
529
|
+
canonicalJson(state.sourceIds) !== canonicalJson(sourceIds) ||
|
|
530
|
+
canonicalJson(state.publicationScope) !== canonicalJson(publicationScope) ||
|
|
531
|
+
state.memberInputsDigest !== frozenMemberInputsDigest ||
|
|
532
|
+
state.memberInputsDigest !== memberInputsDigest(state.members) ||
|
|
533
|
+
canonicalJson(state.members) !== canonicalJson(frozenMembers) ||
|
|
534
|
+
state.maxBodyBytes !== maxBodyBytes ||
|
|
535
|
+
state.measurePerRecordControl !== Boolean(opts.measurePerRecordControl)) {
|
|
536
|
+
throw new Error('manifest journal conflict: invocation does not match frozen plan');
|
|
537
|
+
}
|
|
538
|
+
let lastPersisted = persisted;
|
|
539
|
+
const active = {
|
|
540
|
+
batchKey,
|
|
541
|
+
state,
|
|
542
|
+
save: () => {
|
|
543
|
+
const nextStateJson = JSON.stringify(state);
|
|
544
|
+
const saved = journal.compareAndSwapManifestBatchJournal(batchKey, lastPersisted, nextStateJson);
|
|
545
|
+
if (!saved) {
|
|
546
|
+
throw new Error(`manifest journal conflict: concurrent writer advanced batch ${batchKey}`);
|
|
547
|
+
}
|
|
548
|
+
lastPersisted = nextStateJson;
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
if (persisted === null)
|
|
552
|
+
active.save();
|
|
553
|
+
return active;
|
|
554
|
+
}
|
|
555
|
+
function manifestMember(member) {
|
|
556
|
+
return {
|
|
557
|
+
cid: member.envelopeRef,
|
|
558
|
+
sha256: member.sha256,
|
|
559
|
+
...(member.polarity === undefined ? {} : { polarity: member.polarity }),
|
|
560
|
+
...(member.instanceId === undefined ? {} : { instanceId: member.instanceId }),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function buildManifestBody(published, batchKind, createdAt) {
|
|
564
|
+
const root = merkleRoot(published.map((member) => hashLeaf(member.envelopeRef)));
|
|
565
|
+
return parseManifestV0({
|
|
566
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
567
|
+
batchKind,
|
|
568
|
+
createdAt,
|
|
569
|
+
merkleRoot: root,
|
|
570
|
+
members: published.map(manifestMember),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
function manifestBodyByteLength(published, batchKind, createdAt) {
|
|
574
|
+
// The real root is always the same 0x + 64-character width as this
|
|
575
|
+
// placeholder, so this is the exact canonical byte length without
|
|
576
|
+
// rebuilding an O(n log n) tree for every partition probe.
|
|
577
|
+
return new TextEncoder().encode(canonicalJson({
|
|
578
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
579
|
+
batchKind,
|
|
580
|
+
createdAt,
|
|
581
|
+
merkleRoot: `0x${'0'.repeat(64)}`,
|
|
582
|
+
members: published.map(manifestMember),
|
|
583
|
+
})).byteLength;
|
|
584
|
+
}
|
|
585
|
+
function partitionManifestMembers(published, batchKind, createdAt, maxBodyBytes) {
|
|
586
|
+
const partitions = [];
|
|
587
|
+
let current = [];
|
|
588
|
+
for (const member of published) {
|
|
589
|
+
const candidate = [...current, member];
|
|
590
|
+
if (manifestBodyByteLength(candidate, batchKind, createdAt) <= maxBodyBytes) {
|
|
591
|
+
current = candidate;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (current.length === 0) {
|
|
595
|
+
throw new Error(`manifest member ${member.envelopeRef} cannot fit in one ${maxBodyBytes}-byte raw IPFS block`);
|
|
596
|
+
}
|
|
597
|
+
partitions.push(current);
|
|
598
|
+
current = [member];
|
|
599
|
+
if (manifestBodyByteLength(current, batchKind, createdAt) > maxBodyBytes) {
|
|
600
|
+
throw new Error(`manifest member ${member.envelopeRef} cannot fit in one ${maxBodyBytes}-byte raw IPFS block`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (current.length > 0)
|
|
604
|
+
partitions.push(current);
|
|
605
|
+
return partitions;
|
|
606
|
+
}
|
|
607
|
+
function errorWithTransactionHash(error, txHash, context) {
|
|
608
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
609
|
+
return Object.assign(new Error(`${context}: ${detail}`), {
|
|
610
|
+
cause: error,
|
|
611
|
+
txHash,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
async function publishPreparedManifest(published, deps, args) {
|
|
615
|
+
const body = buildManifestBody(published, args.batchKind, args.createdAt);
|
|
616
|
+
const bodyBytes = new TextEncoder().encode(canonicalJson(body));
|
|
617
|
+
if (bodyBytes.byteLength > args.maxBodyBytes) {
|
|
618
|
+
throw new Error(`manifest body is ${bodyBytes.byteLength} bytes, exceeding the ${args.maxBodyBytes}-byte raw IPFS block limit`);
|
|
619
|
+
}
|
|
620
|
+
const partition = args.partition ?? {
|
|
621
|
+
body,
|
|
622
|
+
manifestUpload: null,
|
|
623
|
+
transaction: null,
|
|
624
|
+
anchorRecorded: false,
|
|
625
|
+
};
|
|
626
|
+
if (canonicalJson(partition.body) !== canonicalJson(body)) {
|
|
627
|
+
throw new Error('manifest journal conflict: frozen partition plan changed');
|
|
628
|
+
}
|
|
629
|
+
const saveJournal = () => args.journal?.save();
|
|
630
|
+
const durable = args.journal?.batchKey !== null &&
|
|
631
|
+
args.journal?.batchKey !== undefined;
|
|
632
|
+
const memberRefs = published.map((member) => member.envelopeRef);
|
|
633
|
+
const manifestPreparationError = (stage, error) => new ManifestBatchPreparationError(stage, memberRefs, error, args.journal?.batchKey ?? null);
|
|
634
|
+
const saveManifestUpload = () => {
|
|
635
|
+
try {
|
|
636
|
+
saveJournal();
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
throw manifestPreparationError('manifest-upload', error);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
const expectedCid = rawSha256Cid(bodyBytes);
|
|
643
|
+
let createdUploadIntent = false;
|
|
644
|
+
if (partition.manifestUpload === null) {
|
|
645
|
+
partition.manifestUpload = { status: 'intent', expectedCid };
|
|
646
|
+
if (durable)
|
|
647
|
+
saveManifestUpload();
|
|
648
|
+
createdUploadIntent = true;
|
|
649
|
+
}
|
|
650
|
+
if (partition.manifestUpload.expectedCid !== expectedCid) {
|
|
651
|
+
throw manifestPreparationError('manifest-validation', new Error(`manifest journal conflict: upload intent CID ${partition.manifestUpload.expectedCid} does not match canonical body ${expectedCid}`));
|
|
652
|
+
}
|
|
653
|
+
if (partition.manifestUpload.status === 'intent' &&
|
|
654
|
+
durable &&
|
|
655
|
+
!createdUploadIntent) {
|
|
656
|
+
if (!deps.reconcileManifestBody) {
|
|
657
|
+
throw manifestPreparationError('manifest-upload', new Error(`manifest upload intent ${expectedCid} is unresolved; read-only reconciliation is unavailable`));
|
|
658
|
+
}
|
|
659
|
+
let reconciliation;
|
|
660
|
+
try {
|
|
661
|
+
reconciliation = await deps.reconcileManifestBody(expectedCid, bodyBytes);
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
throw manifestPreparationError('manifest-upload', error);
|
|
665
|
+
}
|
|
666
|
+
if (reconciliation.status === 'unknown') {
|
|
667
|
+
throw manifestPreparationError('manifest-upload', new Error(`manifest upload intent ${expectedCid} is unknown and cannot be retried safely` +
|
|
668
|
+
`${reconciliation.reason ? `: ${reconciliation.reason}` : ''}`));
|
|
669
|
+
}
|
|
670
|
+
if (reconciliation.status === 'present') {
|
|
671
|
+
partition.manifestUpload = { status: 'uploaded', expectedCid };
|
|
672
|
+
saveManifestUpload();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (partition.manifestUpload.status === 'intent') {
|
|
676
|
+
let manifestBlob;
|
|
677
|
+
try {
|
|
678
|
+
manifestBlob = await deps.publishManifestBody(body);
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
throw manifestPreparationError('manifest-upload', error);
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
assertRawSha256CidMatches(manifestBlob.cid, bodyBytes);
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
throw manifestPreparationError('manifest-validation', error);
|
|
688
|
+
}
|
|
689
|
+
partition.manifestUpload = { status: 'uploaded', expectedCid };
|
|
690
|
+
saveManifestUpload();
|
|
691
|
+
}
|
|
692
|
+
const manifestCid = expectedCid;
|
|
693
|
+
const root = body.merkleRoot;
|
|
694
|
+
const payload = {
|
|
695
|
+
version: 0,
|
|
696
|
+
merkleRoot: root,
|
|
697
|
+
memberCount: published.length,
|
|
698
|
+
createdAt: args.createdAt,
|
|
699
|
+
};
|
|
700
|
+
const reconcile = async (transaction, label = 'manifest') => {
|
|
701
|
+
if (transaction.status === 'intent') {
|
|
702
|
+
throw new Error(`${label} anchor has a hashless write-ahead intent; it cannot be safely retried or transaction-reconciled`);
|
|
703
|
+
}
|
|
704
|
+
if (transaction.status === 'confirmed') {
|
|
705
|
+
return {
|
|
706
|
+
txHash: transaction.txHash,
|
|
707
|
+
blockNumber: transaction.blockNumber ?? null,
|
|
708
|
+
gasUsed: transaction.gasUsed === null || transaction.gasUsed === undefined
|
|
709
|
+
? null
|
|
710
|
+
: BigInt(transaction.gasUsed),
|
|
711
|
+
feeWei: transaction.feeWei === null || transaction.feeWei === undefined
|
|
712
|
+
? null
|
|
713
|
+
: BigInt(transaction.feeWei),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (!deps.reconcileAnchor) {
|
|
717
|
+
throw Object.assign(new Error(`${label} transaction ${transaction.txHash} is unconfirmed; reconciliation is unavailable`), { txHash: transaction.txHash });
|
|
718
|
+
}
|
|
719
|
+
const reconciled = await deps.reconcileAnchor(transaction.txHash);
|
|
720
|
+
if (reconciled.status === 'pending') {
|
|
721
|
+
throw Object.assign(new Error(`${label} transaction ${transaction.txHash} is pending; reconcile before retrying`), { txHash: transaction.txHash });
|
|
722
|
+
}
|
|
723
|
+
if (reconciled.status === 'reverted')
|
|
724
|
+
return null;
|
|
725
|
+
return reconciled;
|
|
726
|
+
};
|
|
727
|
+
let anchor = null;
|
|
728
|
+
if (partition.transaction !== null) {
|
|
729
|
+
const journaledTransaction = partition.transaction;
|
|
730
|
+
try {
|
|
731
|
+
anchor = await reconcile(journaledTransaction);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
throw new ManifestBatchAnchorError(manifestCid, memberRefs, root, error, args.journal?.batchKey ?? null);
|
|
735
|
+
}
|
|
736
|
+
if (anchor === null) {
|
|
737
|
+
partition.transaction = null;
|
|
738
|
+
try {
|
|
739
|
+
saveJournal();
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
const withHash = journaledTransaction.status === 'intent'
|
|
743
|
+
? error
|
|
744
|
+
: errorWithTransactionHash(error, journaledTransaction.txHash, 'failed to persist reverted manifest transaction');
|
|
745
|
+
throw new ManifestBatchAnchorError(manifestCid, memberRefs, root, withHash, args.journal?.batchKey ?? null);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
else if (journaledTransaction.status !== 'confirmed') {
|
|
749
|
+
partition.transaction = {
|
|
750
|
+
status: 'confirmed',
|
|
751
|
+
txHash: anchor.txHash,
|
|
752
|
+
blockNumber: anchor.blockNumber,
|
|
753
|
+
gasUsed: anchor.gasUsed?.toString() ?? null,
|
|
754
|
+
feeWei: anchor.feeWei?.toString() ?? null,
|
|
755
|
+
};
|
|
756
|
+
try {
|
|
757
|
+
saveJournal();
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
throw new ManifestBatchAnchorError(manifestCid, memberRefs, root, errorWithTransactionHash(error, anchor.txHash, 'failed to persist confirmed manifest transaction'), args.journal?.batchKey ?? null);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (anchor === null) {
|
|
765
|
+
if (durable) {
|
|
766
|
+
partition.transaction = { status: 'intent' };
|
|
767
|
+
try {
|
|
768
|
+
saveJournal();
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
throw new ManifestBatchAnchorError(manifestCid, memberRefs, root, error, args.journal?.batchKey ?? null);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
try {
|
|
775
|
+
anchor = await deps.anchorManifest({
|
|
776
|
+
manifestCid,
|
|
777
|
+
payload,
|
|
778
|
+
onBroadcast: (txHash) => {
|
|
779
|
+
partition.transaction = { status: 'broadcast', txHash };
|
|
780
|
+
try {
|
|
781
|
+
saveJournal();
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
throw errorWithTransactionHash(error, txHash, 'failed to persist manifest broadcast hash');
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
partition.transaction = {
|
|
789
|
+
status: 'confirmed',
|
|
790
|
+
txHash: anchor.txHash,
|
|
791
|
+
blockNumber: anchor.blockNumber,
|
|
792
|
+
gasUsed: anchor.gasUsed?.toString() ?? null,
|
|
793
|
+
feeWei: anchor.feeWei?.toString() ?? null,
|
|
794
|
+
};
|
|
795
|
+
try {
|
|
796
|
+
saveJournal();
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
throw errorWithTransactionHash(error, anchor.txHash, 'failed to persist confirmed manifest transaction');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
throw new ManifestBatchAnchorError(manifestCid, memberRefs, root, error, args.journal?.batchKey ?? null);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const result = {
|
|
807
|
+
...(args.journal?.batchKey
|
|
808
|
+
? { batchKey: args.journal.batchKey }
|
|
809
|
+
: {}),
|
|
810
|
+
manifestCid,
|
|
811
|
+
anchorTx: anchor.txHash,
|
|
812
|
+
memberRefs: published.map((member) => member.envelopeRef),
|
|
813
|
+
root,
|
|
814
|
+
gasUsed: anchor.gasUsed,
|
|
815
|
+
feeWei: anchor.feeWei,
|
|
816
|
+
};
|
|
817
|
+
try {
|
|
818
|
+
if (!partition.anchorRecorded) {
|
|
819
|
+
deps.recordManifestAnchor({
|
|
820
|
+
manifestCid,
|
|
821
|
+
contentKind: 'manifest',
|
|
822
|
+
metadataKey: buildManifestMetadataKey(manifestCid),
|
|
823
|
+
payloadHex: encodeManifestPayload(payload),
|
|
824
|
+
txHash: anchor.txHash,
|
|
825
|
+
blockNumber: anchor.blockNumber,
|
|
826
|
+
gasUsed: anchor.gasUsed,
|
|
827
|
+
feeWei: anchor.feeWei,
|
|
828
|
+
anchoredAt: args.createdAt,
|
|
829
|
+
});
|
|
830
|
+
partition.anchorRecorded = true;
|
|
831
|
+
saveJournal();
|
|
832
|
+
}
|
|
833
|
+
if (args.measurePerRecordControl) {
|
|
834
|
+
const controlMember = published[0];
|
|
835
|
+
const metadataKey = `capture:${controlMember.envelopeRef}`;
|
|
836
|
+
const journaledControlTransaction = partition.controlTransaction ?? null;
|
|
837
|
+
let controlAnchor = journaledControlTransaction
|
|
838
|
+
? await reconcile(journaledControlTransaction, 'control')
|
|
839
|
+
: null;
|
|
840
|
+
let controlPayloadHex = partition.controlPayloadHex ??
|
|
841
|
+
(partition.controlTransaction?.status === 'intent'
|
|
842
|
+
? undefined
|
|
843
|
+
: partition.controlTransaction?.payloadHex);
|
|
844
|
+
if (controlAnchor === null && journaledControlTransaction) {
|
|
845
|
+
partition.controlTransaction = null;
|
|
846
|
+
try {
|
|
847
|
+
saveJournal();
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
throw journaledControlTransaction.status === 'intent'
|
|
851
|
+
? error
|
|
852
|
+
: errorWithTransactionHash(error, journaledControlTransaction.txHash, 'failed to persist reverted control transaction');
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (controlAnchor === null) {
|
|
856
|
+
if (durable) {
|
|
857
|
+
partition.controlTransaction = { status: 'intent' };
|
|
858
|
+
saveJournal();
|
|
859
|
+
}
|
|
860
|
+
const publishedControl = await deps.anchorEnvelope({
|
|
861
|
+
metadataKey,
|
|
862
|
+
envelopeCid: controlMember.envelopeRef,
|
|
863
|
+
envelopeHash: controlMember.envelope.signature.hash,
|
|
864
|
+
envelope: controlMember.envelope,
|
|
865
|
+
requireSuccessfulReceipt: true,
|
|
866
|
+
onPrepared: (payloadHex) => {
|
|
867
|
+
partition.controlPayloadHex = payloadHex;
|
|
868
|
+
controlPayloadHex = payloadHex;
|
|
869
|
+
saveJournal();
|
|
870
|
+
},
|
|
871
|
+
onBroadcast: (txHash) => {
|
|
872
|
+
partition.controlTransaction = { status: 'broadcast', txHash };
|
|
873
|
+
try {
|
|
874
|
+
saveJournal();
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
throw errorWithTransactionHash(error, txHash, 'failed to persist control broadcast hash');
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
});
|
|
881
|
+
if (!publishedControl.txHash) {
|
|
882
|
+
throw new Error('per-record control anchor returned no transaction hash');
|
|
883
|
+
}
|
|
884
|
+
controlAnchor = {
|
|
885
|
+
txHash: publishedControl.txHash,
|
|
886
|
+
blockNumber: publishedControl.blockNumber ?? null,
|
|
887
|
+
gasUsed: publishedControl.gasUsed ?? null,
|
|
888
|
+
feeWei: publishedControl.feeWei ?? null,
|
|
889
|
+
};
|
|
890
|
+
partition.controlTransaction = {
|
|
891
|
+
status: 'confirmed',
|
|
892
|
+
txHash: controlAnchor.txHash,
|
|
893
|
+
blockNumber: controlAnchor.blockNumber,
|
|
894
|
+
gasUsed: controlAnchor.gasUsed?.toString() ?? null,
|
|
895
|
+
feeWei: controlAnchor.feeWei?.toString() ?? null,
|
|
896
|
+
...(publishedControl.payloadHex
|
|
897
|
+
? { payloadHex: publishedControl.payloadHex }
|
|
898
|
+
: {}),
|
|
899
|
+
};
|
|
900
|
+
controlPayloadHex = publishedControl.payloadHex;
|
|
901
|
+
try {
|
|
902
|
+
saveJournal();
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
throw errorWithTransactionHash(error, controlAnchor.txHash, 'failed to persist confirmed control transaction');
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
result.control = {
|
|
909
|
+
memberRef: controlMember.envelopeRef,
|
|
910
|
+
anchorTx: controlAnchor.txHash,
|
|
911
|
+
blockNumber: controlAnchor.blockNumber ?? null,
|
|
912
|
+
gasUsed: controlAnchor.gasUsed ?? null,
|
|
913
|
+
feeWei: controlAnchor.feeWei ?? null,
|
|
914
|
+
};
|
|
915
|
+
if (result.control.gasUsed === null || result.control.feeWei === null) {
|
|
916
|
+
throw new Error(`per-record control receipt telemetry unavailable for ${controlAnchor.txHash}`);
|
|
917
|
+
}
|
|
918
|
+
if (!controlPayloadHex) {
|
|
919
|
+
throw new Error(`per-record control payload unavailable for ${controlAnchor.txHash}`);
|
|
920
|
+
}
|
|
921
|
+
if (!partition.controlRecorded) {
|
|
922
|
+
deps.recordControlAnchor({
|
|
923
|
+
envelopeRef: controlMember.envelopeRef,
|
|
924
|
+
contentKind: 'capture',
|
|
925
|
+
metadataKey,
|
|
926
|
+
payloadHex: controlPayloadHex,
|
|
927
|
+
txHash: controlAnchor.txHash,
|
|
928
|
+
blockNumber: result.control.blockNumber,
|
|
929
|
+
gasUsed: result.control.gasUsed,
|
|
930
|
+
feeWei: result.control.feeWei,
|
|
931
|
+
anchoredAt: args.createdAt,
|
|
932
|
+
});
|
|
933
|
+
partition.controlRecorded = true;
|
|
934
|
+
saveJournal();
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
for (const member of published) {
|
|
938
|
+
const ledgerEntry = {
|
|
939
|
+
ts: args.now.toISOString(),
|
|
940
|
+
taskSummary: member.episode.task.summary,
|
|
941
|
+
envelopeRef: member.envelopeRef,
|
|
942
|
+
anchorTx: anchor.txHash,
|
|
943
|
+
verifiabilityTier: member.episode.outcome.verificationStrength,
|
|
944
|
+
status: 'published',
|
|
945
|
+
};
|
|
946
|
+
const alreadyRecorded = deps.ledger.list().some((entry) => entry.status === 'published' &&
|
|
947
|
+
entry.envelopeRef === ledgerEntry.envelopeRef &&
|
|
948
|
+
entry.anchorTx === ledgerEntry.anchorTx);
|
|
949
|
+
if (!alreadyRecorded)
|
|
950
|
+
deps.ledger.append(ledgerEntry);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
catch (error) {
|
|
954
|
+
if (error instanceof ManifestBatchPreparationError ||
|
|
955
|
+
error instanceof ManifestBatchAnchorError ||
|
|
956
|
+
error instanceof ManifestBatchRecordingError) {
|
|
957
|
+
throw error;
|
|
958
|
+
}
|
|
959
|
+
throw new ManifestBatchRecordingError(result, error, args.journal?.batchKey ?? null);
|
|
960
|
+
}
|
|
961
|
+
deps.log?.(`[manifest] batch anchored cid=${manifestCid} members=${published.length} ` +
|
|
962
|
+
`gasUsed=${anchor.gasUsed?.toString() ?? 'null'} ` +
|
|
963
|
+
`feeWei=${anchor.feeWei?.toString() ?? 'null'}`);
|
|
964
|
+
if (result.control) {
|
|
965
|
+
deps.log?.(`[manifest] per-record control anchored cid=${result.control.memberRef} ` +
|
|
966
|
+
`tx=${result.control.anchorTx} gasUsed=${result.control.gasUsed?.toString() ?? 'null'} ` +
|
|
967
|
+
`feeWei=${result.control.feeWei?.toString() ?? 'null'}`);
|
|
968
|
+
}
|
|
969
|
+
return result;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Upload substrate members once, partition their manifest bodies at Kubo's
|
|
973
|
+
* raw-block boundary, and anchor exactly once per resulting manifest.
|
|
974
|
+
*/
|
|
975
|
+
export async function publishManifestBatch(members, deps, opts) {
|
|
976
|
+
if (members.length === 0) {
|
|
977
|
+
throw new Error('publishManifestBatch requires at least one member');
|
|
978
|
+
}
|
|
979
|
+
if (typeof opts.batchKind !== 'string' || opts.batchKind.length === 0) {
|
|
980
|
+
throw new Error('publishManifestBatch requires a non-empty batchKind');
|
|
981
|
+
}
|
|
982
|
+
if (opts.measurePerRecordControl && !deps.recordControlAnchor) {
|
|
983
|
+
throw new Error('per-record measurement control requires recordControlAnchor persistence');
|
|
984
|
+
}
|
|
985
|
+
const maxBodyBytes = MAX_RAW_IPFS_BLOCK_BYTES;
|
|
986
|
+
const requestedNow = deps.now?.() ?? new Date();
|
|
987
|
+
const journal = openManifestJournal(members, deps, opts, requestedNow, maxBodyBytes);
|
|
988
|
+
const published = journal.state.published;
|
|
989
|
+
const now = new Date(journal.state.nowIso);
|
|
990
|
+
if (Number.isNaN(now.getTime())) {
|
|
991
|
+
throw new ManifestBatchPreparationError('partition', published.map((member) => member.envelopeRef), new Error('manifest journal conflict: invalid frozen publication time'), journal.batchKey);
|
|
992
|
+
}
|
|
993
|
+
const createdAt = Math.floor(now.getTime() / 1000);
|
|
994
|
+
try {
|
|
995
|
+
for (let index = published.length; index < journal.state.members.length; index += 1) {
|
|
996
|
+
const member = journal.state.members[index];
|
|
997
|
+
const uploaded = await publishMemberEnvelope(member.pending, deps, { now });
|
|
998
|
+
published.push({ ...member, ...uploaded });
|
|
999
|
+
journal.save();
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
catch (error) {
|
|
1003
|
+
if (error instanceof ManifestBatchPreparationError ||
|
|
1004
|
+
error instanceof ManifestBatchAnchorError ||
|
|
1005
|
+
error instanceof ManifestBatchRecordingError) {
|
|
1006
|
+
throw error;
|
|
1007
|
+
}
|
|
1008
|
+
throw new ManifestBatchPreparationError('member-upload', published.map((member) => member.envelopeRef), error, journal.batchKey);
|
|
1009
|
+
}
|
|
1010
|
+
let partitions;
|
|
1011
|
+
let frozenBodies;
|
|
1012
|
+
try {
|
|
1013
|
+
partitions = partitionManifestMembers(published, opts.batchKind, createdAt, maxBodyBytes);
|
|
1014
|
+
frozenBodies = partitions.map((partition) => buildManifestBody(partition, opts.batchKind, createdAt));
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
if (error instanceof ManifestBatchPreparationError ||
|
|
1018
|
+
error instanceof ManifestBatchAnchorError ||
|
|
1019
|
+
error instanceof ManifestBatchRecordingError) {
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
throw new ManifestBatchPreparationError('partition', published.map((member) => member.envelopeRef), error, journal.batchKey);
|
|
1023
|
+
}
|
|
1024
|
+
if (journal.state.partitions === null) {
|
|
1025
|
+
journal.state.partitions = frozenBodies.map((body) => ({
|
|
1026
|
+
body,
|
|
1027
|
+
manifestUpload: null,
|
|
1028
|
+
transaction: null,
|
|
1029
|
+
anchorRecorded: false,
|
|
1030
|
+
}));
|
|
1031
|
+
try {
|
|
1032
|
+
journal.save();
|
|
1033
|
+
}
|
|
1034
|
+
catch (error) {
|
|
1035
|
+
if (error instanceof ManifestBatchPreparationError ||
|
|
1036
|
+
error instanceof ManifestBatchAnchorError ||
|
|
1037
|
+
error instanceof ManifestBatchRecordingError) {
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
throw new ManifestBatchPreparationError('partition', published.map((member) => member.envelopeRef), error, journal.batchKey);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
else if (journal.state.partitions.length !== frozenBodies.length ||
|
|
1044
|
+
journal.state.partitions.some((partition, index) => canonicalJson(partition.body) !== canonicalJson(frozenBodies[index]))) {
|
|
1045
|
+
throw new ManifestBatchPreparationError('partition', published.map((member) => member.envelopeRef), new Error('manifest journal conflict: frozen partition plan changed'), journal.batchKey);
|
|
1046
|
+
}
|
|
1047
|
+
const batches = [];
|
|
1048
|
+
try {
|
|
1049
|
+
for (let index = 0; index < partitions.length; index += 1) {
|
|
1050
|
+
batches.push(await publishPreparedManifest(partitions[index], deps, {
|
|
1051
|
+
batchKind: opts.batchKind,
|
|
1052
|
+
createdAt,
|
|
1053
|
+
now,
|
|
1054
|
+
measurePerRecordControl: Boolean(opts.measurePerRecordControl) && index === 0,
|
|
1055
|
+
maxBodyBytes,
|
|
1056
|
+
partition: journal.state.partitions[index],
|
|
1057
|
+
journal,
|
|
1058
|
+
}));
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
const failed = error instanceof ManifestBatchPreparationError ||
|
|
1063
|
+
error instanceof ManifestBatchAnchorError ||
|
|
1064
|
+
error instanceof ManifestBatchRecordingError
|
|
1065
|
+
? error
|
|
1066
|
+
: new ManifestBatchPreparationError('manifest-preparation', published.map((member) => member.envelopeRef), error, journal.batchKey);
|
|
1067
|
+
if (batches.length > 0 || partitions.length > 1) {
|
|
1068
|
+
throw new ManifestBatchSetError(batches, failed, published.map((member) => member.envelopeRef), journal.batchKey);
|
|
1069
|
+
}
|
|
1070
|
+
throw failed;
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
batches,
|
|
1074
|
+
memberRefs: published.map((member) => member.envelopeRef),
|
|
1075
|
+
publishedMembers: published.map(({ envelopeRef, sha256, envelope, trace, episode }) => ({
|
|
1076
|
+
envelopeRef,
|
|
1077
|
+
sha256,
|
|
1078
|
+
envelope,
|
|
1079
|
+
trace,
|
|
1080
|
+
episode,
|
|
1081
|
+
})),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
async function signWithPrivateKey(unsigned, deps) {
|
|
1085
|
+
if (!deps.signer.privateKey) {
|
|
1086
|
+
throw new Error('publish() requires signer.privateKey or a signEnvelope dependency');
|
|
1087
|
+
}
|
|
1088
|
+
const signed = await signCanonical(unsigned, deps.signer.privateKey, deps.signer.address);
|
|
1089
|
+
return { algo: 'secp256k1', signer: signed.signer, hash: signed.hash, sig: signed.sig };
|
|
1090
|
+
}
|