@sentropic/artifacts 0.0.0-stage → 0.1.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -2
  3. package/dist/adapters/postgres.d.ts +35 -0
  4. package/dist/adapters/postgres.js +301 -0
  5. package/dist/adapters/s3.d.ts +19 -0
  6. package/dist/adapters/s3.js +70 -0
  7. package/dist/client.d.ts +23 -0
  8. package/dist/client.js +44 -0
  9. package/dist/codecs.d.ts +12 -0
  10. package/dist/codecs.js +77 -0
  11. package/dist/content/api-profile.d.ts +38 -0
  12. package/dist/content/api-profile.js +40 -0
  13. package/dist/content/grants.d.ts +57 -0
  14. package/dist/content/grants.js +143 -0
  15. package/dist/content/handler.d.ts +34 -0
  16. package/dist/content/handler.js +129 -0
  17. package/dist/content/headers.d.ts +25 -0
  18. package/dist/content/headers.js +56 -0
  19. package/dist/content/routing.d.ts +13 -0
  20. package/dist/content/routing.js +59 -0
  21. package/dist/contracts.d.ts +102 -0
  22. package/dist/contracts.js +25 -0
  23. package/dist/http.d.ts +43 -0
  24. package/dist/http.js +502 -0
  25. package/dist/index.d.ts +25 -0
  26. package/dist/index.js +29 -0
  27. package/dist/mcp.d.ts +68 -0
  28. package/dist/mcp.js +293 -0
  29. package/dist/parameters.d.ts +29 -0
  30. package/dist/parameters.js +65 -0
  31. package/dist/provider-definition.d.ts +36 -0
  32. package/dist/provider-definition.js +45 -0
  33. package/dist/server/access.d.ts +39 -0
  34. package/dist/server/access.js +53 -0
  35. package/dist/server/authorization.d.ts +17 -0
  36. package/dist/server/authorization.js +12 -0
  37. package/dist/server/bindings.d.ts +34 -0
  38. package/dist/server/bindings.js +60 -0
  39. package/dist/server/fork.d.ts +51 -0
  40. package/dist/server/fork.js +75 -0
  41. package/dist/server/gc.d.ts +24 -0
  42. package/dist/server/gc.js +90 -0
  43. package/dist/server/idempotency.d.ts +23 -0
  44. package/dist/server/idempotency.js +38 -0
  45. package/dist/server/lifecycle.d.ts +57 -0
  46. package/dist/server/lifecycle.js +184 -0
  47. package/dist/server/object-intents.d.ts +41 -0
  48. package/dist/server/object-intents.js +62 -0
  49. package/dist/server/ports.d.ts +34 -0
  50. package/dist/server/ports.js +50 -0
  51. package/dist/server/publication.d.ts +67 -0
  52. package/dist/server/publication.js +154 -0
  53. package/dist/server/reader-profile.d.ts +27 -0
  54. package/dist/server/reader-profile.js +18 -0
  55. package/dist/server/runtime.d.ts +33 -0
  56. package/dist/server/runtime.js +44 -0
  57. package/dist/server/service.d.ts +19 -0
  58. package/dist/server/service.js +32 -0
  59. package/dist/server/sources.d.ts +27 -0
  60. package/dist/server/sources.js +56 -0
  61. package/dist/server/templates.d.ts +33 -0
  62. package/dist/server/templates.js +86 -0
  63. package/dist/server/uploads.d.ts +29 -0
  64. package/dist/server/uploads.js +86 -0
  65. package/package.json +162 -4
