@prampta/sdk 0.8.1 → 0.9.1

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,33 @@ 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
+ - takes its own copy of the request before doing anything, so changing your
151
+ object afterwards cannot change the job;
152
+ - stores the output hash and the **exact receipts** (signed, if you sign)
153
+ before sending them, and after a failure resends those receipts byte for
154
+ byte — receipt options passed on a later call do not replace them;
155
+ - a receipt counts as filed only when the registry accepted exactly it:
156
+ `unreported` lists receipts to resend (call again with the same id and
157
+ request), `conflicts` lists decisions for which the registry holds a
158
+ different receipt;
107
159
  - 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
- ```
160
+ `generate` throws; that job is then closed, use a new `generationId`;
161
+ - never reruns a job that may have started generating.
127
162
 
128
- It uses `assertLicensed` (pinned operator keys required). The journal needs
129
- `put` to resolve only after a durable write.
163
+ `singleProcessJournal()` is an in-memory journal for tests and a single
164
+ process; two servers each holding one can both run a job. 0.9.0 replaced the
165
+ journal's `get` with `claim` and made the journal required.
130
166
 
131
167
  ## Check answers against PRE-GEN (recommended)
132
168
 
@@ -180,9 +216,10 @@ Persisted `previousSnapshot` detects rollback and signed different-content
180
216
  snapshots at the same sequence. `minSequence` is also available for a sequence
181
217
  floor alone. Storage and concurrent update serialization belong to the host.
182
218
 
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;
219
+ The PRE-GEN steward key is published (pregen.org, DNS) and built into
220
+ `@pregen/verify`; for that directory use `loadPregenDirectory()` above. With
221
+ your own root, never take the trust anchor from the directory itself. An
222
+ authenticated directory snapshot does not prove freshness or rights-holder consent;
186
223
  `minSequence` alone does not prevent stale first-boot snapshots. Full live
187
224
  federation needs the separately specified trust-management work.
188
225
 
package/dist/index.d.mts CHANGED
@@ -383,23 +383,39 @@ 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
+ generatedAt?: number;
402
+ /** The exact receipt bodies (signed, if a key was given), stored before the
403
+ * first send and resent unchanged. No private key is stored. */
404
+ receipts?: Record<string, Record<string, unknown>>;
396
405
  }
397
406
  interface GenerationJournal {
398
- get(generationId: string): Promise<GenerationJournalEntry | null>;
399
- /** Resolve only after a durable write. */
407
+ /** ATOMIC across every worker: store `entry` if no entry exists for its
408
+ * generationId and return null; otherwise change nothing and return the
409
+ * existing entry. E.g. INSERT ... ON CONFLICT DO NOTHING, then SELECT.
410
+ * A read followed by a write is not enough. */
411
+ claim(entry: GenerationJournalEntry): Promise<GenerationJournalEntry | null>;
412
+ /** Replace the entry for its generationId. Resolve only after a durable write. */
400
413
  put(entry: GenerationJournalEntry): Promise<void>;
401
414
  }
402
- type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash">;
415
+ /** An in-memory journal. Correct for ONE process only: two servers each
416
+ * holding one can both run the same job. Use your shared database otherwise. */
417
+ declare function singleProcessJournal(): GenerationJournal;
418
+ type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash" | "generatedAt">;
403
419
  interface AuthorizedGeneration<T> {
404
420
  /** generated: ran now. recovered: output existed from an earlier run, only
405
421
  * receipts were sent. already_reported: nothing left to do. */
@@ -409,9 +425,12 @@ interface AuthorizedGeneration<T> {
409
425
  output?: T;
410
426
  decisions: SignedDecision[];
411
427
  outputHash: string;
412
- /** Receipts that could not be filed yet. Call again with the same
413
- * generationId (needs a journal) or submitReceipt() for each. */
428
+ /** Receipts not filed yet (network, server error). Call again with the same
429
+ * generationId and request to resend them. */
414
430
  unreported: SignedDecision[];
431
+ /** The registry already holds a DIFFERENT receipt for these decisions.
432
+ * Resending will not help; find out why. */
433
+ conflicts: SignedDecision[];
415
434
  }
416
435
  interface OutputMetadata {
417
436
  prampta_decision_id: string;
@@ -451,6 +470,11 @@ declare class PramptaRefusalError extends PramptaError {
451
470
  get decisionId(): string;
452
471
  get licenseId(): string | null;
453
472
  }
473
+ /** The same generationId was used for a different request. */
474
+ declare class PramptaJobConflictError extends PramptaError {
475
+ readonly generationId: string;
476
+ constructor(generationId: string);
477
+ }
454
478
  declare class PramptaFailClosedError extends PramptaError {
455
479
  readonly cause_: unknown;
456
480
  constructor(cause_: unknown);
@@ -593,36 +617,35 @@ declare class Prampta {
593
617
  metadata: OutputMetadata;
594
618
  }>;
595
619
  /**
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.
620
+ * The whole cycle in one call, for every subject in the output (PRE-GEN P-9):
621
+ * ask fresh for each subject, require a licence-backed allow from all of
622
+ * them, generate, file one receipt per subject.
601
623
  *
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.
624
+ * One `generationId` is bound to one unchanging request. The journal's
625
+ * atomic `claim` lets exactly one worker run a job, across every server
626
+ * that shares the journal; a different request under a used id is an
627
+ * error. Every allow is re-checked immediately before `generate` runs. The
628
+ * output hash is stored before receipts are sent, and a receipt counts as
629
+ * filed only when the registry accepted exactly it. A job that may have
630
+ * started generating is never run again: check your own records,
631
+ * releaseDecision() if nothing was produced, and use a new generationId.
632
+ * If a subject refuses or `generate` throws, the allows are given back.
633
+ * `generate` must throw only when nothing was produced.
608
634
  */
609
- generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
610
- subjectIds: string[];
611
- generationId: string;
612
- }, generate: (decisions: SignedDecision[]) => Promise<{
635
+ generateAuthorized<T>(request: AuthorizedRequest, generate: (decisions: SignedDecision[]) => Promise<{
613
636
  output: T;
614
637
  outputHash?: string;
615
638
  outputBytes?: Uint8Array | string;
616
- }>, options?: {
617
- journal?: GenerationJournal;
639
+ }>, options: {
640
+ journal: GenerationJournal;
618
641
  receipt?: ReceiptOptions;
619
642
  }): Promise<AuthorizedGeneration<T>>;
643
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
644
+ private requestHash;
620
645
  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;
646
+ private report;
647
+ /** Release every allow; the job is marked released only when all of them were given back. */
648
+ private giveBack;
626
649
  /** Give back an allow that produced nothing (failed or cancelled job), so
627
650
  * it does not count against the licence's usage limit. Only before a
628
651
  * receipt and within 24 hours; the release is recorded in the audit log. */
@@ -645,6 +668,8 @@ declare class Prampta {
645
668
  /** 32-byte Ed25519 private key, hex; keep server-side. Never sent to PRAMPTA. */
646
669
  providerSigningKeyHex?: string;
647
670
  }): Promise<Record<string, unknown>>;
671
+ /** The exact receipt body submitReceipt() would send, signed if a key is given. */
672
+ buildReceipt(decision: SignedDecision, result?: NonNullable<Parameters<Prampta["submitReceipt"]>[1]>): Promise<Record<string, unknown>>;
648
673
  /**
649
674
  * Get (issuing if needed) the signed `pg.assertion.v1` for a decision this
650
675
  * provider/licensee pair already submitted a receipt for — embed it in
@@ -680,4 +705,4 @@ declare class Prampta {
680
705
  private getOnce;
681
706
  }
682
707
 
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 };
708
+ 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,39 @@ 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
+ generatedAt?: number;
402
+ /** The exact receipt bodies (signed, if a key was given), stored before the
403
+ * first send and resent unchanged. No private key is stored. */
404
+ receipts?: Record<string, Record<string, unknown>>;
396
405
  }
397
406
  interface GenerationJournal {
398
- get(generationId: string): Promise<GenerationJournalEntry | null>;
399
- /** Resolve only after a durable write. */
407
+ /** ATOMIC across every worker: store `entry` if no entry exists for its
408
+ * generationId and return null; otherwise change nothing and return the
409
+ * existing entry. E.g. INSERT ... ON CONFLICT DO NOTHING, then SELECT.
410
+ * A read followed by a write is not enough. */
411
+ claim(entry: GenerationJournalEntry): Promise<GenerationJournalEntry | null>;
412
+ /** Replace the entry for its generationId. Resolve only after a durable write. */
400
413
  put(entry: GenerationJournalEntry): Promise<void>;
401
414
  }
402
- type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash">;
415
+ /** An in-memory journal. Correct for ONE process only: two servers each
416
+ * holding one can both run the same job. Use your shared database otherwise. */
417
+ declare function singleProcessJournal(): GenerationJournal;
418
+ type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash" | "generatedAt">;
403
419
  interface AuthorizedGeneration<T> {
404
420
  /** generated: ran now. recovered: output existed from an earlier run, only
405
421
  * receipts were sent. already_reported: nothing left to do. */
@@ -409,9 +425,12 @@ interface AuthorizedGeneration<T> {
409
425
  output?: T;
410
426
  decisions: SignedDecision[];
411
427
  outputHash: string;
412
- /** Receipts that could not be filed yet. Call again with the same
413
- * generationId (needs a journal) or submitReceipt() for each. */
428
+ /** Receipts not filed yet (network, server error). Call again with the same
429
+ * generationId and request to resend them. */
414
430
  unreported: SignedDecision[];
431
+ /** The registry already holds a DIFFERENT receipt for these decisions.
432
+ * Resending will not help; find out why. */
433
+ conflicts: SignedDecision[];
415
434
  }
416
435
  interface OutputMetadata {
417
436
  prampta_decision_id: string;
@@ -451,6 +470,11 @@ declare class PramptaRefusalError extends PramptaError {
451
470
  get decisionId(): string;
452
471
  get licenseId(): string | null;
453
472
  }
473
+ /** The same generationId was used for a different request. */
474
+ declare class PramptaJobConflictError extends PramptaError {
475
+ readonly generationId: string;
476
+ constructor(generationId: string);
477
+ }
454
478
  declare class PramptaFailClosedError extends PramptaError {
455
479
  readonly cause_: unknown;
456
480
  constructor(cause_: unknown);
@@ -593,36 +617,35 @@ declare class Prampta {
593
617
  metadata: OutputMetadata;
594
618
  }>;
595
619
  /**
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.
620
+ * The whole cycle in one call, for every subject in the output (PRE-GEN P-9):
621
+ * ask fresh for each subject, require a licence-backed allow from all of
622
+ * them, generate, file one receipt per subject.
601
623
  *
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.
624
+ * One `generationId` is bound to one unchanging request. The journal's
625
+ * atomic `claim` lets exactly one worker run a job, across every server
626
+ * that shares the journal; a different request under a used id is an
627
+ * error. Every allow is re-checked immediately before `generate` runs. The
628
+ * output hash is stored before receipts are sent, and a receipt counts as
629
+ * filed only when the registry accepted exactly it. A job that may have
630
+ * started generating is never run again: check your own records,
631
+ * releaseDecision() if nothing was produced, and use a new generationId.
632
+ * If a subject refuses or `generate` throws, the allows are given back.
633
+ * `generate` must throw only when nothing was produced.
608
634
  */
609
- generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
610
- subjectIds: string[];
611
- generationId: string;
612
- }, generate: (decisions: SignedDecision[]) => Promise<{
635
+ generateAuthorized<T>(request: AuthorizedRequest, generate: (decisions: SignedDecision[]) => Promise<{
613
636
  output: T;
614
637
  outputHash?: string;
615
638
  outputBytes?: Uint8Array | string;
616
- }>, options?: {
617
- journal?: GenerationJournal;
639
+ }>, options: {
640
+ journal: GenerationJournal;
618
641
  receipt?: ReceiptOptions;
619
642
  }): Promise<AuthorizedGeneration<T>>;
643
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
644
+ private requestHash;
620
645
  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;
646
+ private report;
647
+ /** Release every allow; the job is marked released only when all of them were given back. */
648
+ private giveBack;
626
649
  /** Give back an allow that produced nothing (failed or cancelled job), so
627
650
  * it does not count against the licence's usage limit. Only before a
628
651
  * receipt and within 24 hours; the release is recorded in the audit log. */
@@ -645,6 +668,8 @@ declare class Prampta {
645
668
  /** 32-byte Ed25519 private key, hex; keep server-side. Never sent to PRAMPTA. */
646
669
  providerSigningKeyHex?: string;
647
670
  }): Promise<Record<string, unknown>>;
671
+ /** The exact receipt body submitReceipt() would send, signed if a key is given. */
672
+ buildReceipt(decision: SignedDecision, result?: NonNullable<Parameters<Prampta["submitReceipt"]>[1]>): Promise<Record<string, unknown>>;
648
673
  /**
649
674
  * Get (issuing if needed) the signed `pg.assertion.v1` for a decision this
650
675
  * provider/licensee pair already submitted a receipt for — embed it in
@@ -680,4 +705,4 @@ declare class Prampta {
680
705
  private getOnce;
681
706
  }
682
707
 
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 };
708
+ 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,108 @@ 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) {
1146
+ request = structuredClone(request);
1079
1147
  const id = request.generationId;
1080
1148
  if (!id) throw new PramptaSchemaError("generateAuthorized requires a generationId");
1149
+ if (!options?.journal) throw new PramptaSchemaError("generateAuthorized requires a journal (singleProcessJournal() for one process)");
1150
+ const requestHash = await this.requestHash(request);
1081
1151
  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);
1152
+ if (running) {
1153
+ if (running.requestHash !== requestHash) throw new PramptaJobConflictError(id);
1154
+ return running.run;
1155
+ }
1156
+ const run = this.runAuthorized(request, requestHash, generate, options).finally(() => this.inFlight.delete(id));
1157
+ this.inFlight.set(id, { requestHash, run });
1085
1158
  return run;
1086
1159
  }
1087
- async runAuthorized(request, generate, { journal, receipt }) {
1160
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
1161
+ async requestHash(request) {
1162
+ const { generationId, subjectIds, prompt, promptHash, idempotencyKey, returnUrl, detectionId, ...rest } = request;
1163
+ return sha2562(canonicalJson(JSON.parse(JSON.stringify({
1164
+ ...rest,
1165
+ generationId,
1166
+ subjectIds: [...new Set(subjectIds)].sort(),
1167
+ promptHash: promptHash ?? (prompt !== void 0 ? await hashPrompt(prompt) : ""),
1168
+ providerId: this.providerId,
1169
+ licenseeId: this.licenseeId
1170
+ }))));
1171
+ }
1172
+ async runAuthorized(request, requestHash, generate, { journal, receipt }) {
1088
1173
  const { subjectIds, generationId, ...rest } = request;
1089
1174
  const subjects = [...new Set(subjectIds)];
1090
1175
  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 };
1176
+ const existing = await journal.claim({ generationId, requestHash, stage: "claimed", decisions: [] });
1177
+ if (existing) {
1178
+ if (existing.requestHash !== requestHash) throw new PramptaJobConflictError(generationId);
1179
+ if (existing.stage === "reported") {
1180
+ return { status: "already_reported", decisions: existing.decisions, outputHash: existing.outputHash ?? "", unreported: [], conflicts: [] };
1181
+ }
1182
+ if (existing.stage === "generated") return this.report(journal, existing, "recovered", void 0, receipt);
1183
+ 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
1184
  }
1103
1185
  const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1104
1186
  const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1105
1187
  const failed = settled.find((r) => r.status === "rejected");
1106
1188
  if (failed) {
1107
- await this.releaseAll(decisions, "another subject in the same output was refused");
1189
+ await this.giveBack(journal, { generationId, requestHash, stage: "claimed", decisions }, "another subject in the same output was refused");
1108
1190
  throw failed.reason;
1109
1191
  }
1192
+ const entry = { generationId, requestHash, stage: "authorized", decisions };
1193
+ await journal.put(entry);
1110
1194
  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");
1195
+ await this.giveBack(journal, entry, "expired before generation started");
1196
+ throw new PramptaError("An authorization expired before generation started; ask again with a new generationId");
1113
1197
  }
1114
- await journal?.put({ generationId, stage: "authorized", decisions });
1115
1198
  let result;
1116
1199
  try {
1117
1200
  result = await generate(decisions);
1118
1201
  } catch (e) {
1119
- if (await this.releaseAll(decisions, "generation failed")) {
1120
- await journal?.put({ generationId, stage: "released", decisions });
1121
- }
1202
+ await this.giveBack(journal, entry, "generation failed");
1122
1203
  throw e;
1123
1204
  }
1124
1205
  const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1125
1206
  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));
1207
+ const generatedAt = Math.floor(Date.now() / 1e3);
1208
+ const receipts = {};
1209
+ for (const d of decisions) receipts[d.decisionId] = await this.buildReceipt(d, { ...receipt, outputHash, generatedAt });
1210
+ const generated = { ...entry, stage: "generated", outputHash, generatedAt, receipts };
1211
+ await journal.put(generated);
1212
+ return this.report(journal, generated, "generated", result.output, receipt);
1213
+ }
1214
+ async report(journal, entry, status, output, receipt) {
1215
+ const results = await Promise.allSettled(entry.decisions.map(async (d) => this.post(
1216
+ "/v1/receipts/",
1217
+ entry.receipts?.[d.decisionId] ?? await this.buildReceipt(d, { ...receipt, outputHash: entry.outputHash ?? "", generatedAt: entry.generatedAt })
1218
+ )));
1219
+ const unreported = [], conflicts = [];
1220
+ results.forEach((r, i) => {
1221
+ if (r.status === "fulfilled") return;
1222
+ (r.reason instanceof PramptaApiError && r.reason.status === 409 ? conflicts : unreported).push(entry.decisions[i]);
1139
1223
  });
1224
+ if (!unreported.length && !conflicts.length) await journal.put({ ...entry, stage: "reported" });
1225
+ return { status, output, decisions: entry.decisions, outputHash: entry.outputHash ?? "", unreported, conflicts };
1140
1226
  }
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));
1227
+ /** Release every allow; the job is marked released only when all of them were given back. */
1228
+ async giveBack(journal, entry, reason) {
1229
+ const results = await Promise.allSettled(entry.decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1230
+ const all = results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1231
+ if (all) await journal.put({ ...entry, stage: "released" });
1145
1232
  }
1146
1233
  /** Give back an allow that produced nothing (failed or cancelled job), so
1147
1234
  * it does not count against the licence's usage limit. Only before a
@@ -1195,6 +1282,10 @@ var Prampta = class _Prampta {
1195
1282
  * provider attestation, not proof of complete reporting or actual execution.
1196
1283
  */
1197
1284
  async submitReceipt(decision, result = {}) {
1285
+ return this.post("/v1/receipts/", await this.buildReceipt(decision, result));
1286
+ }
1287
+ /** The exact receipt body submitReceipt() would send, signed if a key is given. */
1288
+ async buildReceipt(decision, result = {}) {
1198
1289
  const payload = {
1199
1290
  decision_id: decision.decisionId,
1200
1291
  prompt_hash: decision.promptHash,
@@ -1237,7 +1328,7 @@ var Prampta = class _Prampta {
1237
1328
  } else if (result.eventType && result.eventType !== "output_accepted") {
1238
1329
  throw new PramptaError("A non-default eventType requires a provider signing key and pg.receipt.v3");
1239
1330
  }
1240
- return this.post("/v1/receipts/", payload);
1331
+ return payload;
1241
1332
  }
1242
1333
  // ── C2PA bridge (PRE-GEN v1.1 addendum §11) ─────────────────────────────
1243
1334
  /**
@@ -1416,6 +1507,7 @@ function sameEndpoint(a, b) {
1416
1507
  PramptaApiError,
1417
1508
  PramptaError,
1418
1509
  PramptaFailClosedError,
1510
+ PramptaJobConflictError,
1419
1511
  PramptaNetworkError,
1420
1512
  PramptaRefusalError,
1421
1513
  PramptaReporter,
@@ -1431,6 +1523,7 @@ function sameEndpoint(a, b) {
1431
1523
  loadPregenDirectory,
1432
1524
  matchSubjects,
1433
1525
  normalizeForMatch,
1526
+ singleProcessJournal,
1434
1527
  verifyLicenseSignatures,
1435
1528
  verifyMerkleProof
1436
1529
  });
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,108 @@ 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) {
1089
+ request = structuredClone(request);
1024
1090
  const id = request.generationId;
1025
1091
  if (!id) throw new PramptaSchemaError("generateAuthorized requires a generationId");
1092
+ if (!options?.journal) throw new PramptaSchemaError("generateAuthorized requires a journal (singleProcessJournal() for one process)");
1093
+ const requestHash = await this.requestHash(request);
1026
1094
  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);
1095
+ if (running) {
1096
+ if (running.requestHash !== requestHash) throw new PramptaJobConflictError(id);
1097
+ return running.run;
1098
+ }
1099
+ const run = this.runAuthorized(request, requestHash, generate, options).finally(() => this.inFlight.delete(id));
1100
+ this.inFlight.set(id, { requestHash, run });
1030
1101
  return run;
1031
1102
  }
1032
- async runAuthorized(request, generate, { journal, receipt }) {
1103
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
1104
+ async requestHash(request) {
1105
+ const { generationId, subjectIds, prompt, promptHash, idempotencyKey, returnUrl, detectionId, ...rest } = request;
1106
+ return sha2562(canonicalJson(JSON.parse(JSON.stringify({
1107
+ ...rest,
1108
+ generationId,
1109
+ subjectIds: [...new Set(subjectIds)].sort(),
1110
+ promptHash: promptHash ?? (prompt !== void 0 ? await hashPrompt(prompt) : ""),
1111
+ providerId: this.providerId,
1112
+ licenseeId: this.licenseeId
1113
+ }))));
1114
+ }
1115
+ async runAuthorized(request, requestHash, generate, { journal, receipt }) {
1033
1116
  const { subjectIds, generationId, ...rest } = request;
1034
1117
  const subjects = [...new Set(subjectIds)];
1035
1118
  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 };
1119
+ const existing = await journal.claim({ generationId, requestHash, stage: "claimed", decisions: [] });
1120
+ if (existing) {
1121
+ if (existing.requestHash !== requestHash) throw new PramptaJobConflictError(generationId);
1122
+ if (existing.stage === "reported") {
1123
+ return { status: "already_reported", decisions: existing.decisions, outputHash: existing.outputHash ?? "", unreported: [], conflicts: [] };
1124
+ }
1125
+ if (existing.stage === "generated") return this.report(journal, existing, "recovered", void 0, receipt);
1126
+ 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
1127
  }
1048
1128
  const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1049
1129
  const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1050
1130
  const failed = settled.find((r) => r.status === "rejected");
1051
1131
  if (failed) {
1052
- await this.releaseAll(decisions, "another subject in the same output was refused");
1132
+ await this.giveBack(journal, { generationId, requestHash, stage: "claimed", decisions }, "another subject in the same output was refused");
1053
1133
  throw failed.reason;
1054
1134
  }
1135
+ const entry = { generationId, requestHash, stage: "authorized", decisions };
1136
+ await journal.put(entry);
1055
1137
  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");
1138
+ await this.giveBack(journal, entry, "expired before generation started");
1139
+ throw new PramptaError("An authorization expired before generation started; ask again with a new generationId");
1058
1140
  }
1059
- await journal?.put({ generationId, stage: "authorized", decisions });
1060
1141
  let result;
1061
1142
  try {
1062
1143
  result = await generate(decisions);
1063
1144
  } catch (e) {
1064
- if (await this.releaseAll(decisions, "generation failed")) {
1065
- await journal?.put({ generationId, stage: "released", decisions });
1066
- }
1145
+ await this.giveBack(journal, entry, "generation failed");
1067
1146
  throw e;
1068
1147
  }
1069
1148
  const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1070
1149
  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));
1150
+ const generatedAt = Math.floor(Date.now() / 1e3);
1151
+ const receipts = {};
1152
+ for (const d of decisions) receipts[d.decisionId] = await this.buildReceipt(d, { ...receipt, outputHash, generatedAt });
1153
+ const generated = { ...entry, stage: "generated", outputHash, generatedAt, receipts };
1154
+ await journal.put(generated);
1155
+ return this.report(journal, generated, "generated", result.output, receipt);
1156
+ }
1157
+ async report(journal, entry, status, output, receipt) {
1158
+ const results = await Promise.allSettled(entry.decisions.map(async (d) => this.post(
1159
+ "/v1/receipts/",
1160
+ entry.receipts?.[d.decisionId] ?? await this.buildReceipt(d, { ...receipt, outputHash: entry.outputHash ?? "", generatedAt: entry.generatedAt })
1161
+ )));
1162
+ const unreported = [], conflicts = [];
1163
+ results.forEach((r, i) => {
1164
+ if (r.status === "fulfilled") return;
1165
+ (r.reason instanceof PramptaApiError && r.reason.status === 409 ? conflicts : unreported).push(entry.decisions[i]);
1084
1166
  });
1167
+ if (!unreported.length && !conflicts.length) await journal.put({ ...entry, stage: "reported" });
1168
+ return { status, output, decisions: entry.decisions, outputHash: entry.outputHash ?? "", unreported, conflicts };
1085
1169
  }
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));
1170
+ /** Release every allow; the job is marked released only when all of them were given back. */
1171
+ async giveBack(journal, entry, reason) {
1172
+ const results = await Promise.allSettled(entry.decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1173
+ const all = results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1174
+ if (all) await journal.put({ ...entry, stage: "released" });
1090
1175
  }
1091
1176
  /** Give back an allow that produced nothing (failed or cancelled job), so
1092
1177
  * it does not count against the licence's usage limit. Only before a
@@ -1140,6 +1225,10 @@ var Prampta = class _Prampta {
1140
1225
  * provider attestation, not proof of complete reporting or actual execution.
1141
1226
  */
1142
1227
  async submitReceipt(decision, result = {}) {
1228
+ return this.post("/v1/receipts/", await this.buildReceipt(decision, result));
1229
+ }
1230
+ /** The exact receipt body submitReceipt() would send, signed if a key is given. */
1231
+ async buildReceipt(decision, result = {}) {
1143
1232
  const payload = {
1144
1233
  decision_id: decision.decisionId,
1145
1234
  prompt_hash: decision.promptHash,
@@ -1182,7 +1271,7 @@ var Prampta = class _Prampta {
1182
1271
  } else if (result.eventType && result.eventType !== "output_accepted") {
1183
1272
  throw new PramptaError("A non-default eventType requires a provider signing key and pg.receipt.v3");
1184
1273
  }
1185
- return this.post("/v1/receipts/", payload);
1274
+ return payload;
1186
1275
  }
1187
1276
  // ── C2PA bridge (PRE-GEN v1.1 addendum §11) ─────────────────────────────
1188
1277
  /**
@@ -1360,6 +1449,7 @@ export {
1360
1449
  PramptaApiError,
1361
1450
  PramptaError,
1362
1451
  PramptaFailClosedError,
1452
+ PramptaJobConflictError,
1363
1453
  PramptaNetworkError,
1364
1454
  PramptaRefusalError,
1365
1455
  PramptaReporter,
@@ -1375,6 +1465,7 @@ export {
1375
1465
  loadDirectory as loadPregenDirectory,
1376
1466
  matchSubjects,
1377
1467
  normalizeForMatch,
1468
+ singleProcessJournal,
1378
1469
  verifyLicenseSignatures,
1379
1470
  verifyMerkleProof
1380
1471
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prampta/sdk",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
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",