@prampta/sdk 0.8.0 → 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
@@ -32,6 +32,8 @@ declare class TrustedRegistryDirectory {
32
32
  assertEndpoint(subject: string, baseUrl: string): void;
33
33
  /** V-10: use only AFTER independently verifying the object's signature. */
34
34
  assertSigner(code: string, keyId: string): void;
35
+ /** V-14 */
36
+ private assertNotRevoked;
35
37
  assertDecision(subject: string, license: string | null, keyId: string, baseUrl: string): void;
36
38
  }
37
39
 
@@ -381,23 +383,37 @@ interface LicenseRequestResult {
381
383
  requestId: string;
382
384
  status: string;
383
385
  }
384
- /** One generation as `generateAuthorized` records it. Stored by the
385
- * 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. */
386
391
  interface GenerationJournalEntry {
387
392
  generationId: string;
388
- /** authorized: allows held, generation may have started.
389
- * generated: output exists, receipts may be missing.
390
- * reported: receipts filed. released: nothing produced, allows given back. */
391
- 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";
392
399
  decisions: SignedDecision[];
393
400
  outputHash?: string;
401
+ /** Kept so a resent receipt is byte-identical to the first. */
402
+ generatedAt?: number;
394
403
  }
395
404
  interface GenerationJournal {
396
- get(generationId: string): Promise<GenerationJournalEntry | null>;
397
- /** 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. */
398
411
  put(entry: GenerationJournalEntry): Promise<void>;
399
412
  }
