@prampta/sdk 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,48 @@ production PRAMPTA deployment running the v1.0.0-default trust posture
36
36
  refuses it with `PG_NO_PAIR`. Don't build a new integration against it; it
37
37
  exists solely so an already-connected provider keeps working mid-migration.
38
38
 
39
- ## Quick Start
39
+ ## Quick Start: the safe setup (0.9.0)
40
+
41
+ One configuration turns on every check: the signed PRE-GEN directory, your
42
+ pinned operator key, and a journal in your own database that lets exactly
43
+ one worker run each job.
44
+
45
+ ```typescript
46
+ import { Prampta, loadPregenDirectory } from "@prampta/sdk";
47
+
48
+ const pregenDirectory = await loadPregenDirectory({ previous: saved }); // PRE-GEN steward key built in
49
+ const pg = new Prampta({
50
+ baseUrl: "https://api2.prampta.com", providerId, licenseeId,
51
+ token: process.env.PRAMPTA_TOKEN, // server-side only
52
+ operatorPublicKeyHex: process.env.PRAMPTA_OPERATOR_PUBLIC_KEY, // obtained independently
53
+ pregenDirectory,
54
+ });
55
+
56
+ // The journal's claim must be ATOMIC across all your servers, e.g. in Postgres:
57
+ // INSERT INTO pg_jobs (id, entry) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING id
58
+ // -- no row returned: SELECT entry FROM pg_jobs WHERE id = $1 and return it
59
+ const journal = {
60
+ claim: async (entry) => (await db.insertIfAbsent(entry.generationId, entry)) ? null : db.get(entry.generationId),
61
+ put: async (entry) => db.update(entry.generationId, entry),
62
+ };
63
+
64
+ const result = await pg.generateAuthorized(
65
+ { subjectIds: [subjectCode], generationId: jobId, promptHash, model, modality: "image",
66
+ intendedUse: { useCase: "commercial" } },
67
+ async (decisions) => {
68
+ const image = await render(prompt); // apply every decision's obligations
69
+ return { output: image, outputBytes: image.bytes };
70
+ },
71
+ { journal },
72
+ );
73
+ ```
74
+
75
+ `verify`, `assertAllowed` and `assertLicensed` below are lower-level calls:
76
+ they check one decision and leave the job (claiming, retries, receipts) to
77
+ you. Without `pregenDirectory` they check the signature against your pinned
78
+ key, but not the PRE-GEN directory (namespace, revocation).
79
+
80
+ ### Lower-level calls
40
81
 
41
82
  ```typescript
42
83
  import { Prampta } from "@prampta/sdk";
@@ -95,38 +136,30 @@ const decision = await pg.assertLicensed(subjectCode, {
95
136
  // Apply the decision's obligations, generate, then submit a receipt.
96
137
  ```
97
138
 
98
- ## The whole cycle in one call (0.8.0)
99
-
100
- `generateAuthorized` does what every integration otherwise writes by hand:
101
-
102
- - asks fresh for **every subject in the output** (a person, a voice, a brand…)
103
- and generates only if **all** of them return a licence-backed `allow`;
104
- - never reuses a stored allow, and checks each is still live right before it
105
- starts, so a pause or revocation stops the next generation;
106
- - files one receipt per subject;
139
+ ## The whole cycle in one call (0.9.0)
140
+
141
+ `generateAuthorized` binds one `generationId` to one unchanging request:
142
+
143
+ - **one worker per job**: the journal's atomic `claim` lets exactly one
144
+ worker, on any of your servers, run a job; the others get an error;
145
+ - **same id, same request**: a different request under a used
146
+ `generationId` throws `PramptaJobConflictError`, while running or after;
147
+ - asks fresh for **every subject in the output** and generates only if
148
+ **all** of them return a licence-backed `allow`;
149
+ - re-checks every allow **immediately before** your `generate` runs;
150
+ - stores the output hash **before** sending receipts, and resends the
151
+ byte-identical receipt after a failure;
152
+ - a receipt counts as filed only when the registry accepted exactly it:
153
+ `unreported` lists receipts to resend (call again with the same id and
154
+ request), `conflicts` lists decisions for which the registry holds a
155
+ different receipt;
107
156
  - gives the allows back (`releaseDecision`) when a subject refuses or your
108
- `generate` throws, so a failed job does not use up a licence limit;
109
- - with a `journal` (your own database), never generates twice after a crash:
110
- a repeat call resends missing receipts, or refuses a job that crashed
111
- midway instead of rerunning it.
112
-
113
- ```typescript
114
- const result = await pg.generateAuthorized(
115
- { subjectIds: [personCode, voiceCode], generationId: jobId, promptHash,
116
- modality: "video", model: modelName,
117
- intendedUse: { useCase: "commercial", rights: ["output_generation"] } },
118
- async (decisions) => {
119
- // Apply every decision's obligations here. Throw only if nothing was produced.
120
- const video = await render(prompt);
121
- return { output: video, outputBytes: video.bytes };
122
- },
123
- { journal: { get: (id) => db.jobs.get(id), put: (e) => db.jobs.put(e) } },
124
- );
125
- if (result.unreported.length) retryLater(jobId); // receipts are resent by calling again
126
- ```
157
+ `generate` throws; that job is then closed, use a new `generationId`;
158
+ - never reruns a job that may have started generating.
127
159
 
128
- It uses `assertLicensed` (pinned operator keys required). The journal needs
129
- `put` to resolve only after a durable write.
160
+ `singleProcessJournal()` is an in-memory journal for tests and a single
161
+ process; two servers each holding one can both run a job. 0.9.0 replaced the
162
+ journal's `get` with `claim` and made the journal required.
130
163
 
131
164
  ## Check answers against PRE-GEN (recommended)
132
165
 
@@ -180,9 +213,10 @@ Persisted `previousSnapshot` detects rollback and signed different-content
180
213
  snapshots at the same sequence. `minSequence` is also available for a sequence
181
214
  floor alone. Storage and concurrent update serialization belong to the host.
182
215
 
183
- The public steward key is still pending: do not obtain a trust anchor from
184
- the directory itself or invent a production key. An authenticated directory
185
- snapshot does not prove freshness, key non-revocation or rights-holder consent;
216
+ The PRE-GEN steward key is published (pregen.org, DNS) and built into
217
+ `@pregen/verify`; for that directory use `loadPregenDirectory()` above. With
218
+ your own root, never take the trust anchor from the directory itself. An
219
+ authenticated directory snapshot does not prove freshness or rights-holder consent;
186
220
  `minSequence` alone does not prevent stale first-boot snapshots. Full live
187
221
  federation needs the separately specified trust-management work.
188
222
 
package/dist/index.d.mts CHANGED
@@ -383,23 +383,37 @@ interface LicenseRequestResult {
383
383
  requestId: string;
384
384
  status: string;
385
385
  }
