@stigmer/server 3.35.0 → 3.37.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 (48) hide show
  1. package/dist/authorization/model/data/authorization-model.json +18 -36
  2. package/dist/domain/agentexecution/constants.d.ts +0 -10
  3. package/dist/domain/agentexecution/constants.d.ts.map +1 -1
  4. package/dist/domain/agentexecution/constants.js +0 -12
  5. package/dist/domain/agentexecution/constants.js.map +1 -1
  6. package/dist/domain/agentexecution/usage.d.ts +0 -2
  7. package/dist/domain/agentexecution/usage.d.ts.map +1 -1
  8. package/dist/domain/agentexecution/usage.js +12 -10
  9. package/dist/domain/agentexecution/usage.js.map +1 -1
  10. package/dist/domain/oauthapp/controller.d.ts +3 -3
  11. package/dist/domain/organization/controller.d.ts +4 -1
  12. package/dist/domain/organization/controller.d.ts.map +1 -1
  13. package/dist/domain/organization/controller.js +30 -8
  14. package/dist/domain/organization/controller.js.map +1 -1
  15. package/dist/domain/workflow/controller.js +2 -1
  16. package/dist/domain/workflow/controller.js.map +1 -1
  17. package/dist/domain/workflow/steps.d.ts +7 -4
  18. package/dist/domain/workflow/steps.d.ts.map +1 -1
  19. package/dist/domain/workflow/steps.js +29 -17
  20. package/dist/domain/workflow/steps.js.map +1 -1
  21. package/dist/extensions/organization-directory.d.ts +29 -6
  22. package/dist/extensions/organization-directory.d.ts.map +1 -1
  23. package/dist/extensions/organization-directory.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/pipeline/steps/guard-memory-capture.d.ts +9 -4
  28. package/dist/pipeline/steps/guard-memory-capture.d.ts.map +1 -1
  29. package/dist/pipeline/steps/guard-memory-capture.js +10 -12
  30. package/dist/pipeline/steps/guard-memory-capture.js.map +1 -1
  31. package/package.json +6 -6
  32. package/src/authorization/__tests__/authorizer.test.ts +13 -23
  33. package/src/authorization/__tests__/store-tests.test.ts +2 -0
  34. package/src/authorization/model/data/authorization-model.json +18 -36
  35. package/src/domain/agentexecution/__tests__/agentexecution.test.ts +2 -8
  36. package/src/domain/agentexecution/__tests__/execution-usage-report.test.ts +78 -0
  37. package/src/domain/agentexecution/constants.ts +0 -15
  38. package/src/domain/agentexecution/usage.ts +16 -13
  39. package/src/domain/oauthapp/controller.ts +3 -3
  40. package/src/domain/organization/controller.ts +47 -10
  41. package/src/domain/workflow/__tests__/version-audit-degradation.test.ts +192 -0
  42. package/src/domain/workflow/controller.ts +2 -1
  43. package/src/domain/workflow/steps.ts +30 -18
  44. package/src/extensions/__tests__/extension-composition.test.ts +90 -18
  45. package/src/extensions/organization-directory.ts +33 -7
  46. package/src/index.ts +5 -1
  47. package/src/pipeline/steps/__tests__/guard-memory-capture.test.ts +67 -5
  48. package/src/pipeline/steps/guard-memory-capture.ts +24 -19
@@ -465,13 +465,7 @@
465
465
  }
466
466
  ]
467
467
  },
468
- "owner": {
469
- "directly_related_user_types": [
470
- {
471
- "type": "identity_account"
472
- }
473
- ]
474
- },
468
+ "owner": {},
475
469
  "viewer": {
476
470
  "directly_related_user_types": [
477
471
  {
@@ -514,7 +508,14 @@
514
508
  "this": {}
515
509
  },
516
510
  "owner": {
517
- "this": {}
511
+ "tupleToUserset": {
512
+ "computedUserset": {
513
+ "relation": "admin"
514
+ },
515
+ "tupleset": {
516
+ "relation": "organization"
517
+ }
518
+ }
518
519
  },
519
520
  "viewer": {
520
521
  "union": {
@@ -526,16 +527,6 @@
526
527
  "computedUserset": {
527
528
  "relation": "owner"
528
529
  }
529
- },
530
- {
531
- "tupleToUserset": {
532
- "computedUserset": {
533
- "relation": "admin"
534
- },
535
- "tupleset": {
536
- "relation": "organization"
537
- }
538
- }
539
530
  }
540
531
  ]
541
532
  }
@@ -559,13 +550,7 @@
559
550
  }
