@stigmer/server 3.38.1 → 3.38.3

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 (51) hide show
  1. package/dist/domain/agentexecution/controller.js +7 -3
  2. package/dist/domain/agentexecution/controller.js.map +1 -1
  3. package/dist/domain/agentexecution/update-status.d.ts +9 -2
  4. package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
  5. package/dist/domain/agentexecution/update-status.js +28 -5
  6. package/dist/domain/agentexecution/update-status.js.map +1 -1
  7. package/dist/domain/agentexecution/validate-thinking-mode.d.ts +31 -21
  8. package/dist/domain/agentexecution/validate-thinking-mode.d.ts.map +1 -1
  9. package/dist/domain/agentexecution/validate-thinking-mode.js +61 -18
  10. package/dist/domain/agentexecution/validate-thinking-mode.js.map +1 -1
  11. package/dist/domain/artifact/controller.d.ts.map +1 -1
  12. package/dist/domain/artifact/controller.js +9 -6
  13. package/dist/domain/artifact/controller.js.map +1 -1
  14. package/dist/domain/environment/controller.d.ts +2 -2
  15. package/dist/domain/environment/controller.js +7 -3
  16. package/dist/domain/environment/controller.js.map +1 -1
  17. package/dist/domain/environment/steps.d.ts +2 -1
  18. package/dist/domain/environment/steps.d.ts.map +1 -1
  19. package/dist/domain/environment/steps.js +10 -7
  20. package/dist/domain/environment/steps.js.map +1 -1
  21. package/dist/domain/workflow/registry/data/model-registry.json +23 -14
  22. package/dist/domain/workflow/registry/model-registry-store.d.ts +14 -3
  23. package/dist/domain/workflow/registry/model-registry-store.d.ts.map +1 -1
  24. package/dist/domain/workflow/registry/model-registry-store.js +14 -3
  25. package/dist/domain/workflow/registry/model-registry-store.js.map +1 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/pipeline/errors.d.ts +12 -13
  31. package/dist/pipeline/errors.d.ts.map +1 -1
  32. package/dist/pipeline/errors.js +29 -3
  33. package/dist/pipeline/errors.js.map +1 -1
  34. package/package.json +6 -6
  35. package/src/domain/agentexecution/__tests__/run-gate.test.ts +42 -1
  36. package/src/domain/agentexecution/__tests__/update-status.test.ts +58 -2
  37. package/src/domain/agentexecution/__tests__/validate-thinking-mode.test.ts +209 -61
  38. package/src/domain/agentexecution/controller.ts +7 -3
  39. package/src/domain/agentexecution/update-status.ts +43 -3
  40. package/src/domain/agentexecution/validate-thinking-mode.ts +102 -47
  41. package/src/domain/artifact/__tests__/store-faults.test.ts +117 -0
  42. package/src/domain/artifact/controller.ts +12 -6
  43. package/src/domain/environment/__tests__/store-faults.test.ts +150 -0
  44. package/src/domain/environment/controller.ts +8 -5
  45. package/src/domain/environment/steps.ts +10 -7
  46. package/src/domain/workflow/registry/data/model-registry.json +23 -14
  47. package/src/domain/workflow/registry/model-registry-store.ts +16 -3
  48. package/src/index.ts +2 -0
  49. package/src/pipeline/__tests__/blind-not-found.test.ts +0 -3
  50. package/src/pipeline/__tests__/errors.test.ts +72 -0
  51. package/src/pipeline/errors.ts +42 -3
