@stigmer/server 3.38.3 → 3.39.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.
Files changed (90) hide show
  1. package/dist/domain/agent/controller.d.ts +2 -2
  2. package/dist/domain/agent/controller.d.ts.map +1 -1
  3. package/dist/domain/agent/controller.js +7 -3
  4. package/dist/domain/agent/controller.js.map +1 -1
  5. package/dist/domain/agentchannel/controller.d.ts +2 -2
  6. package/dist/domain/agentchannel/controller.d.ts.map +1 -1
  7. package/dist/domain/agentchannel/controller.js +10 -5
  8. package/dist/domain/agentchannel/controller.js.map +1 -1
  9. package/dist/domain/agentinstance/controller.d.ts +2 -2
  10. package/dist/domain/agentinstance/controller.d.ts.map +1 -1
  11. package/dist/domain/agentinstance/controller.js +7 -3
  12. package/dist/domain/agentinstance/controller.js.map +1 -1
  13. package/dist/domain/agentshare/controller.d.ts +2 -2
  14. package/dist/domain/agentshare/controller.d.ts.map +1 -1
  15. package/dist/domain/agentshare/controller.js +7 -3
  16. package/dist/domain/agentshare/controller.js.map +1 -1
  17. package/dist/domain/organization/controller.d.ts.map +1 -1
  18. package/dist/domain/organization/controller.js +33 -14
  19. package/dist/domain/organization/controller.js.map +1 -1
  20. package/dist/domain/organization/slug-ledger.d.ts +86 -0
  21. package/dist/domain/organization/slug-ledger.d.ts.map +1 -0
  22. package/dist/domain/organization/slug-ledger.js +128 -0
  23. package/dist/domain/organization/slug-ledger.js.map +1 -0
  24. package/dist/domain/organization/steps.d.ts +12 -3
  25. package/dist/domain/organization/steps.d.ts.map +1 -1
  26. package/dist/domain/organization/steps.js +28 -6
  27. package/dist/domain/organization/steps.js.map +1 -1
  28. package/dist/extensions/gate-slots.d.ts +7 -5
  29. package/dist/extensions/gate-slots.d.ts.map +1 -1
  30. package/dist/extensions/gate-slots.js.map +1 -1
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/pipeline/errors.d.ts +8 -0
  36. package/dist/pipeline/errors.d.ts.map +1 -1
  37. package/dist/pipeline/errors.js +19 -0
  38. package/dist/pipeline/errors.js.map +1 -1
  39. package/dist/store/interface.d.ts +67 -0
  40. package/dist/store/interface.d.ts.map +1 -1
  41. package/dist/store/interface.js.map +1 -1
  42. package/dist/store/organization-slug-history.d.ts +71 -0
  43. package/dist/store/organization-slug-history.d.ts.map +1 -0
  44. package/dist/store/organization-slug-history.js +88 -0
  45. package/dist/store/organization-slug-history.js.map +1 -0
  46. package/dist/store/postgres/migrations.d.ts +3 -1
  47. package/dist/store/postgres/migrations.d.ts.map +1 -1
  48. package/dist/store/postgres/migrations.js +69 -1
  49. package/dist/store/postgres/migrations.js.map +1 -1
  50. package/dist/store/postgres/store.d.ts +2 -1
  51. package/dist/store/postgres/store.d.ts.map +1 -1
  52. package/dist/store/postgres/store.js +49 -0
  53. package/dist/store/postgres/store.js.map +1 -1
  54. package/dist/store/sqlite/migrations.d.ts +3 -1
  55. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  56. package/dist/store/sqlite/migrations.js +54 -1
  57. package/dist/store/sqlite/migrations.js.map +1 -1
  58. package/dist/store/sqlite/store.d.ts +2 -1
  59. package/dist/store/sqlite/store.d.ts.map +1 -1
  60. package/dist/store/sqlite/store.js +55 -0
  61. package/dist/store/sqlite/store.js.map +1 -1
  62. package/package.json +6 -6
  63. package/src/domain/agent/__tests__/store-faults.test.ts +105 -0
  64. package/src/domain/agent/controller.ts +9 -5
  65. package/src/domain/agentchannel/__tests__/store-faults.test.ts +126 -0
  66. package/src/domain/agentchannel/controller.ts +12 -7
  67. package/src/domain/agentinstance/__tests__/store-faults.test.ts +110 -0
  68. package/src/domain/agentinstance/controller.ts +9 -5
  69. package/src/domain/agentshare/__tests__/store-faults.test.ts +101 -0
  70. package/src/domain/agentshare/controller.ts +9 -5
  71. package/src/domain/organization/__tests__/organization-delete.test.ts +73 -18
  72. package/src/domain/organization/__tests__/organization-slugs.test.ts +386 -0
  73. package/src/domain/organization/controller.ts +36 -14
  74. package/src/domain/organization/slug-ledger.ts +209 -0
  75. package/src/domain/organization/steps.ts +28 -6
  76. package/src/extensions/gate-slots.ts +7 -5
  77. package/src/index.ts +11 -0
  78. package/src/pipeline/__tests__/blind-not-found.test.ts +0 -4
  79. package/src/pipeline/errors.ts +23 -0
  80. package/src/store/__tests__/organization-slug-history.test.ts +109 -0
  81. package/src/store/__tests__/store-contract.ts +86 -1
  82. package/src/store/interface.ts +70 -0
  83. package/src/store/organization-slug-history.ts +159 -0
  84. package/src/store/postgres/__tests__/migrations.test.ts +138 -2
  85. package/src/store/postgres/__tests__/store-contract.test.ts +1 -0
  86. package/src/store/postgres/migrations.ts +84 -1
  87. package/src/store/postgres/store.ts +84 -0
  88. package/src/store/sqlite/__tests__/migrations.test.ts +139 -10
  89. package/src/store/sqlite/migrations.ts +69 -1
  90. package/src/store/sqlite/store.ts +84 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The organization domain's use of the slug ledger (the Store's