386
- /** One generation as `generateAuthorized` records it. Stored by the
387
- * provider, in its own database, so a crash never leads to generating twice. */
386
+ type AuthorizedRequest = Omit<VerifyRequest, "subjectId" | "generationId"> & {
387
+ subjectIds: string[];
388
+ generationId: string;
389
+ };
390
+ /** One job as `generateAuthorized` records it in the provider's own database. */
388
391
  interface GenerationJournalEntry {
389
392
  generationId: string;
390
- /** authorized: allows held, generation may have started.
391
- * generated: output exists, receipts may be missing.
392
- * reported: receipts filed. released: nothing produced, allows given back. */
393
- stage: "authorized" | "generated" | "reported" | "released";
393
+ /** SHA-256 of the request; the same generationId with another request is an error. */
394
+ requestHash: string;
395
+ /** claimed: one worker owns the job. authorized: allows held, generation may
396
+ * have started. generated: output exists, receipts may be missing.
397
+ * reported: receipts accepted. released: nothing produced, allows given back. */
398
+ stage: "claimed" | "authorized" | "generated" | "reported" | "released";
394
399
  decisions: SignedDecision[];
395
400
  outputHash?: string;
401
+ /** Kept so a resent receipt is byte-identical to the first. */
402
+ generatedAt?: number;
396
403
  }
397
404
  interface GenerationJournal {
398
- get(generationId: string): Promise<GenerationJournalEntry | null>;
399
- /** Resolve only after a durable write. */
405
+ /** ATOMIC across every worker: store `entry` if no entry exists for its
406
+ * generationId and return null; otherwise change nothing and return the
407
+ * existing entry. E.g. INSERT ... ON CONFLICT DO NOTHING, then SELECT.
408
+ * A read followed by a write is not enough. */
409
+ claim(entry: GenerationJournalEntry): Promise<GenerationJournalEntry | null>;
410
+ /** Replace the entry for its generationId. Resolve only after a durable write. */
400
411
  put(entry: GenerationJournalEntry): Promise<void>;
401
412
  }