@@ -1,88 +1,143 @@
1
1
  /**
2
- * ValidateThinkingMode — ports validate_thinking_mode.go: fail-closed
3
- * validation of ExecutionConfig.thinking_mode against the model registry
4
- * (stigmer/stigmer#772) — the sibling of ValidateServiceTier for the
5
- * second variant dimension.
2
+ * ValidateThinkingMode — fail-closed validation of
3
+ * ExecutionConfig.thinking_mode against the model registry
4
+ * (stigmer/stigmer#772), the sibling of ValidateServiceTier for the second
5
+ * variant dimension, judged on the harness the execution will run on
6
+ * (stigmer/stigmer#1280).
6
7
  *
7
8
  * Unlike the fast tier, thinking is capability-gated, not pricing-gated:
8
- * thinking variants bill at base per-token rates (ledger-verified), so
9
- * the registry fact that makes ENABLED selectable is
10
- * capabilities.thinking on the model's CURSOR-harness entry. The harness
11
- * scoping is deliberate — native entries truthfully declare the same
12
- * capability (Anthropic models support extended thinking natively) but no
13
- * native wire mapping exists in v1, so validating them would accept a
14
- * config the runner silently cannot honor (the exact silent-no-op class
15
- * #357 exists to kill; mirrors the #361 FAST-on-native hold).
9
+ * thinking variants bill at base per-token rates (ledger-verified), so the
10
+ * registry fact that makes ENABLED selectable is a thinking capability on
11
+ * the model's entry for the execution's harness — `thinking` (a fixed
12
+ * budget) or `adaptiveThinking` (depth adapts). Both harnesses translate
13
+ * the mode: Cursor as its thinking variant, native as Anthropic's
14
+ * `thinking` parameter in the form the entry declares. The harness scoping
15
+ * is what keeps a selection honest: an id such as claude-sonnet-5 has an
16
+ * entry on each harness, and only the entry the execution runs on says
17
+ * what its model does.
16
18
  *
17
- * - UNSPECIFIED / DISABLED: always valid — every model has a base
18
- * variant, and unset resolves to explicitly-requested DISABLED in the
19
- * runner (never the provider account default).
19
+ * - UNSPECIFIED: always valid — it resolves to DISABLED in the runner,
20
+ * and a model that requires thinking thinks under it.
21
+ * - DISABLED: valid, except on a model whose entry declares
22
+ * `thinkingRequired` — it always thinks and the provider refuses a
23
+ * request to turn thinking off.
20
24
  * - ENABLED: requires model_name to be set (Auto has no variant
21
- * dimensions) and that model's cursor-harness registry entry to
22
- * declare the thinking capability.
25
+ * dimensions) and that model's entry on the harness to declare a
26
+ * thinking capability.
23
27
  *
24
- * Positioned beside ValidateServiceTier, before any side-effecting step.
25
- * Mirrors the cloud Java ValidateThinkingModeStep; all three editions
26
- * must refuse the same request with the same message.
28
+ * The harness is the one CreateSessionIfNeeded and dispatch will use: the
29
+ * stored session's for a turn on an existing session, else the bootstrap
30
+ * session_spec's, UNSPECIFIED read as native (harnessName; the Cloud
31
+ * billing gate resolves the same way). Reading a stored session is why the
32
+ * step runs after the run gate: the caller is authorized to add a turn to
33
+ * that session before anything about it is read, so a refusal never tells
34
+ * an unauthorized caller which harness a session uses. It is still a pure
35
+ * step, before any side effect.
27
36
  */
