@substrat-run/engine-protocol 0.3.6 → 0.4.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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { dataSubjectId, entityRef, moduleManifest, permissionKey, } from '@substrat-run/contracts';
2
+ import { dataSubjectId, entityRef, instant, moduleManifest, permissionKey, principalId, } from '@substrat-run/contracts';
3
3
  import { assertAllowed, ulid, } from '@substrat-run/kernel';
4
4
  // ============================================================================
5
5
  // The protocol engine (docs/design/engine-protocol.md, extracted at milestone
@@ -9,25 +9,88 @@ import { assertAllowed, ulid, } from '@substrat-run/kernel';
9
9
  // invariants; template CONTENT (which protocols exist, what they contain)
10
10
  // is 100% vertical-owned:
11
11
  //
12
- // 1. sign freezes — any write to a signed instance's responses fails
13
- // 2. content_hash — SHA-256 over template content + latest responses at
14
- // sign time; verifiable against replayed state
15
- // 3. counter-sign — an ADDITIONAL signature row on the same frozen
16
- // content (hash re-verified, never new content);
17
- // exactly one primary signature per instance
18
- // 4. append-only — a response edit is a NEW row; history is audit
19
- // material ("4.2 5.1 before signing")
20
- // 5. version-pinned — templates version immutably; an instance pins
21
- // (key, version) at instantiation forever
22
- // 6. void, not delete — a protocol is superseded, never mutated or removed
12
+ // 1. freeze freezes — any write to a frozen instance's content fails
13
+ // 2. content_hash — SHA-256 over template content + the frozen content;
14
+ // verifiable against replayed state
15
+ // 3. counter-sign — an ADDITIONAL signature row on the same frozen
16
+ // content (hash re-verified, never new content)
17
+ // 4. append-only — a response edit is a NEW row; history is audit
18
+ // material ("4.2 5.1 before signing")
19
+ // 5. version-pinned — templates version immutably; an instance pins
20
+ // (key, version) at instantiation forever
21
+ // 6. void, not delete — a protocol is superseded, never mutated or removed
23
22
  //
24
23
  // Entity-agnostic: an instance binds to any EntityRef ('workorder' today,
25
24
  // anything tomorrow). The vertical declares the `protocol → <parent>` entity
26
25
  // relation in ITS manifest — the engine cannot know the vertical's vocabulary.
26
+ //
27
+ // ---------------------------------------------------------------------------
28
+ // TWO CONTENT KINDS (milestone D). The engine's attestation half — hash,
29
+ // freeze, signatures, the guards — was always content-agnostic; only `fill`
30
+ // and the template shape were checklist-specific. That seam is now exposed:
31
+ //
32
+ // kind: 'checklist' — sections/items, filled response-by-response. The
33
+ // original shape; templates that predate this carry no `kind` and parse as
34
+ // checklist, and their stored content_json is NEVER rewritten (the hash
35
+ // covers it verbatim — a migration that touched it would invalidate every
36
+ // signature ever made).
37
+ //
38
+ // kind: 'document' — content the ENGINE NEVER SEES. A priced avtal, a
39
+ // styrelserapport, a PDF: the vertical owns the rows and computes their
40
+ // hash, and binds (contentRef, contentHash) to the instance. The engine
41
+ // attests that a signature was made over exactly that hash at that time,
42
+ // and says so honestly rather than pretending its recipe covered content
43
+ // it never read. Recomputation is the VERTICAL's obligation — the engine
44
+ // cannot verify what it cannot see, and claiming otherwise would be the
45
+ // false audit trail a degenerate one-item checklist produces.
46
+ //
47
+ // ---------------------------------------------------------------------------
48
+ // FREEZE IS SEPARATE FROM SIGNING (milestone D). Signing used to freeze as a
49
+ // side effect, which was sound only because `signProtocol` is synchronous:
50
+ // the authenticated principal taps sign and the window between "what we showed
51
+ // them" and "what we hashed" is microseconds.
52
+ //
53
+ // An external signing flow (BankID via Scrive) is not synchronous, and the
54
+ // signatory is not a principal at all:
55
+ //
56
+ // we dispatch a signing request → days pass → a customer signatory with
57
+ // no account in the system signs → a webhook reports party X signed at
58
+ // time T with evidence Y
59
+ //
60
+ // With freeze welded to signing, that instance stays `open` — and therefore
61
+ // writable — for the entire days-long window, so the document the customer
62
+ // saw and the content the hash is computed over can differ with no detection.
63
+ // That is a hole in the freeze invariant for ANY asynchronous signature,
64
+ // checklist or document alike.
65
+ //
66
+ // So freezing is now its own transition, and the missing noun is the
67
+ // SIGNATURE REQUEST:
68
+ //
69
+ // open --requestSignatures--> pending_signature --all resolved--> signed
70
+ // \ |
71
+ // \--signProtocol (in-app)----------+--------------------------> signed
72
+ // |
73
+ // cancelSignatureRequests --> open (renegotiate)
74
+ //
75
+ // `pending_signature` is frozen: no fill, no rebind. The requests table also
76
+ // makes MULTI-PARTY expressible — "every requested party has signed" is
77
+ // `requireAllSigned`, which primary/counter alone could never say.
78
+ //
79
+ // What the engine deliberately does NOT do: talk to Scrive. Module code makes
80
+ // no network calls (boundary-lint R3). `requestSignatures` emits a fat
81
+ // `protocol.signatures-requested`; a connector/executor outside the scope
82
+ // effects it. The return path — webhook ingress and an inbound authority seam
83
+ // that lets a callback invoke `recordSignature` — does not exist in the kernel
84
+ // yet (see the issues filed alongside this change). `recordSignature` is
85
+ // shaped to be callable by that ingress when it lands, and is permissioned as
86
+ // its own key so nothing else can reach it in the meantime.
27
87
  // ============================================================================
