@stigmer/server 3.38.3 → 3.39.1-dev.20260928194002

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 (120) 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/agentinstance/steps.d.ts +3 -1
  14. package/dist/domain/agentinstance/steps.d.ts.map +1 -1
  15. package/dist/domain/agentinstance/steps.js +12 -6
  16. package/dist/domain/agentinstance/steps.js.map +1 -1
  17. package/dist/domain/agentshare/controller.d.ts +2 -2
  18. package/dist/domain/agentshare/controller.d.ts.map +1 -1
  19. package/dist/domain/agentshare/controller.js +7 -3
  20. package/dist/domain/agentshare/controller.js.map +1 -1
  21. package/dist/domain/organization/controller.d.ts.map +1 -1
  22. package/dist/domain/organization/controller.js +33 -14
  23. package/dist/domain/organization/controller.js.map +1 -1
  24. package/dist/domain/organization/slug-ledger.d.ts +86 -0
  25. package/dist/domain/organization/slug-ledger.d.ts.map +1 -0
  26. package/dist/domain/organization/slug-ledger.js +128 -0
  27. package/dist/domain/organization/slug-ledger.js.map +1 -0
  28. package/dist/domain/organization/steps.d.ts +12 -3
  29. package/dist/domain/organization/steps.d.ts.map +1 -1
  30. package/dist/domain/organization/steps.js +28 -6
  31. package/dist/domain/organization/steps.js.map +1 -1
  32. package/dist/domain/plugin/controller.d.ts.map +1 -1
  33. package/dist/domain/plugin/controller.js +10 -5
  34. package/dist/domain/plugin/controller.js.map +1 -1
  35. package/dist/domain/skill/controller.d.ts.map +1 -1
  36. package/dist/domain/skill/controller.js +7 -3
  37. package/dist/domain/skill/controller.js.map +1 -1
  38. package/dist/domain/workflow/controller.js +6 -3
  39. package/dist/domain/workflow/controller.js.map +1 -1
  40. package/dist/domain/workflowinstance/controller.d.ts +2 -2
  41. package/dist/domain/workflowinstance/controller.d.ts.map +1 -1
  42. package/dist/domain/workflowinstance/controller.js +10 -5
  43. package/dist/domain/workflowinstance/controller.js.map +1 -1
  44. package/dist/domain/workflowinstance/steps.d.ts +5 -3
  45. package/dist/domain/workflowinstance/steps.d.ts.map +1 -1
  46. package/dist/domain/workflowinstance/steps.js +14 -9
  47. package/dist/domain/workflowinstance/steps.js.map +1 -1
  48. package/dist/extensions/gate-slots.d.ts +7 -5
  49. package/dist/extensions/gate-slots.d.ts.map +1 -1
  50. package/dist/extensions/gate-slots.js.map +1 -1
  51. package/dist/index.d.ts +3 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +2 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/pipeline/errors.d.ts +8 -0
  56. package/dist/pipeline/errors.d.ts.map +1 -1
  57. package/dist/pipeline/errors.js +19 -0
  58. package/dist/pipeline/errors.js.map +1 -1
  59. package/dist/store/interface.d.ts +67 -0
  60. package/dist/store/interface.d.ts.map +1 -1
  61. package/dist/store/interface.js.map +1 -1
  62. package/dist/store/organization-slug-history.d.ts +71 -0
  63. package/dist/store/organization-slug-history.d.ts.map +1 -0
  64. package/dist/store/organization-slug-history.js +88 -0
  65. package/dist/store/organization-slug-history.js.map +1 -0
  66. package/dist/store/postgres/migrations.d.ts +3 -1
  67. package/dist/store/postgres/migrations.d.ts.map +1 -1
  68. package/dist/store/postgres/migrations.js +69 -1
  69. package/dist/store/postgres/migrations.js.map +1 -1
  70. package/dist/store/postgres/store.d.ts +2 -1
  71. package/dist/store/postgres/store.d.ts.map +1 -1
  72. package/dist/store/postgres/store.js +49 -0
  73. package/dist/store/postgres/store.js.map +1 -1
  74. package/dist/store/sqlite/migrations.d.ts +3 -1
  75. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  76. package/dist/store/sqlite/migrations.js +54 -1
  77. package/dist/store/sqlite/migrations.js.map +1 -1
  78. package/dist/store/sqlite/store.d.ts +2 -1
  79. package/dist/store/sqlite/store.d.ts.map +1 -1
  80. package/dist/store/sqlite/store.js +55 -0
  81. package/dist/store/sqlite/store.js.map +1 -1
  82. package/package.json +6 -6
  83. package/src/domain/agent/__tests__/store-faults.test.ts +105 -0
  84. package/src/domain/agent/controller.ts +9 -5
  85. package/src/domain/agentchannel/__tests__/store-faults.test.ts +126 -0
  86. package/src/domain/agentchannel/controller.ts +12 -7
  87. package/src/domain/agentinstance/__tests__/store-faults.test.ts +161 -0
  88. package/src/domain/agentinstance/controller.ts +9 -5
  89. package/src/domain/agentinstance/steps.ts +12 -6
  90. package/src/domain/agentshare/__tests__/store-faults.test.ts +101 -0
  91. package/src/domain/agentshare/controller.ts +9 -5
  92. package/src/domain/organization/__tests__/organization-delete.test.ts +73 -18
  93. package/src/domain/organization/__tests__/organization-slugs.test.ts +386 -0
  94. package/src/domain/organization/controller.ts +36 -14
  95. package/src/domain/organization/slug-ledger.ts +209 -0
  96. package/src/domain/organization/steps.ts +28 -6
  97. package/src/domain/plugin/__tests__/store-faults.test.ts +104 -0
  98. package/src/domain/plugin/controller.ts +10 -5
  99. package/src/domain/skill/__tests__/store-faults.test.ts +103 -0
  100. package/src/domain/skill/controller.ts +7 -3
  101. package/src/domain/workflow/__tests__/store-faults.test.ts +107 -0
  102. package/src/domain/workflow/controller.ts +6 -3
  103. package/src/domain/workflowinstance/__tests__/store-faults.test.ts +172 -0
  104. package/src/domain/workflowinstance/controller.ts +12 -7
  105. package/src/domain/workflowinstance/steps.ts +14 -10
  106. package/src/extensions/gate-slots.ts +7 -5
  107. package/src/index.ts +11 -0
  108. package/src/pipeline/__tests__/blind-not-found.test.ts +4 -24
  109. package/src/pipeline/errors.ts +23 -0
  110. package/src/store/__tests__/organization-slug-history.test.ts +109 -0
  111. package/src/store/__tests__/store-contract.ts +86 -1
  112. package/src/store/interface.ts +70 -0
  113. package/src/store/organization-slug-history.ts +159 -0
  114. package/src/store/postgres/__tests__/migrations.test.ts +138 -2
  115. package/src/store/postgres/__tests__/store-contract.test.ts +1 -0
  116. package/src/store/postgres/migrations.ts +84 -1
  117. package/src/store/postgres/store.ts +84 -0
  118. package/src/store/sqlite/__tests__/migrations.test.ts +139 -10
  119. package/src/store/sqlite/migrations.ts +69 -1
  120. package/src/store/sqlite/store.ts +84 -0