28
- import type { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
37
+ import type { AgentExecution, AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
29
38
  import { ThinkingMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
39
+ import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
40
+ import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
41
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
30
42
 
31
- import { invalidArgumentError } from "../../pipeline/errors.js";
43
+ import { internalError, invalidArgumentError } from "../../pipeline/errors.js";
32
44
  import type { PipelineStep } from "../../pipeline/pipeline.js";
45
+ import { ResourceNotFoundError, type Store } from "../../store/interface.js";
33
46
  import type { ModelCatalogProvider } from "../workflow/registry/model-catalog-provider.js";
34
- import { THINKING_CAPABILITY_KEY } from "../workflow/registry/model-registry-store.js";
35
- import { HARNESS_NAME_CURSOR } from "../workflow/registry/pin-validation.js";
47
+ import {
48
+ ADAPTIVE_THINKING_CAPABILITY_KEY,
49
+ THINKING_CAPABILITY_KEY,
50
+ THINKING_REQUIRED_CAPABILITY_KEY,
51
+ } from "../workflow/registry/model-registry-store.js";
52
+ import { harnessName } from "../workflow/registry/pin-validation.js";
36
53
 
37
54
  export function newValidateThinkingModeStep(
38
55
  registry: ModelCatalogProvider,
56
+ store: Store,
39
57
  ): PipelineStep<typeof AgentExecutionSchema> {
40
58
  return {
41
59
  name: "ValidateThinkingMode",
42
- execute(ctx) {
60
+ async execute(ctx) {
43
61
  const config = ctx.newState.spec?.executionConfig;
44
- if (config?.thinkingMode !== ThinkingMode.ENABLED) {
45
- // UNSPECIFIED and DISABLED are always valid; unknown enum numbers
46
- // were already refused by proto field validation (defined_only).
62
+ const mode = config?.thinkingMode ?? ThinkingMode.UNSPECIFIED;
63
+ const modelName = (config?.modelName ?? "").trim();
64
+ // UNSPECIFIED is always valid, and so is DISABLED on Auto (no model
65
+ // to require thinking). Unknown enum numbers were already refused by
66
+ // proto field validation (defined_only).
67
+ if (mode === ThinkingMode.UNSPECIFIED || (mode === ThinkingMode.DISABLED && modelName === "")) {
68
+ return;
69
+ }
70
+
71
+ const harness = await executionHarness(ctx.newState, store);
72
+
73
+ if (mode === ThinkingMode.DISABLED) {
74
+ if (registry.hasCapabilityForHarness(harness, modelName, THINKING_REQUIRED_CAPABILITY_KEY)) {
75
+ throw invalidArgumentError(
76
+ `thinking_mode 'disabled' is not available for model '${modelName}': the model always ` +
77
+ `thinks on the ${harness} harness and refuses a request to turn thinking off. ` +
78
+ "Leave thinking_mode unset, or set it to 'enabled'.",
79
+ );
80
+ }
47
81
  return;
48
82
  }
49
83
 
50
- const modelName = (config.modelName ?? "").trim();
51
84
  if (modelName === "") {
52
85
  throw invalidArgumentError(
53
86
  "thinking_mode 'enabled' requires execution_config.model_name: thinking is a " +
54
87
  "per-model capability, and Auto (no pinned model) has no variant dimensions. " +
55
- `Pin a model that supports it${thinkingCapableSuffix(registry)}.`,
88
+ `Pin a model that supports it${thinkingCapableSuffix(registry, harness)}.`,
56
89
  );
57
90
  }
58
91
 
59
- if (
60
- !registry.hasCapabilityForHarness(
61
- HARNESS_NAME_CURSOR,
62
- modelName,
63
- THINKING_CAPABILITY_KEY,
64
- )
65
- ) {
92
+ if (!canThink(registry, harness, modelName)) {
66
93
  throw invalidArgumentError(
67
94
  `thinking_mode 'enabled' is not available for model '${modelName}': the model ` +
68
- "registry declares no thinking capability for it on the cursor " +
69
- `harness${thinkingCapableSuffix(registry)}.`,
95
+ `registry declares no thinking capability for it on the ${harness} ` +
96
+ `harness${thinkingCapableSuffix(registry, harness)}.`,
70
97
  );
71
98
  }
72
99
  },
73
100
  };
74
101
  }
75
102
 
103
+ /** The registry section name of the harness this execution will run on. */
104
+ async function executionHarness(execution: AgentExecution, store: Store): Promise<string> {
105
+ const sessionId = execution.spec?.sessionId ?? "";
106
+ if (sessionId === "") {
107
+ return harnessName(execution.spec?.sessionSpec?.harness ?? Harness.UNSPECIFIED);
108
+ }
109
+ try {
110
+ const session = await store.getResource(ApiResourceKind.session, sessionId, SessionSchema);
111
+ return harnessName(session.spec?.harness ?? Harness.UNSPECIFIED);
112
+ } catch (error) {
113
+ // A dangling session id is the loading steps' refusal to make, with
114
+ // its own NotFound; judge it on the default harness, as dispatch does.
115
+ // Any other failure is a store fault, never a reading of the session.
116
+ if (error instanceof ResourceNotFoundError) {
117
+ return harnessName(Harness.UNSPECIFIED);
118
+ }
119
+ throw internalError(error, "failed to load session for thinking-mode validation");
120
+ }
121
+ }
122
+
123
+ function canThink(registry: ModelCatalogProvider, harness: string, modelName: string): boolean {
124
+ return (
125
+ registry.hasCapabilityForHarness(harness, modelName, THINKING_CAPABILITY_KEY) ||
126
+ registry.hasCapabilityForHarness(harness, modelName, ADAPTIVE_THINKING_CAPABILITY_KEY)
127
+ );
128
+ }
129
+
76
130
  /**
77
- * "; models with a thinking mode: a, b, c" — actionable refusal detail,
78
- * sorted (the store keeps the list sorted), empty when the registry
79
- * declares none.
131
+ * "; models with a thinking mode: a, b, c" — actionable refusal detail for
132
+ * the harness, sorted, empty when the registry declares none there.
80
133
  */
81
- function thinkingCapableSuffix(registry: ModelCatalogProvider): string {
82
- const capable = registry.canonicalModelsWithCapabilityForHarness(
83
- HARNESS_NAME_CURSOR,
84
- THINKING_CAPABILITY_KEY,
85
- );
134
+ function thinkingCapableSuffix(registry: ModelCatalogProvider, harness: string): string {
135
+ const capable = [
136
+ ...new Set([
137
+ ...registry.canonicalModelsWithCapabilityForHarness(harness, THINKING_CAPABILITY_KEY),
138
+ ...registry.canonicalModelsWithCapabilityForHarness(harness, ADAPTIVE_THINKING_CAPABILITY_KEY),
139
+ ]),
140
+ ].sort();
86
141
  if (capable.length === 0) {
87
142
  return "";
88
143
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pins the store-fault contract the artifact load by id shares: a typed
3
+ * ResourceNotFoundError answers NotFound with the domain's pinned Go copy
4
+ * (`Artifact not found: <id>`), and any other store failure is an
5
+ * infrastructure fault answered as a sanitized Internal, never a NotFound
6
+ * that tells a client a run's output does not exist (stigmer/stigmer#1345).
7
+ *
8
+ * The surfaces: the three direct handlers that load through
9
+ * loadArtifactOrNotFound, delete, getDownloadUrl and getContent, each
10
+ * reached through the registered handler on an in-process router. All three
11
+ * load before they authorize (stigmer#224), and a fault answered as Internal
12
+ * says nothing about whether the artifact exists. The composed suites reach
13
+ * only a real store, which cannot fail selectively, so each surface runs here
14
+ * against a store whose read throws. Artifact storage is untouchable: a load
15
+ * that fails must stop the call before any blob read or URL mint.
16
+ *
17
+ * Out of scope: the NotFound copy itself (wire contract, pinned by the
18
+ * artifact conformance suite) and create's best-effort org derivation
19
+ * (stigmer/stigmer#1369).
20
+ */
21
+ import type { Client, ConnectError } from "@connectrpc/connect";
22
+ import { Code, createClient, createRouterTransport } from "@connectrpc/connect";
23
+ import { describe, expect, it } from "vitest";
24
+
25
+ import { ArtifactCommandController } from "@stigmer/protos/ai/stigmer/agentic/artifact/v1/command_pb";
26
+ import { ArtifactQueryController } from "@stigmer/protos/ai/stigmer/agentic/artifact/v1/query_pb";
27
+
28
+ import { createLogger } from "../../../boot/logger.js";
29
+ import { createVerifierChainInterceptor } from "../../../pipeline/interceptors/auth.js";
30
+ import {
31
+ errorOf,
32
+ failingStore,
33
+ untouchable,
34
+ } from "../../../pipeline/__tests__/support.js";
35
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
36
+ import { ResourceNotFoundError } from "../../../store/interface.js";
37
+ import type { Store } from "../../../store/interface.js";
38
+
39
+ import { registerArtifactServices } from "../controller.js";
40
+
41
+ const silentLogger = createLogger({
42
+ level: "error",
43
+ pretty: false,
44
+ write: () => {},
45
+ });
46
+
47
+ const ARTIFACT_ID = "art_storefault";
48
+
49
+ const NOT_FOUND_COPY = `Artifact not found: ${ARTIFACT_ID}`;
50
+ const LOAD_FAULT_COPY = "failed to load artifact";
51
+
52
+ const MISSING = (): Error =>
53
+ new ResourceNotFoundError(`artifact/${ARTIFACT_ID}`);
54
+ const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
55
+
56
+ interface ArtifactClients {
57
+ readonly command: Client<typeof ArtifactCommandController>;
58
+ readonly query: Client<typeof ArtifactQueryController>;
59
+ }
60
+
61
+ /**
62
+ * The registered handlers on an in-process router (`artifact.test.ts`'s
63
+ * harness): the verifier chain stamps the trusted-local caller, each handler
64
+ * checks its id, and the load is the first read of the store.
65
+ */
66
+ function artifactClients(store: Store): ArtifactClients {
67
+ const transport = createRouterTransport(
68
+ (router) => {
69
+ registerArtifactServices(router, {
70
+ store,
71
+ artifactStorage: untouchable("artifactStorage"),
72
+ logger: silentLogger,
73
+ authorizer: newPermissiveSingleTeamAuthorizer(),
74
+ });
75
+ },
76
+ {
77
+ router: {
78
+ interceptors: [createVerifierChainInterceptor([], [], silentLogger)],
79
+ },
80
+ },
81
+ );
82
+ return {
83
+ command: createClient(ArtifactCommandController, transport),
84
+ query: createClient(ArtifactQueryController, transport),
85
+ };
86
+ }
87
+
88
+ const SURFACES: ReadonlyArray<
89
+ readonly [string, (clients: ArtifactClients) => Promise<unknown>]
90
+ > = [
91
+ ["delete", ({ command }) => command.delete({ value: ARTIFACT_ID })],
92
+ [
93
+ "getDownloadUrl",
94
+ ({ query }) => query.getDownloadUrl({ value: ARTIFACT_ID }),
95
+ ],
96
+ ["getContent", ({ query }) => query.getContent({ artifactId: ARTIFACT_ID })],
97
+ ];
98
+
99
+ describe.each(SURFACES)("%s — loadArtifactOrNotFound", (_name, call) => {
100
+ function surfaceError(store: Store): Promise<ConnectError> {
101
+ return errorOf(() => call(artifactClients(store)));
102
+ }
103
+
104
+ it("a missing artifact answers NotFound with the domain's copy", async () => {
105
+ const error = await surfaceError(failingStore(MISSING()));
106
+
107
+ expect(error.code).toBe(Code.NotFound);
108
+ expect(error.rawMessage).toBe(NOT_FOUND_COPY);
109
+ });
110
+
111
+ it("any other store failure answers a sanitized Internal", async () => {
112
+ const error = await surfaceError(failingStore(LOCKED()));
113
+
114
+ expect(error.code).toBe(Code.Internal);
115
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
116
+ });
117
+ });
@@ -9,7 +9,8 @@
9
9
  * storage_state transition rather than the hard-delete pipeline; only
10
10
  * get/listByExecution ride pipelines. Proven by
11
11
  * artifact.conformance.test.ts (CONFORMANCE_TARGET=local, incl. the
12
- * file-server lane) and __tests__/artifact.test.ts.
12
+ * file-server lane), __tests__/artifact.test.ts and
13
+ * __tests__/store-faults.test.ts.
13
14
  *
14
15
  * Go tolerates a nil ArtifactStorage (two-phase wiring) and answers
15
16
  * Internal "artifact storage not configured"; the TS composition root
@@ -79,6 +80,7 @@ import {
79
80
  newLoadTargetStep,
80
81
  } from "../../pipeline/steps/load-target.js";
81
82
  import { newValidateProtoStep } from "../../pipeline/steps/validation.js";
83
+ import { ResourceNotFoundError } from "../../store/interface.js";
82
84
  import type { Store } from "../../store/interface.js";
83
85
  import type { ListIndexRow } from "../../store/list-index.js";
84
86
  import { artifactListIndex } from "./list-index.js";
@@ -649,7 +651,7 @@ async function getContent(
649
651
  });
650
652
  }
651
653
 
652
- /** Load by id with the byte-pinned Go NotFound copy. */
654
+ /** Load by id: a missing artifact answers the byte-pinned Go NotFound copy, a store fault Internal. */
653
655
  async function loadArtifactOrNotFound(
654
656
  deps: ArtifactControllerDeps,
655
657
  artifactId: string,
@@ -660,10 +662,14 @@ async function loadArtifactOrNotFound(
660
662
  artifactId,
661
663
  ArtifactSchema,
662
664
  );
663
- } catch {
664
- // Go answers NotFound for ANY store failure here (status.Errorf on
665
- // err != nil), not only missing rows — mirrored.
666
- throw new ConnectError(artifactNotFoundMessage(artifactId), Code.NotFound);
665
+ } catch (error) {
666
+ if (error instanceof ResourceNotFoundError) {
667
+ throw new ConnectError(
668
+ artifactNotFoundMessage(artifactId),
669
+ Code.NotFound,
670
+ );
671
+ }
672
+ throw internalError(error, "failed to load artifact");
667
673
  }
668
674
  }
669
675
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Pins the store-fault contract every environment load by id shares: a typed
3
+ * ResourceNotFoundError answers NotFound with the domain's pinned copy
4
+ * (`environment not found: <id>`), and any other store failure is an
5
+ * infrastructure fault answered as a sanitized Internal, never a NotFound
6
+ * that tells a client the environment does not exist
7
+ * (stigmer/stigmer#1345).
8
+ *
9
+ * The surfaces: updateVisibility (its own load step) and the three RPCs that
10
+ * share LoadEnvironmentByID, updateVariables, removeVariables and
11
+ * getSecretValue, each reached through the registered handler on an
12
+ * in-process router. The composed suites reach only a real store, which
13
+ * cannot fail selectively, so each surface runs here against a store whose
14
+ * read throws. The secret service is untouchable: a load that fails must stop
15
+ * the call before any decrypt or encrypt.
16
+ *
17
+ * Out of scope: the NotFound copy itself (wire contract, pinned by
18
+ * environment.test.ts and the environment conformance suite) and the
19
+ * personal-environment resolver's fold of a failed secret read into a
20
+ * missing key (stigmer/stigmer#1360).
21
+ */
22
+ import type { Client, ConnectError } from "@connectrpc/connect";
23
+ import { Code, createClient, createRouterTransport } from "@connectrpc/connect";
24
+ import { describe, expect, it } from "vitest";
25
+
26
+ import { EnvironmentCommandController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/command_pb";
27
+ import { EnvironmentQueryController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/query_pb";
28
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
29
+
30
+ import { createLogger } from "../../../boot/logger.js";
31
+ import { createApiResourceInterceptor } from "../../../pipeline/interceptors/apiresource.js";
32
+ import { createVerifierChainInterceptor } from "../../../pipeline/interceptors/auth.js";
33
+ import {
34
+ errorOf,
35
+ failingStore,
36
+ untouchable,
37
+ } from "../../../pipeline/__tests__/support.js";
38
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
39
+ import { ResourceNotFoundError } from "../../../store/interface.js";
40
+ import type { Store } from "../../../store/interface.js";
41
+
42
+ import { registerEnvironmentServices } from "../controller.js";
43
+
44
+ const silentLogger = createLogger({
45
+ level: "error",
46
+ pretty: false,
47
+ write: () => {},
48
+ });
49
+
50
+ const ENVIRONMENT_ID = "env_storefault";
51
+
52
+ const NOT_FOUND_COPY = `environment not found: ${ENVIRONMENT_ID}`;
53
+ const LOAD_FAULT_COPY = "failed to load environment";
54
+
55
+ const MISSING = (): Error =>
56
+ new ResourceNotFoundError(`environment/${ENVIRONMENT_ID}`);
57
+ const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
58
+
59
+ interface EnvironmentClients {
60
+ readonly command: Client<typeof EnvironmentCommandController>;
61
+ readonly query: Client<typeof EnvironmentQueryController>;
62
+ }
63
+
64
+ /**
65
+ * The registered handlers on an in-process router: the verifier chain stamps
66
+ * the trusted-local caller and the apiresource interceptor the kind both
67
+ * loads read, then Authorize and ValidateProto run and the load is the first
68
+ * step that reads the store.
69
+ */
70
+ function environmentClients(store: Store): EnvironmentClients {
71
+ const transport = createRouterTransport(
72
+ (router) => {
73
+ registerEnvironmentServices(router, {
74
+ store,
75
+ logger: silentLogger,
76
+ authorizer: newPermissiveSingleTeamAuthorizer(),
77
+ authorizationLifecycle: undefined,
78
+ secretService: untouchable("secretService"),
79
+ listReadScope: undefined,
80
+ });
81
+ },
82
+ {
83
+ router: {
84
+ interceptors: [
85
+ createVerifierChainInterceptor([], [], silentLogger),
86
+ createApiResourceInterceptor(),
87
+ ],
88
+ },
89
+ },
90
+ );
91
+ return {
92
+ command: createClient(EnvironmentCommandController, transport),
93
+ query: createClient(EnvironmentQueryController, transport),
94
+ };
95
+ }
96
+
97
+ /** Each surface with an input valid enough to pass ValidateProto. */
98
+ const SURFACES: ReadonlyArray<
99
+ readonly [string, (clients: EnvironmentClients) => Promise<unknown>]
100
+ > = [
101
+ [
102
+ "updateVisibility — LoadEnvironmentForVisibilityUpdate",
103
+ ({ command }) =>
104
+ command.updateVisibility({
105
+ resourceId: ENVIRONMENT_ID,
106
+ visibility: ApiResourceVisibility.visibility_private,
107
+ }),
108
+ ],
109
+ [
110
+ "updateVariables — LoadEnvironmentByID",
111
+ ({ command }) =>
112
+ command.updateVariables({
113
+ environmentId: ENVIRONMENT_ID,
114
+ variables: { API_KEY: { value: "v", isSecret: false } },
115
+ }),
116
+ ],
117
+ [
118
+ "removeVariables — LoadEnvironmentByID",
119
+ ({ command }) =>
120
+ command.removeVariables({
121
+ environmentId: ENVIRONMENT_ID,
122
+ keys: ["API_KEY"],
123
+ }),
124
+ ],
125
+ [
126
+ "getSecretValue — LoadEnvironmentByID",
127
+ ({ query }) =>
128
+ query.getSecretValue({ environmentId: ENVIRONMENT_ID, key: "API_KEY" }),
129
+ ],
130
+ ];
131
+
132
+ describe.each(SURFACES)("%s", (_name, call) => {
133
+ function surfaceError(store: Store): Promise<ConnectError> {
134
+ return errorOf(() => call(environmentClients(store)));
135
+ }
136
+
137
+ it("a missing environment answers NotFound with the domain's copy", async () => {
138
+ const error = await surfaceError(failingStore(MISSING()));
139
+
140
+ expect(error.code).toBe(Code.NotFound);
141
+ expect(error.rawMessage).toBe(NOT_FOUND_COPY);
142
+ });
143
+
144
+ it("any other store failure answers a sanitized Internal", async () => {
145
+ const error = await surfaceError(failingStore(LOCKED()));
146
+
147
+ expect(error.code).toBe(Code.Internal);
148
+ expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
149
+ });
150
+ });
@@ -8,8 +8,8 @@
8
8
  * design: limited blast radius, per-key audit, the industry "reveal" UX).
9
9
  *
10
10
  * Pipeline per RPC mirrors the Go step chains character-for-character.
11
- * Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local)
12
- * and __tests__/environment.test.ts.
11
+ * Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local),
12
+ * __tests__/environment.test.ts and __tests__/store-faults.test.ts.
13
13
  *
14
14
  * Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies,
15
15
  * FGA-tuple, and Publish steps (no multi-tenant auth, IAM/FGA, or event
@@ -421,7 +421,7 @@ async function updateVisibility(
421
421
  return env;
422
422
  }
423
423
 
424
- /** Loads the environment by resource_id; ANY load failure → NotFound. */
424
+ /** Loads the environment by resource_id; a missing one answers NotFound, a store fault Internal. */
425
425
  function newLoadEnvironmentForVisibilityUpdateStep(
426
426
  store: Store,
427
427
  ): PipelineStep<UpdateVisibilityDesc> {
@@ -436,8 +436,11 @@ function newLoadEnvironmentForVisibilityUpdateStep(
436
436
  input.resourceId,
437
437
  EnvironmentSchema,
438
438
  );
439
- } catch {
440
- throw notFoundError("environment", input.resourceId);
439
+ } catch (error) {
440
+ if (error instanceof ResourceNotFoundError) {
441
+ throw notFoundError("environment", input.resourceId);
442
+ }
443
+ throw internalError(error, "failed to load environment");
441
444
  }
442
445
  ctx.set(UPDATE_VISIBILITY_ENVIRONMENT_KEY, env);
443
446
  },
@@ -15,8 +15,8 @@
15
15
  * by design and must not hit the forgery rejection.
16
16
  * 3. Redaction (redact.ts) runs AFTER Persist, outside the pipeline.
17
17
  *
18
- * Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local)
19
- * and __tests__/environment.test.ts.
18
+ * Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local),
19
+ * __tests__/environment.test.ts and __tests__/store-faults.test.ts.
20
20
  */
21
21
  import { create } from "@bufbuild/protobuf";
22
22
  import type { DescMessage } from "@bufbuild/protobuf";
@@ -51,6 +51,7 @@ import { findResourceByLabelAndOrg } from "../../pipeline/steps/helpers.js";
51
51
  import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
52
52
  import { TARGET_RESOURCE_KEY } from "../../pipeline/steps/load-target.js";
53
53
  import { destroySecretBackingState } from "../../pipeline/steps/secret-cleanup.js";
54
+ import { ResourceNotFoundError } from "../../store/interface.js";
54
55
  import type { Store } from "../../store/interface.js";
55
56
  import {
56
57
  PERSONAL_LABEL_KEY,
@@ -273,7 +274,8 @@ export function newEnforcePersonalUniquenessStep(
273
274
  * environment_id field (the standard LoadTarget expects a `value` id
274
275
  * field, but EnvironmentSecretValueInput / Update-/RemoveEnvironment-
275
276
  * VariablesRequest carry `environment_id`). Stores under
276
- * TARGET_RESOURCE_KEY.
277
+ * TARGET_RESOURCE_KEY. A missing environment answers NotFound, a store
278
+ * fault Internal.
277
279
  */
278
280
  export function newLoadEnvironmentByIdStep<Desc extends DescMessage>(
279
281
  store: Store,
@@ -294,10 +296,11 @@ export function newLoadEnvironmentByIdStep<Desc extends DescMessage>(
294
296
  environmentId,
295
297
  EnvironmentSchema,
296
298
  );
297
- } catch {
298
- // Go maps EVERY load failure to NotFound here (store errors
299
- // included) — ported as-is.
300
- throw notFoundError("environment", environmentId);
299
+ } catch (error) {
300
+ if (error instanceof ResourceNotFoundError) {
301
+ throw notFoundError("environment", environmentId);
302
+ }
303
+ throw internalError(error, "failed to load environment");
301
304
  }
302
305
 
303
306
  ctx.set(TARGET_RESOURCE_KEY, env);