3
+ * `organizationSlugs`; store/interface.ts states its guarantees): an
4
+ * organization's slug is its for good.
5
+ *
6
+ * An organization's id is its slug, and the delete removes the row. Rows
7
+ * that name the organization can outlive it (every organization-scoped
8
+ * resource names it in `metadata.org`, and an edition may keep its own rows
9
+ * by the id), so a slug taken again would hand them all to the new holder.
10
+ * The ledger closes that: the create claims the slug atomically, the delete
11
+ * retires it, and nothing ever frees a retired slug.
12
+ *
13
+ * The create touches the ledger twice. CheckDuplicate reads it first, so a
14
+ * taken slug is refused before any gate runs (steps.ts). ClaimOrganizationSlug
15
+ * then claims it immediately before Persist, after the pre-side-effect gate
16
+ * slot, so a gate's refusal still leaves nothing written; of two concurrent
17
+ * creates of one slug, exactly one claim wins, which the row alone cannot
18
+ * give because `saveResource` upserts. A create that fails after its claim
19
+ * and before its row is stored releases the claim, so the caller's retry
20
+ * can take the slug again; a create that fails after the row keeps it,
21
+ * because the organization exists.
22
+ *
23
+ * The refusal says why. A retired slug answers AlreadyExists carrying the
24
+ * ORGANIZATION_SLUG_RESERVED reason (the organization create's contract
25
+ * documents it), so the CLI and the console can tell a person the slug
26
+ * belonged to a deleted organization. A held slug keeps the existing copy
27
+ * with no reason: it covers a live organization and also a create between
28
+ * its claim and its row, which must never read as "deleted". Both keep the
29
+ * AlreadyExists code, which the personal-organization retry keys on.
30
+ *
31
+ * Proven by __tests__/organization-slugs.test.ts and the organization
32
+ * conformance suite.
33
+ */
34
+ import type { DescMessage } from "@bufbuild/protobuf";
35
+ import type { ConnectError } from "@connectrpc/connect";
36
+
37
+ import type { Logger } from "../../boot/logger.js";
38
+ import { ResourceNotFoundError } from "../../store/interface.js";
39
+ import type { OrganizationSlugEntry, Store } from "../../store/interface.js";
40
+ import {
41
+ alreadyExistsError,
42
+ alreadyExistsWithReasonError,
43
+ internalError,
44
+ } from "../../pipeline/errors.js";
45
+ import type { PipelineStep } from "../../pipeline/pipeline.js";
46
+ import type { RequestContext } from "../../pipeline/request-context.js";
47
+ import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
48
+ import { metadataOf } from "../../pipeline/steps/shapes.js";
49
+
50
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
51
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
52
+ import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
53
+
54
+ /**
55
+ * The ErrorInfo reason a create of a retired slug carries: a deleted
56
+ * organization held the slug, and a slug is never reused. Wire contract,
57
+ * documented on OrganizationCommandController.create; metadata `slug`.
58
+ */
59
+ export const ORGANIZATION_SLUG_RESERVED = "ORGANIZATION_SLUG_RESERVED";
60
+
61
+ /** Where ClaimOrganizationSlug leaves the entry it won, for the release after a failure. */
62
+ const CLAIMED_SLUG_KEY = "organizationSlugClaim";
63
+
64
+ /** The copy of a retired slug's refusal. */
65
+ export function organizationSlugReservedMessage(slug: string): string {
66
+ return `Organization slug '${slug}' belonged to an organization that was deleted, and a slug is never reused`;
67
+ }
68
+
69
+ /**
70
+ * The refusal for a slug an entry already holds: the reserved refusal for a
71
+ * retired entry, the existing duplicate copy for one still held.
72
+ */
73
+ export function refusalForHeldSlug(entry: OrganizationSlugEntry): ConnectError {
74
+ if (entry.retiredAt !== "") {
75
+ return alreadyExistsWithReasonError(
76
+ organizationSlugReservedMessage(entry.slug),
77
+ { reason: ORGANIZATION_SLUG_RESERVED, metadata: { slug: entry.slug } },
78
+ );
79
+ }
80
+ return alreadyExistsError("Organization", `slug '${entry.slug}'`);
81
+ }
82
+
83
+ /**
84
+ * Claims the new organization's slug, immediately before Persist. A lost
85
+ * claim is refused by the entry that holds the slug; a won claim is left in
86
+ * the request for releaseSlugClaimAfterFailure.
87
+ */
88
+ export function newClaimOrganizationSlugStep(
89
+ store: Store,
90
+ ): PipelineStep<typeof OrganizationSchema> {
91
+ return {
92
+ name: "ClaimOrganizationSlug",
93
+ async execute(
94
+ ctx: RequestContext<typeof OrganizationSchema>,
95
+ ): Promise<void> {
96
+ const slug = metadataOf(ctx.newState)?.slug ?? "";
97
+ // ResolveSlug and CheckDuplicate run first, so an empty slug here is
98
+ // a server-side ordering bug, not bad client input.
99
+ if (slug === "") {
100
+ throw internalError(
101
+ new Error("organization slug is empty"),
102
+ "failed to claim the organization slug",
103
+ );
104
+ }
105
+ let claim;
106
+ try {
107
+ claim = await store.organizationSlugs.claim(slug);
108
+ } catch (error) {
109
+ throw internalError(error, "failed to claim the organization slug");
110
+ }
111
+ if (!claim.claimed) {
112
+ throw refusalForHeldSlug(claim.entry);
113
+ }
114
+ ctx.set(CLAIMED_SLUG_KEY, claim.entry);
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Frees the slug a failed create claimed, when its organization was never
121
+ * stored, so the caller's retry can take the slug again. Called by the
122
+ * create around its chain, as send-signal.ts releases a dedupe claim.
123
+ *
124
+ * Whether the row exists decides, rather than which step failed: a Persist
125
+ * that stored the row and then failed keeps the claim, as it must, because
126
+ * the organization exists. Any fault here is logged and leaves the slug
127
+ * claimed with no organization, which nothing can then take; that is a
128
+ * hand's to resolve, and far rarer than the failure it follows.
129
+ */
130
+ export async function releaseSlugClaimAfterFailure(
131
+ store: Store,
132
+ logger: Logger,
133
+ ctx: RequestContext<typeof OrganizationSchema>,
134
+ ): Promise<void> {
135
+ const entry = ctx.get(CLAIMED_SLUG_KEY) as OrganizationSlugEntry | undefined;
136
+ if (entry === undefined) {
137
+ return;
138
+ }
139
+ try {
140
+ await store.getResource(
141
+ ApiResourceKind.organization,
142
+ entry.slug,
143
+ OrganizationSchema,
144
+ );
145
+ return; // stored: the organization exists and keeps its slug
146
+ } catch (error) {
147
+ if (!(error instanceof ResourceNotFoundError)) {
148
+ logger.error(
149
+ "organization create failed and its slug claim could not be checked; the slug stays claimed",
150
+ {
151
+ slug: entry.slug,
152
+ error: error instanceof Error ? error.message : String(error),
153
+ },
154
+ );
155
+ return;
156
+ }
157
+ }
158
+ try {
159
+ await store.organizationSlugs.release(entry);
160
+ } catch (error) {
161
+ logger.error(
162
+ "organization create failed and its slug claim could not be released; the slug stays claimed",
163
+ {
164
+ slug: entry.slug,
165
+ error: error instanceof Error ? error.message : String(error),
166
+ },
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Retires the organization's slug before anything else the delete does,
173
+ * right after the organization is loaded. Its entry may not exist yet (an
174
+ * organization created before the ledger, or by an older binary during a
175
+ * rolling upgrade), and retire records it either way, so the slug is
176
+ * retired before the row can go.
177
+ *
178
+ * A fault fails the delete with the organization intact. A delete that
179
+ * fails later (an edition's refusal on the pre-delete slot, a revocation
180
+ * fault) leaves a live organization whose slug reads as retired; nothing
181
+ * observes that, because a create of the slug is refused either way, and a
182
+ * retry of the delete resumes.
183
+ */
184
+ export function newRetireOrganizationSlugStep<Desc extends DescMessage>(
185
+ store: Store,
186
+ ): PipelineStep<Desc> {
187
+ return {
188
+ name: "RetireOrganizationSlug",
189
+ async execute(ctx: RequestContext<Desc>): Promise<void> {
190
+ const organization = ctx.get(EXISTING_RESOURCE_KEY) as
191
+ | Organization
192
+ | undefined;
193
+ const id = organization?.metadata?.id ?? "";
194
+ if (id === "") {
195
+ throw internalError(
196
+ new Error(
197
+ "organization delete reached RetireOrganizationSlug without its loaded row",
198
+ ),
199
+ "failed to retire the organization slug",
200
+ );
201
+ }
202
+ try {
203
+ await store.organizationSlugs.retire(id);
204
+ } catch (error) {
205
+ throw internalError(error, "failed to retire the organization slug");
206
+ }
207
+ },
208
+ };
209
+ }
@@ -10,9 +10,11 @@
10
10
  * uniqueness guarantee, mirroring cloud's OrganizationCreateHandler
11
11
  * (CheckDuplicate + CopySlugToId) step-for-step.
12
12
  *
13
- * The same deviation shapes the delete: a freed slug is anyone's to take,
14
- * so newRevokeOrganizationPoliciesStep revokes the organization's policy
15
- * rows BEFORE its row is deleted, and fails the delete when it cannot.
13
+ * The same deviation shapes the delete. A slug is never taken twice
14
+ * (slug-ledger.ts), and newRevokeOrganizationPoliciesStep still revokes
15
+ * the organization's policy rows BEFORE its row is deleted, and fails the
16
+ * delete when it cannot, so nothing that grants on the organization
17
+ * outlives it.
16
18
  *
17
19
  * Proven by organization.conformance.test.ts (CONFORMANCE_TARGET=local),
18
20
  * __tests__/organization.test.ts and __tests__/organization-delete.test.ts.
@@ -28,6 +30,7 @@ import type { RequestContext } from "../../pipeline/request-context.js";
28
30
  import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
29
31
  import { metadataOf } from "../../pipeline/steps/shapes.js";
30
32
  import type { IamPolicyGrantPath } from "../iampolicy/grant-path.js";
33
+ import { refusalForHeldSlug } from "./slug-ledger.js";
31
34
 
32
35
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
33
36
  import { ApiResourceRefSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
@@ -48,6 +51,13 @@ import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organizat
48
51
  * organization. Checking existence by id (== the resolved slug) closes
49
52
  * that hole and mirrors cloud's OrganizationCreateHandler.CheckDuplicate.
50
53
  *
54
+ * A slug is taken for good (slug-ledger.ts), so the slug ledger is read
55
+ * first: a retired slug is refused with the reserved reason, a held one
56
+ * with the duplicate copy. The row is read after it for an organization no
57
+ * ledger entry records yet, one an older binary created during a rolling
58
+ * upgrade. This read is the early refusal, before any gate; the atomic
59
+ * guarantee is ClaimOrganizationSlug's, immediately before Persist.
60
+ *
51
61
  * Runs after ResolveSlug (slug is set) and before BuildNewState/
52
62
  * CopySlugToId (the id is not yet minted), so it keys on the slug value
53
63
  * that will become the id.
@@ -70,6 +80,16 @@ export function newCheckOrgDuplicateStep(
70
80
  throw internalError(new Error("organization slug is empty"), "duplicate check");
71
81
  }
72
82
 
83
+ let entry;
84
+ try {
85
+ entry = await store.organizationSlugs.find(metadata.slug);
86
+ } catch (error) {
87
+ throw internalError(error, "failed to check for duplicate organization");
88
+ }
89
+ if (entry !== undefined) {
90
+ throw refusalForHeldSlug(entry);
91
+ }
92
+
73
93
  try {
74
94
  await store.getResource(
75
95
  ctx.apiResourceKind,
@@ -120,9 +140,11 @@ export function newCopySlugToIdStep(): PipelineStep<typeof OrganizationSchema> {
120
140
  *
121
141
  * The generic delete chains clean up after the row and log a fault,
122
142
  * because a deleted resource's rows grant nothing once it is gone. An
123
- * organization is the exception: its id is its slug, the delete frees the
124
- * slug, and a row left behind would grant whoever creates the slug next.
125
- * So this step does not catch. A fault fails the delete with the
143
+ * organization is the exception: its rows are the grants on the tenancy
144
+ * root itself, and the scope link of every resource under it. The slug is
145
+ * never taken again (slug-ledger.ts), so they cannot pass to a new holder,
146
+ * but a row left behind would still be a grant nobody administers. So this
147
+ * step does not catch. A fault fails the delete with the
126
148
  * organization in place, and a retry resumes where the revocation stopped
127
149
  * (the grant path revokes the organization's owners last, so the owner
128
150
  * who retries still can). The grant path fires the composed driver's
@@ -55,11 +55,13 @@
55
55
  *
56
56
  * The tenth, `org-delete:pre-delete`: the organization delete chain after
57
57
  * LoadExistingForDelete and before any write, so the organization exists
58
- * and is loaded (EXISTING_RESOURCE_KEY) while its steps run. An
59
- * organization's id is its slug and its delete frees the slug for anyone,
60
- * so whatever an edition keeps for the organization must go before the row
61
- * does, or be refused: a row left behind would belong to whoever creates
62
- * the slug next. Its steps may refuse (Enterprise refuses while an
58
+ * and is loaded (EXISTING_RESOURCE_KEY) while its steps run. Whatever an
59
+ * edition keeps for the organization must go before the row does, or be
60
+ * refused: a row left behind is trust or state that nobody administers.
61
+ * (The slug itself is never taken again, domain/organization/slug-ledger.ts,
62
+ * so nothing left behind can pass to a new holder of the slug; the order
63
+ * still keeps an edition's rows from outliving their organization.) Its
64
+ * steps may refuse (Enterprise refuses while an
63
65
  * identity provider still signs in platform-managed organizations) or
64
66
  * remove the edition's own rows; either way a throw fails the delete with
65
67
  * the organization intact, and the retry re-runs every step, so each owns
package/src/index.ts CHANGED
@@ -293,6 +293,7 @@ export { RequestContext } from "./pipeline/request-context.js";
293
293
  export {
294
294
  abortedError,
295
295
  alreadyExistsError,
296
+ alreadyExistsWithReasonError,
296
297
  ERROR_REASON_DOMAIN,
297
298
  failedPreconditionError,
298
299
  internalError,
@@ -398,6 +399,16 @@ export {
398
399
  AuditNotFoundError,
399
400
  ResourceNotFoundError,
400
401
  } from "./store/interface.js";
402
+ // The organization-slug ledger (`Store.organizationSlugs`): an
403
+ // organization's slug is its for good. A composition that keeps its own
404
+ // rows by organization id retires the slugs its history holds through it,
405
+ // and a create of a retired slug carries the reason below.
406
+ export type {
407
+ OrganizationSlugClaim,
408
+ OrganizationSlugEntry,
409
+ OrganizationSlugStore,
410
+ } from "./store/interface.js";
411
+ export { ORGANIZATION_SLUG_RESERVED } from "./domain/organization/slug-ledger.js";
401
412
  // The list index's read shapes, which `Store.queryResources` speaks
402
413
  // (store/list-index.ts). Declaring an index stays internal: the list is
403
414
  // the composition root's (boot/list-indexes.ts), one per server.
@@ -37,10 +37,6 @@ const SRC = path.resolve(HERE, "../..");
37
37
  /** Known blind sites by module (relative to `src`), with their count. */
38
38
  const PENDING: ReadonlyMap<string, number> = new Map([
39
39
  // Store loads, stigmer/stigmer#1345 (fixed domain by domain).
40
- ["domain/agent/controller.ts", 1],
41
- ["domain/agentchannel/controller.ts", 1],
42
- ["domain/agentinstance/controller.ts", 1],
43
- ["domain/agentshare/controller.ts", 1],
44
40
  ["domain/plugin/controller.ts", 1],
45
41
  ["domain/skill/controller.ts", 1],
46
42
  ["domain/workflow/controller.ts", 1],
@@ -53,6 +53,29 @@ export function alreadyExistsError(resource: string, id: string): ConnectError {
53
53
  );
54
54
  }
55
55
 
56
+ /**
57
+ * An AlreadyExists refusal a client acts on without parsing text: the
58
+ * authored message, and the reason carried as an ErrorInfo detail, exactly
59
+ * as failedPreconditionError carries one. The code stays AlreadyExists, so
60
+ * a caller that keys on the code alone (a retry with another name) keeps
61
+ * working.
62
+ */
63
+ export function alreadyExistsWithReasonError(
64
+ message: string,
65
+ reason: RefusalReason,
66
+ ): ConnectError {
67
+ return new ConnectError(message, Code.AlreadyExists, undefined, [
68
+ {
69
+ desc: ErrorInfoSchema,
70
+ value: create(ErrorInfoSchema, {
71
+ reason: reason.reason,
72
+ domain: ERROR_REASON_DOMAIN,
73
+ metadata: { ...reason.metadata },
74
+ }),
75
+ },
76
+ ]);
77
+ }
78
+
56
79
  /**
57
80
  * Go FailedPreconditionError — the system is not in a state required for
58
81
  * the operation (vs AlreadyExists, which tells the caller to stop). With a
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Pins the driver-neutral half of the organization-slug ledger's data
3
+ * migration (organization-slug-history.ts): the frozen kind table is
4
+ * exactly the kinds `api_resource_kind.proto` scoped to an organization
5
+ * when the ledger arrived, spelled out here as the step's statement about
6
+ * the store as it was, each with a schema whose rows carry the shared
7
+ * metadata; a row's
8
+ * organization is read from its bytes, a row with no metadata or an empty
9
+ * organization names none, and bytes that do not decode are a thrown
10
+ * fault, never a skipped row. The drivers' replay tests pin the SQL around
11
+ * these functions.
12
+ */
13
+ import { create, toBinary } from "@bufbuild/protobuf";
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
17
+
18
+ import {
19
+ ORGANIZATION_SCOPED_KINDS_AT_LEDGER,
20
+ organizationNamedBy,
21
+ undecodableRowError,
22
+ } from "../organization-slug-history.js";
23
+
24
+ const AGENT = ORGANIZATION_SCOPED_KINDS_AT_LEDGER.find(
25
+ (entry) => entry.kind === "agent",
26
+ )!;
27
+
28
+ describe("the frozen kind table", () => {
29
+ it("is exactly the kinds api_resource_kind.proto scoped to an organization when the ledger arrived, in its order", () => {
30
+ expect(
31
+ ORGANIZATION_SCOPED_KINDS_AT_LEDGER.map((entry) => entry.kind),
32
+ ).toEqual([
33
+ "iam_policy",
34
+ "invitation",
35
+ "identity_provider",
36
+ "oauth_app",
37
+ "platform_client",
38
+ "team",
39
+ "agent",
40
+ "session",
41
+ "skill",
42
+ "mcp_server",
43
+ "agent_instance",
44
+ "agent_share",
45
+ "agent_channel",
46
+ "channel_app",
47
+ "workflow",
48
+ "workflow_instance",
49
+ "workflow_execution",
50
+ "environment",
51
+ "artifact",
52
+ "schedule",
53
+ "memory",
54
+ "plugin",
55
+ "subscription",
56
+ ]);
57
+ });
58
+
59
+ it("gives every kind a schema whose rows carry the metadata every resource shares", () => {
60
+ for (const entry of ORGANIZATION_SCOPED_KINDS_AT_LEDGER) {
61
+ const metadata = entry.schema.fields.find(
62
+ (field) => field.name === "metadata",
63
+ );
64
+ expect(metadata?.fieldKind, entry.kind).toBe("message");
65
+ }
66
+ });
67
+ });
68
+
69
+ describe("organizationNamedBy", () => {
70
+ it("reads the organization a row names", () => {
71
+ const bytes = toBinary(
72
+ AgentSchema,
73
+ create(AgentSchema, {
74
+ metadata: { id: "agt_1", slug: "helper", org: "acme" },
75
+ spec: { instructions: "a conformant instruction body" },
76
+ }),
77
+ );
78
+ expect(organizationNamedBy(AGENT, bytes)).toBe("acme");
79
+ });
80
+
81
+ it("names no organization for a row with no metadata or an empty org", () => {
82
+ expect(
83
+ organizationNamedBy(AGENT, toBinary(AgentSchema, create(AgentSchema))),
84
+ ).toBe("");
85
+ expect(
86
+ organizationNamedBy(
87
+ AGENT,
88
+ toBinary(
89
+ AgentSchema,
90
+ create(AgentSchema, { metadata: { id: "agt_2", org: "" } }),
91
+ ),
92
+ ),
93
+ ).toBe("");
94
+ });
95
+
96
+ it("throws on bytes that do not decode, and the driver's error names the row", () => {
97
+ const broken = new Uint8Array([0xff, 0xff, 0xff]);
98
+ expect(() => organizationNamedBy(AGENT, broken)).toThrow();
99
+ let caught: unknown;
100
+ try {
101
+ organizationNamedBy(AGENT, broken);
102
+ } catch (error) {
103
+ caught = error;
104
+ }
105
+ expect(undecodableRowError(AGENT, "agt_broken", caught).message).toMatch(
106
+ /^agent 'agt_broken' cannot be read for the organization it names: /,
107
+ );
108
+ });
109
+ });
@@ -17,7 +17,9 @@
17
17
  * token match / single-term prefix / AND; wire-ready 0–1 scores;
18
18
  * list-mode newest-first at exactly 1.0 — search-mode ranking ORDER is
19
19
  * deliberately NOT asserted here, it is driver-relative), the two-phase
20
- * signal-dedupe hold (oss#442), OAuth grants, once-only pending-state
20
+ * signal-dedupe hold (oss#442), the organization-slug ledger (one winner
21
+ * of concurrent claims, a retire that sticks, a release that frees only
22
+ * its own unretired claim), OAuth grants, once-only pending-state
21
23
  * redemption with its 10-minute TTL, the closed-store failure mode, and
22
24
  * the list index (../list-index.ts): one organization's or one parent's
23
25
  * rows newest first, a cursor walk with no gap and no duplicate, keys
@@ -1435,6 +1437,86 @@ export function describeStoreContract(
1435
1437
  });
1436
1438
  });
1437
1439
 
1440
+ describe("organization slugs", () => {
1441
+ it("claims a fresh slug once; a second claim loses to the entry that holds it", async () => {
1442
+ expect(await fx.store.organizationSlugs.find("acme")).toBeUndefined();
1443
+
1444
+ const first = await fx.store.organizationSlugs.claim("acme");
1445
+ expect(first.claimed).toBe(true);
1446
+ expect(first.entry.slug).toBe("acme");
1447
+ expect(first.entry.retiredAt).toBe("");
1448
+ expect(Date.parse(first.entry.claimedAt)).not.toBeNaN();
1449
+
1450
+ const second = await fx.store.organizationSlugs.claim("acme");
1451
+ expect(second.claimed).toBe(false);
1452
+ expect(second.entry).toEqual(first.entry);
1453
+ expect(await fx.store.organizationSlugs.find("acme")).toEqual(
1454
+ first.entry,
1455
+ );
1456
+ });
1457
+
1458
+ it("of concurrent claims of one slug, exactly one wins", async () => {
1459
+ const claims = await Promise.all(
1460
+ Array.from({ length: 8 }, () =>
1461
+ fx.store.organizationSlugs.claim("contended"),
1462
+ ),
1463
+ );
1464
+ expect(claims.filter((claim) => claim.claimed)).toHaveLength(1);
1465
+ const winner = claims.find((claim) => claim.claimed)!;
1466
+ for (const loser of claims.filter((claim) => !claim.claimed)) {
1467
+ expect(loser.entry).toEqual(winner.entry);
1468
+ }
1469
+ });
1470
+
1471
+ it("retire marks a held slug retired, keeps the first time, and records an unknown slug retired", async () => {
1472
+ const { entry } = await fx.store.organizationSlugs.claim("acme");
1473
+ await fx.store.organizationSlugs.retire("acme");
1474
+ const retired = await fx.store.organizationSlugs.find("acme");
1475
+ expect(retired?.claimedAt).toBe(entry.claimedAt);
1476
+ expect(retired?.retiredAt).not.toBe("");
1477
+
1478
+ await fx.store.organizationSlugs.retire("acme");
1479
+ expect(await fx.store.organizationSlugs.find("acme")).toEqual(retired);
1480
+
1481
+ // An organization created before the ledger existed has no entry;
1482
+ // its delete still retires the slug.
1483
+ await fx.store.organizationSlugs.retire("older");
1484
+ const older = await fx.store.organizationSlugs.find("older");
1485
+ expect(older?.retiredAt).not.toBe("");
1486
+
1487
+ const lost = await fx.store.organizationSlugs.claim("older");
1488
+ expect(lost.claimed).toBe(false);
1489
+ expect(lost.entry).toEqual(older);
1490
+ });
1491
+
1492
+ it("release frees only its own unretired claim", async () => {
1493
+ const { entry } = await fx.store.organizationSlugs.claim("acme");
1494
+ await fx.store.organizationSlugs.release(entry);
1495
+ expect(await fx.store.organizationSlugs.find("acme")).toBeUndefined();
1496
+ const reclaimed = await fx.store.organizationSlugs.claim("acme");
1497
+ expect(reclaimed.claimed, "a released slug is claimable at once").toBe(
1498
+ true,
1499
+ );
1500
+
1501
+ // Another create's claim of the same slug carries another time, so
1502
+ // a stale release never frees it.
1503
+ await fx.store.organizationSlugs.release({
1504
+ ...reclaimed.entry,
1505
+ claimedAt: "2000-01-01T00:00:00.000Z",
1506
+ });
1507
+ expect(await fx.store.organizationSlugs.find("acme")).toEqual(
1508
+ reclaimed.entry,
1509
+ );
1510
+
1511
+ // A retired slug is never freed, whoever asks.
1512
+ await fx.store.organizationSlugs.retire("acme");
1513
+ await fx.store.organizationSlugs.release(reclaimed.entry);
1514
+ expect(
1515
+ (await fx.store.organizationSlugs.find("acme"))?.retiredAt,
1516
+ ).not.toBe("");
1517
+ });
1518
+ });
1519
+
1438
1520
  describe("oauth grants", () => {
1439
1521
  const grant: OAuthGrant = {
1440
1522
  identityAccountId: "ida_1",
@@ -1880,6 +1962,9 @@ export function describeStoreContract(
1880
1962
  await expect(fx.store.signalDedupe.release("o", "k")).rejects.toThrow(
1881
1963
  "store is closed",
1882
1964
  );
1965
+ await expect(fx.store.organizationSlugs.claim("o")).rejects.toThrow(
1966
+ "store is closed",
1967
+ );
1883
1968
  });
1884
1969
  });
1885
1970
  }
@@ -328,6 +328,75 @@ export interface SignalDedupeStore {
328
328
  release(org: string, idempotencyKey: string): Promise<void>;
329
329
  }
330
330
 
331
+ // =============================================================================
332
+ // Organization slugs (the ledger of every slug an organization ever took)
333
+ // =============================================================================
334
+
335
+ /**
336
+ * One slug's line in the ledger. An organization's id is its slug, so this
337
+ * is also the history of every organization id the store ever issued.
338
+ */
339
+ export interface OrganizationSlugEntry {
340
+ readonly slug: string;
341
+ /**
342
+ * RFC-3339 time the ledger first recorded the slug: the create's claim,
343
+ * or, for a slug that predates the ledger, the migration or retire that
344
+ * wrote it.
345
+ */
346
+ readonly claimedAt: string;
347
+ /** RFC-3339 time the organization holding it was deleted; "" while it is held. */
348
+ readonly retiredAt: string;
349
+ }
350
+
351
+ /** A claim's outcome: won with its entry, or lost to the entry that holds the slug. */
352
+ export type OrganizationSlugClaim =
353
+ | { readonly claimed: true; readonly entry: OrganizationSlugEntry }
354
+ | { readonly claimed: false; readonly entry: OrganizationSlugEntry };
355
+
356
+ /**
357
+ * The organization-slug ledger: a slug is claimed once, atomically, by the
358
+ * create that takes it, and it is never released by the organization's
359
+ * delete, which retires it instead. The organization row can go; its slug
360
+ * stays taken for good, so nothing a deleted organization left behind
361
+ * (rows that name it in `metadata.org`, an edition's rows kept by its id)
362
+ * can ever pass to a new holder of the same slug.
363
+ *
364
+ * The ledger, not the organization row, decides "taken". A claim is the
365
+ * create's uniqueness guarantee, which the row cannot give: `saveResource`
366
+ * upserts, so two creates that both read "absent" would otherwise both
367
+ * write, the second over the first.
368
+ *
369
+ * An unretired entry is a slug held by a live organization, or by a create
370
+ * between its claim and its row; a retired entry is a slug whose
371
+ * organization was deleted. A caller tells the two apart, never "no row"
372
+ * alone, because a create in flight has a claim and no row yet.
373
+ */
374
+ export interface OrganizationSlugStore {
375
+ /**
376
+ * Claims a slug if no entry holds it, atomically: of concurrent claims,
377
+ * exactly one wins. A loser receives the entry that holds the slug,
378
+ * retired or not.
379
+ */
380
+ claim(slug: string): Promise<OrganizationSlugClaim>;
381
+ /**
382
+ * Marks the slug retired: sets `retiredAt` on its entry when it is
383
+ * empty, or records the slug already retired when no entry exists (an
384
+ * organization created before the ledger). Idempotent; a second retire
385
+ * keeps the first time.
386
+ */
387
+ retire(slug: string): Promise<void>;
388
+ /**
389
+ * Frees a claim whose create failed before its organization was stored,
390
+ * so the caller's retry can claim again. Guarded: only the unretired
391
+ * entry carrying this exact `claimedAt` is removed, so a release can
392
+ * never free a retired slug or another create's claim. Anything else is
393
+ * a no-op.
394
+ */
395
+ release(entry: OrganizationSlugEntry): Promise<void>;
396
+ /** The slug's entry, or undefined when no organization ever took it. */
397
+ find(slug: string): Promise<OrganizationSlugEntry | undefined>;
398
+ }
399
+
331
400
  // =============================================================================
332
401
  // MCP OAuth (Go: pkg/domain/mcpserver/oauth, tables consolidated by v7 / OD-3)
333
402
  // =============================================================================
@@ -863,6 +932,7 @@ export interface Store {
863
932
 
864
933
  readonly bootstrapState: BootstrapStateStore;
865
934
  readonly signalDedupe: SignalDedupeStore;
935
+ readonly organizationSlugs: OrganizationSlugStore;
866
936
  readonly oauthGrants: OAuthGrantStore;
867
937
  readonly pendingOAuthStates: PendingOAuthStateStore;
868
938