From 6ca6e8e0a64d9b2217aa53afc9ee3de24cbac8a7 Mon Sep 17 00:00:00 2001 From: sax3l Date: Fri, 6 Nov 2026 10:29:59 +0100 Subject: [PATCH] feat(idempotency): operation-scope binding for upload identity Per the reap-use mandate: 'identical file + different instruction' is a DIFFERENT operation. bindOperationToUploadIdentity() folds instruction, field contract, model, data class and export format into the DOC0-compatible upload filename so the durable fingerprint covers every result-affecting input. The bytes' SHA-256 stays stable; DOC0 still receives the caller's x-idempotency-key (its own idempotency, its own contract). Classification: STANDARDIZE/0pt-owned (upstream DOC0 untouched). --- src/document-workbench/doc0-upload-client.ts | 198 ++++++++++++++---- .../idempotency-operation-scope.test.ts | 46 ++++ 2 files changed, 205 insertions(+), 39 deletions(-) create mode 100644 tests/document-workbench/idempotency-operation-scope.test.ts diff --git a/src/document-workbench/doc0-upload-client.ts b/src/document-workbench/doc0-upload-client.ts index df5668f..6e3d405 100644 --- a/src/document-workbench/doc0-upload-client.ts +++ b/src/document-workbench/doc0-upload-client.ts @@ -24,7 +24,9 @@ export interface Doc0UploadReceipt { /** The POST may have committed even if its response could not be verified. */ export class Doc0UploadOutcomeUnknownError extends Error { constructor() { - super('DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key'); + super( + 'DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key', + ); this.name = 'Doc0UploadOutcomeUnknownError'; } } @@ -60,6 +62,44 @@ export type Doc0Inspection = * Internal contract only: callers must retain this identity before dispatch, * authorize its scope, and keep the same input bytes/metadata until dispatch. */ +/** + * One upload identity per operation — not per file. + * + * "Identical file, different instruction" is a DIFFERENT operation, so the + * fingerprint must cover every result-affecting input. DOC0's fingerprint + * accepts arbitrary string tuples; we version the domain explicitly. + * + * Boundary kept: this code stays the 0pt-owned fingerprint of an internal + * interface; the SHA-256 of the bytes remains the same regardless, and DOC0 + * still receives the same `x-idempotency-key` (it owns its own idempotency). + */ +export function bindOperationToUploadIdentity( + input: Doc0UploadInput, + operation: { + instruction: string; + fields: ReadonlyArray<{ name: string; type: string; required?: boolean }>; + modelId: string; + dataClass: string; + format: 'json' | 'csv'; + }, +): Doc0UploadInput { + const payloadVersion = createHash('sha256') + .update( + JSON.stringify([ + '0pt-structure-operation-v1', + operation.instruction.length, + operation.instruction, + operation.fields.map((f) => [f.name, f.type, f.required === true]), + operation.modelId, + operation.dataClass, + operation.format, + ]), + ) + .digest('hex') + .slice(0, 16); + return { ...input, filename: `${input.filename}#${payloadVersion}` }; +} + export function prepareDoc0UploadIdentity(input: Doc0UploadInput): { sourceSha256: string; sourceSizeBytes: number; @@ -68,13 +108,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): { wireMimeType: string; } { if ( - ![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey, input.filename, input.mimeType] - .every((value) => typeof value === 'string' && value.trim()) || - ![input.tenantId, input.actorId, input.requestId, input.idempotencyKey] - .every((value) => /^[\x21-\x7e]{1,256}$/.test(value)) || - !['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(input.classification) || - !(input.bytes instanceof Uint8Array) || input.bytes.byteLength === 0 - ) throw new Error('Invalid DOC0 upload input'); + ![ + input.tenantId, + input.projectId, + input.actorId, + input.requestId, + input.idempotencyKey, + input.filename, + input.mimeType, + ].every((value) => typeof value === 'string' && value.trim()) || + ![input.tenantId, input.actorId, input.requestId, input.idempotencyKey].every((value) => + /^[\x21-\x7e]{1,256}$/.test(value), + ) || + !['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes( + input.classification, + ) || + !(input.bytes instanceof Uint8Array) || + input.bytes.byteLength === 0 + ) + throw new Error('Invalid DOC0 upload input'); const wireMimeType = input.mimeType.toLowerCase(); if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) { throw new Error('Invalid DOC0 upload MIME type'); @@ -83,10 +135,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): { const wireFilename = `source.${extension}`; const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex'); const sourceSizeBytes = input.bytes.byteLength; - const requestFingerprint = createHash('sha256').update(JSON.stringify([ - 'doc0-upload-v1', input.tenantId, input.projectId, input.actorId, sourceSha256, - sourceSizeBytes, wireFilename, wireMimeType, input.filename, 'unknown', input.classification, [], null, - ])).digest('hex'); + const requestFingerprint = createHash('sha256') + .update( + JSON.stringify([ + 'doc0-upload-v1', + input.tenantId, + input.projectId, + input.actorId, + sourceSha256, + sourceSizeBytes, + wireFilename, + wireMimeType, + input.filename, + 'unknown', + input.classification, + [], + null, + ]), + ) + .digest('hex'); return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType }; } @@ -109,7 +176,9 @@ async function readBoundedJson(response: Response): Promise { parts.push(chunk.value); } return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown; - } finally { reader.releaseLock(); } + } finally { + reader.releaseLock(); + } } /** @@ -131,16 +200,28 @@ export class Doc0UploadClient { }, ) { let url: URL; - try { url = new URL(options.baseUrl); } - catch { throw new Error('A trusted HTTPS DOC0 endpoint is required'); } + try { + url = new URL(options.baseUrl); + } catch { + throw new Error('A trusted HTTPS DOC0 endpoint is required'); + } if ( - url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || !options.serviceToken.trim() || !Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0 || - (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 300_000)) + (options.timeoutMs !== undefined && + (!Number.isSafeInteger(options.timeoutMs) || + options.timeoutMs < 1_000 || + options.timeoutMs > 300_000)) ) { - throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required'); + throw new Error( + 'A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required', + ); } // Keep the validated values even if the caller later changes its options object. this.options = { ...options }; @@ -167,7 +248,11 @@ export class Doc0UploadClient { classification: input.classification, }), ); - body.set('file', new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }), identity.wireFilename); + body.set( + 'file', + new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }), + identity.wireFilename, + ); let response: Response; try { response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, { @@ -183,11 +268,16 @@ export class Doc0UploadClient { body, signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000), }); - } catch { throw new Doc0UploadOutcomeUnknownError(); } + } catch { + throw new Doc0UploadOutcomeUnknownError(); + } // Even a non-2xx response does not prove the write did not commit. let decoded: unknown; - try { decoded = await readBoundedJson(response); } - catch { throw new Doc0UploadOutcomeUnknownError(); } + try { + decoded = await readBoundedJson(response); + } catch { + throw new Doc0UploadOutcomeUnknownError(); + } const data = decoded as { document?: { documentId?: unknown; @@ -239,33 +329,63 @@ export class Doc0UploadClient { * is complete. No automatic retry, settlement or customer route is wired. */ async inspect(input: Doc0InspectionInput): Promise { - if (![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey] - .every((value) => typeof value === 'string' && value.trim()) || - input.projectId.length > 256 || input.idempotencyKey.length > 256 || - !/^[0-9a-f]{64}$/.test(input.sourceSha256) || !/^[0-9a-f]{64}$/.test(input.requestFingerprint) || - !Number.isSafeInteger(input.sourceSizeBytes) || input.sourceSizeBytes <= 0) { + if ( + ![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey].every( + (value) => typeof value === 'string' && value.trim(), + ) || + input.projectId.length > 256 || + input.idempotencyKey.length > 256 || + !/^[0-9a-f]{64}$/.test(input.sourceSha256) || + !/^[0-9a-f]{64}$/.test(input.requestFingerprint) || + !Number.isSafeInteger(input.sourceSizeBytes) || + input.sourceSizeBytes <= 0 + ) { throw new Error('Invalid DOC0 inspection input'); } let result: unknown; try { const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/upload-intents/inspect`, { - method: 'POST', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000), - headers: { authorization: `Bearer ${this.options.serviceToken}`, 'content-type': 'application/json', - 'x-tenant-id': input.tenantId, 'x-actor-id': input.actorId, 'x-request-id': input.requestId, 'x-idempotency-key': input.idempotencyKey }, - body: JSON.stringify({ projectId: input.projectId, sourceSha256: input.sourceSha256, - sourceSizeBytes: input.sourceSizeBytes, requestFingerprint: input.requestFingerprint }), + method: 'POST', + redirect: 'error', + cache: 'no-store', + signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000), + headers: { + authorization: `Bearer ${this.options.serviceToken}`, + 'content-type': 'application/json', + 'x-tenant-id': input.tenantId, + 'x-actor-id': input.actorId, + 'x-request-id': input.requestId, + 'x-idempotency-key': input.idempotencyKey, + }, + body: JSON.stringify({ + projectId: input.projectId, + sourceSha256: input.sourceSha256, + sourceSizeBytes: input.sourceSizeBytes, + requestFingerprint: input.requestFingerprint, + }), }); result = await readBoundedJson(response); - } catch { throw new Doc0InspectionUnavailableError(); } - if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Doc0InspectionUnavailableError(); + } catch { + throw new Doc0InspectionUnavailableError(); + } + if (!result || typeof result !== 'object' || Array.isArray(result)) + throw new Doc0InspectionUnavailableError(); const record = result as Record; - if (typeof record.status === 'string' && ['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) && - Object.keys(record).length === 1) { + if ( + typeof record.status === 'string' && + ['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) && + Object.keys(record).length === 1 + ) { return { status: record.status as 'not_found' | 'in_progress' | 'reconciliation_required' }; } - if (record.status === 'completed_recorded' && Object.keys(record).length === 3 && - typeof record.documentId === 'string' && record.documentId.trim() && - typeof record.versionId === 'string' && record.versionId.trim()) { + if ( + record.status === 'completed_recorded' && + Object.keys(record).length === 3 && + typeof record.documentId === 'string' && + record.documentId.trim() && + typeof record.versionId === 'string' && + record.versionId.trim() + ) { return { status: 'completed_recorded', documentId: record.documentId, versionId: record.versionId }; } throw new Doc0InspectionUnavailableError(); diff --git a/tests/document-workbench/idempotency-operation-scope.test.ts b/tests/document-workbench/idempotency-operation-scope.test.ts new file mode 100644 index 0000000..f703940 --- /dev/null +++ b/tests/document-workbench/idempotency-operation-scope.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + bindOperationToUploadIdentity, + prepareDoc0UploadIdentity, + type Doc0UploadInput, +} from '../../src/document-workbench/doc0-upload-client'; + +const input: Doc0UploadInput = { + tenantId: 't1', projectId: 'p1', actorId: 'a1', requestId: 'r1', idempotencyKey: 'k1', + filename: 'rapport.txt', mimeType: 'text/plain', classification: 'internal', + bytes: new TextEncoder().encode('Innehall'), +}; +const opA = { + instruction: 'Extrahera leverantor', fields: [{ name: 'supplier', type: 'string' }], + modelId: 'm1', dataClass: 'low', format: 'json' as const, +}; +const opB = { ...opA, instruction: 'Extrahera belopp' }; + +describe('operation-scoped upload identity', () => { + it('same file + same operation binds to one identity', () => { + expect(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA))) + .toEqual(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA))); + }); + it('same file + DIFFERENT instruction = DIFFERENT identity (not the same operation)', () => { + const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)); + const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB)); + expect(a.requestFingerprint).not.toBe(b.requestFingerprint); + }); + it('changed model or data class also changes the operation identity', () => { + const c = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, modelId: 'm2' })); + const d = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, dataClass: 'high' })); + const e = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, format: 'csv' })); + expect(c.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint); + expect(d.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint); + expect(e.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint); + }); + it('raw bytes checksum stays stable across operations', () => { + const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)); + const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB)); + expect(a.sourceSha256).toBe(b.sourceSha256); + }); + it('prefixes the operation marker so odd filenames cannot collide', () => { + const bound = bindOperationToUploadIdentity(input, opA); + expect(bound.filename.startsWith('rapport.txt#')).toBe(true); + }); +}); -- 2.54.0