402
- type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash">;
413
+ /** An in-memory journal. Correct for ONE process only: two servers each
414
+ * holding one can both run the same job. Use your shared database otherwise. */
415
+ declare function singleProcessJournal(): GenerationJournal;
416
+ type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash" | "generatedAt">;
403
417
  interface AuthorizedGeneration<T> {
404
418
  /** generated: ran now. recovered: output existed from an earlier run, only
405
419
  * receipts were sent. already_reported: nothing left to do. */
@@ -409,9 +423,12 @@ interface AuthorizedGeneration<T> {
409
423
  output?: T;
410
424
  decisions: SignedDecision[];
411
425
  outputHash: string;
412
- /** Receipts that could not be filed yet. Call again with the same
413
- * generationId (needs a journal) or submitReceipt() for each. */
426
+ /** Receipts not filed yet (network, server error). Call again with the same
427
+ * generationId and request to resend them. */
414
428
  unreported: SignedDecision[];
429
+ /** The registry already holds a DIFFERENT receipt for these decisions.
430
+ * Resending will not help; find out why. */
431
+ conflicts: SignedDecision[];
415
432
  }
416
433
  interface OutputMetadata {
417
434
  prampta_decision_id: string;
@@ -451,6 +468,11 @@ declare class PramptaRefusalError extends PramptaError {
451
468
  get decisionId(): string;
452
469
  get licenseId(): string | null;
453
470
  }
471
+ /** The same generationId was used for a different request. */
472
+ declare class PramptaJobConflictError extends PramptaError {
473
+ readonly generationId: string;
474
+ constructor(generationId: string);
475
+ }
454
476
  declare class PramptaFailClosedError extends PramptaError {
455
477
  readonly cause_: unknown;
456
478
  constructor(cause_: unknown);
@@ -593,36 +615,35 @@ declare class Prampta {
593
615
  metadata: OutputMetadata;
594
616
  }>;
595
617
  /**
596
- * The whole cycle in one call, for every subject in the output:
597
- * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
598
- * licence-backed allow from all of them, generate, file one receipt per
599
- * subject. If any subject refuses, or `generate` throws, the allows already
600
- * held are released so they do not count against usage limits.
618
+ * The whole cycle in one call, for every subject in the output (PRE-GEN P-9):
619
+ * ask fresh for each subject, require a licence-backed allow from all of
620
+ * them, generate, file one receipt per subject.
601
621
  *
602
- * `generate` must throw only when nothing was produced. With a `journal`,
603
- * calling again with the same generationId after a crash never generates
604
- * twice: it resends missing receipts, or refuses if the crash happened
605
- * mid-generation (then check your own records, releaseDecision() if nothing
606
- * was produced, and use a new generationId). Concurrent calls with the
607
- * same generationId in this process share one run.
622
+ * One `generationId` is bound to one unchanging request. The journal's
623
+ * atomic `claim` lets exactly one worker run a job, across every server
624
+ * that shares the journal; a different request under a used id is an
625
+ * error. Every allow is re-checked immediately before `generate` runs. The
626
+ * output hash is stored before receipts are sent, and a receipt counts as
627
+ * filed only when the registry accepted exactly it. A job that may have
628
+ * started generating is never run again: check your own records,
629
+ * releaseDecision() if nothing was produced, and use a new generationId.
630
+ * If a subject refuses or `generate` throws, the allows are given back.
631
+ * `generate` must throw only when nothing was produced.
608
632
  */
609
- generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
610
- subjectIds: string[];
611
- generationId: string;
612
- }, generate: (decisions: SignedDecision[]) => Promise<{
633
+ generateAuthorized<T>(request: AuthorizedRequest, generate: (decisions: SignedDecision[]) => Promise<{
613
634
  output: T;
614
635
  outputHash?: string;
615
636
  outputBytes?: Uint8Array | string;
616
- }>, options?: {
617
- journal?: GenerationJournal;
637
+ }>, options: {
638
+ journal: GenerationJournal;
618
639
  receipt?: ReceiptOptions;
619
640
  }): Promise<AuthorizedGeneration<T>>;
641
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
642
+ private requestHash;
620
643
  private runAuthorized;
621
- /** Returns the decisions whose receipt could not be filed. A receipt that
622
- * already exists (an earlier attempt landed) counts as filed. */
623
- private fileReceipts;
624
- /** True when every allow was given back. */
625
- private releaseAll;
644
+ private report;
645
+ /** Release every allow; the job is marked released only when all of them were given back. */
646
+ private giveBack;
626
647
  /** Give back an allow that produced nothing (failed or cancelled job), so
627
648
  * it does not count against the licence's usage limit. Only before a
628
649
  * receipt and within 24 hours; the release is recorded in the audit log. */
@@ -680,4 +701,4 @@ declare class Prampta {
680
701
  private getOnce;
681
702
  }
682
703
 
683
- export { type AuthorizedGeneration, type DeliveryResult, type GenerationJournal, type GenerationJournalEntry, HARD_REFUSAL_CODES, type LicenseRequestInput, type LicenseRequestResult, type LicenseSignatureInput, type Observation, type ObservationOutbox, type OutputMetadata, Prampta, PramptaApiError, type PramptaConfig, PramptaError, PramptaFailClosedError, PramptaNetworkError, PramptaRefusalError, PramptaReporter, PramptaSchemaError, PramptaSignatureError, PramptaTimeoutError, REFUSAL_DESCRIPTIONS, type ReceiptOptions, RegistryDirectoryError, type RegistryDirectoryOptions, type ReportInput, SOFT_REFUSAL_CODES, type SignedDecision, type SubjectIndexEntry, type SubjectInfo, TrustedRegistryDirectory, type VerifyOptions, type VerifyRequest, canonicalJson, hashPrompt, matchSubjects, normalizeForMatch, verifyLicenseSignatures, verifyMerkleProof };
704
+ export { type AuthorizedGeneration, type AuthorizedRequest, type DeliveryResult, type GenerationJournal, type GenerationJournalEntry, HARD_REFUSAL_CODES, type LicenseRequestInput, type LicenseRequestResult, type LicenseSignatureInput, type Observation, type ObservationOutbox, type OutputMetadata, Prampta, PramptaApiError, type PramptaConfig, PramptaError, PramptaFailClosedError, PramptaJobConflictError, PramptaNetworkError, PramptaRefusalError, PramptaReporter, PramptaSchemaError, PramptaSignatureError, PramptaTimeoutError, REFUSAL_DESCRIPTIONS, type ReceiptOptions, RegistryDirectoryError, type RegistryDirectoryOptions, type ReportInput, SOFT_REFUSAL_CODES, type SignedDecision, type SubjectIndexEntry, type SubjectInfo, TrustedRegistryDirectory, type VerifyOptions, type VerifyRequest, canonicalJson, hashPrompt, matchSubjects, normalizeForMatch, singleProcessJournal, verifyLicenseSignatures, verifyMerkleProof };
package/dist/index.d.ts CHANGED
@@ -383,23 +383,37 @@ interface LicenseRequestResult {
383
383
  requestId: string;
384
384
  status: string;
385
385
  }
386
- /** One generation as `generateAuthorized` records it. Stored by the
387
- * provider, in its own database, so a crash never leads to generating twice. */
386
+ type AuthorizedRequest = Omit<VerifyRequest, "subjectId" | "generationId"> & {
387
+ subjectIds: string[];
388
+ generationId: string;
389
+ };
390
+ /** One job as `generateAuthorized` records it in the provider's own database. */
388
391
  interface GenerationJournalEntry {
389
392
  generationId: string;
390
- /** authorized: allows held, generation may have started.
391
- * generated: output exists, receipts may be missing.
392
- * reported: receipts filed. released: nothing produced, allows given back. */
393
- stage: "authorized" | "generated" | "reported" | "released";
393
+ /** SHA-256 of the request; the same generationId with another request is an error. */
394
+ requestHash: string;
395
+ /** claimed: one worker owns the job. authorized: allows held, generation may
396
+ * have started. generated: output exists, receipts may be missing.
397
+ * reported: receipts accepted. released: nothing produced, allows given back. */
398
+ stage: "claimed" | "authorized" | "generated" | "reported" | "released";
394
399
  decisions: SignedDecision[];
395
400
  outputHash?: string;
401
+ /** Kept so a resent receipt is byte-identical to the first. */
402
+ generatedAt?: number;
396
403
  }
397
404
  interface GenerationJournal {
398
- get(generationId: string): Promise<GenerationJournalEntry | null>;
399
- /** Resolve only after a durable write. */
405
+ /** ATOMIC across every worker: store `entry` if no entry exists for its
406
+ * generationId and return null; otherwise change nothing and return the
407
+ * existing entry. E.g. INSERT ... ON CONFLICT DO NOTHING, then SELECT.
408
+ * A read followed by a write is not enough. */
409
+ claim(entry: GenerationJournalEntry): Promise<GenerationJournalEntry | null>;
410
+ /** Replace the entry for its generationId. Resolve only after a durable write. */
400
411
  put(entry: GenerationJournalEntry): Promise<void>;
401
412
  }
402
- type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash">;
413
+ /** An in-memory journal. Correct for ONE process only: two servers each
414
+ * holding one can both run the same job. Use your shared database otherwise. */
415
+ declare function singleProcessJournal(): GenerationJournal;
416
+ type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash" | "generatedAt">;
403
417
  interface AuthorizedGeneration<T> {
404
418
  /** generated: ran now. recovered: output existed from an earlier run, only
405
419
  * receipts were sent. already_reported: nothing left to do. */
@@ -409,9 +423,12 @@ interface AuthorizedGeneration<T> {
409
423
  output?: T;
410
424
  decisions: SignedDecision[];
411
425
  outputHash: string;
412
- /** Receipts that could not be filed yet. Call again with the same
413
- * generationId (needs a journal) or submitReceipt() for each. */
426
+ /** Receipts not filed yet (network, server error). Call again with the same
427
+ * generationId and request to resend them. */
414
428
  unreported: SignedDecision[];
429
+ /** The registry already holds a DIFFERENT receipt for these decisions.
430
+ * Resending will not help; find out why. */
431
+ conflicts: SignedDecision[];
415
432
  }
416
433
  interface OutputMetadata {
417
434
  prampta_decision_id: string;
@@ -451,6 +468,11 @@ declare class PramptaRefusalError extends PramptaError {
451
468
  get decisionId(): string;
452
469
  get licenseId(): string | null;
453
470
  }
471
+ /** The same generationId was used for a different request. */
472
+ declare class PramptaJobConflictError extends PramptaError {
473
+ readonly generationId: string;
474
+ constructor(generationId: string);
475
+ }
454
476
  declare class PramptaFailClosedError extends PramptaError {
455
477
  readonly cause_: unknown;
456
478
  constructor(cause_: unknown);
@@ -593,36 +615,35 @@ declare class Prampta {
593
615
  metadata: OutputMetadata;
594
616
  }>;
595
617
  /**
596
- * The whole cycle in one call, for every subject in the output:
597
- * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
598
- * licence-backed allow from all of them, generate, file one receipt per
599
- * subject. If any subject refuses, or `generate` throws, the allows already
600
- * held are released so they do not count against usage limits.
618
+ * The whole cycle in one call, for every subject in the output (PRE-GEN P-9):
619
+ * ask fresh for each subject, require a licence-backed allow from all of
620
+ * them, generate, file one receipt per subject.
601
621
  *
602
- * `generate` must throw only when nothing was produced. With a `journal`,
603
- * calling again with the same generationId after a crash never generates
604
- * twice: it resends missing receipts, or refuses if the crash happened
605
- * mid-generation (then check your own records, releaseDecision() if nothing
606
- * was produced, and use a new generationId). Concurrent calls with the
607
- * same generationId in this process share one run.
622
+ * One `generationId` is bound to one unchanging request. The journal's
623
+ * atomic `claim` lets exactly one worker run a job, across every server
624
+ * that shares the journal; a different request under a used id is an
625
+ * error. Every allow is re-checked immediately before `generate` runs. The
626
+ * output hash is stored before receipts are sent, and a receipt counts as
627
+ * filed only when the registry accepted exactly it. A job that may have
628
+ * started generating is never run again: check your own records,
629
+ * releaseDecision() if nothing was produced, and use a new generationId.
630
+ * If a subject refuses or `generate` throws, the allows are given back.
631
+ * `generate` must throw only when nothing was produced.
608
632
  */
609
- generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
610
- subjectIds: string[];
611
- generationId: string;
612
- }, generate: (decisions: SignedDecision[]) => Promise<{
633
+ generateAuthorized<T>(request: AuthorizedRequest, generate: (decisions: SignedDecision[]) => Promise<{
613
634
  output: T;
614
635
  outputHash?: string;
615
636
  outputBytes?: Uint8Array | string;
616
- }>, options?: {
617
- journal?: GenerationJournal;
637
+ }>, options: {
638
+ journal: GenerationJournal;
618
639
  receipt?: ReceiptOptions;
619
640
  }): Promise<AuthorizedGeneration<T>>;
641
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
642
+ private requestHash;
620
643
  private runAuthorized;
621
- /** Returns the decisions whose receipt could not be filed. A receipt that
622
- * already exists (an earlier attempt landed) counts as filed. */
623
- private fileReceipts;
624
- /** True when every allow was given back. */
625
- private releaseAll;
644
+ private report;
645
+ /** Release every allow; the job is marked released only when all of them were given back. */
646
+ private giveBack;
626
647
  /** Give back an allow that produced nothing (failed or cancelled job), so
627
648
  * it does not count against the licence's usage limit. Only before a
628
649
  * receipt and within 24 hours; the release is recorded in the audit log. */
@@ -680,4 +701,4 @@ declare class Prampta {
680
701
  private getOnce;
681
702
  }
682
703
 
683
- export { type AuthorizedGeneration, type DeliveryResult, type GenerationJournal, type GenerationJournalEntry, HARD_REFUSAL_CODES, type LicenseRequestInput, type LicenseRequestResult, type LicenseSignatureInput, type Observation, type ObservationOutbox, type OutputMetadata, Prampta, PramptaApiError, type PramptaConfig, PramptaError, PramptaFailClosedError, PramptaNetworkError, PramptaRefusalError, PramptaReporter, PramptaSchemaError, PramptaSignatureError, PramptaTimeoutError, REFUSAL_DESCRIPTIONS, type ReceiptOptions, RegistryDirectoryError, type RegistryDirectoryOptions, type ReportInput, SOFT_REFUSAL_CODES, type SignedDecision, type SubjectIndexEntry, type SubjectInfo, TrustedRegistryDirectory, type VerifyOptions, type VerifyRequest, canonicalJson, hashPrompt, matchSubjects, normalizeForMatch, verifyLicenseSignatures, verifyMerkleProof };
704
+ export { type AuthorizedGeneration, type AuthorizedRequest, type DeliveryResult, type GenerationJournal, type GenerationJournalEntry, HARD_REFUSAL_CODES, type LicenseRequestInput, type LicenseRequestResult, type LicenseSignatureInput, type Observation, type ObservationOutbox, type OutputMetadata, Prampta, PramptaApiError, type PramptaConfig, PramptaError, PramptaFailClosedError, PramptaJobConflictError, PramptaNetworkError, PramptaRefusalError, PramptaReporter, PramptaSchemaError, PramptaSignatureError, PramptaTimeoutError, REFUSAL_DESCRIPTIONS, type ReceiptOptions, RegistryDirectoryError, type RegistryDirectoryOptions, type ReportInput, SOFT_REFUSAL_CODES, type SignedDecision, type SubjectIndexEntry, type SubjectInfo, TrustedRegistryDirectory, type VerifyOptions, type VerifyRequest, canonicalJson, hashPrompt, matchSubjects, normalizeForMatch, singleProcessJournal, verifyLicenseSignatures, verifyMerkleProof };
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  PramptaApiError: () => PramptaApiError,
36
36
  PramptaError: () => PramptaError,
37
37
  PramptaFailClosedError: () => PramptaFailClosedError,
38
+ PramptaJobConflictError: () => PramptaJobConflictError,
38
39
  PramptaNetworkError: () => PramptaNetworkError,
39
40
  PramptaRefusalError: () => PramptaRefusalError,
40
41
  PramptaReporter: () => PramptaReporter,
@@ -50,6 +51,7 @@ __export(index_exports, {
50
51
  loadPregenDirectory: () => import_verify2.loadDirectory,
51
52
  matchSubjects: () => matchSubjects,
52
53
  normalizeForMatch: () => normalizeForMatch,
54
+ singleProcessJournal: () => singleProcessJournal,
53
55
  verifyLicenseSignatures: () => verifyLicenseSignatures,
54
56
  verifyMerkleProof: () => verifyMerkleProof
55
57
  });
@@ -409,6 +411,20 @@ var PramptaReporter = class {
409
411
  };
410
412
 
411
413
  // src/index.ts
414
+ function singleProcessJournal() {
415
+ const entries = /* @__PURE__ */ new Map();
416
+ return {
417
+ async claim(entry) {
418
+ const found = entries.get(entry.generationId);
419
+ if (found) return structuredClone(found);
420
+ entries.set(entry.generationId, structuredClone(entry));
421
+ return null;
422
+ },
423
+ async put(entry) {
424
+ entries.set(entry.generationId, structuredClone(entry));
425
+ }
426
+ };
427
+ }
412
428
  var PramptaError = class extends Error {
413
429
  constructor(message) {
414
430
  super(message);
@@ -474,6 +490,14 @@ var PramptaRefusalError = class extends PramptaError {
474
490
  return this.decision.licenseId;
475
491
  }
476
492
  };
493
+ var PramptaJobConflictError = class extends PramptaError {
494
+ constructor(generationId) {
495
+ super(`generationId ${generationId} was already used for a different request`);
496
+ this.generationId = generationId;
497
+ this.name = "PramptaJobConflictError";
498
+ }
499
+ generationId;
500
+ };
477
501
  var PramptaFailClosedError = class extends PramptaError {
478
502
  constructor(cause_) {
479
503
  super(`PRAMPTA fail-closed: generation blocked because verification failed (${cause_})`);
@@ -609,6 +633,42 @@ function matchSubjects(text, entries) {
609
633
  }
610
634
  return hits;
611
635
  }
636
+ var UNDERSTOOD_DECISION_MEMBERS = /* @__PURE__ */ new Set([
637
+ "schema_version",
638
+ "decision_id",
639
+ "nonce",
640
+ "disposition",
641
+ "allowed",
642
+ "reason",
643
+ "policy_version",
644
+ "subject_id",
645
+ "licensee_id",
646
+ "provider_id",
647
+ "license_id",
648
+ "prompt_hash",
649
+ "model",
650
+ "modality",
651
+ "intended_use",
652
+ "obligations",
653
+ "generation_id",
654
+ "issued_at",
655
+ "expires_at",
656
+ "critical",
657
+ "operator_key_id",
658
+ "operator_signature",
659
+ "subject_authority",
660
+ "rules_text",
661
+ "rules_text_hash",
662
+ "watermark_payload",
663
+ "is_hard_refusal",
664
+ "revocation_epoch",
665
+ "max_cache_age_seconds",
666
+ "cache_scope",
667
+ "provider_user_binding",
668
+ "provider_identity_link_id",
669
+ "detection_id",
670
+ "remediation"
671
+ ]);
612
672
  var Prampta = class _Prampta {
613
673
  baseUrl;
614
674
  providerId;
@@ -777,6 +837,11 @@ var Prampta = class _Prampta {
777
837
  */
778
838
  async verifyDecision(raw, decision) {
779
839
  if (typeof raw.allowed !== "boolean") throw new PramptaSchemaError("allowed must be a boolean");
840
+ if (raw.critical !== void 0) {
841
+ if (!Array.isArray(raw.critical)) throw new PramptaSchemaError("critical must be an array");
842
+ const unknown = raw.critical.filter((m) => typeof m !== "string" || !UNDERSTOOD_DECISION_MEMBERS.has(m));
843
+ if (unknown.length) throw new PramptaSchemaError(`Decision lists critical members this SDK does not understand: ${unknown.join(", ")}`);
844
+ }
780
845
  const disposition = raw.disposition ?? (raw.allowed ? "allow" : "deny");
781
846
  if (!["allow", "deny", "review", "not_blocked"].includes(disposition) || raw.allowed !== (disposition === "allow")) {
782
847
  throw new PramptaSchemaError("allowed and disposition are inconsistent or unsupported");
@@ -827,7 +892,7 @@ var Prampta = class _Prampta {
827
892
  );
828
893
  }
829
894
  const now = Math.floor(Date.now() / 1e3);
830
- if (!Number.isSafeInteger(raw.expires_at) || raw.expires_at <= 0 || raw.issued_at !== void 0 && (!Number.isSafeInteger(raw.issued_at) || raw.issued_at <= 0 || raw.issued_at >= raw.expires_at || raw.issued_at > now + 60)) {
895
+ if (!Number.isSafeInteger(raw.expires_at) || !Number.isSafeInteger(raw.issued_at) || raw.issued_at <= 0 || raw.expires_at <= raw.issued_at || raw.expires_at - raw.issued_at > 900 || raw.issued_at > now + 60) {
831
896
  throw new PramptaSchemaError("Decision has invalid validity timestamps");
832
897
  }
833
898
  if (decision.expiresAt <= now) {
@@ -1062,86 +1127,101 @@ var Prampta = class _Prampta {
1062
1127
  return { output, decision, metadata };
1063
1128
  }
1064
1129
  /**
1065
- * The whole cycle in one call, for every subject in the output:
1066
- * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
1067
- * licence-backed allow from all of them, generate, file one receipt per
1068
- * subject. If any subject refuses, or `generate` throws, the allows already
1069
- * held are released so they do not count against usage limits.
1130
+ * The whole cycle in one call, for every subject in the output (PRE-GEN P-9):
1131
+ * ask fresh for each subject, require a licence-backed allow from all of
1132
+ * them, generate, file one receipt per subject.
1070
1133
  *
1071
- * `generate` must throw only when nothing was produced. With a `journal`,
1072
- * calling again with the same generationId after a crash never generates
1073
- * twice: it resends missing receipts, or refuses if the crash happened
1074
- * mid-generation (then check your own records, releaseDecision() if nothing
1075
- * was produced, and use a new generationId). Concurrent calls with the
1076
- * same generationId in this process share one run.
1134
+ * One `generationId` is bound to one unchanging request. The journal's
1135
+ * atomic `claim` lets exactly one worker run a job, across every server
1136
+ * that shares the journal; a different request under a used id is an
1137
+ * error. Every allow is re-checked immediately before `generate` runs. The
1138
+ * output hash is stored before receipts are sent, and a receipt counts as
1139
+ * filed only when the registry accepted exactly it. A job that may have
1140
+ * started generating is never run again: check your own records,
1141
+ * releaseDecision() if nothing was produced, and use a new generationId.
1142
+ * If a subject refuses or `generate` throws, the allows are given back.
1143
+ * `generate` must throw only when nothing was produced.
1077
1144
  */
1078
- async generateAuthorized(request, generate, options = {}) {
1145
+ async generateAuthorized(request, generate, options) {
1079
1146
  const id = request.generationId;
1080
1147
  if (!id) throw new PramptaSchemaError("generateAuthorized requires a generationId");
1148
+ if (!options?.journal) throw new PramptaSchemaError("generateAuthorized requires a journal (singleProcessJournal() for one process)");
1149
+ const requestHash = await this.requestHash(request);
1081
1150
  const running = this.inFlight.get(id);
1082
- if (running) return running;
1083
- const run = this.runAuthorized(request, generate, options).finally(() => this.inFlight.delete(id));
1084
- this.inFlight.set(id, run);
1151
+ if (running) {
1152
+ if (running.requestHash !== requestHash) throw new PramptaJobConflictError(id);
1153
+ return running.run;
1154
+ }
1155
+ const run = this.runAuthorized(request, requestHash, generate, options).finally(() => this.inFlight.delete(id));
1156
+ this.inFlight.set(id, { requestHash, run });
1085
1157
  return run;
1086
1158
  }
1087
- async runAuthorized(request, generate, { journal, receipt }) {
1159
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
1160
+ async requestHash(request) {
1161
+ const { generationId, subjectIds, prompt, promptHash, idempotencyKey, returnUrl, detectionId, ...rest } = request;
1162
+ return sha2562(canonicalJson(JSON.parse(JSON.stringify({
1163
+ ...rest,
1164
+ generationId,
1165
+ subjectIds: [...new Set(subjectIds)].sort(),
1166
+ promptHash: promptHash ?? (prompt !== void 0 ? await hashPrompt(prompt) : ""),
1167
+ providerId: this.providerId,
1168
+ licenseeId: this.licenseeId
1169
+ }))));
1170
+ }
1171
+ async runAuthorized(request, requestHash, generate, { journal, receipt }) {
1088
1172
  const { subjectIds, generationId, ...rest } = request;
1089
1173
  const subjects = [...new Set(subjectIds)];
1090
1174
  if (subjects.length === 0) throw new PramptaSchemaError("generateAuthorized requires at least one subjectId");
1091
- const saved = await journal?.get(generationId);
1092
- if (saved?.stage === "authorized") {
1093
- throw new PramptaError(`Generation ${generationId} was interrupted after it was authorized; it is not run again. If nothing was produced, releaseDecision() each decision and use a new generationId.`);
1094
- }
1095
- if (saved?.stage === "reported") {
1096
- return { status: "already_reported", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: [] };
1097
- }
1098
- if (saved?.stage === "generated") {
1099
- const unreported2 = await this.fileReceipts(saved.decisions, saved.outputHash ?? "", receipt);
1100
- if (unreported2.length === 0) await journal.put({ ...saved, stage: "reported" });
1101
- return { status: "recovered", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: unreported2 };
1175
+ const existing = await journal.claim({ generationId, requestHash, stage: "claimed", decisions: [] });
1176
+ if (existing) {
1177
+ if (existing.requestHash !== requestHash) throw new PramptaJobConflictError(generationId);
1178
+ if (existing.stage === "reported") {
1179
+ return { status: "already_reported", decisions: existing.decisions, outputHash: existing.outputHash ?? "", unreported: [], conflicts: [] };
1180
+ }
1181
+ if (existing.stage === "generated") return this.report(journal, existing, "recovered", void 0, receipt);
1182
+ throw new PramptaError(existing.stage === "released" ? `Generation ${generationId} produced nothing and its allows were given back; use a new generationId` : `Generation ${generationId} is running elsewhere or was interrupted after it was claimed; it is not run again. If nothing was produced, releaseDecision() each decision and use a new generationId.`);
1102
1183
  }
1103
1184
  const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1104
1185
  const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1105
1186
  const failed = settled.find((r) => r.status === "rejected");
1106
1187
  if (failed) {
1107
- await this.releaseAll(decisions, "another subject in the same output was refused");
1188
+ await this.giveBack(journal, { generationId, requestHash, stage: "claimed", decisions }, "another subject in the same output was refused");
1108
1189
  throw failed.reason;
1109
1190
  }
1191
+ const entry = { generationId, requestHash, stage: "authorized", decisions };
1192
+ await journal.put(entry);
1110
1193
  if (decisions.some((d) => d.expiresAt <= Math.floor(Date.now() / 1e3))) {
1111
- await this.releaseAll(decisions, "expired before generation started");
1112
- throw new PramptaError("An authorization expired before generation started; ask again");
1194
+ await this.giveBack(journal, entry, "expired before generation started");
1195
+ throw new PramptaError("An authorization expired before generation started; ask again with a new generationId");
1113
1196
  }
1114
- await journal?.put({ generationId, stage: "authorized", decisions });
1115
1197
  let result;
1116
1198
  try {
1117
1199
  result = await generate(decisions);
1118
1200
  } catch (e) {
1119
- if (await this.releaseAll(decisions, "generation failed")) {
1120
- await journal?.put({ generationId, stage: "released", decisions });
1121
- }
1201
+ await this.giveBack(journal, entry, "generation failed");
1122
1202
  throw e;
1123
1203
  }
1124
1204
  const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1125
1205
  const outputHash = result.outputHash ?? (bytes ? await sha256Bytes(bytes) : "");
1126
- await journal?.put({ generationId, stage: "generated", decisions, outputHash });
1127
- const unreported = await this.fileReceipts(decisions, outputHash, receipt);
1128
- if (unreported.length === 0) await journal?.put({ generationId, stage: "reported", decisions, outputHash });
1129
- return { status: "generated", output: result.output, decisions, outputHash, unreported };
1130
- }
1131
- /** Returns the decisions whose receipt could not be filed. A receipt that
1132
- * already exists (an earlier attempt landed) counts as filed. */
1133
- async fileReceipts(decisions, outputHash, receipt) {
1134
- const results = await Promise.allSettled(decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash })));
1135
- return decisions.filter((_, i) => {
1136
- const r = results[i];
1137
- if (r.status === "fulfilled") return false;
1138
- return !(r.reason instanceof PramptaApiError && r.reason.status === 409 && /already exists/.test(r.reason.detail));
1206
+ const generated = { ...entry, stage: "generated", outputHash, generatedAt: Math.floor(Date.now() / 1e3) };
1207
+ await journal.put(generated);
1208
+ return this.report(journal, generated, "generated", result.output, receipt);
1209
+ }
1210
+ async report(journal, entry, status, output, receipt) {
1211
+ const results = await Promise.allSettled(entry.decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash: entry.outputHash ?? "", generatedAt: entry.generatedAt })));
1212
+ const unreported = [], conflicts = [];
1213
+ results.forEach((r, i) => {
1214
+ if (r.status === "fulfilled") return;
1215
+ (r.reason instanceof PramptaApiError && r.reason.status === 409 ? conflicts : unreported).push(entry.decisions[i]);
1139
1216
  });
1217
+ if (!unreported.length && !conflicts.length) await journal.put({ ...entry, stage: "reported" });
1218
+ return { status, output, decisions: entry.decisions, outputHash: entry.outputHash ?? "", unreported, conflicts };
1140
1219
  }
1141
- /** True when every allow was given back. */
1142
- async releaseAll(decisions, reason) {
1143
- const results = await Promise.allSettled(decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1144
- return results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1220
+ /** Release every allow; the job is marked released only when all of them were given back. */
1221
+ async giveBack(journal, entry, reason) {
1222
+ const results = await Promise.allSettled(entry.decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1223
+ const all = results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1224
+ if (all) await journal.put({ ...entry, stage: "released" });
1145
1225
  }
1146
1226
  /** Give back an allow that produced nothing (failed or cancelled job), so
1147
1227
  * it does not count against the licence's usage limit. Only before a
@@ -1416,6 +1496,7 @@ function sameEndpoint(a, b) {
1416
1496
  PramptaApiError,
1417
1497
  PramptaError,
1418
1498
  PramptaFailClosedError,
1499
+ PramptaJobConflictError,
1419
1500
  PramptaNetworkError,
1420
1501
  PramptaRefusalError,
1421
1502
  PramptaReporter,
@@ -1431,6 +1512,7 @@ function sameEndpoint(a, b) {
1431
1512
  loadPregenDirectory,
1432
1513
  matchSubjects,
1433
1514
  normalizeForMatch,
1515
+ singleProcessJournal,
1434
1516
  verifyLicenseSignatures,
1435
1517
  verifyMerkleProof
1436
1518
  });
package/dist/index.mjs CHANGED
@@ -354,6 +354,20 @@ var PramptaReporter = class {
354
354
  };
355
355
 
356
356
  // src/index.ts
357
+ function singleProcessJournal() {
358
+ const entries = /* @__PURE__ */ new Map();
359
+ return {
360
+ async claim(entry) {
361
+ const found = entries.get(entry.generationId);
362
+ if (found) return structuredClone(found);
363
+ entries.set(entry.generationId, structuredClone(entry));
364
+ return null;
365
+ },
366
+ async put(entry) {
367
+ entries.set(entry.generationId, structuredClone(entry));
368
+ }
369
+ };
370
+ }
357
371
  var PramptaError = class extends Error {
358
372
  constructor(message) {
359
373
  super(message);
@@ -419,6 +433,14 @@ var PramptaRefusalError = class extends PramptaError {
419
433
  return this.decision.licenseId;
420
434
  }
421
435
  };
436
+ var PramptaJobConflictError = class extends PramptaError {
437
+ constructor(generationId) {
438
+ super(`generationId ${generationId} was already used for a different request`);
439
+ this.generationId = generationId;
440
+ this.name = "PramptaJobConflictError";
441
+ }
442
+ generationId;
443
+ };
422
444
  var PramptaFailClosedError = class extends PramptaError {
423
445
  constructor(cause_) {
424
446
  super(`PRAMPTA fail-closed: generation blocked because verification failed (${cause_})`);
@@ -554,6 +576,42 @@ function matchSubjects(text, entries) {
554
576
  }
555
577
  return hits;
556
578
  }
579
+ var UNDERSTOOD_DECISION_MEMBERS = /* @__PURE__ */ new Set([
580
+ "schema_version",
581
+ "decision_id",
582
+ "nonce",
583
+ "disposition",
584
+ "allowed",
585
+ "reason",
586
+ "policy_version",
587
+ "subject_id",
588
+ "licensee_id",
589
+ "provider_id",
590
+ "license_id",
591
+ "prompt_hash",
592
+ "model",
593
+ "modality",
594
+ "intended_use",
595
+ "obligations",
596
+ "generation_id",
597
+ "issued_at",
598
+ "expires_at",
599
+ "critical",
600
+ "operator_key_id",
601
+ "operator_signature",
602
+ "subject_authority",
603
+ "rules_text",
604
+ "rules_text_hash",
605
+ "watermark_payload",
606
+ "is_hard_refusal",
607
+ "revocation_epoch",
608
+ "max_cache_age_seconds",
609
+ "cache_scope",
610
+ "provider_user_binding",
611
+ "provider_identity_link_id",
612
+ "detection_id",
613
+ "remediation"
614
+ ]);
557
615
  var Prampta = class _Prampta {
558
616
  baseUrl;
559
617
  providerId;
@@ -722,6 +780,11 @@ var Prampta = class _Prampta {
722
780
  */
723
781
  async verifyDecision(raw, decision) {
724
782
  if (typeof raw.allowed !== "boolean") throw new PramptaSchemaError("allowed must be a boolean");
783
+ if (raw.critical !== void 0) {
784
+ if (!Array.isArray(raw.critical)) throw new PramptaSchemaError("critical must be an array");
785
+ const unknown = raw.critical.filter((m) => typeof m !== "string" || !UNDERSTOOD_DECISION_MEMBERS.has(m));
786
+ if (unknown.length) throw new PramptaSchemaError(`Decision lists critical members this SDK does not understand: ${unknown.join(", ")}`);
787
+ }
725
788
  const disposition = raw.disposition ?? (raw.allowed ? "allow" : "deny");
726
789
  if (!["allow", "deny", "review", "not_blocked"].includes(disposition) || raw.allowed !== (disposition === "allow")) {
727
790
  throw new PramptaSchemaError("allowed and disposition are inconsistent or unsupported");
@@ -772,7 +835,7 @@ var Prampta = class _Prampta {
772
835
  );
773
836
  }
774
837
  const now = Math.floor(Date.now() / 1e3);
775
- if (!Number.isSafeInteger(raw.expires_at) || raw.expires_at <= 0 || raw.issued_at !== void 0 && (!Number.isSafeInteger(raw.issued_at) || raw.issued_at <= 0 || raw.issued_at >= raw.expires_at || raw.issued_at > now + 60)) {
838
+ if (!Number.isSafeInteger(raw.expires_at) || !Number.isSafeInteger(raw.issued_at) || raw.issued_at <= 0 || raw.expires_at <= raw.issued_at || raw.expires_at - raw.issued_at > 900 || raw.issued_at > now + 60) {
776
839
  throw new PramptaSchemaError("Decision has invalid validity timestamps");
777
840
  }
778
841
  if (decision.expiresAt <= now) {
@@ -1007,86 +1070,101 @@ var Prampta = class _Prampta {
1007
1070
  return { output, decision, metadata };
1008
1071
  }
1009
1072
  /**
1010
- * The whole cycle in one call, for every subject in the output:
1011
- * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
1012
- * licence-backed allow from all of them, generate, file one receipt per
1013
- * subject. If any subject refuses, or `generate` throws, the allows already
1014
- * held are released so they do not count against usage limits.
1073
+ * The whole cycle in one call, for every subject in the output (PRE-GEN P-9):
1074
+ * ask fresh for each subject, require a licence-backed allow from all of
1075
+ * them, generate, file one receipt per subject.
1015
1076
  *
1016
- * `generate` must throw only when nothing was produced. With a `journal`,
1017
- * calling again with the same generationId after a crash never generates
1018
- * twice: it resends missing receipts, or refuses if the crash happened
1019
- * mid-generation (then check your own records, releaseDecision() if nothing
1020
- * was produced, and use a new generationId). Concurrent calls with the
1021
- * same generationId in this process share one run.
1077
+ * One `generationId` is bound to one unchanging request. The journal's
1078
+ * atomic `claim` lets exactly one worker run a job, across every server
1079
+ * that shares the journal; a different request under a used id is an
1080
+ * error. Every allow is re-checked immediately before `generate` runs. The
1081
+ * output hash is stored before receipts are sent, and a receipt counts as
1082
+ * filed only when the registry accepted exactly it. A job that may have
1083
+ * started generating is never run again: check your own records,
1084
+ * releaseDecision() if nothing was produced, and use a new generationId.
1085
+ * If a subject refuses or `generate` throws, the allows are given back.
1086
+ * `generate` must throw only when nothing was produced.
1022
1087
  */
1023
- async generateAuthorized(request, generate, options = {}) {
1088
+ async generateAuthorized(request, generate, options) {
1024
1089
  const id = request.generationId;
1025
1090
  if (!id) throw new PramptaSchemaError("generateAuthorized requires a generationId");
1091
+ if (!options?.journal) throw new PramptaSchemaError("generateAuthorized requires a journal (singleProcessJournal() for one process)");
1092
+ const requestHash = await this.requestHash(request);
1026
1093
  const running = this.inFlight.get(id);
1027
- if (running) return running;
1028
- const run = this.runAuthorized(request, generate, options).finally(() => this.inFlight.delete(id));
1029
- this.inFlight.set(id, run);
1094
+ if (running) {
1095
+ if (running.requestHash !== requestHash) throw new PramptaJobConflictError(id);
1096
+ return running.run;
1097
+ }
1098
+ const run = this.runAuthorized(request, requestHash, generate, options).finally(() => this.inFlight.delete(id));
1099
+ this.inFlight.set(id, { requestHash, run });
1030
1100
  return run;
1031
1101
  }
1032
- async runAuthorized(request, generate, { journal, receipt }) {
1102
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
1103
+ async requestHash(request) {
1104
+ const { generationId, subjectIds, prompt, promptHash, idempotencyKey, returnUrl, detectionId, ...rest } = request;
1105
+ return sha2562(canonicalJson(JSON.parse(JSON.stringify({
1106
+ ...rest,
1107
+ generationId,
1108
+ subjectIds: [...new Set(subjectIds)].sort(),
1109
+ promptHash: promptHash ?? (prompt !== void 0 ? await hashPrompt(prompt) : ""),
1110
+ providerId: this.providerId,
1111
+ licenseeId: this.licenseeId
1112
+ }))));
1113
+ }
1114
+ async runAuthorized(request, requestHash, generate, { journal, receipt }) {
1033
1115
  const { subjectIds, generationId, ...rest } = request;
1034
1116
  const subjects = [...new Set(subjectIds)];
1035
1117
  if (subjects.length === 0) throw new PramptaSchemaError("generateAuthorized requires at least one subjectId");
1036
- const saved = await journal?.get(generationId);
1037
- if (saved?.stage === "authorized") {
1038
- throw new PramptaError(`Generation ${generationId} was interrupted after it was authorized; it is not run again. If nothing was produced, releaseDecision() each decision and use a new generationId.`);
1039
- }
1040
- if (saved?.stage === "reported") {
1041
- return { status: "already_reported", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: [] };
1042
- }
1043
- if (saved?.stage === "generated") {
1044
- const unreported2 = await this.fileReceipts(saved.decisions, saved.outputHash ?? "", receipt);
1045
- if (unreported2.length === 0) await journal.put({ ...saved, stage: "reported" });
1046
- return { status: "recovered", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: unreported2 };
1118
+ const existing = await journal.claim({ generationId, requestHash, stage: "claimed", decisions: [] });
1119
+ if (existing) {
1120
+ if (existing.requestHash !== requestHash) throw new PramptaJobConflictError(generationId);
1121
+ if (existing.stage === "reported") {
1122
+ return { status: "already_reported", decisions: existing.decisions, outputHash: existing.outputHash ?? "", unreported: [], conflicts: [] };
1123
+ }
1124
+ if (existing.stage === "generated") return this.report(journal, existing, "recovered", void 0, receipt);
1125
+ throw new PramptaError(existing.stage === "released" ? `Generation ${generationId} produced nothing and its allows were given back; use a new generationId` : `Generation ${generationId} is running elsewhere or was interrupted after it was claimed; it is not run again. If nothing was produced, releaseDecision() each decision and use a new generationId.`);
1047
1126
  }
1048
1127
  const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1049
1128
  const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1050
1129
  const failed = settled.find((r) => r.status === "rejected");
1051
1130
  if (failed) {
1052
- await this.releaseAll(decisions, "another subject in the same output was refused");
1131
+ await this.giveBack(journal, { generationId, requestHash, stage: "claimed", decisions }, "another subject in the same output was refused");
1053
1132
  throw failed.reason;
1054
1133
  }
1134
+ const entry = { generationId, requestHash, stage: "authorized", decisions };
1135
+ await journal.put(entry);
1055
1136
  if (decisions.some((d) => d.expiresAt <= Math.floor(Date.now() / 1e3))) {
1056
- await this.releaseAll(decisions, "expired before generation started");
1057
- throw new PramptaError("An authorization expired before generation started; ask again");
1137
+ await this.giveBack(journal, entry, "expired before generation started");
1138
+ throw new PramptaError("An authorization expired before generation started; ask again with a new generationId");
1058
1139
  }
1059
- await journal?.put({ generationId, stage: "authorized", decisions });
1060
1140
  let result;
1061
1141
  try {
1062
1142
  result = await generate(decisions);
1063
1143
  } catch (e) {
1064
- if (await this.releaseAll(decisions, "generation failed")) {
1065
- await journal?.put({ generationId, stage: "released", decisions });
1066
- }
1144
+ await this.giveBack(journal, entry, "generation failed");
1067
1145
  throw e;
1068
1146
  }
1069
1147
  const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1070
1148
  const outputHash = result.outputHash ?? (bytes ? await sha256Bytes(bytes) : "");
1071
- await journal?.put({ generationId, stage: "generated", decisions, outputHash });
1072
- const unreported = await this.fileReceipts(decisions, outputHash, receipt);
1073
- if (unreported.length === 0) await journal?.put({ generationId, stage: "reported", decisions, outputHash });
1074
- return { status: "generated", output: result.output, decisions, outputHash, unreported };
1075
- }
1076
- /** Returns the decisions whose receipt could not be filed. A receipt that
1077
- * already exists (an earlier attempt landed) counts as filed. */
1078
- async fileReceipts(decisions, outputHash, receipt) {
1079
- const results = await Promise.allSettled(decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash })));
1080
- return decisions.filter((_, i) => {
1081
- const r = results[i];
1082
- if (r.status === "fulfilled") return false;
1083
- return !(r.reason instanceof PramptaApiError && r.reason.status === 409 && /already exists/.test(r.reason.detail));
1149
+ const generated = { ...entry, stage: "generated", outputHash, generatedAt: Math.floor(Date.now() / 1e3) };
1150
+ await journal.put(generated);
1151
+ return this.report(journal, generated, "generated", result.output, receipt);
1152
+ }
1153
+ async report(journal, entry, status, output, receipt) {
1154
+ const results = await Promise.allSettled(entry.decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash: entry.outputHash ?? "", generatedAt: entry.generatedAt })));
1155
+ const unreported = [], conflicts = [];
1156
+ results.forEach((r, i) => {
1157
+ if (r.status === "fulfilled") return;
1158
+ (r.reason instanceof PramptaApiError && r.reason.status === 409 ? conflicts : unreported).push(entry.decisions[i]);
1084
1159
  });
1160
+ if (!unreported.length && !conflicts.length) await journal.put({ ...entry, stage: "reported" });
1161
+ return { status, output, decisions: entry.decisions, outputHash: entry.outputHash ?? "", unreported, conflicts };
1085
1162
  }
1086
- /** True when every allow was given back. */
1087
- async releaseAll(decisions, reason) {
1088
- const results = await Promise.allSettled(decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1089
- return results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1163
+ /** Release every allow; the job is marked released only when all of them were given back. */
1164
+ async giveBack(journal, entry, reason) {
1165
+ const results = await Promise.allSettled(entry.decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1166
+ const all = results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1167
+ if (all) await journal.put({ ...entry, stage: "released" });
1090
1168
  }
1091
1169
  /** Give back an allow that produced nothing (failed or cancelled job), so
1092
1170
  * it does not count against the licence's usage limit. Only before a
@@ -1360,6 +1438,7 @@ export {
1360
1438
  PramptaApiError,
1361
1439
  PramptaError,
1362
1440
  PramptaFailClosedError,
1441
+ PramptaJobConflictError,
1363
1442
  PramptaNetworkError,
1364
1443
  PramptaRefusalError,
1365
1444
  PramptaReporter,
@@ -1375,6 +1454,7 @@ export {
1375
1454
  loadDirectory as loadPregenDirectory,
1376
1455
  matchSubjects,
1377
1456
  normalizeForMatch,
1457
+ singleProcessJournal,
1378
1458
  verifyLicenseSignatures,
1379
1459
  verifyMerkleProof
1380
1460
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prampta/sdk",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "PRAMPTA SDK — authorization before generation",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@noble/ed25519": "^3.1.0",
50
- "@pregen/verify": "^0.5.0"
50
+ "@pregen/verify": "^0.6.0"
51
51
  },
52
52
  "repository": {
53
53
  "type": "git",