@@ -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
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pins the store-fault contract of the plugin's visibility-update load: a
3
+ * typed ResourceNotFoundError answers NotFound with the domain's pinned copy
4
+ * (`plugin not found: <id>`), and any other store failure is an
5
+ * infrastructure fault answered as a sanitized Internal, never a NotFound that
6
+ * tells a client the plugin does not exist (stigmer/stigmer#1345).
7
+ *
8
+ * The surface is updateVisibility, reached through the registered handler on
9
+ * an in-process router. The composed suites reach only a real store, which
10
+ * cannot fail selectively, so it runs here against a store whose read throws.
11
+ * The archive store and the member materializer are untouchable: a load that
12
+ * fails must stop the call before the plugin's level fans out to its members.
13
+ *
14
+ * Out of scope: the NotFound copy itself (wire contract).
15
+ */
16
+ import type { Client, ConnectError } from "@connectrpc/connect";
17
+ import { Code, createClient, createRouterTransport } from "@connectrpc/connect";
18
+ import { describe, expect, it } from "vitest";
19
+
20
+ import { PluginCommandController } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/command_pb";
21
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
22
+
23
+ import { createLogger } from "../../../boot/logger.js";
24
+ import { createApiResourceInterceptor } from "../../../pipeline/interceptors/apiresource.js";
25
+ import { createVerifierChainInterceptor } from "../../../pipeline/interceptors/auth.js";
26
+ import {
27
+ errorOf,
28
+ failingStore,
29
+ untouchable,
30
+ } from "../../../pipeline/__tests__/support.js";
31
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
32
+ import { ResourceNotFoundError } from "../../../store/interface.js";
33
+ import type { Store } from "../../../store/interface.js";
34
+
35
+ import { registerPluginServices } from "../controller.js";
36
+
37
+ const silentLogger = createLogger({
38
+ level: "error",
39
+ pretty: false,
40
+ write: () => {},
41
+ });
42
+
43
+ const PLUGIN_ID = "plg_storefault";
44
+
45
+ const NOT_FOUND_COPY = `plugin not found: ${PLUGIN_ID}`;
46
+ const LOAD_FAULT_COPY = "failed to load plugin";
47
+
48
+ const MISSING = (): Error => new ResourceNotFoundError(`plugin/${PLUGIN_ID}`);
49
+ const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
50
+
51
+ /**
52
+ * The registered handlers on an in-process router: the verifier chain stamps
53
+ * the trusted-local caller and the apiresource interceptor the kind, then
54
+ * Authorize and ValidateProto run and the load is the first step that reads
55
+ * the store.
56
+ */
57
+ function pluginCommand(store: Store): Client<typeof PluginCommandController> {
58
+ const transport = createRouterTransport(
59
+ (router) => {
60
+ registerPluginServices(router, {
61
+ store,
62
+ logger: silentLogger,
63
+ authorizer: newPermissiveSingleTeamAuthorizer(),
64
+ authorizationLifecycle: undefined,
65
+ artifactStorage: untouchable("artifactStorage"),
66
+ materializerProvider: untouchable("materializerProvider"),
67
+ });
68
+ },
69
+ {
70
+ router: {
71
+ interceptors: [
72
+ createVerifierChainInterceptor([], [], silentLogger),
73
+ createApiResourceInterceptor(),
74
+ ],
75
+ },
76
+ },
77
+ );
78
+ return createClient(PluginCommandController, transport);
79
+ }
80
+
81
+ describe("updateVisibility — LoadPluginForVisibilityUpdate", () => {
82
+ function surfaceError(store: Store): Promise<ConnectError> {
83
+ return errorOf(() =>
84
+ pluginCommand(store).updateVisibility({
85
+ resourceId: PLUGIN_ID,
86
+ visibility: ApiResourceVisibility.visibility_private,
87
+ }),
88
+ );
89
+ }
90
+
91
+ it("a missing plugin answers NotFound with the domain's copy", async () => {
92
+ const error = await surfaceError(failingStore(MISSING()));
93
+
94
+ expect(error.code).toBe(Code.NotFound);
95
+ expect(error.rawMessage).toBe(NOT_FOUND_COPY);
96
+ });
97
+
98
+ it("any other store failure answers a sanitized Internal", async () => {
99
+ const error = await surfaceError(failingStore(LOCKED()));
100
+
101
+ expect(error.code).toBe(Code.Internal);
102
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
103
+ });
104
+ });
@@ -16,8 +16,9 @@
16
16
  * in-process clients it rides exist only after the routes this controller
17
17
  * is registered in.
18
18
  *
19
- * Proven by __tests__/plugin.test.ts (composed-server round-trips) and
20
- * plugin.conformance.test.ts (CONFORMANCE_TARGET=local, local-postgres).
19
+ * Proven by __tests__/plugin.test.ts (composed-server round-trips),
20
+ * __tests__/store-faults.test.ts and plugin.conformance.test.ts
21
+ * (CONFORMANCE_TARGET=local, local-postgres).
21
22
  */
22
23
  import { create, fromBinary } from "@bufbuild/protobuf";
23
24
  import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
@@ -104,6 +105,7 @@ import {
104
105
  } from "../../pipeline/steps/authorize-resolved-target.js";
105
106
  import type { VersionHistoryBinding } from "../../pipeline/steps/version-history.js";
106
107
  import { metadataOf } from "../../pipeline/steps/shapes.js";
108
+ import { ResourceNotFoundError } from "../../store/interface.js";
107
109
  import type { Store } from "../../store/interface.js";
108
110
  import type {
109
111
  ArchiveStaging,
@@ -386,7 +388,7 @@ async function updateVisibility(
386
388
  return reqCtx.get(UPDATE_VISIBILITY_PLUGIN_KEY) as Plugin;
387
389
  }
388
390
 
389
- /** Loads the plugin by resource_id; ANY load failure → NotFound. */
391
+ /** Loads the plugin by resource_id; a missing one answers NotFound, a store fault Internal. */
390
392
  function newLoadPluginByIdStep(
391
393
  store: Store,
392
394
  key: string,
@@ -401,8 +403,11 @@ function newLoadPluginByIdStep(
401
403
  ctx.input.resourceId,
402
404
  PluginSchema,
403
405
  );
404
- } catch {
405
- throw notFoundError("plugin", ctx.input.resourceId);
406
+ } catch (error) {
407
+ if (error instanceof ResourceNotFoundError) {
408
+ throw notFoundError("plugin", ctx.input.resourceId);
409
+ }
410
+ throw internalError(error, "failed to load plugin");
406
411
  }
407
412
  ctx.set(key, plugin);
408
413
  },
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Pins the store-fault contract of the skill's visibility-update load: a
3
+ * typed ResourceNotFoundError answers NotFound with the domain's pinned copy
4
+ * (`skill not found: <id>`), and any other store failure is an
5
+ * infrastructure fault answered as a sanitized Internal, never a NotFound that
6
+ * tells a client the skill does not exist (stigmer/stigmer#1345).
7
+ *
8
+ * The surface is updateVisibility, reached through the registered handler on
9
+ * an in-process router. The composed suites reach only a real store, which
10
+ * cannot fail selectively, so it runs here against a store whose read throws.
11
+ * The artifact storage is untouchable: a load that fails must stop the call
12
+ * before anything past it runs.
13
+ *
14
+ * Out of scope: the NotFound copy itself (wire contract).
15
+ */
16
+ import type { Client, ConnectError } from "@connectrpc/connect";
17
+ import { Code, createClient, createRouterTransport } from "@connectrpc/connect";
18
+ import { describe, expect, it } from "vitest";
19
+
20
+ import { SkillCommandController } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/command_pb";
21
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
22
+
23
+ import { createLogger } from "../../../boot/logger.js";
24
+ import { createApiResourceInterceptor } from "../../../pipeline/interceptors/apiresource.js";
25
+ import { createVerifierChainInterceptor } from "../../../pipeline/interceptors/auth.js";
26
+ import {
27
+ errorOf,
28
+ failingStore,
29
+ untouchable,
30
+ } from "../../../pipeline/__tests__/support.js";
31
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
32
+ import { ResourceNotFoundError } from "../../../store/interface.js";
33
+ import type { Store } from "../../../store/interface.js";
34
+
35
+ import { registerSkillServices } from "../controller.js";
36
+
37
+ const silentLogger = createLogger({
38
+ level: "error",
39
+ pretty: false,
40
+ write: () => {},
41
+ });
42
+
43
+ const SKILL_ID = "skl_storefault";
44
+
45
+ const NOT_FOUND_COPY = `skill not found: ${SKILL_ID}`;
46
+ const LOAD_FAULT_COPY = "failed to load skill";
47
+
48
+ const MISSING = (): Error => new ResourceNotFoundError(`skill/${SKILL_ID}`);
49
+ const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
50
+
51
+ /**
52
+ * The registered handlers on an in-process router: the verifier chain stamps
53
+ * the trusted-local caller and the apiresource interceptor the kind, then
54
+ * Authorize and ValidateProto run and the load is the first step that reads
55
+ * the store.
56
+ */
57
+ function skillCommand(store: Store): Client<typeof SkillCommandController> {
58
+ const transport = createRouterTransport(
59
+ (router) => {
60
+ registerSkillServices(router, {
61
+ store,
62
+ logger: silentLogger,
63
+ authorizer: newPermissiveSingleTeamAuthorizer(),
64
+ authorizationLifecycle: undefined,
65
+ artifactStorage: untouchable("artifactStorage"),
66
+ });
67
+ },
68
+ {
69
+ router: {
70
+ interceptors: [
71
+ createVerifierChainInterceptor([], [], silentLogger),
72
+ createApiResourceInterceptor(),
73
+ ],
74
+ },
75
+ },
76
+ );
77
+ return createClient(SkillCommandController, transport);
78
+ }
79
+
80
+ describe("updateVisibility — LoadSkillForVisibilityUpdate", () => {
81
+ function surfaceError(store: Store): Promise<ConnectError> {
82
+ return errorOf(() =>
83
+ skillCommand(store).updateVisibility({
84
+ resourceId: SKILL_ID,
85
+ visibility: ApiResourceVisibility.visibility_private,
86
+ }),
87
+ );
88
+ }
89
+
90
+ it("a missing skill answers NotFound with the domain's copy", async () => {
91
+ const error = await surfaceError(failingStore(MISSING()));
92
+
93
+ expect(error.code).toBe(Code.NotFound);
94
+ expect(error.rawMessage).toBe(NOT_FOUND_COPY);
95
+ });
96
+
97
+ it("any other store failure answers a sanitized Internal", async () => {
98
+ const error = await surfaceError(failingStore(LOCKED()));
99
+
100
+ expect(error.code).toBe(Code.Internal);
101
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
102
+ });
103
+ });
@@ -99,6 +99,7 @@ import { newGuardReservedLabelsStep } from "../../pipeline/steps/guard-reserved-
99
99
  import { newValidateProtoStep } from "../../pipeline/steps/validation.js";
100
100
  import { newValidateVisibilityUpdateStep } from "../../pipeline/steps/validate-visibility.js";
101
101
  import { newDeleteVersionArchivesStep } from "../../pipeline/steps/version-archive.js";
102
+ import { ResourceNotFoundError } from "../../store/interface.js";
102
103
  import type { Store } from "../../store/interface.js";
103
104
  import { MAX_ZIP_SIZE, TRANSFER_LANE_NOT_CONFIGURED } from "./constants.js";
104
105
  import {
@@ -463,7 +464,7 @@ async function updateVisibility(
463
464
  return reqCtx.get(UPDATE_VISIBILITY_SKILL_KEY) as Skill;
464
465
  }
465
466
 
466
- /** Loads the skill by resource_id; ANY load failure → NotFound. */
467
+ /** Loads the skill by resource_id; a missing one answers NotFound, a store fault Internal. */
467
468
  function newLoadSkillForVisibilityUpdateStep(
468
469
  store: Store,
469
470
  ): PipelineStep<UpdateVisibilityDesc> {
@@ -478,8 +479,11 @@ function newLoadSkillForVisibilityUpdateStep(
478
479
  input.resourceId,
479
480
  SkillSchema,
480
481
  );
481
- } catch {
482
- throw notFoundError("skill", input.resourceId);
482
+ } catch (error) {
483
+ if (error instanceof ResourceNotFoundError) {
484
+ throw notFoundError("skill", input.resourceId);
485
+ }
486
+ throw internalError(error, "failed to load skill");
483
487
  }
484
488
  ctx.set(UPDATE_VISIBILITY_SKILL_KEY, skill);
485
489
  },
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pins the store-fault contract of the workflow's visibility-update load: a
3
+ * typed ResourceNotFoundError answers NotFound with the domain's pinned copy
4
+ * (`workflow not found: <id>`), and any other store failure is an
5
+ * infrastructure fault answered as a sanitized Internal, never a NotFound that
6
+ * tells a client the workflow does not exist (stigmer/stigmer#1345).
7
+ *
8
+ * The surface is updateVisibility, reached through the registered handler on
9
+ * an in-process router. The composed suites reach only a real store, which
10
+ * cannot fail selectively, so it runs here against a store whose read throws.
11
+ * The validator and the default-instance creator are untouchable: a load that
12
+ * fails must stop the call before anything past it runs.
13
+ *
14
+ * Out of scope: the NotFound copy itself (wire contract).
15
+ */
16
+ import type { Client, ConnectError } from "@connectrpc/connect";
17
+ import { Code, createClient, createRouterTransport } from "@connectrpc/connect";
18
+ import { describe, expect, it } from "vitest";
19
+
20
+ import { WorkflowCommandController } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/command_pb";
21
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
22
+
23
+ import { createLogger } from "../../../boot/logger.js";
24
+ import { createApiResourceInterceptor } from "../../../pipeline/interceptors/apiresource.js";
25
+ import { createVerifierChainInterceptor } from "../../../pipeline/interceptors/auth.js";
26
+ import {
27
+ errorOf,
28
+ failingStore,
29
+ untouchable,
30
+ } from "../../../pipeline/__tests__/support.js";
31
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
32
+ import { ResourceNotFoundError } from "../../../store/interface.js";
33
+ import type { Store } from "../../../store/interface.js";
34
+
35
+ import { registerWorkflowServices } from "../controller.js";
36
+
37
+ const silentLogger = createLogger({
38
+ level: "error",
39
+ pretty: false,
40
+ write: () => {},
41
+ });
42
+
43
+ const WORKFLOW_ID = "wfl_storefault";
44
+
45
+ const NOT_FOUND_COPY = `workflow not found: ${WORKFLOW_ID}`;
46
+ const LOAD_FAULT_COPY = "failed to load workflow";
47
+
48
+ const MISSING = (): Error =>
49
+ new ResourceNotFoundError(`workflow/${WORKFLOW_ID}`);
50
+ const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
51
+
52
+ /**
53
+ * The registered handlers on an in-process router: the verifier chain stamps
54
+ * the trusted-local caller and the apiresource interceptor the kind, then
55
+ * Authorize and ValidateProto run and the load is the first step that reads
56
+ * the store.
57
+ */
58
+ function workflowCommand(
59
+ store: Store,
60
+ ): Client<typeof WorkflowCommandController> {
61
+ const transport = createRouterTransport(
62
+ (router) => {
63
+ registerWorkflowServices(router, {
64
+ store,
65
+ logger: silentLogger,
66
+ authorizer: newPermissiveSingleTeamAuthorizer(),
67
+ authorizationLifecycle: undefined,
68
+ validator: untouchable("validator"),
69
+ workflowInstanceCreator: untouchable("workflowInstanceCreator"),
70
+ });
71
+ },
72
+ {
73
+ router: {
74
+ interceptors: [
75
+ createVerifierChainInterceptor([], [], silentLogger),
76
+ createApiResourceInterceptor(),
77
+ ],
78
+ },
79
+ },
80
+ );
81
+ return createClient(WorkflowCommandController, transport);
82
+ }
83
+
84
+ describe("updateVisibility — LoadWorkflowForVisibilityUpdate", () => {
85
+ function surfaceError(store: Store): Promise<ConnectError> {
86
+ return errorOf(() =>
87
+ workflowCommand(store).updateVisibility({
88
+ resourceId: WORKFLOW_ID,
89
+ visibility: ApiResourceVisibility.visibility_private,
90
+ }),
91
+ );
92
+ }
93
+
94
+ it("a missing workflow answers NotFound with the domain's copy", async () => {
95
+ const error = await surfaceError(failingStore(MISSING()));
96
+
97
+ expect(error.code).toBe(Code.NotFound);
98
+ expect(error.rawMessage).toBe(NOT_FOUND_COPY);
99
+ });
100
+
101
+ it("any other store failure answers a sanitized Internal", async () => {
102
+ const error = await surfaceError(failingStore(LOCKED()));
103
+
104
+ expect(error.code).toBe(Code.Internal);
105
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
106
+ });
107
+ });
@@ -492,7 +492,7 @@ async function updateVisibility(
492
492
  return reqCtx.get(UPDATE_VISIBILITY_WORKFLOW_KEY) as Workflow;
493
493
  }
494
494
 
495
- /** Loads the workflow by resource_id; ANY load failure → NotFound. */
495
+ /** Loads the workflow by resource_id; a missing one answers NotFound, a store fault Internal. */
496
496
  function newLoadWorkflowForVisibilityUpdateStep(
497
497
  store: Store,
498
498
  ): PipelineStep<UpdateVisibilityDesc> {
@@ -507,8 +507,11 @@ function newLoadWorkflowForVisibilityUpdateStep(
507
507
  input.resourceId,
508
508
  WorkflowSchema,
509
509
  );
510
- } catch {
511
- throw notFoundError("workflow", input.resourceId);
510
+ } catch (error) {
511
+ if (error instanceof ResourceNotFoundError) {
512
+ throw notFoundError("workflow", input.resourceId);
513
+ }
514
+ throw internalError(error, "failed to load workflow");
512
515
  }
513
516
  ctx.set(UPDATE_VISIBILITY_WORKFLOW_KEY, workflow);
514
517
  },
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Pins the store-fault contract of the workflow instance's loads. The shared
3
+ * targeted-update load (updateVisibility and updateExecutionVisibility): a
4
+ * typed ResourceNotFoundError answers NotFound with the domain's pinned copy
5
+ * (`workflow instance not found: <id>`), and any other store failure is an
6
+ * infrastructure fault answered as a sanitized Internal, never a NotFound that
7
+ * tells a client the instance does not exist (stigmer/stigmer#1345). Create's
8
+ * parent-workflow load reads the workflow through the in-process query
9
+ * client, so the store's typed miss arrives as a Connect NotFound: that
10
+ * answers the pinned `Workflow not found: <id>`, and any other failure an
11
+ * Internal, never a missing parent (stigmer/stigmer#1351).
12
+ *
13
+ * Every surface is reached through the registered handler on an in-process
14
+ * router. The composed suites reach only a real store, which cannot fail
15
+ * selectively, so the updates run against a store whose read throws and
16
+ * create against a parent loader whose read throws. Whatever the surface does
17
+ * not need is untouchable: a load that fails must stop the call before
18
+ * anything past it runs.
19
+ *
20
+ * Out of scope: the NotFound copies themselves (wire contract).
21
+ */
22
+ import type { Client } from "@connectrpc/connect";
23
+ import {
24
+ Code,
25
+ ConnectError,
26
+ createClient,
27
+ createRouterTransport,
28
+ } from "@connectrpc/connect";
29
+ import { describe, expect, it } from "vitest";
30
+
31
+ import { WorkflowInstanceCommandController } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/command_pb";
32
+ import { WorkflowExecutionVisibility } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/spec_pb";
33
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
34
+
35
+ import { createLogger } from "../../../boot/logger.js";
36
+ import { createApiResourceInterceptor } from "../../../pipeline/interceptors/apiresource.js";
37
+ import { createVerifierChainInterceptor } from "../../../pipeline/interceptors/auth.js";
38
+ import {
39
+ errorOf,
40
+ failingStore,
41
+ untouchable,
42
+ } from "../../../pipeline/__tests__/support.js";
43
+ import { INTERNAL_FALLBACK_MESSAGE } from "../../../pipeline/pipeline.js";
44
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
45
+ import { ResourceNotFoundError } from "../../../store/interface.js";
46
+ import type { Store } from "../../../store/interface.js";
47
+
48
+ import { registerWorkflowInstanceServices } from "../controller.js";
49
+ import type { ParentWorkflowLoaderProvider } from "../steps.js";
50
+
51
+ const silentLogger = createLogger({
52
+ level: "error",
53
+ pretty: false,
54
+ write: () => {},
55
+ });
56
+
57
+ const INSTANCE_ID = "win_storefault";
58
+ const WORKFLOW_ID = "wfl_storefault";
59
+
60
+ const NOT_FOUND_COPY = `workflow instance not found: ${INSTANCE_ID}`;
61
+ const LOAD_FAULT_COPY = "failed to load workflow instance";
62
+ const PARENT_NOT_FOUND_COPY = `Workflow not found: ${WORKFLOW_ID}`;
63
+ const PARENT_LOAD_FAULT_COPY = "failed to load parent workflow";
64
+
65
+ const MISSING = (): Error =>
66
+ new ResourceNotFoundError(`workflow_instance/${INSTANCE_ID}`);
67
+ const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
68
+
69
+ /** What the workflow query's `get` answers for a missing and a faulted workflow. */
70
+ const PARENT_MISSING = (): Error =>
71
+ new ConnectError(`Workflow not found: ${WORKFLOW_ID}`, Code.NotFound);
72
+ const PARENT_FAULTED = (): Error =>
73
+ new ConnectError(INTERNAL_FALLBACK_MESSAGE, Code.Internal);
74
+
75
+ /**
76
+ * The registered handlers on an in-process router: the verifier chain stamps
77
+ * the trusted-local caller and the apiresource interceptor the kind, then
78
+ * Authorize and ValidateProto run and the load is the first step that reads
79
+ * the store or the parent.
80
+ */
81
+ function instanceCommand(
82
+ store: Store,
83
+ parentWorkflowLoader: ParentWorkflowLoaderProvider = untouchable(
84
+ "parentWorkflowLoader",
85
+ ),
86
+ ): Client<typeof WorkflowInstanceCommandController> {
87
+ const transport = createRouterTransport(
88
+ (router) => {
89
+ registerWorkflowInstanceServices(router, {
90
+ store,
91
+ logger: silentLogger,
92
+ authorizer: newPermissiveSingleTeamAuthorizer(),
93
+ authorizationLifecycle: undefined,
94
+ parentWorkflowLoader,
95
+ listReadScope: undefined,
96
+ });
97
+ },
98
+ {
99
+ router: {
100
+ interceptors: [
101
+ createVerifierChainInterceptor([], [], silentLogger),
102
+ createApiResourceInterceptor(),
103
+ ],
104
+ },
105
+ },
106
+ );
107
+ return createClient(WorkflowInstanceCommandController, transport);
108
+ }
109
+
110
+ describe.each([
111
+ {
112
+ surface: "updateVisibility — LoadInstanceForVisibilityUpdate",
113
+ call: (store: Store) =>
114
+ instanceCommand(store).updateVisibility({
115
+ resourceId: INSTANCE_ID,
116
+ visibility: ApiResourceVisibility.visibility_private,
117
+ }),
118
+ },
119
+ {
120
+ surface:
121
+ "updateExecutionVisibility — LoadInstanceForExecutionVisibilityUpdate",
122
+ call: (store: Store) =>
123
+ instanceCommand(store).updateExecutionVisibility({
124
+ resourceId: INSTANCE_ID,
125
+ executionVisibility: WorkflowExecutionVisibility.private,
126
+ }),
127
+ },
128
+ ])("$surface", ({ call }) => {
129
+ it("a missing workflow instance answers NotFound with the domain's copy", async () => {
130
+ const error = await errorOf(() => call(failingStore(MISSING())));
131
+
132
+ expect(error.code).toBe(Code.NotFound);
133
+ expect(error.rawMessage).toBe(NOT_FOUND_COPY);
134
+ });
135
+
136
+ it("any other store failure answers a sanitized Internal", async () => {
137
+ const error = await errorOf(() => call(failingStore(LOCKED())));
138
+
139
+ expect(error.code).toBe(Code.Internal);
140
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
141
+ });
142
+ });
143
+
144
+ describe("create — LoadParentWorkflow", () => {
145
+ function surfaceError(parentError: Error): Promise<ConnectError> {
146
+ const failingParent: ParentWorkflowLoaderProvider = () => ({
147
+ get: () => Promise.reject(parentError),
148
+ });
149
+ return errorOf(() =>
150
+ instanceCommand(untouchable("store"), failingParent).create({
151
+ apiVersion: "agentic.stigmer.ai/v1",
152
+ kind: "WorkflowInstance",
153
+ metadata: { name: "Store Fault Instance", org: "org_storefault" },
154
+ spec: { workflowId: WORKFLOW_ID },
155
+ }),
156
+ );
157
+ }
158
+
159
+ it("a missing parent workflow answers NotFound with the pinned copy", async () => {
160
+ const error = await surfaceError(PARENT_MISSING());
161
+
162
+ expect(error.code).toBe(Code.NotFound);
163
+ expect(error.rawMessage).toBe(PARENT_NOT_FOUND_COPY);
164
+ });
165
+
166
+ it("any other parent-load failure answers a sanitized Internal", async () => {
167
+ const error = await surfaceError(PARENT_FAULTED());
168
+
169
+ expect(error.code).toBe(Code.Internal);
170
+ expect(error.rawMessage).toBe(PARENT_LOAD_FAULT_COPY);
171
+ });
172
+ });