560
551
  ]
561
552
  },
562
- "owner": {
563
- "directly_related_user_types": [
564
- {
565
- "type": "identity_account"
566
- }
567
- ]
568
- },
553
+ "owner": {},
569
554
  "viewer": {
570
555
  "directly_related_user_types": [
571
556
  {
@@ -608,7 +593,14 @@
608
593
  "this": {}
609
594
  },
610
595
  "owner": {
611
- "this": {}
596
+ "tupleToUserset": {
597
+ "computedUserset": {
598
+ "relation": "admin"
599
+ },
600
+ "tupleset": {
601
+ "relation": "organization"
602
+ }
603
+ }
612
604
  },
613
605
  "viewer": {
614
606
  "union": {
@@ -620,16 +612,6 @@
620
612
  "computedUserset": {
621
613
  "relation": "owner"
622
614
  }
623
- },
624
- {
625
- "tupleToUserset": {
626
- "computedUserset": {
627
- "relation": "admin"
628
- },
629
- "tupleset": {
630
- "relation": "organization"
631
- }
632
- }
633
615
  }
634
616
  ]
635
617
  }
@@ -66,7 +66,6 @@ import { loadConfig } from "../../../boot/config.js";
66
66
  import { composeServer } from "../../../boot/compose.js";
67
67
  import type { ComposedServer } from "../../../boot/compose.js";
68
68
  import { createLogger } from "../../../boot/logger.js";
69
- import { executionUsageReportNotFoundMessage } from "../constants.js";
70
69
  import type { AgentExecutionStatusTransition } from "../../../extensions/status-hooks.js";
71
70
  import type {
72
71
  ConnectedExecutionEngine,
@@ -485,7 +484,7 @@ describe("update / delete over the wire", () => {
485
484
  });
486
485
 
487
486
  describe("usage reports over the wire", () => {
488
- it("getExecutionUsageReport pins the mangled NotFound copy byte-for-byte (DD-001)", async () => {
487
+ it("getExecutionUsageReport answers the domain's NotFound copy for an unknown execution (#859)", async () => {
489
488
  await expectCode(
490
489
  () => query.getExecutionUsageReport({ executionId: "" }),
491
490
  Code.InvalidArgument,
@@ -495,12 +494,7 @@ describe("usage reports over the wire", () => {
495
494
  () => query.getExecutionUsageReport({ executionId: "aexec_missing" }),
496
495
  Code.NotFound,
497
496
  );
498
- expect(err.rawMessage).toBe(
499
- executionUsageReportNotFoundMessage("aexec_missing"),
500
- );
501
- expect(err.rawMessage).toBe(
502
- "agent execution '%s' not found not found: aexec_missing",
503
- );
497
+ expect(err.rawMessage).toBe("agent_execution not found: aexec_missing");
504
498
  });
505
499
 
506
500
  it("getExecutionUsageReport answers the zero aggregate for a seeded execution", async () => {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Pins getExecutionUsageReport's store-fault mapping in LoadExecution — the
3
+ * composed suite (agentexecution.test.ts) reaches only the real store, which
4
+ * cannot fail selectively:
5
+ *
6
+ * - a typed ResourceNotFoundError answers the domain's NotFound copy,
7
+ * `agent_execution not found: <id>` (stigmer/stigmer#859);
8
+ * - any other store failure is an infrastructure fault: a sanitized
9
+ * Internal whose wire text never carries the cause, never a NotFound
10
+ * that would tell a client the execution does not exist.
11
+ */
12
+ import { create } from "@bufbuild/protobuf";
13
+ import { Code, ConnectError } from "@connectrpc/connect";
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import { AgentExecutionQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/query_pb";
17
+
18
+ import { createLogger } from "../../../boot/logger.js";
19
+ import type { Authorizer } from "../../../extensions/authorizer.js";
20
+ import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
21
+ import { ResourceNotFoundError } from "../../../store/interface.js";
22
+ import type { Store } from "../../../store/interface.js";
23
+
24
+ import { getExecutionUsageReport } from "../usage.js";
25
+
26
+ const silentLogger = createLogger({
27
+ level: "error",
28
+ pretty: false,
29
+ write: () => {},
30
+ });
31
+
32
+ const allowingAuthorizer: Authorizer = {
33
+ authorize: () => Promise.resolve({ kind: "allow" }),
34
+ };
35
+
36
+ /** A store whose one read the chain makes fails with the given error. */
37
+ function failingStore(error: Error): Store {
38
+ return {
39
+ getResource: () => Promise.reject(error),
40
+ } as unknown as Store;
41
+ }
42
+
43
+ async function reportError(store: Store): Promise<ConnectError> {
44
+ const error = await getExecutionUsageReport(
45
+ {
46
+ store,
47
+ logger: silentLogger,
48
+ authorizer: allowingAuthorizer,
49
+ listReadScope: undefined,
50
+ },
51
+ create(AgentExecutionQueryController.method.getExecutionUsageReport.input, {
52
+ executionId: "aex_missing",
53
+ }),
54
+ testCallerIdentity(),
55
+ ).catch((e: unknown) => e);
56
+ expect(error).toBeInstanceOf(ConnectError);
57
+ return error as ConnectError;
58
+ }
59
+
60
+ describe("getExecutionUsageReport — LoadExecution store faults", () => {
61
+ it("a missing execution answers NotFound with the domain's copy", async () => {
62
+ const error = await reportError(
63
+ failingStore(new ResourceNotFoundError("agent_execution/aex_missing")),
64
+ );
65
+
66
+ expect(error.code).toBe(Code.NotFound);
67
+ expect(error.rawMessage).toBe("agent_execution not found: aex_missing");
68
+ });
69
+
70
+ it("any other store failure answers a sanitized Internal, never NotFound", async () => {
71
+ const error = await reportError(
72
+ failingStore(new Error("SQLITE_BUSY: database is locked")),
73
+ );
74
+
75
+ expect(error.code).toBe(Code.Internal);
76
+ expect(error.rawMessage).toBe("failed to load agent execution");
77
+ });
78
+ });
@@ -14,21 +14,6 @@
14
14
  export const ENGINE_UNAVAILABLE_MESSAGE =
15
15
  "The execution engine is temporarily unavailable. Please try again shortly.";
16
16
 
17
- /**
18
- * getExecutionUsageReport's unknown-execution refusal. Go
19
- * (get_execution_usage_report.go) calls
20
- * NotFoundError("agent execution '%s' not found", executionID) — but that
21
- * helper's signature is (resource, id) rendering "%s not found: %s", so
22
- * the literal '%s' and the doubled "not found" reach the wire. Ported
23
- * byte-faithfully per sub-project DD-001 (owner-ratified 2026-08-24);
24
- * the both-editions fix is stigmer/stigmer#859.
25
- */
26
- export function executionUsageReportNotFoundMessage(
27
- executionId: string,
28
- ): string {
29
- return `agent execution '%s' not found not found: ${executionId}`;
30
- }
31
-
32
17
  /**
33
18
  * The run gate's deny copy per request shape (P1 sp.run-gate, ruling
34
19
  * Q-RG-4; wire once merged, asserted by the conformance run-gate suite).
@@ -13,8 +13,6 @@
13
13
  * the ONE report that 404s (it verifies the execution exists).
14
14
  *
15
15
  * Faithful-port notes:
16
- * - The unknown-execution message is Go's mangled helper output,
17
- * byte-for-byte (constants.ts, sub-project DD-001).
18
16
  * - Org matching is case-insensitive (Go strings.EqualFold); slug-shaped
19
17
  * orgs make the exotic Unicode fold edges unreachable, so toLowerCase
20
18
  * comparison sits inside Go's envelope.
@@ -26,7 +24,6 @@
26
24
  import { create, fromBinary } from "@bufbuild/protobuf";
27
25
  import type { Duration } from "@bufbuild/protobuf/wkt";
28
26
  import { DurationSchema } from "@bufbuild/protobuf/wkt";
29
- import { Code, ConnectError } from "@connectrpc/connect";
30
27
 
31
28
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
32
29
  import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
@@ -68,17 +65,21 @@ import type { Logger } from "../../boot/logger.js";
68
65
  import type { Authorizer } from "../../extensions/authorizer.js";
69
66
  import type { ListReadScope } from "../../extensions/list-read-scope.js";
70
67
  import { restrictListByReadScope } from "../../extensions/list-read-scope.js";
71
- import { internalError, invalidArgumentError } from "../../pipeline/errors.js";
68
+ import {
69
+ internalError,
70
+ invalidArgumentError,
71
+ notFoundError,
72
+ } from "../../pipeline/errors.js";
72
73
  import type { PipelineStep } from "../../pipeline/pipeline.js";
73
74
  import { newPipeline } from "../../pipeline/pipeline.js";
74
75
  import type { CallerIdentity } from "../../extensions/identity.js";
75
76
  import { RequestContext } from "../../pipeline/request-context.js";
76
77
  import { newAuthorizeStep } from "../../pipeline/steps/authorize.js";
78
+ import { ResourceNotFoundError } from "../../store/interface.js";
77
79
  import type { Store } from "../../store/interface.js";
78
80
  import { listIndexInstantOfMillis } from "../../store/list-index.js";
79
81
  import type { ListIndexRow } from "../../store/list-index.js";
80
82
 
81
- import { executionUsageReportNotFoundMessage } from "./constants.js";
82
83
  import { agentExecutionListIndex } from "./list-index.js";
83
84
  import { EXECUTION_LIST_KEY, loadAllAgentExecutions } from "./steps.js";
84
85
 
@@ -457,9 +458,11 @@ export async function getExecutionUsageReport(
457
458
  }
458
459
 
459
460
  /**
460
- * LoadExecution — verifies existence; any load failure answers the
461
- * (mangled, DD-001) NotFound exactly as Go's step converts every
462
- * GetResource error.
461
+ * LoadExecution — verifies existence. A missing execution answers the
462
+ * domain's agent_execution NotFound (stigmer/stigmer#859); any other store
463
+ * failure is an infrastructure fault answered as a sanitized Internal, as
464
+ * the approval loads do (submit-approval.ts), never a NotFound that tells
465
+ * the client the execution does not exist.
463
466
  */
464
467
  function newLoadExecutionStep(store: Store): PipelineStep<ExecutionReportDesc> {
465
468
  return {
@@ -473,11 +476,11 @@ function newLoadExecutionStep(store: Store): PipelineStep<ExecutionReportDesc> {
473
476
  executionId,
474
477
  AgentExecutionSchema,
475
478
  );
476
- } catch {
477
- throw new ConnectError(
478
- executionUsageReportNotFoundMessage(executionId),
479
- Code.NotFound,
480
- );
479
+ } catch (error) {
480
+ if (error instanceof ResourceNotFoundError) {
481
+ throw notFoundError("agent_execution", executionId);
482
+ }
483
+ throw internalError(error, "failed to load agent execution");
481
484
  }
482
485
  ctx.set(EXECUTION_KEY, execution);
483
486
  },
@@ -22,9 +22,9 @@
22
22
  * loads, then authorizes the loaded app exactly as `get` would
23
23
  * (AuthorizeResolvedTarget); listByOrg narrows through the list read
24
24
  * scope, so a caller who may not `get` an app does not see it listed
25
- * either: the model shows an app to its creator and the organization's
26
- * admins, never to members or viewers (fga/model/iam/oauth_app.fga,
27
- * stigmer/stigmer#1257). OAuthApp is deliberately not search-indexed (a
25
+ * either: the model shows an app to the organization's admins, who manage
26
+ * it whoever created it, and never to members or viewers
27
+ * (fga/model/iam/oauth_app.fga, stigmer/stigmer#1257, #1329). OAuthApp is deliberately not search-indexed (a
28
28
  * configuration resource; `not_search_indexed` in the kind registry).
29
29
  */
30
30
  import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
@@ -9,7 +9,10 @@
9
9
  * __tests__/organization.test.ts.
10
10
  *
11
11
  * getByExternalOrgId is implemented ONLY when the composed
12
- * OrganizationDirectory provides the lookup (C2, ruling Q7) — with no
12
+ * OrganizationDirectory provides the lookup (C2, ruling Q7). It answers
13
+ * only a caller who may view the identity provider the request names,
14
+ * and resolves the external id within that provider alone (see the
15
+ * handler); with no
13
16
  * directory the method stays absent from the partial service
14
17
  * implementation and ConnectRPC answers Unimplemented (the SDK
15
18
  * capability-probes this and must see UNIMPLEMENTED, not NotFound;
@@ -32,6 +35,7 @@ import type { FindApiResourcesRequest } from "@stigmer/protos/ai/stigmer/commons
32
35
  import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
33
36
  import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
34
37
  import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
38
+ import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
35
39
  import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
36
40
  import {
37
41
  OrganizationListSchema,
@@ -61,7 +65,10 @@ import { newPipeline } from "../../pipeline/pipeline.js";
61
65
  import type { PipelineStep } from "../../pipeline/pipeline.js";
62
66
  import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
63
67
  import { RequestContext } from "../../pipeline/request-context.js";
64
- import { newAuthorizeStep } from "../../pipeline/steps/authorize.js";
68
+ import {
69
+ authorizeResolvedResource,
70
+ newAuthorizeStep,
71
+ } from "../../pipeline/steps/authorize.js";
65
72
  import { newGuardReservedLabelsStep } from "../../pipeline/steps/guard-reserved-labels.js";
66
73
  import { newBuildNewStateStep } from "../../pipeline/steps/defaults.js";
67
74
  import { newBuildUpdateStateStep } from "../../pipeline/steps/build-update-state.js";
@@ -129,7 +136,7 @@ export function registerOrganizationServices(
129
136
  // partial implementation and ConnectRPC answers Unimplemented (the
130
137
  // capability-probed pin; see the module header).
131
138
  const externalLookup =
132
- deps.organizationDirectory?.getOrganizationIdByExternalOrgId?.bind(
139
+ deps.organizationDirectory?.lookupExternalOrganization?.bind(
133
140
  deps.organizationDirectory,
134
141
  );
135
142
  router.service(OrganizationQueryController, {
@@ -548,15 +555,22 @@ async function findMyOrganizations(
548
555
 
549
556
  /**
550
557
  * GetByExternalOrgId — registered only with a directory lookup composed
551
- * (ruling Q7). The chain stays controller-owned: Authorize → validate →
552
- * directory resolves the external id → LoadTarget-shaped store read.
553
- * Both miss arms (no mapping, mapped row gone) answer the same NotFound
554
- * — an external caller cannot distinguish a stale mapping from a
555
- * missing org.
558
+ * (ruling Q7). The chain stays controller-owned: Authorize (a no-op for
559
+ * this skip-annotated method) → validate → the directory resolves the
560
+ * named identity provider and the external id within it → `can_view` on
561
+ * that provider → LoadTarget-shaped store read.
562
+ *
563
+ * The contract's check is the provider's, so it runs here, on the id the
564
+ * directory resolved mid-chain (`authorizeResolvedResource`, the pattern
565
+ * for skip lanes the annotation cannot express), and before the answer
566
+ * says whether a mapping exists: a caller who may not view the provider
567
+ * learns nothing about its tenants. Every miss arm (no such provider, no
568
+ * mapping, mapped row gone) answers the same NotFound, so an external
569
+ * caller cannot tell a stale mapping from a missing org.
556
570
  */
557
571
  async function getByExternalOrgId(
558
572
  deps: OrganizationControllerDeps,
559
- lookup: (externalOrgId: string) => Promise<string | undefined>,
573
+ lookup: NonNullable<OrganizationDirectory["lookupExternalOrganization"]>,
560
574
  input: OrganizationExternalLookup,
561
575
  ctx: HandlerContext,
562
576
  ): Promise<Organization> {
@@ -582,7 +596,27 @@ async function getByExternalOrgId(
582
596
  if (input.externalOrgId === "") {
583
597
  throw invalidArgumentError("external_org_id is required");
584
598
  }
585
- const orgId = await lookup(input.externalOrgId);
599
+ const found = await lookup(
600
+ {
601
+ org: input.identityProviderRef?.org ?? "",
602
+ slug: input.identityProviderRef?.slug ?? "",
603
+ },
604
+ input.externalOrgId,
605
+ );
606
+ if (found.kind === "no-identity-provider") {
607
+ throw notFoundError("Organization", input.externalOrgId);
608
+ }
609
+ await authorizeResolvedResource(
610
+ deps.authorizer,
611
+ reqCtx.callerIdentity,
612
+ {
613
+ permission: IamPermission.can_view,
614
+ resourceKind: ApiResourceKind.identity_provider,
615
+ resourceId: found.identityProviderId,
616
+ },
617
+ EXTERNAL_LOOKUP_DENIED_MESSAGE,
618
+ );
619
+ const orgId = found.organizationId;
586
620
  if (orgId === undefined) {
587
621
  throw notFoundError("Organization", input.externalOrgId);
588
622
  }
@@ -599,3 +633,6 @@ async function getByExternalOrgId(
599
633
  throw internalError(error, "failed to load organization");
600
634
  }
601
635
  }
636
+
637
+ /** The lookup's deny copy: the provider's own `get` annotation's words. */
638
+ const EXTERNAL_LOOKUP_DENIED_MESSAGE = "unauthorized to view identity provider";
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Pins SaveVersionAudit's safe-degradation arms at step level — the
3
+ * composed suite (workflow.test.ts) cannot make the real store fail
4
+ * selectively:
5
+ *
6
+ * - archive (saveAudit) failure clears status.version_hash and
7
+ * metadata.version.id, so the head never references an unresolvable
8
+ * audit entry, and the tag is never assigned;
9
+ * - tag assignment (setAuditTag) failure clears the live
10
+ * metadata.version.tag, so the head never advertises a tag the audit
11
+ * column cannot resolve (stigmer/stigmer#855);
12
+ * - persistOnRevert flushes either revert to the stored row, because on
13
+ * create no Persist step follows this one; without it (update) the
14
+ * chain's own Persist flushes the revert and the step writes nothing;
15
+ * - a failing re-persist is logged, never a failed apply.
16
+ */
17
+ import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
18
+ import { create } from "@bufbuild/protobuf";
19
+ import { describe, expect, it } from "vitest";
20
+
21
+ import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
22
+ import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
23
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
24
+
25
+ import { createLogger } from "../../../boot/logger.js";
26
+ import { RequestContext } from "../../../pipeline/request-context.js";
27
+ import { AuditNotFoundError } from "../../../store/interface.js";
28
+ import type { Store } from "../../../store/interface.js";
29
+ import { newSaveVersionAuditStep } from "../steps.js";
30
+
31
+ const silentLogger = createLogger({
32
+ level: "error",
33
+ pretty: false,
34
+ write: () => {},
35
+ });
36
+
37
+ const HASH = "c".repeat(64);
38
+
39
+ /** What the stored row carried at each re-persist. */
40
+ interface PersistedHead {
41
+ versionHash: string;
42
+ versionId: string;
43
+ tag: string;
44
+ }
45
+
46
+ interface FakeAuditStore {
47
+ saveAuditError: Error | undefined;
48
+ setAuditTagError: Error | undefined;
49
+ saveResourceError: Error | undefined;
50
+ setAuditTagCalls: number;
51
+ persisted: PersistedHead[];
52
+ }
53
+
54
+ /** A store exposing ONLY the members the step touches; the cast is the seam. */
55
+ function fakeStore(overrides?: Partial<FakeAuditStore>): {
56
+ store: Store;
57
+ state: FakeAuditStore;
58
+ } {
59
+ const state: FakeAuditStore = {
60
+ saveAuditError: undefined,
61
+ setAuditTagError: undefined,
62
+ saveResourceError: undefined,
63
+ setAuditTagCalls: 0,
64
+ persisted: [],
65
+ ...overrides,
66
+ };
67
+ const store = {
68
+ async getAuditByHash(): Promise<never> {
69
+ throw new AuditNotFoundError("not archived");
70
+ },
71
+ async saveAudit(): Promise<void> {
72
+ if (state.saveAuditError !== undefined) {
73
+ throw state.saveAuditError;
74
+ }
75
+ },
76
+ async setAuditTag(): Promise<void> {
77
+ state.setAuditTagCalls += 1;
78
+ if (state.setAuditTagError !== undefined) {
79
+ throw state.setAuditTagError;
80
+ }
81
+ },
82
+ async saveResource(
83
+ _k: unknown,
84
+ _id: unknown,
85
+ _schema: unknown,
86
+ wf: Workflow,
87
+ ): Promise<void> {
88
+ state.persisted.push({
89
+ versionHash: wf.status?.versionHash ?? "",
90
+ versionId: wf.metadata?.version?.id ?? "",
91
+ tag: wf.metadata?.version?.tag ?? "",
92
+ });
93
+ if (state.saveResourceError !== undefined) {
94
+ throw state.saveResourceError;
95
+ }
96
+ },
97
+ } as unknown as Store;
98
+ return { store, state };
99
+ }
100
+
101
+ function contextWithWorkflow(
102
+ tag: string,
103
+ ): RequestContext<typeof WorkflowSchema> {
104
+ return new RequestContext(
105
+ WorkflowSchema,
106
+ create(WorkflowSchema, {
107
+ metadata: {
108
+ id: "wfl_test",
109
+ org: "acme",
110
+ version: { id: HASH, tag },
111
+ },
112
+ status: { versionHash: HASH },
113
+ }),
114
+ testCallerIdentity(),
115
+ ApiResourceKind.workflow,
116
+ );
117
+ }
118
+
119
+ /** The create chain's arguments: isCreate, and no Persist follows. */
120
+ function createStep(store: Store) {
121
+ return newSaveVersionAuditStep(store, silentLogger, true, true);
122
+ }
123
+
124
+ describe("SaveVersionAudit — safe degradation", () => {
125
+ it("tag failure on create clears the live tag and re-persists the row tagless", async () => {
126
+ const { store, state } = fakeStore({
127
+ setAuditTagError: new Error("tag column locked"),
128
+ });
129
+ const ctx = contextWithWorkflow("stable");
130
+
131
+ await createStep(store).execute(ctx);
132
+
133
+ expect(ctx.newState.metadata?.version?.tag).toBe("");
134
+ expect(state.persisted).toEqual([
135
+ { versionHash: HASH, versionId: HASH, tag: "" },
136
+ ]);
137
+ });
138
+
139
+ it("tag failure without persistOnRevert clears the live tag and leaves the write to the chain's Persist", async () => {
140
+ const { store, state } = fakeStore({
141
+ setAuditTagError: new Error("tag column locked"),
142
+ });
143
+ const ctx = contextWithWorkflow("stable");
144
+
145
+ await newSaveVersionAuditStep(store, silentLogger, true, false).execute(
146
+ ctx,
147
+ );
148
+
149
+ expect(ctx.newState.metadata?.version?.tag).toBe("");
150
+ expect(state.persisted).toEqual([]);
151
+ });
152
+
153
+ it("archive failure on create clears the hash and version id, re-persists, and never assigns the tag", async () => {
154
+ const { store, state } = fakeStore({
155
+ saveAuditError: new Error("disk full"),
156
+ });
157
+ const ctx = contextWithWorkflow("stable");
158
+
159
+ await createStep(store).execute(ctx);
160
+
161
+ expect(ctx.newState.status?.versionHash).toBe("");
162
+ expect(ctx.newState.metadata?.version?.id).toBe("");
163
+ expect(state.setAuditTagCalls).toBe(0);
164
+ expect(state.persisted).toEqual([
165
+ { versionHash: "", versionId: "", tag: "stable" },
166
+ ]);
167
+ });
168
+
169
+ it("a failing re-persist after a tag failure is logged, and the step does not throw", async () => {
170
+ const { store, state } = fakeStore({
171
+ setAuditTagError: new Error("tag column locked"),
172
+ saveResourceError: new Error("connection reset"),
173
+ });
174
+ const ctx = contextWithWorkflow("stable");
175
+
176
+ await expect(createStep(store).execute(ctx)).resolves.toBeUndefined();
177
+
178
+ expect(ctx.newState.metadata?.version?.tag).toBe("");
179
+ expect(state.persisted).toHaveLength(1);
180
+ });
181
+
182
+ it("a clean archive and tag write nothing beyond the audit row", async () => {
183
+ const { store, state } = fakeStore();
184
+ const ctx = contextWithWorkflow("stable");
185
+
186
+ await createStep(store).execute(ctx);
187
+
188
+ expect(ctx.newState.metadata?.version?.tag).toBe("stable");
189
+ expect(state.setAuditTagCalls).toBe(1);
190
+ expect(state.persisted).toEqual([]);
191
+ });
192
+ });
@@ -217,7 +217,8 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
217
217
  * created via the in-process client AFTER Persist (children need the
218
218
  * parent's id), status.default_instance_id lands in an explicit second
219
219
  * persist, and v1 archives LAST so the snapshot captures it (the audit
220
- * step re-persists on revert because no Persist follows it here).
220
+ * step re-persists either of its reverts, a stripped hash or a cleared
221
+ * tag, because no Persist follows it here).
221
222
  */
222
223
  async function createWorkflow(
223
224
  deps: WorkflowControllerDeps,