28
88
  export const PROTOCOL_PERM = {
29
89
  create: permissionKey.parse('protocol:create'),
30
90
  fill: permissionKey.parse('protocol:fill'),
91
+ bind: permissionKey.parse('protocol:bind'),
92
+ requestSignature: permissionKey.parse('protocol:request-signature'),
93
+ recordSignature: permissionKey.parse('protocol:record-signature'),
31
94
  sign: permissionKey.parse('protocol:sign'),
32
95
  countersign: permissionKey.parse('protocol:countersign'),
33
96
  read: permissionKey.parse('protocol:read'),
@@ -35,20 +98,27 @@ export const PROTOCOL_PERM = {
35
98
  };
36
99
  export const protocolManifest = moduleManifest.parse({
37
100
  id: '@substrat-run/engine-protocol',
38
- version: '0.0.1',
101
+ version: '0.0.2',
39
102
  kernelContract: '^0.0.1',
40
103
  permissions: [
41
104
  { key: 'protocol:create', description: 'Define protocol templates and start protocol instances on entities' },
42
- { key: 'protocol:fill', description: 'Record responses on an open protocol (append-only)' },
43
- { key: 'protocol:sign', description: 'Sign a protocol freezes it forever (separate from fill: the technician fills, the arbetsledare signs)' },
105
+ { key: 'protocol:fill', description: 'Record responses on an open checklist protocol (append-only)' },
106
+ { key: 'protocol:bind', description: 'Bind vertical-owned document content (ref + hash) to an open document protocol' },
107
+ { key: 'protocol:request-signature', description: 'Freeze a protocol and request signatures from named parties (external signing flows); also cancels a pending request set' },
108
+ { key: 'protocol:record-signature', description: 'Record a signature reported by an external signing provider — held by connector ingress, never by a human role' },
109
+ { key: 'protocol:sign', description: 'Sign a protocol in-app — freezes it forever (separate from fill: the technician fills, the arbetsledare signs)' },
44
110
  { key: 'protocol:countersign', description: 'Counter-sign an already-signed protocol — a second signature on the same frozen content (customer at pickup)' },
45
- { key: 'protocol:read', description: 'Read protocol templates, instances, responses and signatures' },
111
+ { key: 'protocol:read', description: 'Read protocol templates, instances, responses, signature requests and signatures' },
46
112
  { key: 'protocol:void', description: 'Void (supersede) a protocol — never deletes' },
47
113
  ],
48
114
  events: {
49
115
  emits: [
50
116
  { type: 'protocol.instantiated', schemaVersion: 1 },
51
117
  { type: 'protocol.response-recorded', schemaVersion: 1 },
118
+ { type: 'protocol.content-bound', schemaVersion: 1 },
119
+ { type: 'protocol.signatures-requested', schemaVersion: 1 },
120
+ { type: 'protocol.signature-declined', schemaVersion: 1 },
121
+ { type: 'protocol.signatures-cancelled', schemaVersion: 1 },
52
122
  { type: 'protocol.signed', schemaVersion: 1 },
53
123
  { type: 'protocol.countersigned', schemaVersion: 1 },
54
124
  { type: 'protocol.voided', schemaVersion: 1 },
@@ -109,12 +179,143 @@ export const protocolMigrations = [
109
179
  );
110
180
  `,
111
181
  },
182
+ // 0002 — MILESTONE D. Asynchronous, non-principal signatures and the
183
+ // document content kind. Three shape changes:
184
+ //
185
+ // 1. `protocol_instances.status` gains 'pending_signature'. SQLite cannot
186
+ // ALTER a CHECK constraint, so this is the standard table rebuild.
187
+ // 2. `protocol_instances` gains the frozen hash and the document binding.
188
+ // `frozen_hash` is BACKFILLED from each instance's earliest signature,
189
+ // so already-signed instances carry the hash they were frozen at.
190
+ // 3. `protocol_signature_requests` — the new noun.
191
+ //
192
+ // The rebuild covers all three data tables rather than just `instances`,
193
+ // because `responses`/`signatures` carry `REFERENCES protocol_instances(id)`
194
+ // clauses: renaming out from under them leaves those clauses pointing at a
195
+ // dropped table. The FK clauses are dropped rather than re-pointed —
196
+ // neither adapter enables `PRAGMA foreign_keys` (and DO SQLite restricts
197
+ // PRAGMA entirely), so they were never enforced; the engine is the only
198
+ // writer and enforces the relationships in code.
199
+ //
200
+ // `protocol_templates` is untouched, and NO stored `content_json` is
201
+ // rewritten: the content hash covers that string verbatim, so adding an
202
+ // explicit `"kind":"checklist"` would invalidate every signature ever made.
203
+ // Legacy content parses as checklist by normalisation at read time instead.
204
+ //
205
+ // Column names and order are preserved for every pre-existing column so that
206
+ // Callout's `0003-protocols-to-engine` extraction handoff — which INSERTs
207
+ // into these tables by explicit column list and runs after this migration —
208
+ // keeps working untouched.
209
+ {
210
+ version: '0002-signature-requests',
211
+ sql: `
212
+ CREATE TABLE protocol_instances_v2 (
213
+ id TEXT PRIMARY KEY,
214
+ template_key TEXT NOT NULL,
215
+ template_version INTEGER NOT NULL,
216
+ entity_type TEXT NOT NULL,
217
+ entity_id TEXT NOT NULL,
218
+ status TEXT NOT NULL
219
+ CHECK (status IN ('open','pending_signature','signed','voided')),
220
+ created_by TEXT NOT NULL,
221
+ created_at TEXT NOT NULL,
222
+ voided_by TEXT,
223
+ voided_reason TEXT,
224
+ voided_at TEXT,
225
+ content_ref_type TEXT,
226
+ content_ref_id TEXT,
227
+ bound_hash TEXT,
228
+ frozen_hash TEXT,
229
+ frozen_at TEXT
230
+ );
231
+ INSERT INTO protocol_instances_v2
232
+ (id, template_key, template_version, entity_type, entity_id, status,
233
+ created_by, created_at, voided_by, voided_reason, voided_at,
234
+ content_ref_type, content_ref_id, bound_hash, frozen_hash, frozen_at)
235
+ SELECT i.id, i.template_key, i.template_version, i.entity_type, i.entity_id, i.status,
236
+ i.created_by, i.created_at, i.voided_by, i.voided_reason, i.voided_at,
237
+ NULL, NULL, NULL,
238
+ (SELECT s.content_hash FROM protocol_signatures s
239
+ WHERE s.instance_id = i.id ORDER BY s.rowid LIMIT 1),
240
+ (SELECT s.signed_at FROM protocol_signatures s
241
+ WHERE s.instance_id = i.id ORDER BY s.rowid LIMIT 1)
242
+ FROM protocol_instances i;
243
+
244
+ CREATE TABLE protocol_responses_v2 (
245
+ id TEXT PRIMARY KEY,
246
+ instance_id TEXT NOT NULL,
247
+ item_key TEXT NOT NULL,
248
+ value_json TEXT NOT NULL,
249
+ note TEXT,
250
+ responded_by TEXT NOT NULL,
251
+ responded_at TEXT NOT NULL
252
+ );
253
+ INSERT INTO protocol_responses_v2
254
+ (id, instance_id, item_key, value_json, note, responded_by, responded_at)
255
+ SELECT id, instance_id, item_key, value_json, note, responded_by, responded_at
256
+ FROM protocol_responses;
257
+
258
+ CREATE TABLE protocol_signatures_v2 (
259
+ id TEXT PRIMARY KEY,
260
+ instance_id TEXT NOT NULL,
261
+ signed_by TEXT NOT NULL,
262
+ kind TEXT NOT NULL CHECK (kind IN ('primary','counter')),
263
+ method TEXT NOT NULL,
264
+ content_hash TEXT NOT NULL,
265
+ evidence_ref TEXT,
266
+ signed_at TEXT NOT NULL,
267
+ request_id TEXT,
268
+ signatory_kind TEXT NOT NULL DEFAULT 'principal'
269
+ CHECK (signatory_kind IN ('principal','external')),
270
+ signatory_label TEXT
271
+ );
272
+ INSERT INTO protocol_signatures_v2
273
+ (id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
274
+ request_id, signatory_kind, signatory_label)
275
+ SELECT id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
276
+ NULL, 'principal', NULL
277
+ FROM protocol_signatures;
278
+
279
+ DROP TABLE protocol_signatures;
280
+ DROP TABLE protocol_responses;
281
+ DROP TABLE protocol_instances;
282
+ ALTER TABLE protocol_instances_v2 RENAME TO protocol_instances;
283
+ ALTER TABLE protocol_responses_v2 RENAME TO protocol_responses;
284
+ ALTER TABLE protocol_signatures_v2 RENAME TO protocol_signatures;
285
+
286
+ CREATE TABLE protocol_signature_requests (
287
+ id TEXT PRIMARY KEY,
288
+ instance_id TEXT NOT NULL,
289
+ party_label TEXT NOT NULL,
290
+ party_kind TEXT NOT NULL CHECK (party_kind IN ('principal','external')),
291
+ party_ref TEXT,
292
+ signature_kind TEXT NOT NULL CHECK (signature_kind IN ('primary','counter')),
293
+ method TEXT NOT NULL,
294
+ status TEXT NOT NULL
295
+ CHECK (status IN ('pending','signed','declined','expired','cancelled')),
296
+ content_hash TEXT NOT NULL,
297
+ external_ref TEXT,
298
+ resolved_note TEXT,
299
+ requested_by TEXT NOT NULL,
300
+ requested_at TEXT NOT NULL,
301
+ resolved_at TEXT
302
+ );
303
+ CREATE INDEX protocol_signature_requests_by_instance
304
+ ON protocol_signature_requests (instance_id, status);
305
+ CREATE INDEX protocol_instances_by_entity
306
+ ON protocol_instances (entity_type, entity_id, template_key, status);
307
+ `,
308
+ },
112
309
  ];
113
310
  // ---------------------------------------------------------------------------
114
311
  // Template content SHAPE — engine-owned so fills can be validated against the
115
312
  // pinned template. The content VALUES (sections, items, vocabulary,
116
- // branschprotokoll packs) are written by verticals. v0 item types:
117
- // check | value (measurement, decimal string) | text.
313
+ // branschprotokoll packs) are written by verticals.
314
+ //
315
+ // Two kinds, discriminated on `kind`. Content stored before the discriminant
316
+ // existed carries no `kind` and is normalised to 'checklist' at PARSE time
317
+ // only — never rewritten in the database, because the hash covers the stored
318
+ // string byte-for-byte.
118
319
  // ---------------------------------------------------------------------------
119
320
  export const protocolItem = z.object({
120
321
  key: z.string().min(1),
@@ -122,13 +323,78 @@ export const protocolItem = z.object({
122
323
  type: z.enum(['check', 'value', 'text']),
123
324
  unit: z.string().optional(), // 'MΩ' on measurements
124
325
  });
125
- export const protocolTemplateContent = z.object({
326
+ /** The original shape: sections of items, filled response-by-response. */
327
+ export const checklistContent = z.object({
328
+ kind: z.literal('checklist'),
126
329
  sections: z
127
330
  .array(z.object({ title: z.string().min(1), items: z.array(protocolItem).min(1) }))
128
331
  .min(1),
129
332
  });
333
+ /**
334
+ * Content the engine never sees. The template says what KIND of document this
335
+ * is and how to render it; the instance carries the vertical's `EntityRef` and
336
+ * the hash the vertical computed over its own rows.
337
+ *
338
+ * `hashRecipe` is free text, and it is the load-bearing honesty of this kind:
339
+ * a document signature attests to a hash the engine did not compute, so the
340
+ * recipe for reproducing it must be written down where an auditor reading the
341
+ * template finds it. The engine cannot enforce that the text is true — but a
342
+ * signature over an unreproducible hash is worth nothing, and a required field
343
+ * is what makes the vertical say out loud how to reproduce it.
344
+ */
345
+ export const documentContent = z.object({
346
+ kind: z.literal('document'),
347
+ /** Vertical vocabulary for what this is — 'avtal', 'styrelserapport'. */
348
+ documentType: z.string().min(1),
349
+ /** How to recompute `boundHash` from the vertical's own rows. */
350
+ hashRecipe: z.string().min(1),
351
+ description: z.string().optional(),
352
+ });
353
+ const contentUnion = z.discriminatedUnion('kind', [checklistContent, documentContent]);
354
+ /**
355
+ * Parses either kind, defaulting a missing discriminant to 'checklist' so
356
+ * every template defined before milestone D still parses. Note this is a
357
+ * READ-time normalisation: `defineTemplate` stores what it is given after
358
+ * parsing, so new templates carry an explicit `kind`, and old rows keep their
359
+ * bytes (and therefore their hashes) exactly as signed.
360
+ */
361
+ export const protocolTemplateContent = z.preprocess((value) => value && typeof value === 'object' && !Array.isArray(value) && !('kind' in value)
362
+ ? { ...value, kind: 'checklist' }
363
+ : value, contentUnion);
130
364
  /** Booleans for checks; strings for measurements/text (decimals stay strings, K-14). */
131
365
  const responseValue = z.union([z.boolean(), z.string()]);
366
+ /**
367
+ * Who signed. Two kinds, and the difference is the whole point of milestone D:
368
+ *
369
+ * - `principal` — an authenticated principal in this scope. `ref` is their
370
+ * `PrincipalId`. Every in-app signature.
371
+ * - `external` — a human with no account, identified by an external provider
372
+ * (BankID via Scrive). `ref` is an OPAQUE `DataSubjectId` the vertical minted
373
+ * for that person.
374
+ *
375
+ * A personnummer, an email or a name must NEVER land in `ref`. It is `direct`
376
+ * PII, and `subjectId` on the emitted event is what crypto-shredding keys the
377
+ * erasure on (§5.3) — a `DataSubjectId` is shreddable, a personnummer written
378
+ * into a signature row is a GDPR liability that immutability makes permanent.
379
+ * The provider's own party identifier belongs in `evidenceRef`, which is where
380
+ * the sealed PDF and the provider audit log are reachable from.
381
+ *
382
+ * This follows `engines/booking`'s `partyRef`: a participant is a person with
383
+ * no principal, and it names them with a `DataSubjectId` for exactly this
384
+ * reason.
385
+ */
386
+ export const signatory = z.discriminatedUnion('kind', [
387
+ z.object({
388
+ kind: z.literal('principal'),
389
+ ref: principalId,
390
+ label: z.string().min(1).optional(),
391
+ }),
392
+ z.object({
393
+ kind: z.literal('external'),
394
+ ref: dataSubjectId,
395
+ label: z.string().min(1).optional(),
396
+ }),
397
+ ]);
132
398
  const protocolRef = (id) => ({ entityType: 'protocol', entityId: id });
133
399
  function getInstanceRow(ctx, instanceId) {
134
400
  const row = ctx.sql.query('SELECT * FROM protocol_instances WHERE id = ?', [instanceId])[0];
@@ -142,6 +408,7 @@ function getTemplateRow(ctx, key, version) {
142
408
  throw new Error(`protocol template not found: ${key}@${version}`);
143
409
  return row;
144
410
  }
411
+ const templateContentOf = (template) => protocolTemplateContent.parse(JSON.parse(template.content_json));
145
412
  /** Append order is authoritative for "latest wins" — rowid, not ULID (same-ms safe). */
146
413
  function getResponseRows(ctx, instanceId) {
147
414
  return ctx.sql.query('SELECT * FROM protocol_responses WHERE instance_id = ? ORDER BY rowid', [instanceId]);
@@ -149,6 +416,9 @@ function getResponseRows(ctx, instanceId) {
149
416
  function getSignatureRows(ctx, instanceId) {
150
417
  return ctx.sql.query('SELECT * FROM protocol_signatures WHERE instance_id = ? ORDER BY rowid', [instanceId]);
151
418
  }
419
+ function getRequestRows(ctx, instanceId) {
420
+ return ctx.sql.query('SELECT * FROM protocol_signature_requests WHERE instance_id = ? ORDER BY rowid', [instanceId]);
421
+ }
152
422
  function latestPerItem(responses) {
153
423
  const latest = {};
154
424
  for (const r of responses)
@@ -156,17 +426,53 @@ function latestPerItem(responses) {
156
426
  return latest;
157
427
  }
158
428
  const frozenAnswers = (latest) => Object.fromEntries(Object.entries(latest).map(([k, r]) => [k, JSON.parse(r.value_json)]));
159
- export async function protocolContentHash(template, latest) {
429
+ const signatoryOf = (row) => ({
430
+ kind: row.signatory_kind,
431
+ ref: row.signed_by,
432
+ ...(row.signatory_label ? { label: row.signatory_label } : {}),
433
+ });
434
+ async function sha256Hex(input) {
435
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
436
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
437
+ }
438
+ export async function protocolContentHash(template, latest, boundHash) {
439
+ const head = `${template.key}@${template.version}\n${template.content_json}\n`;
440
+ if (boundHash)
441
+ return sha256Hex(`${head}document:${boundHash}\n`);
160
442
  const lines = Object.keys(latest)
161
443
  .sort()
162
444
  .map((k) => `${k}=${latest[k].value_json}\n`)
163
445
  .join('');
164
- const input = `${template.key}@${template.version}\n${template.content_json}\n${lines}`;
165
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
166
- return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
446
+ return sha256Hex(`${head}${lines}`);
447
+ }
448
+ /** The hash an instance's content currently produces, whatever kind it is. */
449
+ async function currentHash(ctx, instance) {
450
+ const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
451
+ const content = templateContentOf(template);
452
+ if (content.kind === 'document') {
453
+ if (!instance.bound_hash) {
454
+ throw new Error(`document protocol ${instance.id} has no bound content — bind it before freezing`);
455
+ }
456
+ return protocolContentHash(template, {}, instance.bound_hash);
457
+ }
458
+ return protocolContentHash(template, latestPerItem(getResponseRows(ctx, instance.id)));
459
+ }
460
+ /**
461
+ * Re-derive the frozen hash and refuse if it moved. Fail closed: a signature
462
+ * binds to VERIFIED frozen content, never to a trusted column.
463
+ */
464
+ async function verifyFrozen(ctx, instance) {
465
+ if (!instance.frozen_hash) {
466
+ throw new Error(`protocol ${instance.id} is not frozen: nothing to sign against`);
467
+ }
468
+ const replayed = await currentHash(ctx, instance);
469
+ if (replayed !== instance.frozen_hash) {
470
+ throw new Error(`content hash mismatch: frozen ${instance.frozen_hash}, replayed ${replayed}`);
471
+ }
472
+ return instance.frozen_hash;
167
473
  }
168
474
  // ---------------------------------------------------------------------------
169
- // THE GUARD (engine-protocol.md §6, kernel-design open question 11). ONE
475
+ // THE GUARDS (engine-protocol.md §6, kernel-design open question 11). One
170
476
  // predicate body, TWO ways to reach it — that is the whole open question:
171
477
  //
172
478
  // pole 1 — VERTICAL-COMPOSED (milestone A): the vertical calls requireSigned()
@@ -197,7 +503,7 @@ export function requireSigned(ctx, entity, templateKey) {
197
503
  }
198
504
  /**
199
505
  * The stronger form: signed AND counter-signed — the frozen content was
200
- * ACCEPTED by a second principal (the customer at pickup). Invariant 3 already
506
+ * ACCEPTED by a second signatory (the customer at pickup). Invariant 3 already
201
507
  * guarantees a counter-signature can only exist on verified frozen content, so
202
508
  * the existence of the row is the whole check.
203
509
  */
@@ -213,6 +519,14 @@ export function requireCountersigned(ctx, entity, templateKey) {
213
519
  `(${entity.entityType} ${entity.entityId})`);
214
520
  }
215
521
  }
522
+ /**
523
+ * NOTE on multi-party: there is deliberately no `requireAllSigned`. In the
524
+ * request-driven path an instance reaches `signed` only once EVERY requested
525
+ * party has signed (see `recordSignature`), so "all parties signed" IS
526
+ * `requireSigned` — a separate guard would read as though it checked something
527
+ * stronger while checking the same thing. The multi-party question is answered
528
+ * by the state machine, not by a second predicate.
529
+ */
216
530
  /**
217
531
  * Config for the `protocol/all-signed` predicate — parsed by the PREDICATE, not
218
532
  * by the kernel (the kernel keeps `config` opaque). `entityIdFrom` names the
@@ -224,7 +538,7 @@ export const allSignedGuardConfig = z.object({
224
538
  templateKey: z.string().min(1), // vertical content: 'tillstandsrapport'
225
539
  entityType: z.string().min(1), // what the protocol hangs on: 'workorder'
226
540
  entityIdFrom: z.string().min(1), // input field holding the id: 'orderId'
227
- countersigned: z.boolean().default(false), // require the customer's acceptance too
541
+ countersigned: z.boolean().default(false), // require a second signatory's acceptance too
228
542
  });
229
543
  /** The named predicate the kernel resolves for `predicate: 'protocol/all-signed'`. */
230
544
  export const allSignedPredicate = (ctx, rawConfig, input) => {
@@ -284,8 +598,11 @@ export function instantiateProtocol(ctx, rawInput) {
284
598
  const template = ctx.sql.query('SELECT * FROM protocol_templates WHERE key = ? ORDER BY version DESC LIMIT 1', [input.templateKey])[0];
285
599
  if (!template)
286
600
  throw new Error(`protocol template not found: ${input.templateKey}`);
601
+ // An instance being signed is still "in play": a second one would race the
602
+ // first for the same (template, entity) slot.
287
603
  const dup = ctx.sql.query(`SELECT id FROM protocol_instances
288
- WHERE entity_type = ? AND entity_id = ? AND template_key = ? AND status = 'open' LIMIT 1`, [input.entity.entityType, input.entity.entityId, input.templateKey])[0];
604
+ WHERE entity_type = ? AND entity_id = ? AND template_key = ?
605
+ AND status IN ('open','pending_signature') LIMIT 1`, [input.entity.entityType, input.entity.entityId, input.templateKey])[0];
289
606
  if (dup) {
290
607
  throw new Error(`protocol '${input.templateKey}' already open on this ${input.entity.entityType}`);
291
608
  }
@@ -314,11 +631,23 @@ export function instantiateProtocol(ctx, rawInput) {
314
631
  templateKey: template.key,
315
632
  templateVersion: template.version,
316
633
  title: template.title,
634
+ contentKind: templateContentOf(template).kind,
317
635
  entity: input.entity,
318
636
  },
319
637
  });
320
638
  return getInstanceRow(ctx, id);
321
639
  }
640
+ /** The one place that decides whether content may still change. */
641
+ function assertUnfrozen(instance, what) {
642
+ if (instance.status === 'open')
643
+ return;
644
+ if (instance.status === 'pending_signature') {
645
+ throw new Error(`protocol is out for signature: content is frozen until the requests resolve ` +
646
+ `or are cancelled (instance ${instance.id})`);
647
+ }
648
+ throw new Error(`protocol is ${instance.status}: content is frozen, ${what} can no longer change ` +
649
+ `(append-only history kept)`);
650
+ }
322
651
  export const fillProtocolInput = z.object({
323
652
  instanceId: z.string().min(1),
324
653
  itemKey: z.string().min(1),
@@ -328,12 +657,14 @@ export const fillProtocolInput = z.object({
328
657
  export function fillProtocol(ctx, rawInput) {
329
658
  const input = fillProtocolInput.parse(rawInput);
330
659
  const instance = getInstanceRow(ctx, input.instanceId);
331
- // Invariant 1+4: responses bind to an OPEN instance only, and always append.
332
- if (instance.status !== 'open') {
333
- throw new Error(`protocol is ${instance.status}: responses are frozen (append-only history kept)`);
334
- }
660
+ // Invariant 1+4: responses bind to an UNFROZEN instance only, and always append.
661
+ assertUnfrozen(instance, 'responses');
335
662
  const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
336
- const content = protocolTemplateContent.parse(JSON.parse(template.content_json));
663
+ const content = templateContentOf(template);
664
+ if (content.kind !== 'checklist') {
665
+ throw new Error(`template ${instance.template_key}@${instance.template_version} is a '${content.kind}' ` +
666
+ `protocol: it carries no items — bind its content instead of filling it`);
667
+ }
337
668
  const item = content.sections.flatMap((s) => s.items).find((i) => i.key === input.itemKey);
338
669
  if (!item) {
339
670
  throw new Error(`unknown item '${input.itemKey}' in template ${instance.template_key}@${instance.template_version}`);
@@ -374,55 +705,455 @@ export function fillProtocol(ctx, rawInput) {
374
705
  id,
375
706
  ])[0];
376
707
  }
708
+ export const bindDocumentInput = z.object({
709
+ instanceId: z.string().min(1),
710
+ /** The vertical entity that holds the real content — an avtal, a report. */
711
+ contentRef: entityRef,
712
+ /** The hash the VERTICAL computed over its own rows, per `hashRecipe`. */
713
+ contentHash: z.string().regex(/^[0-9a-f]{64}$/, 'contentHash must be lowercase hex SHA-256'),
714
+ });
377
715
  /**
378
- * In-app sign (engine-protocol.md §5): the authenticated principal signs;
379
- * integrity comes from the hash + immutability + the spine event. Connector
380
- * methods (bankid/scrive) arrive later through the SAME operation shape with
381
- * upgraded evidence. Exactly ONE primary signature per instance enforced by
382
- * the open → signed transition.
716
+ * Bind (or re-bind) a document protocol's content while it is still open —
717
+ * the document-kind counterpart of `fillProtocol`. Re-binding is the whole
718
+ * point during negotiation: an avtal's price changes until it is sent out, and
719
+ * each rebind moves the hash the signature will be taken over.
720
+ *
721
+ * Once frozen, this fails like any other write to frozen content.
383
722
  */
384
- export async function signProtocol(ctx, input) {
385
- const instance = getInstanceRow(ctx, z.string().min(1).parse(input.instanceId));
723
+ export function bindDocument(ctx, rawInput) {
724
+ const input = bindDocumentInput.parse(rawInput);
725
+ const instance = getInstanceRow(ctx, input.instanceId);
726
+ assertUnfrozen(instance, 'the binding');
727
+ const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
728
+ const content = templateContentOf(template);
729
+ if (content.kind !== 'document') {
730
+ throw new Error(`template ${instance.template_key}@${instance.template_version} is a '${content.kind}' ` +
731
+ `protocol: fill its items instead of binding content`);
732
+ }
733
+ ctx.sql.exec(`UPDATE protocol_instances
734
+ SET content_ref_type = ?, content_ref_id = ?, bound_hash = ? WHERE id = ?`, [input.contentRef.entityType, input.contentRef.entityId, input.contentHash, instance.id]);
735
+ ctx.emit({
736
+ type: 'protocol.content-bound',
737
+ schemaVersion: 1,
738
+ entity: protocolRef(instance.id),
739
+ piiClass: 'none',
740
+ payload: {
741
+ instanceId: instance.id,
742
+ templateKey: instance.template_key,
743
+ templateVersion: instance.template_version,
744
+ documentType: content.documentType,
745
+ contentRef: input.contentRef,
746
+ boundHash: input.contentHash,
747
+ entity: { entityType: instance.entity_type, entityId: instance.entity_id },
748
+ },
749
+ });
750
+ return getInstanceRow(ctx, instance.id);
751
+ }
752
+ // ---------------------------------------------------------------------------
753
+ // Asynchronous signing — freeze first, collect signatures over days.
754
+ // ---------------------------------------------------------------------------
755
+ export const signatureRequestParty = z.object({
756
+ /** Display name for the role, never PII: 'Beställare', 'Leverantör'. */
757
+ label: z.string().min(1),
758
+ kind: z.enum(['principal', 'external']),
759
+ /**
760
+ * Who is expected to sign, when that is known up front. A `PrincipalId` for
761
+ * `principal`, an opaque `DataSubjectId` for `external`.
762
+ *
763
+ * Optional because it often is NOT known: a BankID flow addressed to a
764
+ * company mailbox is signed by whichever firmatecknare opens it, and their
765
+ * identity only becomes known when the provider reports it. Left unset, the
766
+ * signatory is whoever `recordSignature` reports; set, it is a constraint
767
+ * the recorded signatory must match.
768
+ */
769
+ ref: z.string().min(1).optional(),
770
+ /**
771
+ * 'primary' for the issuing party, 'counter' for accepting parties.
772
+ *
773
+ * Optional, and resolved so that a request set ALWAYS has exactly one
774
+ * primary: declare one explicitly, or the first party becomes it. A set with
775
+ * no primary would leave a signed instance whose issuing signature is null —
776
+ * `requireCountersigned` would then pass on a document nobody issued.
777
+ */
778
+ signatureKind: z.enum(['primary', 'counter']).optional(),
779
+ });
780
+ export const requestSignaturesInput = z.object({
781
+ instanceId: z.string().min(1),
782
+ /** 'scrive', 'bankid' — the provider a connector will dispatch to. */
783
+ method: z.string().min(1),
784
+ parties: z.array(signatureRequestParty).min(1),
785
+ });
786
+ /**
787
+ * Freeze the content and ask named parties to sign it — the asynchronous
788
+ * counterpart of `signProtocol`.
789
+ *
790
+ * This is the transition that closes the drift window: the instance leaves
791
+ * `open` immediately, so nothing can fill or rebind it while it sits at the
792
+ * provider. The hash is computed ONCE, here, and every signature that comes
793
+ * back must match it.
794
+ *
795
+ * The engine dispatches nothing. It emits `protocol.signatures-requested` with
796
+ * everything a connector needs (the hash, the parties, the method) and an
797
+ * executor outside the scope makes the call — module code never touches the
798
+ * network (boundary-lint R3).
799
+ */
800
+ export async function requestSignatures(ctx, rawInput) {
801
+ const input = requestSignaturesInput.parse(rawInput);
802
+ const instance = getInstanceRow(ctx, input.instanceId);
386
803
  if (instance.status !== 'open') {
387
- throw new Error(`protocol is ${instance.status}: only an open protocol can be signed`);
804
+ throw new Error(`protocol is ${instance.status}: only an open protocol can be sent for signature`);
388
805
  }
389
- const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
390
- const latest = latestPerItem(getResponseRows(ctx, instance.id));
391
- const contentHash = await protocolContentHash(template, latest);
392
- const id = ulid();
393
- ctx.sql.exec(`INSERT INTO protocol_signatures
394
- (id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at)
395
- VALUES (?, ?, ?, 'primary', 'in-app', ?, NULL, ?)`, [id, instance.id, ctx.principal, contentHash, new Date().toISOString()]);
396
- ctx.sql.exec(`UPDATE protocol_instances SET status = 'signed' WHERE id = ?`, [instance.id]);
806
+ const primaries = input.parties.filter((p) => p.signatureKind === 'primary');
807
+ if (primaries.length > 1) {
808
+ throw new Error('at most one party may sign as primary — the rest counter-sign');
809
+ }
810
+ // Exactly one primary, always: the declared one, else the first party.
811
+ const primaryIndex = primaries.length === 1
812
+ ? input.parties.findIndex((p) => p.signatureKind === 'primary')
813
+ : 0;
814
+ // Validate the refs that were supplied, so a personnummer cannot be smuggled
815
+ // into a request row and land in a signature by way of the matching check.
816
+ for (const party of input.parties) {
817
+ if (party.ref === undefined)
818
+ continue;
819
+ signatory.parse({ kind: party.kind, ref: party.ref, label: party.label });
820
+ }
821
+ const contentHash = await currentHash(ctx, instance);
822
+ const now = new Date().toISOString();
823
+ ctx.sql.exec(`UPDATE protocol_instances
824
+ SET status = 'pending_signature', frozen_hash = ?, frozen_at = ? WHERE id = ?`, [contentHash, now, instance.id]);
825
+ const created = [];
826
+ for (const [index, party] of input.parties.entries()) {
827
+ const id = ulid();
828
+ created.push(id);
829
+ ctx.sql.exec(`INSERT INTO protocol_signature_requests
830
+ (id, instance_id, party_label, party_kind, party_ref, signature_kind, method,
831
+ status, content_hash, external_ref, resolved_note, requested_by, requested_at, resolved_at)
832
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, NULL, NULL, ?, ?, NULL)`, [
833
+ id,
834
+ instance.id,
835
+ party.label,
836
+ party.kind,
837
+ party.ref ?? null,
838
+ index === primaryIndex ? 'primary' : 'counter',
839
+ input.method,
840
+ contentHash,
841
+ ctx.principal,
842
+ now,
843
+ ]);
844
+ }
845
+ const requests = getRequestRows(ctx, instance.id).filter((r) => created.includes(r.id));
397
846
  ctx.emit({
398
- type: 'protocol.signed',
847
+ type: 'protocol.signatures-requested',
399
848
  schemaVersion: 1,
400
849
  entity: protocolRef(instance.id),
401
- piiClass: 'pseudonymous',
402
- subjectId: dataSubjectId.parse(ctx.principal),
850
+ piiClass: 'none', // party refs are opaque ids; labels are role names, never PII
403
851
  payload: {
404
852
  instanceId: instance.id,
405
853
  templateKey: instance.template_key,
406
854
  templateVersion: instance.template_version,
407
855
  entity: { entityType: instance.entity_type, entityId: instance.entity_id },
408
- signedBy: ctx.principal,
409
- method: 'in-app',
856
+ method: input.method,
410
857
  contentHash,
411
- // fat payload: the frozen answers travel with the event
412
- responses: frozenAnswers(latest),
858
+ // Fat: a connector must never need a cross-module read to dispatch.
859
+ contentRef: instance.content_ref_type && instance.content_ref_id
860
+ ? { entityType: instance.content_ref_type, entityId: instance.content_ref_id }
861
+ : null,
862
+ boundHash: instance.bound_hash,
863
+ parties: requests.map((r) => ({
864
+ requestId: r.id,
865
+ label: r.party_label,
866
+ kind: r.party_kind,
867
+ ref: r.party_ref,
868
+ signatureKind: r.signature_kind,
869
+ })),
413
870
  },
414
871
  });
415
- return {
872
+ return { instance: getInstanceRow(ctx, instance.id), contentHash, requests };
873
+ }
874
+ export const recordSignatureInput = z.object({
875
+ requestId: z.string().min(1),
876
+ signatory,
877
+ /** When the party actually signed, per the provider — NOT when we heard. */
878
+ signedAt: instant,
879
+ /**
880
+ * The hash the provider signed over, as the provider reports it. Checked
881
+ * against the frozen hash: a mismatch means the document that was signed is
882
+ * not the document we froze, and that must fail closed rather than record a
883
+ * signature over unknown content.
884
+ */
885
+ contentHash: z.string().regex(/^[0-9a-f]{64}$/),
886
+ /** Sealed PDF, provider transaction id, audit log — where the proof lives. */
887
+ evidenceRef: z.string().min(1).optional(),
888
+ });
889
+ /**
890
+ * Record a signature that happened OUTSIDE this system — the webhook's half.
891
+ *
892
+ * Everything `signProtocol` takes from ambient context, this takes as data:
893
+ * the signatory is supplied (and may be an external person with no account),
894
+ * the timestamp is the provider's, the method is the request's, and the
895
+ * evidence reference points at the provider's sealed artifact.
896
+ *
897
+ * The last pending request resolving is what transitions the instance to
898
+ * `signed` — which is the multi-party rule stated in code: an avtal is signed
899
+ * when every requested party has signed it, not when the first one has.
900
+ *
901
+ * NOTE ON THE CALLER: there is no webhook ingress in the kernel yet, and no
902
+ * inbound authority seam that would let a provider callback invoke a scope
903
+ * operation (`ScopeHost.getScope` demands a `PrincipalId`; `ExecutorHandler`
904
+ * has no return path into a scope). Until those land this is reachable only by
905
+ * a principal holding `protocol:record-signature` — a key deliberately held by
906
+ * no human role in any demo.
907
+ */
908
+ export async function recordSignature(ctx, rawInput) {
909
+ const input = recordSignatureInput.parse(rawInput);
910
+ const request = ctx.sql.query('SELECT * FROM protocol_signature_requests WHERE id = ?', [input.requestId])[0];
911
+ if (!request)
912
+ throw new Error(`signature request not found: ${input.requestId}`);
913
+ if (request.status !== 'pending') {
914
+ throw new Error(`signature request is already ${request.status}: ${request.id}`);
915
+ }
916
+ const instance = getInstanceRow(ctx, request.instance_id);
917
+ if (instance.status !== 'pending_signature') {
918
+ throw new Error(`protocol is ${instance.status}: signatures are only recorded while out for signature`);
919
+ }
920
+ // Re-derive rather than trust the column, then check the provider agrees.
921
+ const frozen = await verifyFrozen(ctx, instance);
922
+ if (input.contentHash !== frozen) {
923
+ throw new Error(`signed content does not match the frozen protocol: provider reported ` +
924
+ `${input.contentHash}, frozen ${frozen}`);
925
+ }
926
+ if (request.party_kind !== input.signatory.kind) {
927
+ throw new Error(`signature request ${request.id} expects a ${request.party_kind} signatory, ` +
928
+ `got ${input.signatory.kind}`);
929
+ }
930
+ if (request.party_ref && request.party_ref !== input.signatory.ref) {
931
+ throw new Error(`signature request ${request.id} was addressed to a different party than the one who signed`);
932
+ }
933
+ if (getSignatureRows(ctx, instance.id).some((s) => s.signed_by === input.signatory.ref)) {
934
+ throw new Error('this signatory has already signed this protocol');
935
+ }
936
+ const id = ulid();
937
+ ctx.sql.exec(`INSERT INTO protocol_signatures
938
+ (id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
939
+ request_id, signatory_kind, signatory_label)
940
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
941
+ id,
942
+ instance.id,
943
+ input.signatory.ref,
944
+ request.signature_kind,
945
+ request.method,
946
+ frozen,
947
+ input.evidenceRef ?? null,
948
+ input.signedAt,
949
+ request.id,
950
+ input.signatory.kind,
951
+ input.signatory.label ?? request.party_label,
952
+ ]);
953
+ ctx.sql.exec(`UPDATE protocol_signature_requests
954
+ SET status = 'signed', resolved_at = ?, external_ref = COALESCE(?, external_ref)
955
+ WHERE id = ?`, [input.signedAt, input.evidenceRef ?? null, request.id]);
956
+ // The instance is signed only when EVERY requested party signed — not merely
957
+ // when nothing is left pending. A declined or expired request is not pending
958
+ // either, and treating that as completion would mark an avtal fully executed
959
+ // that one party refused. An unresolved refusal holds the instance frozen
960
+ // until someone explicitly withdraws the request set.
961
+ const unsigned = ctx.sql.query(`SELECT COUNT(*) AS n FROM protocol_signature_requests
962
+ WHERE instance_id = ? AND status <> 'signed'`, [instance.id])[0].n;
963
+ const complete = unsigned === 0;
964
+ if (complete) {
965
+ ctx.sql.exec(`UPDATE protocol_instances SET status = 'signed' WHERE id = ?`, [instance.id]);
966
+ }
967
+ const signature = getSignatureRows(ctx, instance.id).find((s) => s.id === id);
968
+ emitSignatureEvent(ctx, {
416
969
  instance: getInstanceRow(ctx, instance.id),
417
- signature: getSignatureRows(ctx, instance.id).find((s) => s.id === id),
970
+ signature,
971
+ contentHash: frozen,
972
+ complete,
973
+ });
974
+ return { instance: getInstanceRow(ctx, instance.id), signature };
975
+ }
976
+ export const declineSignatureInput = z.object({
977
+ requestId: z.string().min(1),
978
+ reason: z.string().min(1),
979
+ /** 'declined' when a party refused; 'expired' when the provider timed out. */
980
+ outcome: z.enum(['declined', 'expired']).default('declined'),
981
+ });
982
+ /**
983
+ * A party refused, or the provider's window expired. The instance stays
984
+ * `pending_signature` and therefore frozen — a refusal is not permission to
985
+ * edit. Renegotiating means cancelling the request set explicitly, which is a
986
+ * separate, permissioned, audited act.
987
+ */
988
+ export function declineSignature(ctx, rawInput) {
989
+ const input = declineSignatureInput.parse(rawInput);
990
+ const request = ctx.sql.query('SELECT * FROM protocol_signature_requests WHERE id = ?', [input.requestId])[0];
991
+ if (!request)
992
+ throw new Error(`signature request not found: ${input.requestId}`);
993
+ if (request.status !== 'pending') {
994
+ throw new Error(`signature request is already ${request.status}: ${request.id}`);
995
+ }
996
+ const instance = getInstanceRow(ctx, request.instance_id);
997
+ ctx.sql.exec(`UPDATE protocol_signature_requests
998
+ SET status = ?, resolved_at = ?, resolved_note = ? WHERE id = ?`, [input.outcome ?? 'declined', new Date().toISOString(), input.reason, request.id]);
999
+ ctx.emit({
1000
+ type: 'protocol.signature-declined',
1001
+ schemaVersion: 1,
1002
+ entity: protocolRef(instance.id),
1003
+ piiClass: 'none',
1004
+ payload: {
1005
+ instanceId: instance.id,
1006
+ requestId: request.id,
1007
+ templateKey: instance.template_key,
1008
+ entity: { entityType: instance.entity_type, entityId: instance.entity_id },
1009
+ partyLabel: request.party_label,
1010
+ outcome: input.outcome ?? 'declined',
1011
+ reason: input.reason,
1012
+ },
1013
+ });
1014
+ return ctx.sql.query('SELECT * FROM protocol_signature_requests WHERE id = ?', [request.id])[0];
1015
+ }
1016
+ export const cancelSignatureRequestsInput = z.object({
1017
+ instanceId: z.string().min(1),
1018
+ reason: z.string().min(1),
1019
+ });
1020
+ /**
1021
+ * Withdraw an outstanding request set and thaw the instance — the
1022
+ * renegotiation path an avtal needs when a party declines or the price moves.
1023
+ *
1024
+ * Cancelling THAWS: status returns to `open` and the frozen hash is cleared,
1025
+ * so the next `requestSignatures` freezes fresh content at a fresh hash.
1026
+ * Signatures already collected are NOT removed — they are append-only history
1027
+ * attesting to content that really was frozen at the time — but they were
1028
+ * taken over the OLD hash, so they can never satisfy the new one. That is the
1029
+ * intended reading: a party who signed v1 has not signed v2.
1030
+ */
1031
+ export function cancelSignatureRequests(ctx, rawInput) {
1032
+ const input = cancelSignatureRequestsInput.parse(rawInput);
1033
+ const instance = getInstanceRow(ctx, input.instanceId);
1034
+ if (instance.status !== 'pending_signature') {
1035
+ throw new Error(`protocol is ${instance.status}: only a protocol out for signature can be withdrawn`);
1036
+ }
1037
+ const now = new Date().toISOString();
1038
+ const cancelled = ctx.sql.exec(`UPDATE protocol_signature_requests
1039
+ SET status = 'cancelled', resolved_at = ?, resolved_note = ?
1040
+ WHERE instance_id = ? AND status = 'pending'`, [now, input.reason, instance.id]);
1041
+ ctx.sql.exec(`UPDATE protocol_instances
1042
+ SET status = 'open', frozen_hash = NULL, frozen_at = NULL WHERE id = ?`, [instance.id]);
1043
+ ctx.emit({
1044
+ type: 'protocol.signatures-cancelled',
1045
+ schemaVersion: 1,
1046
+ entity: protocolRef(instance.id),
1047
+ piiClass: 'none',
1048
+ payload: {
1049
+ instanceId: instance.id,
1050
+ templateKey: instance.template_key,
1051
+ entity: { entityType: instance.entity_type, entityId: instance.entity_id },
1052
+ cancelled: cancelled.changes,
1053
+ reason: input.reason,
1054
+ },
1055
+ });
1056
+ return getInstanceRow(ctx, instance.id);
1057
+ }
1058
+ // ---------------------------------------------------------------------------
1059
+ // In-app signing — the everyday field case, unchanged in shape and behaviour.
1060
+ // ---------------------------------------------------------------------------
1061
+ /** Both signing paths emit the same pair of events, so they agree by construction. */
1062
+ function emitSignatureEvent(ctx, args) {
1063
+ const { instance, signature, contentHash, complete } = args;
1064
+ const signatories = getSignatureRows(ctx, instance.id).map(signatoryOf);
1065
+ const base = {
1066
+ instanceId: instance.id,
1067
+ templateKey: instance.template_key,
1068
+ templateVersion: instance.template_version,
1069
+ entity: { entityType: instance.entity_type, entityId: instance.entity_id },
1070
+ method: signature.method,
1071
+ contentHash,
1072
+ // Document kind: what was signed lives in the vertical, so the event
1073
+ // carries the pointer and the hash rather than the content.
1074
+ contentRef: instance.content_ref_type && instance.content_ref_id
1075
+ ? { entityType: instance.content_ref_type, entityId: instance.content_ref_id }
1076
+ : null,
1077
+ boundHash: instance.bound_hash,
1078
+ // fat payload: the frozen answers travel with the event (checklist kind)
1079
+ responses: args.responses ?? {},
1080
+ signatory: signatoryOf(signature),
1081
+ // Retained for consumers that read the flat field: the signatory's ref.
1082
+ signedBy: signature.signed_by,
1083
+ evidenceRef: signature.evidence_ref,
1084
+ signedAt: signature.signed_at,
1085
+ /** False while other requested parties are still outstanding. */
1086
+ complete,
1087
+ signatories,
418
1088
  };
1089
+ if (signature.kind === 'primary') {
1090
+ ctx.emit({
1091
+ type: 'protocol.signed',
1092
+ schemaVersion: 1,
1093
+ entity: protocolRef(instance.id),
1094
+ piiClass: 'pseudonymous',
1095
+ subjectId: dataSubjectId.parse(signature.signed_by),
1096
+ payload: base,
1097
+ });
1098
+ return;
1099
+ }
1100
+ const primary = getSignatureRows(ctx, instance.id).find((s) => s.kind === 'primary');
1101
+ ctx.emit({
1102
+ type: 'protocol.countersigned',
1103
+ schemaVersion: 1,
1104
+ entity: protocolRef(instance.id),
1105
+ piiClass: 'pseudonymous',
1106
+ subjectId: dataSubjectId.parse(signature.signed_by),
1107
+ payload: {
1108
+ ...base,
1109
+ signedBy: primary?.signed_by ?? null,
1110
+ countersignedBy: signature.signed_by,
1111
+ countersignatory: signatoryOf(signature),
1112
+ },
1113
+ });
1114
+ }
1115
+ /**
1116
+ * In-app sign (engine-protocol.md §5): the authenticated principal signs, now;
1117
+ * integrity comes from the hash + immutability + the spine event. Freezing and
1118
+ * signing coincide here, which is sound precisely BECAUSE it is synchronous —
1119
+ * there is no window between what the signer saw and what was hashed.
1120
+ *
1121
+ * For an external provider flow (BankID via Scrive) use `requestSignatures` +
1122
+ * `recordSignature` instead: the signatory is not `ctx.principal`, the moment
1123
+ * is not now, and freezing must happen at dispatch rather than at signature.
1124
+ * Exactly ONE primary signature per instance — enforced by the open → signed
1125
+ * transition.
1126
+ */
1127
+ export async function signProtocol(ctx, input) {
1128
+ const instance = getInstanceRow(ctx, z.string().min(1).parse(input.instanceId));
1129
+ if (instance.status !== 'open') {
1130
+ throw new Error(`protocol is ${instance.status}: only an open protocol can be signed`);
1131
+ }
1132
+ const latest = latestPerItem(getResponseRows(ctx, instance.id));
1133
+ const contentHash = await currentHash(ctx, instance);
1134
+ const now = new Date().toISOString();
1135
+ const id = ulid();
1136
+ ctx.sql.exec(`INSERT INTO protocol_signatures
1137
+ (id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
1138
+ request_id, signatory_kind, signatory_label)
1139
+ VALUES (?, ?, ?, 'primary', 'in-app', ?, NULL, ?, NULL, 'principal', NULL)`, [id, instance.id, ctx.principal, contentHash, now]);
1140
+ ctx.sql.exec(`UPDATE protocol_instances SET status = 'signed', frozen_hash = ?, frozen_at = ? WHERE id = ?`, [contentHash, now, instance.id]);
1141
+ const signature = getSignatureRows(ctx, instance.id).find((s) => s.id === id);
1142
+ emitSignatureEvent(ctx, {
1143
+ instance: getInstanceRow(ctx, instance.id),
1144
+ signature,
1145
+ contentHash,
1146
+ complete: true,
1147
+ responses: frozenAnswers(latest),
1148
+ });
1149
+ return { instance: getInstanceRow(ctx, instance.id), signature };
419
1150
  }
420
1151
  /**
421
1152
  * Counter-sign (invariant 3): a SECOND signature on the SAME frozen content —
422
1153
  * the customer at pickup. Requires a signed instance; the content hash is
423
- * recomputed and must equal the primary signature's hash (frozen content,
424
- * verified, never assumed). One counter-signature per principal; a principal
425
- * never counter-signs what they primary-signed.
1154
+ * recomputed and must equal the frozen hash (frozen content, verified, never
1155
+ * assumed). One counter-signature per signatory; a signatory never
1156
+ * counter-signs what they primary-signed.
426
1157
  */
427
1158
  export async function countersignProtocol(ctx, input) {
428
1159
  const instance = getInstanceRow(ctx, z.string().min(1).parse(input.instanceId));
@@ -433,46 +1164,27 @@ export async function countersignProtocol(ctx, input) {
433
1164
  const primary = signatures.find((s) => s.kind === 'primary');
434
1165
  if (!primary)
435
1166
  throw new Error(`signed protocol has no primary signature: ${instance.id}`); // corrupt state, fail closed
436
- if (primary.signed_by === ctx.principal) {
437
- throw new Error('counter-signature must come from a different principal than the signer');
438
- }
439
- if (signatures.some((s) => s.kind === 'counter' && s.signed_by === ctx.principal)) {
440
- throw new Error('already counter-signed by this principal');
1167
+ if (signatures.some((s) => s.signed_by === ctx.principal)) {
1168
+ throw new Error('counter-signature must come from a signatory who has not already signed');
441
1169
  }
442
- // Re-run the hash recipe against stored state: the counter-signature binds
443
- // to verified frozen content, not to a trusted column.
444
- const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
1170
+ // Re-run the recipe against stored state: the counter-signature binds to
1171
+ // verified frozen content, not to a trusted column.
1172
+ const contentHash = await verifyFrozen(ctx, instance);
445
1173
  const latest = latestPerItem(getResponseRows(ctx, instance.id));
446
- const contentHash = await protocolContentHash(template, latest);
447
- if (contentHash !== primary.content_hash) {
448
- throw new Error(`content hash mismatch on counter-sign: stored ${primary.content_hash}, replayed ${contentHash}`);
449
- }
450
1174
  const id = ulid();
451
1175
  ctx.sql.exec(`INSERT INTO protocol_signatures
452
- (id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at)
453
- VALUES (?, ?, ?, 'counter', 'in-app', ?, NULL, ?)`, [id, instance.id, ctx.principal, contentHash, new Date().toISOString()]);
454
- ctx.emit({
455
- type: 'protocol.countersigned',
456
- schemaVersion: 1,
457
- entity: protocolRef(instance.id),
458
- piiClass: 'pseudonymous',
459
- subjectId: dataSubjectId.parse(ctx.principal),
460
- payload: {
461
- instanceId: instance.id,
462
- templateKey: instance.template_key,
463
- templateVersion: instance.template_version,
464
- entity: { entityType: instance.entity_type, entityId: instance.entity_id },
465
- signedBy: primary.signed_by,
466
- countersignedBy: ctx.principal,
467
- method: 'in-app',
468
- contentHash,
469
- responses: frozenAnswers(latest),
470
- },
1176
+ (id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
1177
+ request_id, signatory_kind, signatory_label)
1178
+ VALUES (?, ?, ?, 'counter', 'in-app', ?, NULL, ?, NULL, 'principal', NULL)`, [id, instance.id, ctx.principal, contentHash, new Date().toISOString()]);
1179
+ const signature = getSignatureRows(ctx, instance.id).find((s) => s.id === id);
1180
+ emitSignatureEvent(ctx, {
1181
+ instance,
1182
+ signature,
1183
+ contentHash,
1184
+ complete: true,
1185
+ responses: frozenAnswers(latest),
471
1186
  });
472
- return {
473
- instance: getInstanceRow(ctx, instance.id),
474
- signature: getSignatureRows(ctx, instance.id).find((s) => s.id === id),
475
- };
1187
+ return { instance: getInstanceRow(ctx, instance.id), signature };
476
1188
  }
477
1189
  /** Voiding, not deleting: a superseded protocol keeps its rows forever. */
478
1190
  export function voidProtocol(ctx, input) {
@@ -480,8 +1192,15 @@ export function voidProtocol(ctx, input) {
480
1192
  const instance = getInstanceRow(ctx, z.string().min(1).parse(input.instanceId));
481
1193
  if (instance.status === 'voided')
482
1194
  throw new Error('protocol is already voided');
1195
+ const now = new Date().toISOString();
1196
+ // An outstanding request set dies with the protocol — leaving rows `pending`
1197
+ // on a voided instance would keep `requireAllSigned` reading a live gate on
1198
+ // a document that is out of play.
1199
+ ctx.sql.exec(`UPDATE protocol_signature_requests
1200
+ SET status = 'cancelled', resolved_at = ?, resolved_note = ?
1201
+ WHERE instance_id = ? AND status = 'pending'`, [now, `protocol voided: ${reason}`, instance.id]);
483
1202
  ctx.sql.exec(`UPDATE protocol_instances
484
- SET status = 'voided', voided_by = ?, voided_reason = ?, voided_at = ? WHERE id = ?`, [ctx.principal, reason, new Date().toISOString(), instance.id]);
1203
+ SET status = 'voided', voided_by = ?, voided_reason = ?, voided_at = ? WHERE id = ?`, [ctx.principal, reason, now, instance.id]);
485
1204
  ctx.emit({
486
1205
  type: 'protocol.voided',
487
1206
  schemaVersion: 1,
@@ -508,12 +1227,13 @@ export function getProtocol(ctx, instanceId) {
508
1227
  key: template.key,
509
1228
  version: template.version,
510
1229
  title: template.title,
511
- content: protocolTemplateContent.parse(JSON.parse(template.content_json)),
1230
+ content: templateContentOf(template),
512
1231
  },
513
1232
  responses,
514
1233
  latest: latestPerItem(responses),
515
1234
  signature: signatures.find((s) => s.kind === 'primary') ?? null,
516
1235
  signatures,
1236
+ requests: getRequestRows(ctx, instance.id),
517
1237
  };
518
1238
  }
519
1239
  export function listProtocolsForEntity(ctx, entity) {
@@ -521,21 +1241,33 @@ export function listProtocolsForEntity(ctx, entity) {
521
1241
  WHERE entity_type = ? AND entity_id = ? ORDER BY rowid`, [entity.entityType, entity.entityId]);
522
1242
  return instances.map((instance) => {
523
1243
  const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
524
- const content = protocolTemplateContent.parse(JSON.parse(template.content_json));
525
- const total = content.sections.reduce((n, s) => n + s.items.length, 0);
526
- const answered = Object.keys(latestPerItem(getResponseRows(ctx, instance.id))).length;
1244
+ const content = templateContentOf(template);
1245
+ // A document has one thing to settle — its bound content so it reads as
1246
+ // 0/1 or 1/1 rather than pretending to a checklist's item count.
1247
+ const total = content.kind === 'checklist'
1248
+ ? content.sections.reduce((n, s) => n + s.items.length, 0)
1249
+ : 1;
1250
+ const answered = content.kind === 'checklist'
1251
+ ? Object.keys(latestPerItem(getResponseRows(ctx, instance.id))).length
1252
+ : instance.bound_hash
1253
+ ? 1
1254
+ : 0;
527
1255
  const signatures = getSignatureRows(ctx, instance.id);
528
1256
  const primary = signatures.find((s) => s.kind === 'primary');
529
1257
  const counter = signatures.filter((s) => s.kind === 'counter').at(-1);
1258
+ const pendingSignatures = ctx.sql.query(`SELECT COUNT(*) AS n FROM protocol_signature_requests
1259
+ WHERE instance_id = ? AND status = 'pending'`, [instance.id])[0].n;
530
1260
  return {
531
1261
  instance,
532
1262
  title: template.title,
1263
+ contentKind: content.kind,
533
1264
  answered,
534
1265
  total,
535
1266
  signedBy: primary?.signed_by ?? null,
536
1267
  signedAt: primary?.signed_at ?? null,
537
1268
  countersignedBy: counter?.signed_by ?? null,
538
1269
  countersignedAt: counter?.signed_at ?? null,
1270
+ pendingSignatures,
539
1271
  };
540
1272
  });
541
1273
  }
@@ -564,6 +1296,32 @@ const fillOp = async (ctx, input) => {
564
1296
  assertAllowed(await ctx.check(PROTOCOL_PERM.fill, protocolRef(input.instanceId)));
565
1297
  return fillProtocol(ctx, input);
566
1298
  };
1299
+ const bindOp = async (ctx, input) => {
1300
+ assertAllowed(await ctx.check(PROTOCOL_PERM.bind, protocolRef(input.instanceId)));
1301
+ return bindDocument(ctx, input);
1302
+ };
1303
+ const requestSignaturesOp = async (ctx, input) => {
1304
+ assertAllowed(await ctx.check(PROTOCOL_PERM.requestSignature, protocolRef(input.instanceId)));
1305
+ return requestSignatures(ctx, input);
1306
+ };
1307
+ const cancelSignaturesOp = async (ctx, input) => {
1308
+ assertAllowed(await ctx.check(PROTOCOL_PERM.requestSignature, protocolRef(input.instanceId)));
1309
+ return cancelSignatureRequests(ctx, input);
1310
+ };
1311
+ /**
1312
+ * The ingress-facing pair. Both check `protocol:record-signature`, which is a
1313
+ * connector's key rather than a person's — the permission diff is where a
1314
+ * deployment declares that it trusts something to speak for an external
1315
+ * signing provider.
1316
+ */
1317
+ const recordSignatureOp = async (ctx, input) => {
1318
+ assertAllowed(await ctx.check(PROTOCOL_PERM.recordSignature));
1319
+ return recordSignature(ctx, input);
1320
+ };
1321
+ const declineSignatureOp = async (ctx, input) => {
1322
+ assertAllowed(await ctx.check(PROTOCOL_PERM.recordSignature));
1323
+ return declineSignature(ctx, input);
1324
+ };
567
1325
  const signOp = async (ctx, input) => {
568
1326
  assertAllowed(await ctx.check(PROTOCOL_PERM.sign, protocolRef(input.instanceId)));
569
1327
  return signProtocol(ctx, input);
@@ -599,6 +1357,11 @@ export const protocolModule = {
599
1357
  'protocol/list-templates': listTemplatesOp,
600
1358
  'protocol/instantiate': instantiateOp,
601
1359
  'protocol/fill': fillOp,
1360
+ 'protocol/bind-document': bindOp,
1361
+ 'protocol/request-signatures': requestSignaturesOp,
1362
+ 'protocol/cancel-signatures': cancelSignaturesOp,
1363
+ 'protocol/record-signature': recordSignatureOp,
1364
+ 'protocol/decline-signature': declineSignatureOp,
602
1365
  'protocol/sign': signOp,
603
1366
  'protocol/countersign': countersignOp,
604
1367
  'protocol/void': voidOp,