@@ -0,0 +1,60 @@
1
+ /**
2
+ * L-5a — owner-scoped stable bindings (spec-v8 §9).
3
+ *
4
+ * A logical binding (owner + name) resolves to one server-allocated UUID no
5
+ * matter which client ensures it first; both fresh clients converge on the
6
+ * same identity. Another owner resolves nothing: lookup is always qualified
7
+ * by ownerId, so binding names and digests cannot be guessed across owners.
8
+ * A conflicting storage scope on an existing binding fails closed (mappings
9
+ * stay server-verified; ambiguous mappings never silently win). Concurrent
10
+ * ensures serialize on a per-key singleflight so exactly one creation wins.
11
+ * Persistence goes through the injected KvPort only (ports.ts).
12
+ */
13
+ import { createCryptoUuid, createKeyMutex, createMemoryKv, } from './ports.js';
14
+ function bindingKey(ownerId, bindingName) {
15
+ return `binding:${ownerId}:${bindingName}`;
16
+ }
17
+ /** Memory-backed KvPort for unit tests (re-exported from the ports module). */
18
+ export function createMemoryBindingKv() {
19
+ return createMemoryKv();
20
+ }
21
+ /** Create a binding service over an injected KvPort (no driver imports). */
22
+ export function createBindingService(kv, uuid = createCryptoUuid()) {
23
+ const mutex = createKeyMutex();
24
+ async function read(key) {
25
+ const raw = await kv.get(key);
26
+ if (raw === undefined)
27
+ return undefined;
28
+ return JSON.parse(raw);
29
+ }
30
+ async function ensureOnce(input) {
31
+ const key = bindingKey(input.ownerId, input.bindingName);
32
+ const found = await read(key);
33
+ if (found !== undefined) {
34
+ if (found.storageScopeId !== input.storageScopeId) {
35
+ throw new Error(`binding scope mismatch for ${input.bindingName}: ambiguous mappings fail closed`);
36
+ }
37
+ return { artifactId: found.artifactId, created: false };
38
+ }
39
+ const candidate = { artifactId: uuid.randomUUID(), storageScopeId: input.storageScopeId };
40
+ const won = await kv.putIfAbsent(key, JSON.stringify(candidate));
41
+ if (won)
42
+ return { artifactId: candidate.artifactId, created: true };
43
+ const raced = await read(key);
44
+ if (raced === undefined)
45
+ throw new Error(`binding lost for ${input.bindingName}`);
46
+ if (raced.storageScopeId !== input.storageScopeId) {
47
+ throw new Error(`binding scope mismatch for ${input.bindingName}: ambiguous mappings fail closed`);
48
+ }
49
+ return { artifactId: raced.artifactId, created: false };
50
+ }
51
+ return {
52
+ ensure(input) {
53
+ return mutex.runSerialized(bindingKey(input.ownerId, input.bindingName), () => ensureOnce(input));
54
+ },
55
+ async find(lookup) {
56
+ const stored = await read(bindingKey(lookup.ownerId, lookup.bindingName));
57
+ return stored?.artifactId;
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * L-5c — private forks (spec-v8 §9 5c).
3
+ *
4
+ * A fork creates a private independent artifact under a fresh server UUID:
5
+ * manifest content is copied, the ACL is not (the forker becomes sole
6
+ * owner), blobs stay shared through reference counting, and provenance
7
+ * records the origin release. Forking a deleted or purged artifact is
8
+ * refused; a fork never resurrects its parent and the parent's later
9
+ * lifecycle never disables the fork. Storage is a port; the memory double
10
+ * serves unit tests.
11
+ */
12
+ export type ForkableState = 'active' | 'withdrawn' | 'deleted' | 'purged';
13
+ export interface ForkRecord {
14
+ artifactId: string;
15
+ ownerId: string;
16
+ releaseId: string;
17
+ manifest: string;
18
+ state: ForkableState;
19
+ readerIds: string[];
20
+ blobDigests: string[];
21
+ }
22
+ export interface ForkStore {
23
+ get(artifactId: string): Promise<ForkRecord | undefined>;
24
+ put(record: ForkRecord): Promise<void>;
25
+ setState(artifactId: string, state: ForkableState): Promise<void>;
26
+ addRef(digest: string): Promise<void>;
27
+ }
28
+ export interface ForkService {
29
+ fork(input: {
30
+ actorId: string;
31
+ artifactId: string;
32
+ }): Promise<{
33
+ artifactId: string;
34
+ ownerId: string;
35
+ origin: {
36
+ artifactId: string;
37
+ releaseId: string;
38
+ };
39
+ }>;
40
+ setState(artifactId: string, state: ForkableState): Promise<void>;
41
+ /** Track a parent record so hosts can sync the source of truth (no-op read). */
42
+ track(record: ForkRecord): Promise<void>;
43
+ }
44
+ /** In-memory ForkStore double for unit tests (also exposes test readers). */
45
+ export declare function createMemoryForkStore(): ForkStore & {
46
+ seed(record: ForkRecord): void;
47
+ refcount(digest: string): number;
48
+ readers(artifactId: string): string[];
49
+ };
50
+ /** Create a fork service over an injected store (no driver imports). */
51
+ export declare function createForkService(store: ForkStore, ids?: () => string): ForkService;
@@ -0,0 +1,75 @@
1
+ /** In-memory ForkStore double for unit tests (also exposes test readers). */
2
+ export function createMemoryForkStore() {
3
+ const records = new Map();
4
+ const refcounts = new Map();
5
+ const store = {
6
+ seed(record) {
7
+ records.set(record.artifactId, { ...record, readerIds: [...record.readerIds] });
8
+ for (const digest of record.blobDigests) {
9
+ refcounts.set(digest, (refcounts.get(digest) ?? 0) + 1);
10
+ }
11
+ },
12
+ refcount(digest) {
13
+ return refcounts.get(digest) ?? 0;
14
+ },
15
+ readers(artifactId) {
16
+ return [...(records.get(artifactId)?.readerIds ?? [])];
17
+ },
18
+ get(artifactId) {
19
+ return Promise.resolve(records.get(artifactId));
20
+ },
21
+ put(record) {
22
+ records.set(record.artifactId, record);
23
+ return Promise.resolve();
24
+ },
25
+ setState(artifactId, state) {
26
+ const record = records.get(artifactId);
27
+ if (record === undefined)
28
+ throw new Error(`unknown artifact: ${artifactId}`);
29
+ record.state = state;
30
+ return Promise.resolve();
31
+ },
32
+ addRef(digest) {
33
+ refcounts.set(digest, (refcounts.get(digest) ?? 0) + 1);
34
+ return Promise.resolve();
35
+ },
36
+ };
37
+ return store;
38
+ }
39
+ /** Create a fork service over an injected store (no driver imports). */
40
+ export function createForkService(store, ids = () => crypto.randomUUID()) {
41
+ return {
42
+ async fork(input) {
43
+ const parent = await store.get(input.artifactId);
44
+ if (parent === undefined)
45
+ throw new Error(`unknown artifact: ${input.artifactId}`);
46
+ if (parent.state === 'deleted' || parent.state === 'purged') {
47
+ throw new Error(`cannot fork ${parent.state} artifact: ${input.artifactId}`);
48
+ }
49
+ const artifactId = ids();
50
+ await store.put({
51
+ artifactId,
52
+ ownerId: input.actorId,
53
+ releaseId: parent.releaseId,
54
+ manifest: parent.manifest,
55
+ state: 'active',
56
+ readerIds: [],
57
+ blobDigests: [...parent.blobDigests],
58
+ });
59
+ for (const digest of parent.blobDigests) {
60
+ await store.addRef(digest);
61
+ }
62
+ return {
63
+ artifactId,
64
+ ownerId: input.actorId,
65
+ origin: { artifactId: parent.artifactId, releaseId: parent.releaseId },
66
+ };
67
+ },
68
+ async setState(artifactId, state) {
69
+ await store.setState(artifactId, state);
70
+ },
71
+ async track(record) {
72
+ await store.put(record);
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,24 @@
1
+ import { type PublicationBlobStore } from './publication.js';
2
+ export interface RefcountStore {
3
+ addRef(digest: string, holder: string): Promise<void>;
4
+ releaseRef(digest: string, holder: string): Promise<void>;
5
+ pin(digest: string, reason: string): Promise<void>;
6
+ unpin(digest: string, reason: string): Promise<void>;
7
+ count(digest: string): Promise<number>;
8
+ isPinned(digest: string): Promise<boolean>;
9
+ }
10
+ /** In-memory RefcountStore double (yields mid-update to expose races). */
11
+ export declare function createMemoryRefcountStore(): RefcountStore;
12
+ export interface GcReport {
13
+ deleted: string[];
14
+ retained: string[];
15
+ failures: {
16
+ key: string;
17
+ error: string;
18
+ }[];
19
+ }
20
+ export interface GcService {
21
+ collect(blobs: PublicationBlobStore, prefix: string): Promise<GcReport>;
22
+ }
23
+ /** Create a GC service over an injected refcount store (no driver imports). */
24
+ export declare function createGcService(refs: RefcountStore): GcService;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * L-5c — garbage collection (spec-v8 §9 5c).
3
+ *
4
+ * Blobs stay alive while referenced (per-digest holder sets) or pinned
5
+ * (explicit retention against concurrent GC). Collection deletes only
6
+ * unreferenced, unpinned blobs and reports per-key failures without
7
+ * throwing, so revoked access, pending purge and actual erasure stay
8
+ * distinguishable. Reference updates serialize per digest (in-process
9
+ * mutex); the memory double yields mid-update to simulate store latency so
10
+ * races are genuinely exercised. Deleting state (refcounts vs purge) is
11
+ * serialized by the caller: purge dereferences first, then collects.
12
+ */
13
+ import { createKeyMutex } from './ports.js';
14
+ import { blobDigestOfKey } from './publication.js';
15
+ /** In-memory RefcountStore double (yields mid-update to expose races). */
16
+ export function createMemoryRefcountStore() {
17
+ const holders = new Map();
18
+ const pins = new Map();
19
+ const mutex = createKeyMutex();
20
+ return {
21
+ async addRef(digest, holder) {
22
+ await mutex.runSerialized(digest, async () => {
23
+ const set = holders.get(digest) ?? new Set();
24
+ await Promise.resolve();
25
+ set.add(holder);
26
+ holders.set(digest, set);
27
+ });
28
+ },
29
+ async releaseRef(digest, holder) {
30
+ await mutex.runSerialized(digest, async () => {
31
+ const set = holders.get(digest);
32
+ await Promise.resolve();
33
+ if (set === undefined || !set.has(holder)) {
34
+ throw new Error(`refcount not held: ${digest} by ${holder}`);
35
+ }
36
+ set.delete(holder);
37
+ });
38
+ },
39
+ async pin(digest, reason) {
40
+ const set = pins.get(digest) ?? new Set();
41
+ if (set.has(reason))
42
+ throw new Error(`duplicate pin: ${digest} (${reason})`);
43
+ set.add(reason);
44
+ pins.set(digest, set);
45
+ },
46
+ async unpin(digest, reason) {
47
+ const set = pins.get(digest);
48
+ if (set === undefined || !set.has(reason)) {
49
+ throw new Error(`not pinned: ${digest} (${reason})`);
50
+ }
51
+ set.delete(reason);
52
+ },
53
+ async count(digest) {
54
+ return holders.get(digest)?.size ?? 0;
55
+ },
56
+ async isPinned(digest) {
57
+ return (pins.get(digest)?.size ?? 0) !== 0;
58
+ },
59
+ };
60
+ }
61
+ /** Create a GC service over an injected refcount store (no driver imports). */
62
+ export function createGcService(refs) {
63
+ return {
64
+ async collect(blobs, prefix) {
65
+ const report = { deleted: [], retained: [], failures: [] };
66
+ for (const key of await blobs.listKeys(prefix)) {
67
+ let digest;
68
+ try {
69
+ digest = blobDigestOfKey(key);
70
+ }
71
+ catch {
72
+ report.retained.push(key);
73
+ continue;
74
+ }
75
+ if ((await refs.count(digest)) !== 0 || (await refs.isPinned(digest))) {
76
+ report.retained.push(key);
77
+ continue;
78
+ }
79
+ try {
80
+ await blobs.deleteBlob(key);
81
+ report.deleted.push(key);
82
+ }
83
+ catch (error) {
84
+ report.failures.push({ key, error: String(error) });
85
+ }
86
+ }
87
+ return report;
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * L-5a — operation idempotency (spec-v8 §9).
3
+ *
4
+ * One stored result per idempotency key: the first execution runs the work
5
+ * and records {paramsHash, result}; replays with the same key and hash
6
+ * return the stored result without re-executing. A reused key with a
7
+ * different hash is an explicit conflict, never a silent overwrite.
8
+ * Executions serialize per key (in-process mutex) so concurrent replays
9
+ * converge without double work. Persistence goes through the injected
10
+ * KvPort only (ports.ts); the memory double serves unit tests.
11
+ */
12
+ import { type KvPort } from './ports.js';
13
+ export interface IdempotentResult<T> {
14
+ result: T;
15
+ replayed: boolean;
16
+ }
17
+ export interface IdempotencyStore {
18
+ execute<T>(key: string, paramsHash: string, work: () => Promise<T>): Promise<IdempotentResult<T>>;
19
+ }
20
+ /** Memory-backed KvPort for unit tests (re-exported from the ports module). */
21
+ export declare function createMemoryIdempotencyKv(): KvPort;
22
+ /** Create an idempotency store over an injected KvPort (no driver imports). */
23
+ export declare function createIdempotencyStore(kv: KvPort): IdempotencyStore;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * L-5a — operation idempotency (spec-v8 §9).
3
+ *
4
+ * One stored result per idempotency key: the first execution runs the work
5
+ * and records {paramsHash, result}; replays with the same key and hash
6
+ * return the stored result without re-executing. A reused key with a
7
+ * different hash is an explicit conflict, never a silent overwrite.
8
+ * Executions serialize per key (in-process mutex) so concurrent replays
9
+ * converge without double work. Persistence goes through the injected
10
+ * KvPort only (ports.ts); the memory double serves unit tests.
11
+ */
12
+ import { createKeyMutex, createMemoryKv } from './ports.js';
13
+ /** Memory-backed KvPort for unit tests (re-exported from the ports module). */
14
+ export function createMemoryIdempotencyKv() {
15
+ return createMemoryKv();
16
+ }
17
+ /** Create an idempotency store over an injected KvPort (no driver imports). */
18
+ export function createIdempotencyStore(kv) {
19
+ const mutex = createKeyMutex();
20
+ return {
21
+ execute(key, paramsHash, work) {
22
+ const storeKey = `idempotency:${key}`;
23
+ return mutex.runSerialized(storeKey, async () => {
24
+ const raw = await kv.get(storeKey);
25
+ if (raw !== undefined) {
26
+ const stored = JSON.parse(raw);
27
+ if (stored.paramsHash !== paramsHash) {
28
+ throw new Error(`idempotency conflict: key ${key} already used with different parameters`);
29
+ }
30
+ return { result: stored.result, replayed: true };
31
+ }
32
+ const result = await work();
33
+ await kv.putIfAbsent(storeKey, JSON.stringify({ paramsHash, result }));
34
+ return { result, replayed: false };
35
+ });
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,57 @@
1
+ import { type AccessAction, type ArtifactAcl } from './access.js';
2
+ import type { PublicationStateStore } from './publication.js';
3
+ /**
4
+ * The current-release pointer the service promotes through. A subset of the
5
+ * publication state store on purpose: there is exactly one current pointer,
6
+ * owned by publication state (DB: `artifact_current`). Hosts must wire both
7
+ * services to the same backing.
8
+ */
9
+ export type CurrentPointer = Pick<PublicationStateStore, 'getCurrentReleaseId' | 'compareAndSwapCurrent'>;
10
+ export type ArtifactState = 'active' | 'withdrawn' | 'deleted' | 'purged';
11
+ /** Release states use `ready` (not `active`) to match the releases table. */
12
+ export type ReleaseState = 'ready' | 'retired' | 'purged';
13
+ export interface LifecycleStore {
14
+ register(artifactId: string, ownerId: string): Promise<void>;
15
+ getOwner(artifactId: string): Promise<string | undefined>;
16
+ getArtifactState(artifactId: string): Promise<ArtifactState>;
17
+ setArtifactState(artifactId: string, state: ArtifactState): Promise<void>;
18
+ getRenewedAt(artifactId: string): Promise<string | undefined>;
19
+ setRenewedAt(artifactId: string, renewedAt: string): Promise<void>;
20
+ addRelease(artifactId: string, releaseId: string): Promise<void>;
21
+ listArtifactReleases(artifactId: string): Promise<string[]>;
22
+ getReleaseState(releaseId: string): Promise<ReleaseState>;
23
+ setReleaseState(releaseId: string, state: ReleaseState): Promise<void>;
24
+ }
25
+ /** In-memory LifecycleStore double for unit tests (plus a test setup alias). */
26
+ export declare function createMemoryLifecycleStore(): LifecycleStore & {
27
+ setRelease(artifactId: string, releaseId: string): Promise<void>;
28
+ };
29
+ export interface LifecycleService {
30
+ /** Register an artifact owner (idempotent; production binds the DB row). */
31
+ register(artifactId: string, ownerId: string): Promise<void>;
32
+ stateOf(artifactId: string): Promise<ArtifactState>;
33
+ canRead(artifact: {
34
+ artifactId: string;
35
+ state: ArtifactState;
36
+ }, acl: ArtifactAcl, actorId: string, action?: AccessAction, isApproved?: (principalId: string) => boolean): boolean;
37
+ withdraw(artifactId: string, actorId: string): Promise<void>;
38
+ restore(artifactId: string, actorId: string): Promise<void>;
39
+ deleteArtifact(artifactId: string, actorId: string): Promise<void>;
40
+ purge(artifactId: string, actorId: string, blobDigests: string[]): Promise<{
41
+ artifactId: string;
42
+ purgedReleases: string[];
43
+ blobDigests: string[];
44
+ }>;
45
+ promote(artifactId: string, actorId: string, releaseId: string, expectedCurrent: string): Promise<{
46
+ releaseId: string;
47
+ }>;
48
+ renew(artifactId: string, actorId: string): Promise<{
49
+ renewedAt: string;
50
+ }>;
51
+ /** List release IDs for purge derivation (delegates to the store). */
52
+ listReleases(artifactId: string): Promise<string[]>;
53
+ /** Track a published release so promotion sees it (idempotent). */
54
+ trackRelease(artifactId: string, releaseId: string): Promise<void>;
55
+ }
56
+ /** Create a lifecycle service over an injected store (mutations owner-only). */
57
+ export declare function createLifecycleService(store: LifecycleStore, clock?: () => string, current?: CurrentPointer): LifecycleService;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * L-5c — lifecycle: withdraw/restore/delete/purge, promote/rollback, renew.
3
+ *
4
+ * Part 1: states and the store port. Artifact states run active → withdrawn
5
+ * → active (restore) or → deleted → active (undo) → purged (terminal);
6
+ * releases run active → retired (supplanted) → purged (cascade). `retired`
7
+ * is also derivable (exists but not current); the stored flag lets the
8
+ * Postgres adapter answer without joining the current pointer. Like
9
+ * production (ensureArtifact before any lifecycle step), artifacts register
10
+ * with their owner first. Part 2 adds the service.
11
+ */
12
+ import { assertArtifactAccess } from './authorization.js';
13
+ import { evaluateAccess } from './access.js';
14
+ /** In-memory LifecycleStore double for unit tests (plus a test setup alias). */
15
+ export function createMemoryLifecycleStore() {
16
+ const artifacts = new Map();
17
+ const releaseStates = new Map();
18
+ const store = {
19
+ async register(artifactId, ownerId) {
20
+ const entry = artifacts.get(artifactId);
21
+ if (entry !== undefined) {
22
+ if (entry.ownerId !== ownerId)
23
+ throw new Error(`owner mismatch for ${artifactId}`);
24
+ return;
25
+ }
26
+ artifacts.set(artifactId, { ownerId, state: 'active', releases: [] });
27
+ },
28
+ async getOwner(artifactId) {
29
+ return artifacts.get(artifactId)?.ownerId;
30
+ },
31
+ async getArtifactState(artifactId) {
32
+ return artifacts.get(artifactId)?.state ?? 'active';
33
+ },
34
+ async setArtifactState(artifactId, state) {
35
+ const entry = artifacts.get(artifactId);
36
+ if (entry === undefined)
37
+ throw new Error(`unknown artifact: ${artifactId}`);
38
+ entry.state = state;
39
+ },
40
+ async getRenewedAt(artifactId) {
41
+ return artifacts.get(artifactId)?.renewedAt;
42
+ },
43
+ async setRenewedAt(artifactId, renewedAt) {
44
+ const entry = artifacts.get(artifactId);
45
+ if (entry === undefined)
46
+ throw new Error(`unknown artifact: ${artifactId}`);
47
+ entry.renewedAt = renewedAt;
48
+ },
49
+ async addRelease(artifactId, releaseId) {
50
+ const entry = artifacts.get(artifactId);
51
+ if (entry === undefined)
52
+ throw new Error(`unknown artifact: ${artifactId}`);
53
+ if (!entry.releases.includes(releaseId))
54
+ entry.releases.push(releaseId);
55
+ if (!releaseStates.has(releaseId))
56
+ releaseStates.set(releaseId, 'ready');
57
+ },
58
+ async listArtifactReleases(artifactId) {
59
+ return [...(artifacts.get(artifactId)?.releases ?? [])];
60
+ },
61
+ async getReleaseState(releaseId) {
62
+ return releaseStates.get(releaseId) ?? 'ready';
63
+ },
64
+ async setReleaseState(releaseId, state) {
65
+ releaseStates.set(releaseId, state);
66
+ },
67
+ async setRelease(artifactId, releaseId) {
68
+ await store.addRelease(artifactId, releaseId);
69
+ },
70
+ };
71
+ return store;
72
+ }
73
+ /** Create a lifecycle service over an injected store (mutations owner-only). */
74
+ export function createLifecycleService(store, clock = () => new Date().toISOString(), current) {
75
+ async function ownerOf(artifactId) {
76
+ const owner = await store.getOwner(artifactId);
77
+ if (owner === undefined)
78
+ throw new Error(`unknown artifact: ${artifactId}`);
79
+ return owner;
80
+ }
81
+ async function assertOwner(artifactId, actorId) {
82
+ assertArtifactAccess({ actorUserId: actorId, artifactOwnerId: await ownerOf(artifactId), action: 'mutate' });
83
+ }
84
+ return {
85
+ async register(artifactId, ownerId) {
86
+ await store.register(artifactId, ownerId);
87
+ },
88
+ async stateOf(artifactId) {
89
+ return store.getArtifactState(artifactId);
90
+ },
91
+ canRead(artifact, acl, actorId, action = 'read', isApproved = () => false) {
92
+ if (artifact.state === 'deleted' || artifact.state === 'purged')
93
+ return false;
94
+ if (artifact.state === 'withdrawn')
95
+ return actorId === acl.ownerId;
96
+ return evaluateAccess(acl, actorId, action, isApproved);
97
+ },
98
+ async withdraw(artifactId, actorId) {
99
+ await assertOwner(artifactId, actorId);
100
+ const state = await store.getArtifactState(artifactId);
101
+ if (state === 'purged')
102
+ throw new Error(`cannot withdraw purged artifact: terminal ${artifactId}`);
103
+ if (state === 'deleted')
104
+ throw new Error(`cannot withdraw deleted artifact: ${artifactId}`);
105
+ if (state === 'withdrawn')
106
+ throw new Error(`already withdrawn: ${artifactId}`);
107
+ await store.setArtifactState(artifactId, 'withdrawn');
108
+ },
109
+ async restore(artifactId, actorId) {
110
+ await assertOwner(artifactId, actorId);
111
+ const state = await store.getArtifactState(artifactId);
112
+ if (state === 'purged')
113
+ throw new Error(`cannot restore purged artifact: terminal ${artifactId}`);
114
+ if (state === 'active')
115
+ throw new Error(`already active: ${artifactId}`);
116
+ await store.setArtifactState(artifactId, 'active');
117
+ },
118
+ async deleteArtifact(artifactId, actorId) {
119
+ await assertOwner(artifactId, actorId);
120
+ const state = await store.getArtifactState(artifactId);
121
+ if (state === 'purged')
122
+ throw new Error(`cannot delete purged artifact: terminal ${artifactId}`);
123
+ if (state === 'deleted')
124
+ throw new Error(`already deleted: ${artifactId}`);
125
+ await store.setArtifactState(artifactId, 'deleted');
126
+ },
127
+ async purge(artifactId, actorId, blobDigests) {
128
+ await assertOwner(artifactId, actorId);
129
+ const state = await store.getArtifactState(artifactId);
130
+ if (state !== 'deleted') {
131
+ throw new Error(`can only purge a deleted artifact: ${artifactId} is ${state}`);
132
+ }
133
+ const releases = await store.listArtifactReleases(artifactId);
134
+ for (const releaseId of releases) {
135
+ await store.setReleaseState(releaseId, 'purged');
136
+ }
137
+ await store.setArtifactState(artifactId, 'purged');
138
+ return { artifactId, purgedReleases: releases, blobDigests: [...blobDigests] };
139
+ },
140
+ async promote(artifactId, actorId, releaseId, expectedCurrent) {
141
+ await assertOwner(artifactId, actorId);
142
+ if (current === undefined)
143
+ throw new Error(`promotion needs a current pointer for ${artifactId}`);
144
+ const state = await store.getArtifactState(artifactId);
145
+ if (state !== 'active')
146
+ throw new Error(`cannot promote on ${state} artifact: ${artifactId}`);
147
+ const releases = await store.listArtifactReleases(artifactId);
148
+ if (!releases.includes(releaseId))
149
+ throw new Error(`unknown release: ${releaseId}`);
150
+ if ((await store.getReleaseState(releaseId)) === 'purged') {
151
+ throw new Error(`cannot promote purged release: ${releaseId}`);
152
+ }
153
+ const actual = (await current.getCurrentReleaseId(artifactId)) ?? null;
154
+ if (actual !== expectedCurrent) {
155
+ throw new Error(`promotion conflict for ${artifactId}: expected ${expectedCurrent}`);
156
+ }
157
+ const won = await current.compareAndSwapCurrent(artifactId, expectedCurrent, releaseId);
158
+ if (!won)
159
+ throw new Error(`promotion conflict for ${artifactId}: concurrent move won`);
160
+ if (actual !== null && actual !== releaseId) {
161
+ await store.setReleaseState(actual, 'retired');
162
+ }
163
+ await store.setReleaseState(releaseId, 'ready');
164
+ return { releaseId };
165
+ },
166
+ async renew(artifactId, actorId) {
167
+ await assertOwner(artifactId, actorId);
168
+ const state = await store.getArtifactState(artifactId);
169
+ if (state === 'deleted')
170
+ throw new Error(`cannot renew deleted artifact: ${artifactId}`);
171
+ if (state === 'purged')
172
+ throw new Error(`cannot renew purged artifact: terminal ${artifactId}`);
173
+ const renewedAt = clock();
174
+ await store.setRenewedAt(artifactId, renewedAt);
175
+ return { renewedAt };
176
+ },
177
+ async listReleases(artifactId) {
178
+ return store.listArtifactReleases(artifactId);
179
+ },
180
+ async trackRelease(artifactId, releaseId) {
181
+ await store.addRelease(artifactId, releaseId);
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * L-5b — object intents (spec-v8 §9).
3
+ *
4
+ * An intent is recorded before every external object operation (blob put,
5
+ * release commit, promotion). Crash reprise reconciles pending intents:
6
+ * completed effects are marked done, missing effects are marked failed for
7
+ * explicit retry — a pending intent never becomes a visible release on its
8
+ * own. The store is a port; the memory double serves unit tests and the
9
+ * Postgres adapter serves the L-5b integration proof.
10
+ */
11
+ import type { JsonValue } from '../contracts.js';
12
+ export type IntentKind = 'commit-release' | 'delete-blob' | 'promote-release';
13
+ export type IntentState = 'pending' | 'done' | 'failed';
14
+ export interface ObjectIntent {
15
+ intentId: string;
16
+ kind: IntentKind;
17
+ state: IntentState;
18
+ payload: Record<string, JsonValue | undefined>;
19
+ error?: string;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ }
23
+ export interface IntentStore {
24
+ record(kind: IntentKind, payload: Record<string, JsonValue | undefined>): Promise<ObjectIntent>;
25
+ complete(intentId: string): Promise<void>;
26
+ markFailed(intentId: string, error: string): Promise<void>;
27
+ get(intentId: string): Promise<ObjectIntent | undefined>;
28
+ listPending(): Promise<ObjectIntent[]>;
29
+ }
30
+ /** In-memory IntentStore double for unit tests. */
31
+ export declare function createMemoryIntentStore(clock?: () => string, ids?: () => string): IntentStore;
32
+ export type IntentResolution = 'done' | 'failed';
33
+ /**
34
+ * Reconcile pending intents after a crash: the resolver inspects each
35
+ * pending intent (effect present → done, effect missing → failed for
36
+ * explicit retry) and the store records the outcome. Returns the report.
37
+ */
38
+ export declare function reconcileIntents(store: IntentStore, resolve: (intent: ObjectIntent) => Promise<IntentResolution>): Promise<{
39
+ done: string[];
40
+ failed: string[];
41
+ }>;