400
- 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">;
401
417
  interface AuthorizedGeneration<T> {
402
418
  /** generated: ran now. recovered: output existed from an earlier run, only
403
419
  * receipts were sent. already_reported: nothing left to do. */
@@ -407,9 +423,12 @@ interface AuthorizedGeneration<T> {
407
423
  output?: T;
408
424
  decisions: SignedDecision[];
409
425
  outputHash: string;
410
- /** Receipts that could not be filed yet. Call again with the same
411
- * 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. */
412
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[];
413
432
  }
414
433
  interface OutputMetadata {
415
434
  prampta_decision_id: string;
@@ -449,6 +468,11 @@ declare class PramptaRefusalError extends PramptaError {
449
468
  get decisionId(): string;
450
469
  get licenseId(): string | null;
451
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
+ }
452
476
  declare class PramptaFailClosedError extends PramptaError {
453
477
  readonly cause_: unknown;
454
478
  constructor(cause_: unknown);
@@ -591,36 +615,35 @@ declare class Prampta {
591
615
  metadata: OutputMetadata;
592
616
  }>;
593
617
  /**
594
- * The whole cycle in one call, for every subject in the output:
595
- * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
596
- * licence-backed allow from all of them, generate, file one receipt per
597
- * subject. If any subject refuses, or `generate` throws, the allows already
598
- * 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.
599
621
  *
600
- * `generate` must throw only when nothing was produced. With a `journal`,
601
- * calling again with the same generationId after a crash never generates
602
- * twice: it resends missing receipts, or refuses if the crash happened
603
- * mid-generation (then check your own records, releaseDecision() if nothing
604
- * was produced, and use a new generationId). Concurrent calls with the
605
- * 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.
606
632
  */
607
- generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
608
- subjectIds: string[];
609
- generationId: string;
610
- }, generate: (decisions: SignedDecision[]) => Promise<{
633
+ generateAuthorized<T>(request: AuthorizedRequest, generate: (decisions: SignedDecision[]) => Promise<{
611
634
  output: T;
612
635
  outputHash?: string;
613
636
  outputBytes?: Uint8Array | string;
614
- }>, options?: {
615
- journal?: GenerationJournal;
637
+ }>, options: {
638
+ journal: GenerationJournal;
616
639
  receipt?: ReceiptOptions;
617
640
  }): Promise<AuthorizedGeneration<T>>;
641
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
642
+ private requestHash;
618
643
  private runAuthorized;
619
- /** Returns the decisions whose receipt could not be filed. A receipt that
620
- * already exists (an earlier attempt landed) counts as filed. */
621
- private fileReceipts;
622
- /** True when every allow was given back. */
623
- 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;
624
647
  /** Give back an allow that produced nothing (failed or cancelled job), so
625
648
  * it does not count against the licence's usage limit. Only before a
626
649
  * receipt and within 24 hours; the release is recorded in the audit log. */
@@ -678,4 +701,4 @@ declare class Prampta {
678
701
  private getOnce;
679
702
  }
680
703
 
681
- 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
@@ -32,6 +32,8 @@ declare class TrustedRegistryDirectory {
32
32
  assertEndpoint(subject: string, baseUrl: string): void;
33
33
  /** V-10: use only AFTER independently verifying the object's signature. */
34
34
  assertSigner(code: string, keyId: string): void;
35
+ /** V-14 */
36
+ private assertNotRevoked;
35
37
  assertDecision(subject: string, license: string | null, keyId: string, baseUrl: string): void;
36
38
  }
37
39
 
@@ -381,23 +383,37 @@ interface LicenseRequestResult {
381
383
  requestId: string;
382
384
  status: string;
383
385
  }
384
- /** One generation as `generateAuthorized` records it. Stored by the
385
- * 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. */
386
391
  interface GenerationJournalEntry {
387
392
  generationId: string;
388
- /** authorized: allows held, generation may have started.
389
- * generated: output exists, receipts may be missing.
390
- * reported: receipts filed. released: nothing produced, allows given back. */
391
- 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";
392
399
  decisions: SignedDecision[];
393
400
  outputHash?: string;
401
+ /** Kept so a resent receipt is byte-identical to the first. */
402
+ generatedAt?: number;
394
403
  }
395
404
  interface GenerationJournal {
396
- get(generationId: string): Promise<GenerationJournalEntry | null>;
397
- /** 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. */
398
411
  put(entry: GenerationJournalEntry): Promise<void>;
399
412
  }
400
- 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">;
401
417
  interface AuthorizedGeneration<T> {
402
418
  /** generated: ran now. recovered: output existed from an earlier run, only
403
419
  * receipts were sent. already_reported: nothing left to do. */
@@ -407,9 +423,12 @@ interface AuthorizedGeneration<T> {
407
423
  output?: T;
408
424
  decisions: SignedDecision[];
409
425
  outputHash: string;
410
- /** Receipts that could not be filed yet. Call again with the same
411
- * 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. */
412
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[];
413
432
  }
414
433
  interface OutputMetadata {
415
434
  prampta_decision_id: string;
@@ -449,6 +468,11 @@ declare class PramptaRefusalError extends PramptaError {
449
468
  get decisionId(): string;
450
469
  get licenseId(): string | null;
451
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
+ }
452
476
  declare class PramptaFailClosedError extends PramptaError {
453
477
  readonly cause_: unknown;
454
478
  constructor(cause_: unknown);
@@ -591,36 +615,35 @@ declare class Prampta {
591
615
  metadata: OutputMetadata;
592
616
  }>;
593
617
  /**
594
- * The whole cycle in one call, for every subject in the output:
595
- * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
596
- * licence-backed allow from all of them, generate, file one receipt per
597
- * subject. If any subject refuses, or `generate` throws, the allows already
598
- * 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.
599
621
  *
600
- * `generate` must throw only when nothing was produced. With a `journal`,
601
- * calling again with the same generationId after a crash never generates
602
- * twice: it resends missing receipts, or refuses if the crash happened
603
- * mid-generation (then check your own records, releaseDecision() if nothing
604
- * was produced, and use a new generationId). Concurrent calls with the
605
- * 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.
606
632
  */
607
- generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
608
- subjectIds: string[];
609
- generationId: string;
610
- }, generate: (decisions: SignedDecision[]) => Promise<{
633
+ generateAuthorized<T>(request: AuthorizedRequest, generate: (decisions: SignedDecision[]) => Promise<{
611
634
  output: T;
612
635
  outputHash?: string;
613
636
  outputBytes?: Uint8Array | string;
614
- }>, options?: {
615
- journal?: GenerationJournal;
637
+ }>, options: {
638
+ journal: GenerationJournal;
616
639
  receipt?: ReceiptOptions;
617
640
  }): Promise<AuthorizedGeneration<T>>;
641
+ /** SHA-256 of everything that defines the job. Same id + different hash = a different job. */
642
+ private requestHash;
618
643
  private runAuthorized;
619
- /** Returns the decisions whose receipt could not be filed. A receipt that
620
- * already exists (an earlier attempt landed) counts as filed. */
621
- private fileReceipts;
622
- /** True when every allow was given back. */
623
- 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;
624
647
  /** Give back an allow that produced nothing (failed or cancelled job), so
625
648
  * it does not count against the licence's usage limit. Only before a
626
649
  * receipt and within 24 hours; the release is recorded in the audit log. */
@@ -678,4 +701,4 @@ declare class Prampta {
678
701
  private getOnce;
679
702
  }
680
703
 
681
- 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
  });
@@ -140,16 +142,18 @@ function registryNamespace(code) {
140
142
  return "";
141
143
  }
142
144
  var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
143
- constructor(registries, sequence, snapshotHash, seal) {
145
+ constructor(registries, revokedKeys, sequence, snapshotHash, seal) {
144
146
  this.sequence = sequence;
145
147
  this.snapshotHash = snapshotHash;
146
148
  if (seal !== verifiedConstruction) fail("Construct directories through signature verification");
147
149
  this.#registries = registries;
150
+ this.#revokedKeys = new Set(revokedKeys);
148
151
  Object.freeze(this);
149
152
  }
150
153
  sequence;
151
154
  snapshotHash;
152
155
  #registries;
156
+ #revokedKeys;
153
157
  static async verify(input, options) {
154
158
  const min = options.minSequence ?? 0;
155
159
  if (!Number.isSafeInteger(min) || min < 0) fail("minSequence must be a nonnegative safe integer");
@@ -189,6 +193,16 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
189
193
  }
190
194
  }
191
195
  if (!prefixes.has("")) fail("Missing origin namespace");
196
+ for (const r of d.registries) {
197
+ if (r.revoked !== void 0 && !(r.revoked && typeof r.revoked === "object" && typeof r.revoked.at === "string")) {
198
+ fail("Invalid registry revocation");
199
+ }
200
+ }
201
+ if (d.revoked_keys !== void 0 && !(Array.isArray(d.revoked_keys) && d.revoked_keys.every(
202
+ (k) => k && /^pg-ed25519:[0-9a-f]{32}$/.test(String(k.key_id)) && typeof k.at === "string"
203
+ ))) {
204
+ fail("Invalid revoked key list");
205
+ }
192
206
  const publicKey = hexBytes(options.stewardPublicKeyHex, 32);
193
207
  const hash = await sha256(publicKey);
194
208
  const fp = "pg-ed25519:" + hash.slice(0, 32);
@@ -206,7 +220,13 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
206
220
  if (!valid) fail("Invalid directory signature");
207
221
  const snapshotHash = await sha256(message);
208
222
  if (previous && previous.sequence === d.sequence && previous.hash !== snapshotHash) fail("Directory equivocation detected");
209
- return new _TrustedRegistryDirectory(d.registries, d.sequence, snapshotHash, verifiedConstruction);
223
+ return new _TrustedRegistryDirectory(
224
+ d.registries,
225
+ (d.revoked_keys ?? []).map((k) => k.key_id),
226
+ d.sequence,
227
+ snapshotHash,
228
+ verifiedConstruction
229
+ );
210
230
  }
211
231
  registryFor(code) {
212
232
  const prefix = registryNamespace(code);
@@ -222,7 +242,13 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
222
242
  }
223
243
  /** V-10: use only AFTER independently verifying the object's signature. */
224
244
  assertSigner(code, keyId) {
225
- if (!this.registryFor(code).key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
245
+ const owner = this.registryFor(code);
246
+ if (!owner.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
247
+ this.assertNotRevoked(owner, keyId);
248
+ }
249
+ /** V-14 */
250
+ assertNotRevoked(owner, keyId) {
251
+ if (owner.revoked || this.#revokedKeys.has(keyId)) fail("Revoked registry or key");
226
252
  }
227
253
  assertDecision(subject, license, keyId, baseUrl) {
228
254
  this.assertEndpoint(subject, baseUrl);
@@ -230,6 +256,7 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
230
256
  else {
231
257
  const owner = this.#registries.find((r) => apiUrl(r.api) === apiUrl(baseUrl));
232
258
  if (!owner?.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
259
+ this.assertNotRevoked(owner, keyId);
233
260
  }
234
261
  if (license) {
235
262
  this.assertEndpoint(license, baseUrl);
@@ -384,6 +411,20 @@ var PramptaReporter = class {
384
411
  };
385
412
 
386
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
+ }
387
428
  var PramptaError = class extends Error {
388
429
  constructor(message) {
389
430
  super(message);
@@ -449,6 +490,14 @@ var PramptaRefusalError = class extends PramptaError {
449
490
  return this.decision.licenseId;
450
491
  }
451
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
+ };
452
501
  var PramptaFailClosedError = class extends PramptaError {
453
502
  constructor(cause_) {
454
503
  super(`PRAMPTA fail-closed: generation blocked because verification failed (${cause_})`);
@@ -584,6 +633,42 @@ function matchSubjects(text, entries) {
584
633
  }
585
634
  return hits;
586
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
+ ]);
587
672
  var Prampta = class _Prampta {
588
673
  baseUrl;
589
674
  providerId;
@@ -752,6 +837,11 @@ var Prampta = class _Prampta {
752
837
  */
753
838
  async verifyDecision(raw, decision) {
754
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
+ }
755
845
  const disposition = raw.disposition ?? (raw.allowed ? "allow" : "deny");
756
846
  if (!["allow", "deny", "review", "not_blocked"].includes(disposition) || raw.allowed !== (disposition === "allow")) {
757
847
  throw new PramptaSchemaError("allowed and disposition are inconsistent or unsupported");
@@ -802,7 +892,7 @@ var Prampta = class _Prampta {
802
892
  );
803
893
  }
804
894
  const now = Math.floor(Date.now() / 1e3);
805
- 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) {
806
896
  throw new PramptaSchemaError("Decision has invalid validity timestamps");
807
897
  }
808
898
  if (decision.expiresAt <= now) {
@@ -1037,86 +1127,101 @@ var Prampta = class _Prampta {
1037
1127
  return { output, decision, metadata };
1038
1128
  }
1039
1129
  /**
1040
- * The whole cycle in one call, for every subject in the output:
1041
- * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
1042
- * licence-backed allow from all of them, generate, file one receipt per
1043
- * subject. If any subject refuses, or `generate` throws, the allows already
1044
- * 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.
1045
1133
  *
1046
- * `generate` must throw only when nothing was produced. With a `journal`,
1047
- * calling again with the same generationId after a crash never generates
1048
- * twice: it resends missing receipts, or refuses if the crash happened
1049
- * mid-generation (then check your own records, releaseDecision() if nothing
1050
- * was produced, and use a new generationId). Concurrent calls with the
1051
- * 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.
1052
1144
  */
1053
- async generateAuthorized(request, generate, options = {}) {
1145
+ async generateAuthorized(request, generate, options) {
1054
1146
  const id = request.generationId;
1055
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);
1056
1150
  const running = this.inFlight.get(id);
1057
- if (running) return running;
1058
- const run = this.runAuthorized(request, generate, options).finally(() => this.inFlight.delete(id));
1059
- 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 });
1060
1157
  return run;
1061
1158
  }
1062
- 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 }) {
1063
1172
  const { subjectIds, generationId, ...rest } = request;
1064
1173
  const subjects = [...new Set(subjectIds)];
1065
1174
  if (subjects.length === 0) throw new PramptaSchemaError("generateAuthorized requires at least one subjectId");
1066
- const saved = await journal?.get(generationId);
1067
- if (saved?.stage === "authorized") {
1068
- 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.`);
1069
- }
1070
- if (saved?.stage === "reported") {
1071
- return { status: "already_reported", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: [] };
1072
- }
1073
- if (saved?.stage === "generated") {
1074
- const unreported2 = await this.fileReceipts(saved.decisions, saved.outputHash ?? "", receipt);
1075
- if (unreported2.length === 0) await journal.put({ ...saved, stage: "reported" });
1076
- 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.`);
1077
1183
  }
1078
1184
  const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1079
1185
  const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1080
1186
  const failed = settled.find((r) => r.status === "rejected");
1081
1187
  if (failed) {
1082
- 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");
1083
1189
  throw failed.reason;
1084
1190
  }
1191
+ const entry = { generationId, requestHash, stage: "authorized", decisions };
1192
+ await journal.put(entry);
1085
1193
  if (decisions.some((d) => d.expiresAt <= Math.floor(Date.now() / 1e3))) {
1086
- await this.releaseAll(decisions, "expired before generation started");
1087
- 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");
1088
1196
  }
1089
- await journal?.put({ generationId, stage: "authorized", decisions });
1090
1197
  let result;
1091
1198
  try {
1092
1199
  result = await generate(decisions);
1093
1200
  } catch (e) {
1094
- if (await this.releaseAll(decisions, "generation failed")) {
1095
- await journal?.put({ generationId, stage: "released", decisions });
1096
- }
1201
+ await this.giveBack(journal, entry, "generation failed");
1097
1202
  throw e;
1098
1203
  }
1099
1204
  const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1100
1205
  const outputHash = result.outputHash ?? (bytes ? await sha256Bytes(bytes) : "");
1101
- await journal?.put({ generationId, stage: "generated", decisions, outputHash });
1102
- const unreported = await this.fileReceipts(decisions, outputHash, receipt);
1103
- if (unreported.length === 0) await journal?.put({ generationId, stage: "reported", decisions, outputHash });
1104
- return { status: "generated", output: result.output, decisions, outputHash, unreported };
1105
- }
1106
- /** Returns the decisions whose receipt could not be filed. A receipt that
1107
- * already exists (an earlier attempt landed) counts as filed. */
1108
- async fileReceipts(decisions, outputHash, receipt) {
1109
- const results = await Promise.allSettled(decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash })));
1110
- return decisions.filter((_, i) => {
1111
- const r = results[i];
1112
- if (r.status === "fulfilled") return false;
1113
- 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]);
1114
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 };
1115
1219
  }
1116
- /** True when every allow was given back. */
1117
- async releaseAll(decisions, reason) {
1118
- const results = await Promise.allSettled(decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1119
- 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" });
1120
1225
  }
1121
1226
  /** Give back an allow that produced nothing (failed or cancelled job), so
1122
1227
  * it does not count against the licence's usage limit. Only before a
@@ -1391,6 +1496,7 @@ function sameEndpoint(a, b) {
1391
1496
  PramptaApiError,
1392
1497
  PramptaError,
1393
1498
  PramptaFailClosedError,
1499
+ PramptaJobConflictError,
1394
1500
  PramptaNetworkError,
1395
1501
  PramptaRefusalError,
1396
1502
  PramptaReporter,
@@ -1406,6 +1512,7 @@ function sameEndpoint(a, b) {
1406
1512
  loadPregenDirectory,
1407
1513
  matchSubjects,
1408
1514
  normalizeForMatch,
1515
+ singleProcessJournal,
1409
1516
  verifyLicenseSignatures,
1410
1517
  verifyMerkleProof
1411
1518
  });
package/dist/index.mjs CHANGED
@@ -85,16 +85,18 @@ function registryNamespace(code) {
85
85
  return "";
86
86
  }
87
87
  var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
88
- constructor(registries, sequence, snapshotHash, seal) {
88
+ constructor(registries, revokedKeys, sequence, snapshotHash, seal) {
89
89
  this.sequence = sequence;
90
90
  this.snapshotHash = snapshotHash;
91
91
  if (seal !== verifiedConstruction) fail("Construct directories through signature verification");
92
92
  this.#registries = registries;
93
+ this.#revokedKeys = new Set(revokedKeys);
93
94
  Object.freeze(this);
94
95
  }
95
96
  sequence;
96
97
  snapshotHash;
97
98
  #registries;
99
+ #revokedKeys;
98
100
  static async verify(input, options) {
99
101
  const min = options.minSequence ?? 0;
100
102
  if (!Number.isSafeInteger(min) || min < 0) fail("minSequence must be a nonnegative safe integer");
@@ -134,6 +136,16 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
134
136
  }
135
137
  }
136
138
  if (!prefixes.has("")) fail("Missing origin namespace");
139
+ for (const r of d.registries) {
140
+ if (r.revoked !== void 0 && !(r.revoked && typeof r.revoked === "object" && typeof r.revoked.at === "string")) {
141
+ fail("Invalid registry revocation");
142
+ }
143
+ }
144
+ if (d.revoked_keys !== void 0 && !(Array.isArray(d.revoked_keys) && d.revoked_keys.every(
145
+ (k) => k && /^pg-ed25519:[0-9a-f]{32}$/.test(String(k.key_id)) && typeof k.at === "string"
146
+ ))) {
147
+ fail("Invalid revoked key list");
148
+ }
137
149
  const publicKey = hexBytes(options.stewardPublicKeyHex, 32);
138
150
  const hash = await sha256(publicKey);
139
151
  const fp = "pg-ed25519:" + hash.slice(0, 32);
@@ -151,7 +163,13 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
151
163
  if (!valid) fail("Invalid directory signature");
152
164
  const snapshotHash = await sha256(message);
153
165
  if (previous && previous.sequence === d.sequence && previous.hash !== snapshotHash) fail("Directory equivocation detected");
154
- return new _TrustedRegistryDirectory(d.registries, d.sequence, snapshotHash, verifiedConstruction);
166
+ return new _TrustedRegistryDirectory(
167
+ d.registries,
168
+ (d.revoked_keys ?? []).map((k) => k.key_id),
169
+ d.sequence,
170
+ snapshotHash,
171
+ verifiedConstruction
172
+ );
155
173
  }
156
174
  registryFor(code) {
157
175
  const prefix = registryNamespace(code);
@@ -167,7 +185,13 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
167
185
  }
168
186
  /** V-10: use only AFTER independently verifying the object's signature. */
169
187
  assertSigner(code, keyId) {
170
- if (!this.registryFor(code).key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
188
+ const owner = this.registryFor(code);
189
+ if (!owner.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
190
+ this.assertNotRevoked(owner, keyId);
191
+ }
192
+ /** V-14 */
193
+ assertNotRevoked(owner, keyId) {
194
+ if (owner.revoked || this.#revokedKeys.has(keyId)) fail("Revoked registry or key");
171
195
  }
172
196
  assertDecision(subject, license, keyId, baseUrl) {
173
197
  this.assertEndpoint(subject, baseUrl);
@@ -175,6 +199,7 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
175
199
  else {
176
200
  const owner = this.#registries.find((r) => apiUrl(r.api) === apiUrl(baseUrl));
177
201
  if (!owner?.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
202
+ this.assertNotRevoked(owner, keyId);
178
203
  }
179
204
  if (license) {
180
205
  this.assertEndpoint(license, baseUrl);
@@ -329,6 +354,20 @@ var PramptaReporter = class {
329
354
  };
330
355
 
331
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
+ }
332
371
  var PramptaError = class extends Error {
333
372
  constructor(message) {
334
373
  super(message);
@@ -394,6 +433,14 @@ var PramptaRefusalError = class extends PramptaError {
394
433
  return this.decision.licenseId;
395
434
  }
396
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
+ };
397
444
  var PramptaFailClosedError = class extends PramptaError {
398
445
  constructor(cause_) {
399
446
  super(`PRAMPTA fail-closed: generation blocked because verification failed (${cause_})`);
@@ -529,6 +576,42 @@ function matchSubjects(text, entries) {
529
576
  }
530
577
  return hits;
531
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
+ ]);
532
615
  var Prampta = class _Prampta {
533
616
  baseUrl;
534
617
  providerId;
@@ -697,6 +780,11 @@ var Prampta = class _Prampta {
697
780
  */
698
781
  async verifyDecision(raw, decision) {
699
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
+ }
700
788
  const disposition = raw.disposition ?? (raw.allowed ? "allow" : "deny");
701
789
  if (!["allow", "deny", "review", "not_blocked"].includes(disposition) || raw.allowed !== (disposition === "allow")) {
702
790
  throw new PramptaSchemaError("allowed and disposition are inconsistent or unsupported");
@@ -747,7 +835,7 @@ var Prampta = class _Prampta {
747
835
  );
748
836
  }
749
837
  const now = Math.floor(Date.now() / 1e3);
750
- 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) {
751
839
  throw new PramptaSchemaError("Decision has invalid validity timestamps");
752
840
  }
753
841
  if (decision.expiresAt <= now) {
@@ -982,86 +1070,101 @@ var Prampta = class _Prampta {
982
1070
  return { output, decision, metadata };
983
1071
  }
984
1072
  /**
985
- * The whole cycle in one call, for every subject in the output:
986
- * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
987
- * licence-backed allow from all of them, generate, file one receipt per
988
- * subject. If any subject refuses, or `generate` throws, the allows already
989
- * 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.
990
1076
  *
991
- * `generate` must throw only when nothing was produced. With a `journal`,
992
- * calling again with the same generationId after a crash never generates
993
- * twice: it resends missing receipts, or refuses if the crash happened
994
- * mid-generation (then check your own records, releaseDecision() if nothing
995
- * was produced, and use a new generationId). Concurrent calls with the
996
- * 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.
997
1087
  */
998
- async generateAuthorized(request, generate, options = {}) {
1088
+ async generateAuthorized(request, generate, options) {
999
1089
  const id = request.generationId;
1000
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);
1001
1093
  const running = this.inFlight.get(id);
1002
- if (running) return running;
1003
- const run = this.runAuthorized(request, generate, options).finally(() => this.inFlight.delete(id));
1004
- 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 });
1005
1100
  return run;
1006
1101
  }
1007
- 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 }) {
1008
1115
  const { subjectIds, generationId, ...rest } = request;
1009
1116
  const subjects = [...new Set(subjectIds)];
1010
1117
  if (subjects.length === 0) throw new PramptaSchemaError("generateAuthorized requires at least one subjectId");
1011
- const saved = await journal?.get(generationId);
1012
- if (saved?.stage === "authorized") {
1013
- 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.`);
1014
- }
1015
- if (saved?.stage === "reported") {
1016
- return { status: "already_reported", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: [] };
1017
- }
1018
- if (saved?.stage === "generated") {
1019
- const unreported2 = await this.fileReceipts(saved.decisions, saved.outputHash ?? "", receipt);
1020
- if (unreported2.length === 0) await journal.put({ ...saved, stage: "reported" });
1021
- 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.`);
1022
1126
  }
1023
1127
  const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1024
1128
  const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1025
1129
  const failed = settled.find((r) => r.status === "rejected");
1026
1130
  if (failed) {
1027
- 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");
1028
1132
  throw failed.reason;
1029
1133
  }
1134
+ const entry = { generationId, requestHash, stage: "authorized", decisions };
1135
+ await journal.put(entry);
1030
1136
  if (decisions.some((d) => d.expiresAt <= Math.floor(Date.now() / 1e3))) {
1031
- await this.releaseAll(decisions, "expired before generation started");
1032
- 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");
1033
1139
  }
1034
- await journal?.put({ generationId, stage: "authorized", decisions });
1035
1140
  let result;
1036
1141
  try {
1037
1142
  result = await generate(decisions);
1038
1143
  } catch (e) {
1039
- if (await this.releaseAll(decisions, "generation failed")) {
1040
- await journal?.put({ generationId, stage: "released", decisions });
1041
- }
1144
+ await this.giveBack(journal, entry, "generation failed");
1042
1145
  throw e;
1043
1146
  }
1044
1147
  const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1045
1148
  const outputHash = result.outputHash ?? (bytes ? await sha256Bytes(bytes) : "");
1046
- await journal?.put({ generationId, stage: "generated", decisions, outputHash });
1047
- const unreported = await this.fileReceipts(decisions, outputHash, receipt);
1048
- if (unreported.length === 0) await journal?.put({ generationId, stage: "reported", decisions, outputHash });
1049
- return { status: "generated", output: result.output, decisions, outputHash, unreported };
1050
- }
1051
- /** Returns the decisions whose receipt could not be filed. A receipt that
1052
- * already exists (an earlier attempt landed) counts as filed. */
1053
- async fileReceipts(decisions, outputHash, receipt) {
1054
- const results = await Promise.allSettled(decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash })));
1055
- return decisions.filter((_, i) => {
1056
- const r = results[i];
1057
- if (r.status === "fulfilled") return false;
1058
- 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]);
1059
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 };
1060
1162
  }
1061
- /** True when every allow was given back. */
1062
- async releaseAll(decisions, reason) {
1063
- const results = await Promise.allSettled(decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1064
- 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" });
1065
1168
  }
1066
1169
  /** Give back an allow that produced nothing (failed or cancelled job), so
1067
1170
  * it does not count against the licence's usage limit. Only before a
@@ -1335,6 +1438,7 @@ export {
1335
1438
  PramptaApiError,
1336
1439
  PramptaError,
1337
1440
  PramptaFailClosedError,
1441
+ PramptaJobConflictError,
1338
1442
  PramptaNetworkError,
1339
1443
  PramptaRefusalError,
1340
1444
  PramptaReporter,
@@ -1350,6 +1454,7 @@ export {
1350
1454
  loadDirectory as loadPregenDirectory,
1351
1455
  matchSubjects,
1352
1456
  normalizeForMatch,
1457
+ singleProcessJournal,
1353
1458
  verifyLicenseSignatures,
1354
1459
  verifyMerkleProof
1355
1460
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prampta/sdk",
3
- "version": "0.8.0",
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.4.0"
50
+ "@pregen/verify": "^0.6.0"
51
51
  },
52
52
  "repository": {
53
53
  "type": "git",