@opengeni/contracts 0.10.0 → 0.18.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.
package/src/index.ts CHANGED
@@ -1,4 +1,50 @@
1
1
  import { z } from "zod";
2
+ import {
3
+ boundSessionEventPayload,
4
+ measureSessionEventJson,
5
+ sessionEventJsonBytes,
6
+ type SessionEventBoundarySurface,
7
+ } from "./event-preview";
8
+
9
+ export {
10
+ SESSION_EVENT_PAYLOAD_MAX_BYTES,
11
+ approximateSessionEventTokens,
12
+ boundSessionEventPayload,
13
+ measureSessionEventJson,
14
+ sessionEventJsonBytes,
15
+ sessionEventMediaPreview,
16
+ sessionEventMediaPreviewFromDataUrl,
17
+ sessionEventPayloadTruncation,
18
+ type BoundSessionEventPayloadOptions,
19
+ type SessionEventBoundarySurface,
20
+ type SessionEventMediaPreview,
21
+ type SessionEventJsonMeasurement,
22
+ type SessionEventPayloadTruncation,
23
+ } from "./event-preview";
24
+
25
+ export {
26
+ RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
27
+ RETAINED_OUTPUT_MAX_PAGE_BYTES,
28
+ RETAINED_OUTPUT_RECEIPT_MAX_BYTES,
29
+ RetainedArtifactMetadataSchema,
30
+ RetainedArtifactReferenceSchema,
31
+ RetainedArtifactUnavailableSchema,
32
+ RetainedOutputEvidenceSchema,
33
+ RetainedOutputKind,
34
+ RetainedOutputUnavailableReason,
35
+ retainedArtifactReferenceFromFile,
36
+ retainedOutputUnavailable,
37
+ resolveRetainedOutputRange,
38
+ validateRetainedOutputEvidence,
39
+ type RetainedArtifactFileInput,
40
+ type RetainedArtifactMetadata,
41
+ type RetainedArtifactReference,
42
+ type RetainedArtifactUnavailable,
43
+ type RetainedOutputAvailableEvidence,
44
+ type RetainedOutputEvidence,
45
+ type RetainedOutputRangeResolution,
46
+ type RetainedOutputResolvedRange,
47
+ } from "./retained-output";
2
48
 
3
49
  export const SessionStatus = z.enum([
4
50
  "queued",
@@ -7,7 +53,6 @@ export const SessionStatus = z.enum([
7
53
  "requires_action",
8
54
  "recovering",
9
55
  "waiting_capacity",
10
- "paused",
11
56
  "failed",
12
57
  "cancelled",
13
58
  ]);
@@ -62,9 +107,16 @@ export type CapabilityDescriptor = {
62
107
  os: { supported: SandboxOs[]; default: SandboxOs };
63
108
  capabilities: {
64
109
  FileSystem: { available: boolean; readOnly: boolean };
65
- Terminal: { available: boolean; transport: "sse-events" | "pty-ws" | null; pty: boolean };
110
+ Terminal: {
111
+ available: boolean;
112
+ transport: "sse-events" | "pty-ws" | null;
113
+ pty: boolean;
114
+ };
66
115
  Git: { available: boolean };
67
- DesktopStream: { available: boolean; transport: "vnc-ws" | "rdp-ws" | "webrtc" | null };
116
+ DesktopStream: {
117
+ available: boolean;
118
+ transport: "vnc-ws" | "rdp-ws" | "webrtc" | null;
119
+ };
68
120
  // Feasibility only (== DesktopStream.available && os==linux); NOT a request.
69
121
  Recording: { available: boolean };
70
122
  };
@@ -97,7 +149,7 @@ export const DESKTOP_STREAM_PORT = 6080;
97
149
  // Terminal cell's `url` is the tunnel address resolved against this port.
98
150
  export const TERMINAL_STREAM_PORT = 7681;
99
151
 
100
- // The Part-D matrix (master-spine PART D + module 03-providers). One row per
152
+ // The provider capability matrix (sandbox contract PART D + module 03-providers). One row per
101
153
  // backend (10 rows). v1 reachable cells are all Linux; macos/windows are seam
102
154
  // placeholders (no enum members shipped). Reading rule: a capability cell is
103
155
  // `available:false` + a reason in the negotiated doc, never absent.
@@ -444,7 +496,7 @@ export const Permission = z.enum([
444
496
  "sessions:create",
445
497
  "sessions:read",
446
498
  "sessions:control",
447
- // Sandbox-surfacing (master-spine §C.3 / crosscut PART 1.2). stream:view is a
499
+ // sandbox workspace (sandbox contract §C.3 / crosscut PART 1.2). stream:view is a
448
500
  // REAL, distinct permission — strictly BROADER than sessions:read — because the
449
501
  // pixel plane (Channel B) is UN-REDACTED: a viewer of raw pixels can see cloud
450
502
  // creds the agent cat's into a terminal, which the redacted Channel-A event log
@@ -507,6 +559,30 @@ export const Permission = z.enum([
507
559
  ]);
508
560
  export type Permission = z.infer<typeof Permission>;
509
561
 
562
+ /**
563
+ * Capability-first permissions signed into a session's first-party OpenGeni
564
+ * MCP token when a top-level creator does not explicitly narrow them.
565
+ *
566
+ * Keep this contract shared by admission and runtime signing: a worker-signed
567
+ * child whose parent was narrowed must inherit the parent's effective subset,
568
+ * never fall back to a different runtime-local default.
569
+ */
570
+ export const DEFAULT_FIRST_PARTY_MCP_PERMISSIONS = [
571
+ "workspace:read",
572
+ "files:read",
573
+ "documents:search",
574
+ "scheduled_tasks:manage",
575
+ "scheduled_tasks:run",
576
+ "goals:manage",
577
+ "sessions:read",
578
+ "sessions:create",
579
+ "sessions:control",
580
+ "variable-sets:use",
581
+ "variable-sets:manage",
582
+ "rigs:use",
583
+ "github:use",
584
+ ] as const satisfies readonly Permission[];
585
+
510
586
  export function prefixedMcpToolName(registryId: string, toolName: string): string {
511
587
  return `${registryId}__${toolName}`;
512
588
  }
@@ -553,11 +629,13 @@ export const Workspace = z.object({
553
629
  // validated by WorkspaceSettingsSchema; unknown keys are preserved across
554
630
  // PATCH merges so newer settings survive an older server.
555
631
  settings: z.record(z.string(), z.unknown()),
556
- inferenceState: z.enum(["active", "paused"]),
557
- inferenceGeneration: z.number().int().nonnegative(),
558
- inferenceReason: z.string().nullable(),
559
- inferenceChangedBy: z.string().nullable(),
560
- inferenceChangedAt: z.string().nullable(),
632
+ inferenceControl: z.object({
633
+ state: z.enum(["active", "paused"]),
634
+ revision: z.number().int().nonnegative(),
635
+ reason: z.string().nullable(),
636
+ changedBy: z.string().nullable(),
637
+ changedAt: z.string().nullable(),
638
+ }),
561
639
  // Workspace default rig used by session/scheduled-task create fallback.
562
640
  defaultRigId: z.string().uuid().nullable(),
563
641
  createdAt: z.string(),
@@ -565,12 +643,297 @@ export const Workspace = z.object({
565
643
  });
566
644
  export type Workspace = z.infer<typeof Workspace>;
567
645
 
646
+ export const WorkspaceTranscriptionTarget = z
647
+ .object({
648
+ provider: z.string().trim().min(1).max(128),
649
+ model: z.string().trim().min(1).max(256).nullable(),
650
+ credentialMode: z.enum(["managed", "byok"]),
651
+ // A workspace-scoped connection reference, never credential material.
652
+ credentialConnectionId: z.string().uuid().nullable(),
653
+ region: z.string().trim().min(1).max(128).nullable(),
654
+ })
655
+ .strict()
656
+ .superRefine((target, context) => {
657
+ if (target.provider === "azure-speech" && target.credentialMode !== "byok") {
658
+ context.addIssue({
659
+ code: "custom",
660
+ path: ["credentialMode"],
661
+ message: "Azure Speech is supported only through workspace BYOK",
662
+ });
663
+ }
664
+ if (target.credentialMode === "byok" && target.credentialConnectionId === null) {
665
+ context.addIssue({
666
+ code: "custom",
667
+ path: ["credentialConnectionId"],
668
+ message: "BYOK transcription targets require a workspace connection reference",
669
+ });
670
+ }
671
+ if (target.credentialMode === "managed" && target.credentialConnectionId !== null) {
672
+ context.addIssue({
673
+ code: "custom",
674
+ path: ["credentialConnectionId"],
675
+ message: "managed transcription targets cannot name a BYOK connection",
676
+ });
677
+ }
678
+ });
679
+ export type WorkspaceTranscriptionTarget = z.infer<typeof WorkspaceTranscriptionTarget>;
680
+
681
+ export const TranscriptionErrorCode = z.enum([
682
+ "permission_denied",
683
+ "not_supported",
684
+ "network",
685
+ "provider",
686
+ "policy_blocked",
687
+ "timeout",
688
+ "cancelled",
689
+ "unknown",
690
+ ]);
691
+ export type TranscriptionErrorCode = z.infer<typeof TranscriptionErrorCode>;
692
+
693
+ export const TranscriptionTimeSpan = z
694
+ .object({
695
+ startMilliseconds: z.number().finite().nonnegative(),
696
+ endMilliseconds: z.number().finite().nonnegative(),
697
+ })
698
+ .strict()
699
+ .superRefine((span, context) => {
700
+ if (span.endMilliseconds < span.startMilliseconds) {
701
+ context.addIssue({
702
+ code: "custom",
703
+ path: ["endMilliseconds"],
704
+ message: "transcription spans must not end before they start",
705
+ });
706
+ }
707
+ });
708
+ export type TranscriptionTimeSpan = z.infer<typeof TranscriptionTimeSpan>;
709
+
710
+ export const TranscriptionSpeaker = z
711
+ .object({
712
+ id: z.string().trim().min(1).max(128),
713
+ label: z.string().trim().min(1).max(128).optional(),
714
+ })
715
+ .strict();
716
+ export type TranscriptionSpeaker = z.infer<typeof TranscriptionSpeaker>;
717
+
718
+ export const TranscriptionWord = z
719
+ .object({
720
+ text: z.string().min(1).max(4096),
721
+ span: TranscriptionTimeSpan,
722
+ confidence: z.number().finite().min(0).max(1).optional(),
723
+ speaker: TranscriptionSpeaker.optional(),
724
+ })
725
+ .strict();
726
+ export type TranscriptionWord = z.infer<typeof TranscriptionWord>;
727
+
728
+ export const TranscriptionResultMetadata = z
729
+ .object({
730
+ detectedLanguage: z.string().trim().min(1).max(64).optional(),
731
+ span: TranscriptionTimeSpan.optional(),
732
+ confidence: z.number().finite().min(0).max(1).optional(),
733
+ speaker: TranscriptionSpeaker.optional(),
734
+ words: z.array(TranscriptionWord).max(10_000).optional(),
735
+ })
736
+ .strict()
737
+ .superRefine((metadata, context) => {
738
+ let previousStart = -1;
739
+ for (const [index, word] of (metadata.words ?? []).entries()) {
740
+ if (word.span.startMilliseconds < previousStart) {
741
+ context.addIssue({
742
+ code: "custom",
743
+ path: ["words", index, "span", "startMilliseconds"],
744
+ message: "transcription words must be ordered by start time",
745
+ });
746
+ }
747
+ previousStart = word.span.startMilliseconds;
748
+ if (
749
+ metadata.span &&
750
+ (word.span.startMilliseconds < metadata.span.startMilliseconds ||
751
+ word.span.endMilliseconds > metadata.span.endMilliseconds)
752
+ ) {
753
+ context.addIssue({
754
+ code: "custom",
755
+ path: ["words", index, "span"],
756
+ message: "transcription word spans must fall within the result span",
757
+ });
758
+ }
759
+ }
760
+ });
761
+ export type TranscriptionResultMetadata = z.infer<typeof TranscriptionResultMetadata>;
762
+
763
+ const TranscriptionEventBase = z
764
+ .object({
765
+ localSessionId: z.string().min(1).max(256),
766
+ sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
767
+ occurredAt: z.string().datetime({ offset: true }),
768
+ })
769
+ .strict();
770
+
771
+ /** Strict provider-neutral event surface; provider payload bags are rejected. */
772
+ export const TranscriptionEvent = z.discriminatedUnion("type", [
773
+ TranscriptionEventBase.extend({ type: z.literal("permission.requested") }),
774
+ TranscriptionEventBase.extend({
775
+ type: z.literal("session.opened"),
776
+ providerSessionId: z.string().min(1).max(512),
777
+ }),
778
+ TranscriptionEventBase.extend({
779
+ type: z.literal("transcript.partial"),
780
+ segmentId: z.string().min(1).max(512),
781
+ text: z.string().max(1_000_000),
782
+ metadata: TranscriptionResultMetadata.optional(),
783
+ }),
784
+ TranscriptionEventBase.extend({
785
+ type: z.literal("transcript.final"),
786
+ segmentId: z.string().min(1).max(512),
787
+ text: z.string().max(1_000_000),
788
+ providerAcceptanceId: z.string().min(1).max(512),
789
+ metadata: TranscriptionResultMetadata.optional(),
790
+ }),
791
+ TranscriptionEventBase.extend({
792
+ type: z.literal("usage"),
793
+ audioMilliseconds: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
794
+ costUsd: z.number().finite().nonnegative().max(1_000_000_000).nullable(),
795
+ }),
796
+ TranscriptionEventBase.extend({
797
+ type: z.literal("session.reconnecting"),
798
+ attempt: z.number().int().nonnegative().max(10_000),
799
+ reason: z.string().min(1).max(256),
800
+ }),
801
+ TranscriptionEventBase.extend({
802
+ type: z.literal("session.error"),
803
+ code: TranscriptionErrorCode,
804
+ recoverable: z.boolean(),
805
+ }),
806
+ TranscriptionEventBase.extend({
807
+ type: z.literal("session.closed"),
808
+ reason: z.enum(["completed", "cancelled", "error", "replaced"]),
809
+ }),
810
+ ]);
811
+ export type TranscriptionEvent = z.infer<typeof TranscriptionEvent>;
812
+
813
+ /**
814
+ * Workspace-only policy for the distinct speech-to-text capability. It never
815
+ * authorizes a turn model/provider and contains connection references rather
816
+ * than secrets. `acceptanceId` changes whenever an admin accepts a new target
817
+ * set, so clients can bind a microphone session to one exact policy revision.
818
+ */
819
+ export const WorkspaceTranscriptionPolicy = z
820
+ .object({
821
+ enabled: z.boolean(),
822
+ acceptanceId: z.string().uuid().nullable(),
823
+ primary: WorkspaceTranscriptionTarget.nullable(),
824
+ language: z.string().trim().min(1).max(64).nullable(),
825
+ autoDetectLanguage: z.boolean(),
826
+ diarization: z
827
+ .object({
828
+ enabled: z.boolean(),
829
+ maxSpeakers: z.number().int().min(2).max(100).nullable(),
830
+ })
831
+ .strict(),
832
+ retention: z
833
+ .object({
834
+ mode: z.enum(["none", "provider-policy"]),
835
+ maxDays: z.number().int().nonnegative().max(3650).nullable(),
836
+ })
837
+ .strict(),
838
+ privacy: z
839
+ .object({
840
+ allowProviderLogging: z.boolean(),
841
+ allowProviderTraining: z.boolean(),
842
+ })
843
+ .strict(),
844
+ fallback: z
845
+ .object({
846
+ mode: z.enum(["disabled", "explicit"]),
847
+ targets: z.array(WorkspaceTranscriptionTarget).max(8),
848
+ })
849
+ .strict(),
850
+ cost: z
851
+ .object({
852
+ currency: z.literal("USD"),
853
+ maxPerHour: z.number().finite().nonnegative().max(10_000).nullable(),
854
+ maxPerMonth: z.number().finite().nonnegative().max(1_000_000).nullable(),
855
+ })
856
+ .strict(),
857
+ })
858
+ .strict()
859
+ .superRefine((policy, context) => {
860
+ if (policy.enabled && policy.acceptanceId === null) {
861
+ context.addIssue({
862
+ code: "custom",
863
+ path: ["acceptanceId"],
864
+ message: "enabled transcription requires an accepted policy identity",
865
+ });
866
+ }
867
+ if (policy.enabled && policy.primary === null) {
868
+ context.addIssue({
869
+ code: "custom",
870
+ path: ["primary"],
871
+ message: "enabled transcription requires a primary target",
872
+ });
873
+ }
874
+ if (policy.enabled && !policy.autoDetectLanguage && policy.language === null) {
875
+ context.addIssue({
876
+ code: "custom",
877
+ path: ["language"],
878
+ message: "enabled transcription requires a language or accepted automatic detection",
879
+ });
880
+ }
881
+ if (policy.autoDetectLanguage && policy.language !== null) {
882
+ context.addIssue({
883
+ code: "custom",
884
+ path: ["language"],
885
+ message: "automatic language detection and a fixed language are mutually exclusive",
886
+ });
887
+ }
888
+ if (!policy.diarization.enabled && policy.diarization.maxSpeakers !== null) {
889
+ context.addIssue({
890
+ code: "custom",
891
+ path: ["diarization", "maxSpeakers"],
892
+ message: "disabled diarization cannot retain a speaker limit",
893
+ });
894
+ }
895
+ if (policy.fallback.mode === "disabled" && policy.fallback.targets.length > 0) {
896
+ context.addIssue({
897
+ code: "custom",
898
+ path: ["fallback", "targets"],
899
+ message: "disabled fallback cannot retain accepted targets",
900
+ });
901
+ }
902
+ if (policy.fallback.mode === "explicit" && policy.fallback.targets.length === 0) {
903
+ context.addIssue({
904
+ code: "custom",
905
+ path: ["fallback", "targets"],
906
+ message: "explicit fallback requires at least one accepted target",
907
+ });
908
+ }
909
+ const targetKeys = [policy.primary, ...policy.fallback.targets]
910
+ .filter((target): target is WorkspaceTranscriptionTarget => target !== null)
911
+ .map((target) =>
912
+ [
913
+ target.provider,
914
+ target.model ?? "",
915
+ target.credentialMode,
916
+ target.credentialConnectionId ?? "",
917
+ target.region ?? "",
918
+ ].join("\u0000"),
919
+ );
920
+ if (new Set(targetKeys).size !== targetKeys.length) {
921
+ context.addIssue({
922
+ code: "custom",
923
+ path: ["fallback", "targets"],
924
+ message: "transcription targets must be unique",
925
+ });
926
+ }
927
+ });
928
+ export type WorkspaceTranscriptionPolicy = z.infer<typeof WorkspaceTranscriptionPolicy>;
929
+
568
930
  // Validates the KNOWN keys of workspaces.settings; passthrough keeps unknown
569
- // (future) keys rather than stripping them. memoryEnabled gates Workspace Memory
570
- // V1 agent surfaces (turn injection + first-party memory tools); default false.
931
+ // (future) keys rather than stripping them. memoryEnabled and transcription are
932
+ // both default-off capabilities.
571
933
  export const WorkspaceSettingsSchema = z
572
934
  .object({
573
935
  memoryEnabled: z.boolean().optional(),
936
+ transcription: WorkspaceTranscriptionPolicy.optional(),
574
937
  })
575
938
  .passthrough();
576
939
  export type WorkspaceSettings = z.infer<typeof WorkspaceSettingsSchema>;
@@ -581,12 +944,13 @@ export function resolveWorkspaceMemoryEnabled(settings: unknown): boolean {
581
944
  return parsed.success ? parsed.data.memoryEnabled === true : false;
582
945
  }
583
946
 
584
- // PATCH body for workspace settings: a partial patch that deep-merges into the
585
- // stored bag. memoryEnabled is the only typed key today; passthrough carries
586
- // forward-compatible unknown keys through validation.
947
+ // PATCH body for workspace settings: a partial top-level patch that merges into
948
+ // the stored bag. Nested transcription policy updates are therefore full
949
+ // replacements; passthrough carries forward-compatible unknown keys.
587
950
  export const UpdateWorkspaceSettingsRequest = z
588
951
  .object({
589
952
  memoryEnabled: z.boolean().optional(),
953
+ transcription: WorkspaceTranscriptionPolicy.optional(),
590
954
  })
591
955
  .passthrough();
592
956
  export type UpdateWorkspaceSettingsRequest = z.infer<typeof UpdateWorkspaceSettingsRequest>;
@@ -606,6 +970,78 @@ export const UpdateWorkspaceModelPolicyRequest = z.object({
606
970
  });
607
971
  export type UpdateWorkspaceModelPolicyRequest = z.infer<typeof UpdateWorkspaceModelPolicyRequest>;
608
972
 
973
+ const turnInitiatorIdentityFields = {
974
+ subjectId: z.string().min(1),
975
+ /** Immutable display snapshot; never an authorization input. */
976
+ label: z.string().min(1).optional(),
977
+ } as const;
978
+
979
+ /** Reserved creator/initiator id used only by legacy-row migration defaults. */
980
+ export const UNATTRIBUTED_LEGACY_INITIATOR_SUBJECT_ID = "unattributed-legacy" as const;
981
+
982
+ /**
983
+ * A named machine/service principal asserted by a trusted embedding host. This
984
+ * deliberately excludes `kind: "subject"`: a delegated service assertion may
985
+ * describe causal machine work, but it is not a generic human-impersonation
986
+ * mechanism. The authenticated grant remains the authorization boundary.
987
+ */
988
+ export const ServiceTurnInitiator = z.object({
989
+ kind: z.literal("service"),
990
+ subjectId: z
991
+ .string()
992
+ .min(1)
993
+ .max(1024)
994
+ .refine((value) => value !== UNATTRIBUTED_LEGACY_INITIATOR_SUBJECT_ID, {
995
+ message: "unattributed-legacy is reserved for migrated rows",
996
+ }),
997
+ /** Immutable display snapshot; never an authorization input. */
998
+ label: z.string().min(1).max(256).optional(),
999
+ });
1000
+ export type ServiceTurnInitiator = z.infer<typeof ServiceTurnInitiator>;
1001
+
1002
+ /**
1003
+ * Immutable, non-secret provenance captured with an initiator. This is audit
1004
+ * context (for example an external occurrence id), not a second identity or
1005
+ * authorization surface.
1006
+ */
1007
+ export const TurnInitiatorContext = z.record(z.string(), z.unknown());
1008
+ export type TurnInitiatorContext = z.infer<typeof TurnInitiatorContext>;
1009
+
1010
+ const reservedServiceTurnInitiatorContextKeys = new Set([
1011
+ "backfill",
1012
+ "label",
1013
+ "provenanceError",
1014
+ "via",
1015
+ "viaTruncated",
1016
+ ]);
1017
+
1018
+ /** Bounded host provenance that cannot forge OpenGeni-owned lineage fields. */
1019
+ export const ServiceTurnInitiatorContext = TurnInitiatorContext.superRefine((value, ctx) => {
1020
+ for (const key of reservedServiceTurnInitiatorContextKeys) {
1021
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
1022
+ ctx.addIssue({
1023
+ code: z.ZodIssueCode.custom,
1024
+ path: [key],
1025
+ message: `${key} is reserved OpenGeni initiator context`,
1026
+ });
1027
+ }
1028
+ }
1029
+ try {
1030
+ if (new TextEncoder().encode(JSON.stringify(value)).byteLength > 4096) {
1031
+ ctx.addIssue({
1032
+ code: z.ZodIssueCode.custom,
1033
+ message: "service initiator context exceeds 4096 UTF-8 bytes",
1034
+ });
1035
+ }
1036
+ } catch {
1037
+ ctx.addIssue({
1038
+ code: z.ZodIssueCode.custom,
1039
+ message: "service initiator context must be JSON-serializable",
1040
+ });
1041
+ }
1042
+ });
1043
+ export type ServiceTurnInitiatorContext = z.infer<typeof ServiceTurnInitiatorContext>;
1044
+
609
1045
  export const AccountGrant = z.object({
610
1046
  accountId: z.string().uuid(),
611
1047
  subjectId: z.string().min(1),
@@ -623,6 +1059,10 @@ export const AccessGrant = z.object({
623
1059
  subjectLabel: z.string().optional(),
624
1060
  permissions: z.array(Permission),
625
1061
  metadata: z.record(z.string(), z.unknown()).optional(),
1062
+ // Optional trusted causal principal for a command submitted by an embedding
1063
+ // host. Authorization still uses subjectId + permissions above.
1064
+ serviceInitiator: ServiceTurnInitiator.optional(),
1065
+ serviceInitiatorContext: ServiceTurnInitiatorContext.optional(),
626
1066
  });
627
1067
  export type AccessGrant = z.infer<typeof AccessGrant>;
628
1068
 
@@ -637,34 +1077,76 @@ export const AccessContext = z.object({
637
1077
  });
638
1078
  export type AccessContext = z.infer<typeof AccessContext>;
639
1079
 
640
- export const DelegatedAccessTokenPayload = z.object({
641
- accountId: z.string().uuid(),
642
- workspaceId: z.string().uuid(),
643
- subjectId: z.string().min(1),
644
- subjectLabel: z.string().optional(),
645
- permissions: z.array(Permission).min(1),
646
- // Worker-asserted session scope for first-party MCP calls (HMAC-signed, not
647
- // agent-controlled); enables session-scoped tools such as goal management.
648
- sessionId: z.string().uuid().optional(),
649
- // The turn making the call (the caller's identity), HMAC-signed by the worker
650
- // at turn setup. Lets a tool classify WHO is calling from the token itself,
651
- // instead of racily re-reading the session's live active_turn_id — e.g. the
652
- // sacred-pause guard must know if the CALLER is a machine child-notification
653
- // turn, and the active pointer can flip to another turn mid-check.
654
- turnId: z.string().uuid().optional(),
655
- exp: z.number().int().positive(),
656
- });
1080
+ export const DelegatedAccessTokenPayload = z
1081
+ .object({
1082
+ accountId: z.string().uuid(),
1083
+ workspaceId: z.string().uuid(),
1084
+ subjectId: z.string().min(1),
1085
+ subjectLabel: z.string().optional(),
1086
+ permissions: z.array(Permission).min(1),
1087
+ // Trusted embedding hosts can sign a causal service principal separately
1088
+ // from the grant subject that authorizes the request. The claim is consumed
1089
+ // only when a command creates a new session/turn.
1090
+ serviceInitiator: ServiceTurnInitiator.optional(),
1091
+ serviceInitiatorContext: ServiceTurnInitiatorContext.optional(),
1092
+ // Worker-asserted session scope for first-party MCP calls (HMAC-signed, not
1093
+ // agent-controlled); enables session-scoped tools such as goal management.
1094
+ sessionId: z.string().uuid().optional(),
1095
+ // The turn making the call (the caller's identity), HMAC-signed by the worker
1096
+ // at turn setup. Lets a tool classify WHO is calling from the token itself,
1097
+ // instead of racily re-reading the session's live active_turn_id — e.g. the
1098
+ // sacred-pause guard must know if the CALLER is a machine child-notification
1099
+ // turn, and the active pointer can flip to another turn mid-check.
1100
+ turnId: z.string().uuid().optional(),
1101
+ // Exact execution owner. Agent control commands are accepted only while this
1102
+ // attempt still owns the signed turn.
1103
+ attemptId: z.string().uuid().optional(),
1104
+ executionGeneration: z.number().int().positive().optional(),
1105
+ exp: z.number().int().positive(),
1106
+ })
1107
+ .superRefine((payload, ctx) => {
1108
+ if (payload.serviceInitiatorContext && !payload.serviceInitiator) {
1109
+ ctx.addIssue({
1110
+ code: z.ZodIssueCode.custom,
1111
+ path: ["serviceInitiatorContext"],
1112
+ message: "serviceInitiatorContext requires serviceInitiator",
1113
+ });
1114
+ }
1115
+ if (
1116
+ payload.serviceInitiator &&
1117
+ (payload.turnId !== undefined ||
1118
+ payload.attemptId !== undefined ||
1119
+ payload.executionGeneration !== undefined)
1120
+ ) {
1121
+ ctx.addIssue({
1122
+ code: z.ZodIssueCode.custom,
1123
+ path: ["serviceInitiator"],
1124
+ message: "serviceInitiator cannot replace an exact agent-attempt initiator",
1125
+ });
1126
+ }
1127
+ });
657
1128
  export type DelegatedAccessTokenPayload = z.infer<typeof DelegatedAccessTokenPayload>;
658
1129
 
1130
+ const delegatedAccessTokenPrefix = "ogd_";
1131
+ const delegatedServiceAccessTokenPrefix = "ogd2_";
1132
+
659
1133
  export async function signDelegatedAccessToken(
660
1134
  secret: string,
661
1135
  payload: DelegatedAccessTokenPayload,
662
1136
  ): Promise<string> {
663
- const encodedPayload = base64UrlEncode(
664
- JSON.stringify(DelegatedAccessTokenPayload.parse(payload)),
1137
+ const parsed = DelegatedAccessTokenPayload.parse(payload);
1138
+ const prefix = parsed.serviceInitiator
1139
+ ? delegatedServiceAccessTokenPrefix
1140
+ : delegatedAccessTokenPrefix;
1141
+ const encodedPayload = base64UrlEncode(JSON.stringify(parsed));
1142
+ // The service-capable envelope binds its prefix into the signature. An old
1143
+ // verifier accepts only ogd_ and therefore fails closed during a rolling
1144
+ // deploy; changing ogd2_ to ogd_ cannot turn provenance loss into success.
1145
+ const signature = await hmacSha256Base64Url(
1146
+ secret,
1147
+ prefix === delegatedServiceAccessTokenPrefix ? `${prefix}${encodedPayload}` : encodedPayload,
665
1148
  );
666
- const signature = await hmacSha256Base64Url(secret, encodedPayload);
667
- return `ogd_${encodedPayload}.${signature}`;
1149
+ return `${prefix}${encodedPayload}.${signature}`;
668
1150
  }
669
1151
 
670
1152
  export async function verifyDelegatedAccessToken(
@@ -672,30 +1154,48 @@ export async function verifyDelegatedAccessToken(
672
1154
  token: string,
673
1155
  nowSeconds = Math.floor(Date.now() / 1000),
674
1156
  ): Promise<DelegatedAccessTokenPayload | null> {
675
- if (!token.startsWith("ogd_")) {
1157
+ const prefix = token.startsWith(delegatedServiceAccessTokenPrefix)
1158
+ ? delegatedServiceAccessTokenPrefix
1159
+ : token.startsWith(delegatedAccessTokenPrefix)
1160
+ ? delegatedAccessTokenPrefix
1161
+ : null;
1162
+ if (!prefix) {
676
1163
  return null;
677
1164
  }
678
- const withoutPrefix = token.slice("ogd_".length);
1165
+ const withoutPrefix = token.slice(prefix.length);
679
1166
  const dot = withoutPrefix.lastIndexOf(".");
680
1167
  if (dot <= 0) {
681
1168
  return null;
682
1169
  }
683
1170
  const encodedPayload = withoutPrefix.slice(0, dot);
684
1171
  const signature = withoutPrefix.slice(dot + 1);
685
- const expected = await hmacSha256Base64Url(secret, encodedPayload);
1172
+ const expected = await hmacSha256Base64Url(
1173
+ secret,
1174
+ prefix === delegatedServiceAccessTokenPrefix ? `${prefix}${encodedPayload}` : encodedPayload,
1175
+ );
686
1176
  if (!constantTimeEqual(signature, expected)) {
687
1177
  return null;
688
1178
  }
689
- const payload = DelegatedAccessTokenPayload.safeParse(
690
- JSON.parse(base64UrlDecode(encodedPayload)),
691
- );
1179
+ let decoded: unknown;
1180
+ try {
1181
+ decoded = JSON.parse(base64UrlDecode(encodedPayload));
1182
+ } catch {
1183
+ return null;
1184
+ }
1185
+ const payload = DelegatedAccessTokenPayload.safeParse(decoded);
692
1186
  if (!payload.success || payload.data.exp < nowSeconds) {
693
1187
  return null;
694
1188
  }
1189
+ if (
1190
+ (prefix === delegatedServiceAccessTokenPrefix) !==
1191
+ (payload.data.serviceInitiator !== undefined)
1192
+ ) {
1193
+ return null;
1194
+ }
695
1195
  return payload.data;
696
1196
  }
697
1197
 
698
- // --- Enrollment bearer credential (bring-your-own-compute M5, dossier §10.2) ---
1198
+ // --- Enrollment bearer credential (bring-your-own-compute M5) ---
699
1199
  //
700
1200
  // The signed bearer the agent presents to the control plane after enrollment (the
701
1201
  // EnrollmentCredentials.bearer the poll returns). REUSES the SAME HMAC envelope as
@@ -828,7 +1328,7 @@ export async function verifyEnrollToken(
828
1328
  return payload.data;
829
1329
  }
830
1330
 
831
- // --- Scoped data-plane stream token (master-spine §C.3 / crosscut PART 1.3) ---
1331
+ // --- Scoped data-plane stream token (sandbox contract §C.3 / crosscut PART 1.3) ---
832
1332
  //
833
1333
  // REUSES the existing HMAC envelope (sign/verifyDelegatedAccessToken's
834
1334
  // base64Url + hmacSha256Base64Url) — NOT a second crypto — but with a distinct
@@ -913,7 +1413,7 @@ export async function verifyStreamToken(
913
1413
  return payload.data;
914
1414
  }
915
1415
 
916
- // --- Relay PRODUCER token (bring-your-own-compute M8b, dossier §10.5) ---
1416
+ // --- Relay PRODUCER token (bring-your-own-compute M8b) ---
917
1417
  //
918
1418
  // The token the AGENT presents to the relay edge when it registers a pty/desktop
919
1419
  // stream channel (role=AGENT) — distinct from the viewer's `ogs_` token. It is
@@ -1147,7 +1647,11 @@ export type Entitlements = z.infer<typeof Entitlements>;
1147
1647
 
1148
1648
  export const LimitDecision = z.discriminatedUnion("allowed", [
1149
1649
  z.object({ allowed: z.literal(true) }),
1150
- z.object({ allowed: z.literal(false), code: z.string(), message: z.string() }),
1650
+ z.object({
1651
+ allowed: z.literal(false),
1652
+ code: z.string(),
1653
+ message: z.string(),
1654
+ }),
1151
1655
  ]);
1152
1656
  export type LimitDecision = z.infer<typeof LimitDecision>;
1153
1657
 
@@ -1195,10 +1699,21 @@ export type EntitlementsPort = {
1195
1699
  export const GitCredentialProvider = z.enum(["github", "gitlab", "azure_devops"]);
1196
1700
  export type GitCredentialProvider = z.infer<typeof GitCredentialProvider>;
1197
1701
 
1702
+ // Host-opaque identity for one independently mintable Git credential. It is
1703
+ // deliberately NOT constrained to a filesystem-safe alphabet: runtimes hash it
1704
+ // before using it in paths, command text, or environment variable names.
1705
+ export const GitCredentialBindingId = z.string().min(1).max(256);
1706
+ export type GitCredentialBindingId = z.infer<typeof GitCredentialBindingId>;
1707
+
1708
+ export const GitRepositoryAccess = z.enum(["read", "write"]);
1709
+ export type GitRepositoryAccess = z.infer<typeof GitRepositoryAccess>;
1710
+
1198
1711
  const GitProviderRepositoryId = z.union([z.number().int().positive(), z.string().min(1)]);
1199
1712
 
1200
1713
  export const GitCredentialRepositoryRef = z.object({
1201
1714
  provider: GitCredentialProvider.optional(),
1715
+ credentialBindingId: GitCredentialBindingId.optional(),
1716
+ access: GitRepositoryAccess.optional(),
1202
1717
  uri: z.string().min(1),
1203
1718
  ref: z.string().min(1),
1204
1719
  repositoryId: GitProviderRepositoryId.optional(),
@@ -1208,10 +1723,10 @@ export const GitCredentialRepositoryRef = z.object({
1208
1723
  });
1209
1724
  export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryRef>;
1210
1725
 
1211
- // ============ P4a — Connection-credential provider (§7.6) ============
1726
+ // ============ connection-credential provider — Connection-credential provider (§7.6) ============
1212
1727
  //
1213
- // The host-providable per-run credential-mint seam over OpenGeni's TWO
1214
- // run-scoped credential sites in the worker:
1728
+ // The host-providable credential seam over OpenGeni's run-scoped credential
1729
+ // sites in the worker and API:
1215
1730
  // - GIT credentials: run-scoped provider tokens minted in
1216
1731
  // `sandboxEnvironmentForRun` (standalone self-mints GitHub App tokens from
1217
1732
  // `settings`; embedded hosts can broker GitHub, GitLab, and Azure DevOps)
@@ -1219,6 +1734,8 @@ export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryR
1219
1734
  // - SANDBOX secrets: the decrypted variable set values loaded in
1220
1735
  // `loadVariableSetForRun` (today decrypted with
1221
1736
  // `environmentsEncryptionKeyBytes(settings)`).
1737
+ // - MCP credentials: request-time transport headers for connection-backed
1738
+ // servers, shared by normal model tools and Toolspace/Code Mode.
1222
1739
  //
1223
1740
  // In embedded/separate topologies the HOST owns these external connections
1224
1741
  // (its GitHub App, its secret vault + encryption key). When a host binds this
@@ -1226,7 +1743,7 @@ export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryR
1226
1743
  // from `settings`. Unset (standalone default) → byte-for-byte today's
1227
1744
  // self-mint.
1228
1745
  //
1229
- // FORK-7 CROSS-CHECK (the host-mapping safety guardrail): a credential
1746
+ // Workspace-scope cross-check (the host-mapping safety guardrail): a credential
1230
1747
  // provider returns the `workspaceId` it scoped the credential to, and the
1231
1748
  // activity ASSERTS it agrees with the run's workspace BEFORE injecting
1232
1749
  // any git provider token seed (or applying decrypted environment values). A host mapping bug that
@@ -1236,10 +1753,26 @@ export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryR
1236
1753
  export type GitCredentialsRequest = {
1237
1754
  accountId: string;
1238
1755
  workspaceId: string;
1756
+ /** Immutable authority admitted with the turn requesting this credential. */
1757
+ sessionId: string;
1758
+ rootSessionId: string;
1759
+ turnId: string;
1760
+ attemptId: string;
1761
+ executionGeneration: number;
1762
+ initiator: TurnInitiator;
1763
+ initiatorContext: TurnInitiatorContext;
1239
1764
  // Provider defaults to "github" for the legacy request shape. GitHub-only
1240
1765
  // hosts can keep reading installationId/repositoryIds exactly as before;
1241
1766
  // provider-aware hosts should branch on this and repositoryRefs.
1242
1767
  provider?: GitCredentialProvider;
1768
+ // Present when the host supplied an explicit binding or when more than one
1769
+ // independently mintable credential exists for this provider. A host must
1770
+ // mint only this binding; OpenGeni never treats provider identity as enough
1771
+ // to select among multiple accounts/installations.
1772
+ credentialBindingId?: GitCredentialBindingId;
1773
+ // Canonical lower-case host shared by this binding's repository refs when
1774
+ // there is exactly one. Binding-aware providers echo it when present.
1775
+ providerHost?: string;
1243
1776
  // Token requests are the existing behavior. Identity requests let lazy
1244
1777
  // sandbox provisioning resolve stable git author/committer identity before
1245
1778
  // the box exists while deferring the rotating token value to first provision.
@@ -1252,14 +1785,49 @@ export type GitCredentialsRequest = {
1252
1785
  repositoryIds: number[];
1253
1786
  };
1254
1787
 
1788
+ /**
1789
+ * One exact repository route exposed by a host-owned HTTPS smart-Git broker.
1790
+ *
1791
+ * `repositoryUri` must echo one URI from the request's `repositoryRefs`.
1792
+ * `brokerUri` is a stable, credential-free HTTPS remote. The rotating bearer
1793
+ * remains separate in `GitCredentials.token`, so it cannot leak through Git
1794
+ * configuration, provider-CLI arguments, manifests, or repository metadata.
1795
+ */
1796
+ export type GitHttpBrokerRepositoryRoute = {
1797
+ repositoryUri: string;
1798
+ brokerUri: string;
1799
+ };
1800
+
1801
+ /**
1802
+ * Optional transport override for credentials that cannot be safely narrowed
1803
+ * into a provider token. Omission retains the provider-token behavior.
1804
+ */
1805
+ export type GitCredentialTransport = {
1806
+ kind: "http_broker";
1807
+ repositories: GitHttpBrokerRepositoryRoute[];
1808
+ };
1809
+
1255
1810
  export type GitCredentials = {
1256
- // The minted provider token. Required for purpose="token"; optional for
1257
- // purpose="identity" so hosts can return only stable git identity before lazy
1258
- // sandbox provision. The value never enters the manifest.
1811
+ // The minted secret. For the default transport this is a provider token; for
1812
+ // `http_broker` it is the broker bearer. Required for purpose="token";
1813
+ // optional for purpose="identity" so hosts can return only stable git identity
1814
+ // before lazy sandbox provision. The value never enters the manifest.
1259
1815
  token?: string;
1260
- // FORK-7 echo: the workspace the provider scoped this token to. The activity
1816
+ // A host-owned exact smart-Git transport for providers whose available token
1817
+ // cannot be constrained to the selected repositories. OpenGeni rewrites only
1818
+ // the echoed repository remotes and never exposes this bearer to provider
1819
+ // CLIs. Omitted means the token is a direct provider credential.
1820
+ transport?: GitCredentialTransport;
1821
+ // workspace-scope cross-check echo: the workspace the provider scoped this token to. The activity
1261
1822
  // asserts `workspaceId === request.workspaceId` before injecting.
1262
1823
  workspaceId: string;
1824
+ // Strict request echoes for binding-aware requests. OpenGeni validates these
1825
+ // before accepting a token, preventing a host routing bug from returning a
1826
+ // sibling connection's credential. They remain optional for legacy single-
1827
+ // binding/provider hosts.
1828
+ credentialBindingId?: GitCredentialBindingId;
1829
+ provider?: GitCredentialProvider;
1830
+ providerHost?: string;
1263
1831
  // Optional provider expiry for host-managed proactive renewal. ISO-8601;
1264
1832
  // null/omitted means the host does not expose a deadline and OpenGeni uses
1265
1833
  // its conservative bounded refresh cadence instead.
@@ -1282,7 +1850,7 @@ export type SandboxSecrets = {
1282
1850
  // `environmentsEncryptionKeyBytes` decrypt. Same shape the self-mint path
1283
1851
  // produces (plaintext name→value).
1284
1852
  values: Record<string, string>;
1285
- // FORK-7 echo: the workspace the provider scoped these secrets to.
1853
+ // workspace-scope cross-check echo: the workspace the provider scoped these secrets to.
1286
1854
  workspaceId: string;
1287
1855
  // Optional variableSet metadata; when omitted the activity uses the
1288
1856
  // variableSetId as both id and name (the local decrypt carries the row's
@@ -1292,24 +1860,268 @@ export type SandboxSecrets = {
1292
1860
  description?: string | null;
1293
1861
  };
1294
1862
 
1863
+ export type CredentialAuthNeededReason =
1864
+ | "missing_connection"
1865
+ | "expired"
1866
+ | "insufficient_scope"
1867
+ | "refresh_failed";
1868
+
1869
+ /**
1870
+ * Host-owned run credentials are materialized below one OpenGeni-owned sandbox
1871
+ * directory. Paths are relative POSIX names; the runtime validates traversal,
1872
+ * collisions, bounds, and modes before any content reaches a sandbox.
1873
+ */
1874
+ export type RunCredentialFile = {
1875
+ path: string;
1876
+ content: string;
1877
+ mode?: "0400" | "0600";
1878
+ };
1879
+
1880
+ export type RunCredentialAuthNeeded = {
1881
+ reason: CredentialAuthNeededReason;
1882
+ providerDomain?: string;
1883
+ connectionId?: string;
1884
+ scopes?: string[];
1885
+ resource?: string;
1886
+ authorizationUrl?: string;
1887
+ /** Bounded non-secret guidance. Never place credential material here. */
1888
+ message?: string;
1889
+ };
1890
+
1891
+ export type RunCredentialRedaction = {
1892
+ /** Bounded diagnostic label used only in the replacement marker. */
1893
+ name: string;
1894
+ /** One atomic secret value that must be removed from streamed/audit output. */
1895
+ value: string;
1896
+ };
1897
+
1898
+ export type RunCredentialsRequest = {
1899
+ accountId: string;
1900
+ workspaceId: string;
1901
+ sessionId: string;
1902
+ parentSessionId: string | null;
1903
+ rootSessionId: string;
1904
+ /** All sessions sharing this sandbox group share one OS/filesystem trust boundary. */
1905
+ sandboxGroupId: string;
1906
+ turnId: string;
1907
+ attemptId: string;
1908
+ executionGeneration: number;
1909
+ /** Immutable authority admitted with this turn. */
1910
+ initiator: TurnInitiator;
1911
+ initiatorContext: TurnInitiatorContext;
1912
+ effectiveSandboxBackend: SandboxBackend;
1913
+ sandboxOs: SandboxOs;
1914
+ purpose: "provision" | "renewal";
1915
+ forceRefresh: boolean;
1916
+ /** Informational standalone variable-set identity; never gates host resolution. */
1917
+ variableSet: { id: string; name: string } | null;
1918
+ };
1919
+
1920
+ export type RunCredentialsResolution =
1921
+ | {
1922
+ /**
1923
+ * The frozen target/attempt must not receive host material. Hosts use
1924
+ * this for unsupported OSes/backends and policy-based opt-out; the
1925
+ * decision must remain stable for the attempt.
1926
+ */
1927
+ status: "not_applicable";
1928
+ accountId: string;
1929
+ workspaceId: string;
1930
+ sessionId: string;
1931
+ }
1932
+ | {
1933
+ status: "ok";
1934
+ /** Scope echoes are mandatory and checked before materialization. */
1935
+ accountId: string;
1936
+ workspaceId: string;
1937
+ sessionId: string;
1938
+ /** Secret environment values. Always delivered off-manifest. */
1939
+ environment: Record<string, string>;
1940
+ files?: RunCredentialFile[];
1941
+ /** Environment name to one returned relative file path. */
1942
+ fileEnvironment?: Record<string, string>;
1943
+ /**
1944
+ * Atomic sensitive values embedded inside credential files or derived
1945
+ * material. Environment values are registered automatically; hosts list
1946
+ * additional file-contained values here so chunked output is redacted.
1947
+ */
1948
+ redactions?: RunCredentialRedaction[];
1949
+ /** Earliest material expiry. Null/omitted uses a bounded refresh cadence. */
1950
+ expiresAt?: string | null;
1951
+ /** Partial degradation: usable material may coexist with reconnect notices. */
1952
+ authNeeded?: RunCredentialAuthNeeded[];
1953
+ }
1954
+ | {
1955
+ status: "auth_needed";
1956
+ accountId: string;
1957
+ workspaceId: string;
1958
+ sessionId: string;
1959
+ authNeeded: RunCredentialAuthNeeded[];
1960
+ };
1961
+
1962
+ export const McpConnectionResourceScope = z
1963
+ .object({
1964
+ /** Provider-stable repository identity, serialized as a string on the wire. */
1965
+ id: z.string().min(1).max(512),
1966
+ kind: z.literal("repository"),
1967
+ })
1968
+ .strict();
1969
+ export type McpConnectionResourceScope = z.infer<typeof McpConnectionResourceScope>;
1970
+
1971
+ const McpConnectionResourceScopes = z
1972
+ .array(McpConnectionResourceScope)
1973
+ .min(1)
1974
+ .max(256)
1975
+ .superRefine((resources, context) => {
1976
+ const seen = new Set<string>();
1977
+ for (const [index, resource] of resources.entries()) {
1978
+ const key = `${resource.kind}\0${resource.id}`;
1979
+ if (seen.has(key)) {
1980
+ context.addIssue({
1981
+ code: "custom",
1982
+ message: "selectedResources must not contain duplicates",
1983
+ path: [index],
1984
+ });
1985
+ }
1986
+ seen.add(key);
1987
+ }
1988
+ });
1989
+
1990
+ export const McpServerConnectionRef = z
1991
+ .object({
1992
+ /** Opaque host or standalone connection identifier. */
1993
+ connectionId: z.string().min(1).optional(),
1994
+ /** Stable provider family (for example github, gitlab, or azure_devops). */
1995
+ provider: z.string().min(1).max(128).optional(),
1996
+ /** Provider host or tenant domain. */
1997
+ providerDomain: z.string().min(1),
1998
+ kind: z.enum(["oauth2", "api_key", "app_install", "delegated"]).optional(),
1999
+ scopes: z.array(z.string().min(1)).optional(),
2000
+ /** OAuth resource indicator. This is distinct from selectedResources. */
2001
+ resource: z.string().min(1).optional(),
2002
+ /** Exact provider resources this MCP binding is allowed to operate on. */
2003
+ selectedResources: McpConnectionResourceScopes.optional(),
2004
+ subjectScope: z.enum(["workspace", "subject"]).optional(),
2005
+ })
2006
+ .strict()
2007
+ .superRefine((reference, context) => {
2008
+ if (!reference.selectedResources) return;
2009
+ if (!reference.connectionId) {
2010
+ context.addIssue({
2011
+ code: "custom",
2012
+ message: "selectedResources requires connectionId",
2013
+ path: ["connectionId"],
2014
+ });
2015
+ }
2016
+ if (!reference.provider) {
2017
+ context.addIssue({
2018
+ code: "custom",
2019
+ message: "selectedResources requires provider",
2020
+ path: ["provider"],
2021
+ });
2022
+ }
2023
+ });
2024
+ export type McpServerConnectionRef = z.infer<typeof McpServerConnectionRef>;
2025
+
2026
+ export type McpCredentialsRequest = {
2027
+ accountId: string;
2028
+ workspaceId: string;
2029
+ /** Immediate session whose model or Toolspace call needs the credential. */
2030
+ sessionId: string;
2031
+ /** Workspace-scoped lineage root for host authorization and binding lookup. */
2032
+ rootSessionId: string;
2033
+ turnId: string;
2034
+ /** Null only while a durable turn exists without a currently executing attempt. */
2035
+ attemptId: string | null;
2036
+ executionGeneration: number;
2037
+ /** The immutable authority that admitted this turn. Never substitute the sandbox caller. */
2038
+ initiator: TurnInitiator;
2039
+ initiatorContext: TurnInitiatorContext;
2040
+ /** Immediate technical caller, retained only as non-authoritative audit context. */
2041
+ callerSubjectId?: string;
2042
+ surface: "model" | "toolspace";
2043
+ /** Canonical MCP destination that will receive the resolved headers. */
2044
+ destinationUrl: string;
2045
+ serverId: string;
2046
+ toolName?: string;
2047
+ connectionRef: McpServerConnectionRef;
2048
+ forceRefresh: boolean;
2049
+ };
2050
+
2051
+ export type McpCredentialAuthNeededReason =
2052
+ | CredentialAuthNeededReason
2053
+ | "unsupported_auth"
2054
+ | "resource_scope_unavailable";
2055
+
2056
+ export type McpCredentialResolution =
2057
+ | {
2058
+ status: "ok";
2059
+ /** Scope echoes are mandatory and verified before any header is used. */
2060
+ accountId: string;
2061
+ workspaceId: string;
2062
+ sessionId: string;
2063
+ headers: Record<string, string>;
2064
+ connectionId: string;
2065
+ providerDomain: string;
2066
+ provider?: string;
2067
+ scopes?: string[];
2068
+ resource?: string;
2069
+ selectedResources?: McpConnectionResourceScope[];
2070
+ expiresAt?: string | null;
2071
+ }
2072
+ | {
2073
+ status: "auth_needed";
2074
+ /** Scope echoes are mandatory even when the credential cannot be resolved. */
2075
+ accountId: string;
2076
+ workspaceId: string;
2077
+ sessionId: string;
2078
+ reason: McpCredentialAuthNeededReason;
2079
+ providerDomain: string;
2080
+ provider?: string;
2081
+ connectionId?: string;
2082
+ scopes?: string[];
2083
+ resource?: string;
2084
+ selectedResources?: McpConnectionResourceScope[];
2085
+ authorizationUrl?: string;
2086
+ };
2087
+
1295
2088
  export type ConnectionCredentialsPort = {
1296
- // Both legs are optional: a host may drive ONLY git creds (BYO-GitHub-App)
1297
- // and leave sandbox secrets to OpenGeni's local decrypt, or vice-versa. An
1298
- // unset leg falls through to today's self-mint for THAT leg only.
2089
+ // Every leg is optional: a host may drive only the credential classes it
2090
+ // owns. An unset leg falls through to today's standalone implementation for
2091
+ // that leg only.
1299
2092
  gitCredentials?(input: GitCredentialsRequest): Promise<GitCredentials>;
1300
2093
  sandboxSecrets?(input: SandboxSecretsRequest): Promise<SandboxSecrets>;
2094
+ /**
2095
+ * Resolve host-owned, session-aware sandbox credentials independently of an
2096
+ * OpenGeni variable set. OpenGeni transports and renews the material; the host
2097
+ * remains the sole owner of connection selection and credential policy.
2098
+ */
2099
+ runCredentials?(input: RunCredentialsRequest): Promise<RunCredentialsResolution>;
2100
+ /**
2101
+ * Resolve rotating MCP transport credentials at request time. Embedded hosts
2102
+ * use this to keep their provider connection as the sole credential source;
2103
+ * OpenGeni never requires a duplicate connection record. The same resolver is
2104
+ * used by model-visible MCP tools and the additive Toolspace/Code Mode proxy.
2105
+ */
2106
+ mcpCredentials?(input: McpCredentialsRequest): Promise<McpCredentialResolution>;
1301
2107
  };
1302
2108
 
1303
- // ============ P4a — GitHub App API port (BYO-App, §7.6 / SPIKE-2 remainder) ===
2109
+ // ============ connection-credential provider — GitHub App API port (BYO-App, §7.6 / GitHub credential prototype remainder) ===
1304
2110
  //
1305
- // The host-driven GitHub-API credential leg. SPIKE-2 closed the establishment +
1306
- // gate (storage) axis; this closes the credential leg by making the two live
2111
+ // The host-driven GitHub-API credential leg. GitHub credential prototype closed the establishment +
2112
+ // gate (storage) axis; this closes the credential leg by making the live
1307
2113
  // GitHub-API calls host-PROVIDABLE so a BYO-GitHub-App host drives its OWN App
1308
2114
  // credentials (its own JWT-signing key, its own OAuth client) instead of
1309
2115
  // OpenGeni self-minting from `settings`:
1310
- // - verifyInstallationAccessForUser: the OAuth code→token + installation
1311
- // lookup that PROVES the install is real (today
1312
- // `verifyGitHubInstallationAccessForUser(settings, …)`).
2116
+ // - authorizeUser: OAuth code exchange + user-visible installation and
2117
+ // repository permission discovery. Retained for provider ABI compatibility;
2118
+ // visibility is not proof of installation authority and core does not use
2119
+ // this method for new workspace binding.
2120
+ // - verifyInstallationAccessForUser: OAuth code→token + installation lookup,
2121
+ // also retained for provider ABI compatibility and not used for binding.
2122
+ // - getInstallation: retained in the provider ABI for compatibility. Direct
2123
+ // existing-installation selection is fail-closed and core does not call
2124
+ // this method for binding.
1313
2125
  // - listRepositories: the installation-scoped repo listing behind
1314
2126
  // `GET /v1/workspaces/:id/github/repositories` (today
1315
2127
  // `listGitHubAppRepositories(settings, …)`).
@@ -1324,11 +2136,31 @@ export type GitHubInstallationSummary = {
1324
2136
  suspended: boolean;
1325
2137
  };
1326
2138
 
2139
+ export type GitHubRepositoryPermissions = {
2140
+ admin: boolean;
2141
+ maintain: boolean;
2142
+ push: boolean;
2143
+ triage: boolean;
2144
+ pull: boolean;
2145
+ };
2146
+
2147
+ export type GitHubUserRepositoryAccess = GitHubRepository & {
2148
+ permissions: GitHubRepositoryPermissions;
2149
+ };
2150
+
2151
+ export type GitHubUserInstallationAccess = GitHubInstallationSummary & {
2152
+ repositories: GitHubUserRepositoryAccess[];
2153
+ };
2154
+
1327
2155
  export type GitHubAppApiPort = {
2156
+ authorizeUser?: (input: { code: string }) => Promise<GitHubUserInstallationAccess[]>;
1328
2157
  verifyInstallationAccessForUser?: (input: {
1329
2158
  code: string;
1330
2159
  installationId: number;
1331
2160
  }) => Promise<GitHubInstallationSummary>;
2161
+ getInstallation?: (input: {
2162
+ installationId: number;
2163
+ }) => Promise<GitHubInstallationSummary | null>;
1332
2164
  listRepositories?: (input: { installationIds?: number[] }) => Promise<GitHubRepository[]>;
1333
2165
  };
1334
2166
 
@@ -1368,6 +2200,8 @@ export const RepositoryResourceRef = z.object({
1368
2200
  mountPath: z.string().min(1).optional(),
1369
2201
  subpath: z.string().min(1).optional(),
1370
2202
  provider: GitCredentialProvider.optional(),
2203
+ credentialBindingId: GitCredentialBindingId.optional(),
2204
+ access: GitRepositoryAccess.optional(),
1371
2205
  repositoryId: GitProviderRepositoryId.optional(),
1372
2206
  installationId: GitProviderRepositoryId.optional(),
1373
2207
  projectId: GitProviderRepositoryId.optional(),
@@ -1377,15 +2211,165 @@ export const RepositoryResourceRef = z.object({
1377
2211
  });
1378
2212
  export type RepositoryResourceRef = z.infer<typeof RepositoryResourceRef>;
1379
2213
 
1380
- export const FileResourceRef = z.object({
1381
- kind: z.literal("file"),
1382
- fileId: z.string().uuid(),
1383
- mountPath: z.string().min(1).optional(),
1384
- });
1385
- export type FileResourceRef = z.infer<typeof FileResourceRef>;
1386
-
1387
- export const ResourceRef = z.discriminatedUnion("kind", [RepositoryResourceRef, FileResourceRef]);
1388
- export type ResourceRef = z.infer<typeof ResourceRef>;
2214
+ function positiveGitProviderInteger(value: unknown): number | null {
2215
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
2216
+ if (typeof value === "string" && /^\d+$/.test(value) && Number(value) > 0) {
2217
+ return Number(value);
2218
+ }
2219
+ return null;
2220
+ }
2221
+
2222
+ /**
2223
+ * Resolve whether a repository participates in platform-brokered Git auth.
2224
+ * Provider-less public repositories return null; legacy GitHub aliases infer
2225
+ * GitHub only when both positive installation and repository ids are present.
2226
+ */
2227
+ export function gitCredentialProviderForRepository(
2228
+ resource: RepositoryResourceRef,
2229
+ ): GitCredentialProvider | null {
2230
+ if (resource.provider) return resource.provider;
2231
+ if (
2232
+ positiveGitProviderInteger(resource.githubInstallationId) &&
2233
+ positiveGitProviderInteger(resource.githubRepositoryId)
2234
+ ) {
2235
+ return "github";
2236
+ }
2237
+ return null;
2238
+ }
2239
+
2240
+ /**
2241
+ * Derive the one canonical runtime/broker identity for a repository credential.
2242
+ * Every consumer must use this helper so mint grouping, token filenames, and
2243
+ * credential-helper routing cannot diverge on legacy provider ids.
2244
+ */
2245
+ export function gitCredentialBindingIdForRepository(
2246
+ resource: RepositoryResourceRef,
2247
+ provider: GitCredentialProvider | null = gitCredentialProviderForRepository(resource),
2248
+ ): GitCredentialBindingId | null {
2249
+ if (!provider) return null;
2250
+ const installationId =
2251
+ provider === "github"
2252
+ ? positiveGitProviderInteger(resource.githubInstallationId ?? resource.installationId)
2253
+ : null;
2254
+ return (
2255
+ resource.credentialBindingId ??
2256
+ resource.connectionId ??
2257
+ (installationId ? `github-installation:${installationId}` : provider)
2258
+ );
2259
+ }
2260
+
2261
+ export const FileResourceRef = z.object({
2262
+ kind: z.literal("file"),
2263
+ fileId: z.string().uuid(),
2264
+ mountPath: z.string().min(1).optional(),
2265
+ });
2266
+ export type FileResourceRef = z.infer<typeof FileResourceRef>;
2267
+
2268
+ export const ResourceRef = z.discriminatedUnion("kind", [RepositoryResourceRef, FileResourceRef]);
2269
+ export type ResourceRef = z.infer<typeof ResourceRef>;
2270
+
2271
+ export class ResourceMountPathError extends Error {
2272
+ constructor(message: string) {
2273
+ super(message);
2274
+ this.name = "ResourceMountPathError";
2275
+ }
2276
+ }
2277
+
2278
+ /**
2279
+ * Normalize one workspace-relative resource mount path for every runtime.
2280
+ *
2281
+ * Backslashes are treated as separators so a path cannot be harmless on Linux
2282
+ * but become traversal on a connected Windows machine. Empty, absolute,
2283
+ * drive-qualified, dot-segment, NUL-containing, and repeated-separator paths
2284
+ * fail closed instead of being silently reinterpreted.
2285
+ */
2286
+ export function normalizeResourceMountPath(path: string): string {
2287
+ const normalizedSeparators = path.trim().replace(/\\/g, "/");
2288
+ if (
2289
+ !normalizedSeparators ||
2290
+ normalizedSeparators.startsWith("/") ||
2291
+ /^[A-Za-z]:\//.test(normalizedSeparators) ||
2292
+ normalizedSeparators.includes("\0")
2293
+ ) {
2294
+ throw new ResourceMountPathError(`invalid resource mount path: ${path}`);
2295
+ }
2296
+ const segments = normalizedSeparators.split("/");
2297
+ if (
2298
+ segments.some(
2299
+ (segment) =>
2300
+ !segment ||
2301
+ segment === "." ||
2302
+ segment === ".." ||
2303
+ /[<>:"|?*\u0000-\u001f]/.test(segment) ||
2304
+ /[ .]$/.test(segment) ||
2305
+ /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(segment),
2306
+ )
2307
+ ) {
2308
+ throw new ResourceMountPathError(`invalid resource mount path: ${path}`);
2309
+ }
2310
+ return segments.join("/");
2311
+ }
2312
+
2313
+ /** Normalize a repository-internal subpath while preserving legacy `/path/` input. */
2314
+ export function normalizeRepositorySubpath(path: string): string {
2315
+ const relative = path
2316
+ .trim()
2317
+ .replace(/\\/g, "/")
2318
+ .replace(/^\/+|\/+$/g, "");
2319
+ return normalizeResourceMountPath(relative);
2320
+ }
2321
+
2322
+ /** A conservative collision identity that is portable to case-insensitive hosts. */
2323
+ export function resourceMountPathCollisionKey(path: string): string {
2324
+ return normalizeResourceMountPath(path).normalize("NFKC").toLowerCase();
2325
+ }
2326
+
2327
+ /**
2328
+ * Default repository mount identity. The normalized remote host (including a
2329
+ * non-default port) is part of the path, so equal owner/repo names on GitHub,
2330
+ * GitLab, Azure DevOps, or a custom host do not collide. Encoding the host keeps
2331
+ * IPv6/custom-port identities inside one portable path segment.
2332
+ */
2333
+ export function defaultRepositoryMountPath(uri: string): string {
2334
+ let url: URL;
2335
+ try {
2336
+ url = new URL(uri);
2337
+ } catch {
2338
+ throw new ResourceMountPathError(`invalid repository URI for mount path: ${uri}`);
2339
+ }
2340
+ if (url.protocol !== "https:" || !url.host) {
2341
+ throw new ResourceMountPathError(`invalid repository URI for mount path: ${uri}`);
2342
+ }
2343
+ const repositoryPath = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
2344
+ const segments = repositoryPath.split("/").filter(Boolean);
2345
+ if (segments.length < 2) {
2346
+ throw new ResourceMountPathError(`repository URI must include owner and repo: ${uri}`);
2347
+ }
2348
+ return normalizeResourceMountPath(
2349
+ `repos/${encodeURIComponent(url.host.toLowerCase())}/${segments.join("/")}`,
2350
+ );
2351
+ }
2352
+
2353
+ /** Resolve the exact mount used by API normalization, manifests, and clone hooks. */
2354
+ export function resourceMountPath(resource: ResourceRef): string {
2355
+ if (resource.mountPath) return normalizeResourceMountPath(resource.mountPath);
2356
+ return resource.kind === "file"
2357
+ ? normalizeResourceMountPath(`files/${resource.fileId}`)
2358
+ : defaultRepositoryMountPath(resource.uri);
2359
+ }
2360
+
2361
+ /** Fail before sandbox execution when two resources share a portable path. */
2362
+ export function assertUniqueResourceMountPaths(resources: readonly ResourceRef[]): void {
2363
+ const mounted = new Set<string>();
2364
+ for (const resource of resources) {
2365
+ const path = resourceMountPath(resource);
2366
+ const key = resourceMountPathCollisionKey(path);
2367
+ if (mounted.has(key)) {
2368
+ throw new ResourceRefConflictError(`resource mount path is already attached: ${path}`);
2369
+ }
2370
+ mounted.add(key);
2371
+ }
2372
+ }
1389
2373
 
1390
2374
  export const FileStatus = z.enum(["pending_upload", "ready", "failed", "expired", "deleted"]);
1391
2375
  export type FileStatus = z.infer<typeof FileStatus>;
@@ -1698,6 +2682,63 @@ export const ToolRef = z.object({
1698
2682
  export type ToolRef = z.infer<typeof ToolRef>;
1699
2683
 
1700
2684
  const registryId = /^[A-Za-z0-9_-]+$/;
2685
+ export const SessionMcpServerId = z.string().min(1).regex(registryId);
2686
+ export type SessionMcpServerId = z.infer<typeof SessionMcpServerId>;
2687
+
2688
+ // How a session's persisted `tools` snapshot was selected. `legacy` is
2689
+ // reserved for rows written before this descriptor existed; those rows must
2690
+ // keep their materialized historical allow-list rather than being guessed to
2691
+ // mean either omitted or explicitly empty.
2692
+ export const SessionToolPolicy = z.object({
2693
+ mode: z.enum(["workspace_default", "explicit", "inherited", "legacy"]),
2694
+ inheritedFromSessionId: z.string().uuid().nullable(),
2695
+ });
2696
+ export type SessionToolPolicy = z.infer<typeof SessionToolPolicy>;
2697
+
2698
+ export const SESSION_EFFECTIVE_TOOL_POLICY_ID_LIMIT = 64;
2699
+ export const SESSION_EFFECTIVE_TOOL_POLICY_ID_MAX_LENGTH = 200;
2700
+ const SessionEffectiveToolPolicyId = z
2701
+ .string()
2702
+ .min(1)
2703
+ .max(SESSION_EFFECTIVE_TOOL_POLICY_ID_MAX_LENGTH)
2704
+ .regex(registryId);
2705
+ const SessionEffectiveToolPolicyIds = z
2706
+ .array(SessionEffectiveToolPolicyId)
2707
+ .max(SESSION_EFFECTIVE_TOOL_POLICY_ID_LIMIT);
2708
+
2709
+ // Secret-safe, read-time policy truth. This projection contains only bounded
2710
+ // MCP registry ids and exact counts: never URLs, names, headers, credentials,
2711
+ // connector configuration, or tool schemas. IDs are samples when capped;
2712
+ // counts remain exact and idsTruncated makes that explicit to clients.
2713
+ export const SessionEffectiveToolPolicy = z
2714
+ .object({
2715
+ mode: z.enum(["workspace_default", "explicit", "inherited", "legacy"]),
2716
+ inheritedFromSessionId: z.string().uuid().nullable(),
2717
+ selectedIds: SessionEffectiveToolPolicyIds,
2718
+ effectiveIds: SessionEffectiveToolPolicyIds,
2719
+ mandatoryIds: SessionEffectiveToolPolicyIds,
2720
+ lazyRouter: z
2721
+ .object({
2722
+ state: z.enum(["required", "disabled"]),
2723
+ deferredIds: SessionEffectiveToolPolicyIds,
2724
+ })
2725
+ .strict(),
2726
+ configuredIds: SessionEffectiveToolPolicyIds,
2727
+ droppedIds: SessionEffectiveToolPolicyIds,
2728
+ counts: z
2729
+ .object({
2730
+ selected: z.number().int().nonnegative(),
2731
+ effective: z.number().int().nonnegative(),
2732
+ mandatory: z.number().int().nonnegative(),
2733
+ deferred: z.number().int().nonnegative(),
2734
+ configured: z.number().int().nonnegative(),
2735
+ dropped: z.number().int().nonnegative(),
2736
+ })
2737
+ .strict(),
2738
+ idsTruncated: z.boolean(),
2739
+ })
2740
+ .strict();
2741
+ export type SessionEffectiveToolPolicy = z.infer<typeof SessionEffectiveToolPolicy>;
1701
2742
  const httpsUrl = z
1702
2743
  .string()
1703
2744
  .url()
@@ -1712,42 +2753,101 @@ const httpsUrl = z
1712
2753
  { message: "URL must use https" },
1713
2754
  );
1714
2755
 
2756
+ /**
2757
+ * Human-approval policy for one MCP server. `true` gates every tool, `false`
2758
+ * gates none, and a list gates only those unprefixed names.
2759
+ */
2760
+ export const SESSION_MCP_APPROVAL_POLICY_MAX_TOOL_NAMES = 2_048;
2761
+ export const SESSION_MCP_APPROVAL_POLICY_MAX_BYTES = 256 * 1024;
2762
+ export const SESSION_MCP_APPROVAL_TOOL_NAME_MAX_BYTES = 1_024;
2763
+ export const SESSION_MCP_SERVERS_MAX = 64;
2764
+
2765
+ const sessionMcpApprovalToolName = z
2766
+ .string()
2767
+ .min(1)
2768
+ .superRefine((name, ctx) => {
2769
+ if (new TextEncoder().encode(name).byteLength > SESSION_MCP_APPROVAL_TOOL_NAME_MAX_BYTES) {
2770
+ ctx.addIssue({
2771
+ code: z.ZodIssueCode.custom,
2772
+ message: `MCP approval tool names must be at most ${SESSION_MCP_APPROVAL_TOOL_NAME_MAX_BYTES} UTF-8 bytes`,
2773
+ });
2774
+ }
2775
+ });
2776
+ const selectiveSessionMcpApprovalPolicy = z
2777
+ .array(sessionMcpApprovalToolName)
2778
+ .max(SESSION_MCP_APPROVAL_POLICY_MAX_TOOL_NAMES)
2779
+ .superRefine((names, ctx) => {
2780
+ const bytes = names.reduce(
2781
+ (total, name) => total + new TextEncoder().encode(name).byteLength,
2782
+ 0,
2783
+ );
2784
+ if (bytes > SESSION_MCP_APPROVAL_POLICY_MAX_BYTES) {
2785
+ ctx.addIssue({
2786
+ code: z.ZodIssueCode.custom,
2787
+ message: `MCP approval policies must be at most ${SESSION_MCP_APPROVAL_POLICY_MAX_BYTES} UTF-8 bytes`,
2788
+ });
2789
+ }
2790
+ })
2791
+ .transform((names) => [...new Set(names)].sort());
2792
+ export const SessionMcpApprovalPolicy = z.union([z.boolean(), selectiveSessionMcpApprovalPolicy]);
2793
+ export type SessionMcpApprovalPolicy = z.infer<typeof SessionMcpApprovalPolicy>;
2794
+
1715
2795
  export const SessionMcpServerInput = z.object({
1716
- id: z.string().min(1).regex(registryId),
2796
+ id: SessionMcpServerId,
1717
2797
  name: z.string().min(1).optional(),
1718
2798
  url: httpsUrl,
1719
2799
  allowedTools: z.array(z.string().min(1)).optional(),
1720
2800
  timeoutMs: z.number().int().positive().optional(),
1721
2801
  cacheToolsList: z.boolean().optional(),
1722
- // Human-approval policy for this server's tools. `true` = every tool of this
1723
- // server requires approval before it runs (a `session.requiresAction` pause
1724
- // the caller resolves with `user.approvalDecision`); a string[] = ONLY the
1725
- // listed UNPREFIXED tool names require approval (e.g. reads auto-run, writes
1726
- // ask); absent / `false` = auto-run everything (the historical default).
1727
- requireApproval: z.union([z.boolean(), z.array(z.string().min(1))]).optional(),
2802
+ // The caller resolves an approval pause with `user.approvalDecision`.
2803
+ requireApproval: SessionMcpApprovalPolicy.optional(),
1728
2804
  // Write-only credential headers. Values are encrypted at rest and never
1729
2805
  // returned in session responses or events; response metadata exposes names.
1730
2806
  headers: z.record(z.string(), z.string()).optional(),
2807
+ // Non-secret opaque pointer resolved at request time by the standalone
2808
+ // connection broker or an embedding host's mcpCredentials port.
2809
+ connectionRef: McpServerConnectionRef.optional(),
1731
2810
  });
1732
2811
  export type SessionMcpServerInput = z.infer<typeof SessionMcpServerInput>;
1733
2812
 
1734
2813
  export const SessionMcpCredentialUpdateInput = z.object({
1735
- id: z.string().min(1).regex(registryId),
2814
+ id: SessionMcpServerId,
1736
2815
  headers: z.record(z.string(), z.string()),
1737
2816
  });
1738
2817
  export type SessionMcpCredentialUpdateInput = z.infer<typeof SessionMcpCredentialUpdateInput>;
1739
2818
 
1740
2819
  export const SessionMcpServerMetadata = z
1741
2820
  .object({
1742
- id: z.string().min(1).regex(registryId),
2821
+ id: SessionMcpServerId,
1743
2822
  name: z.string().min(1).nullable(),
1744
2823
  url: httpsUrl,
1745
2824
  headerNames: z.array(z.string()).default([]),
1746
2825
  credentialVersion: z.number().int().positive(),
2826
+ requireApproval: SessionMcpApprovalPolicy.default(false),
2827
+ connectionRef: McpServerConnectionRef.nullable().default(null),
1747
2828
  })
1748
2829
  .strict();
1749
2830
  export type SessionMcpServerMetadata = z.infer<typeof SessionMcpServerMetadata>;
1750
2831
 
2832
+ export const UpdateSessionMcpApprovalPolicyRequest = z
2833
+ .object({
2834
+ requireApproval: SessionMcpApprovalPolicy,
2835
+ })
2836
+ .strict();
2837
+ export type UpdateSessionMcpApprovalPolicyRequest = z.infer<
2838
+ typeof UpdateSessionMcpApprovalPolicyRequest
2839
+ >;
2840
+
2841
+ export const UpdateSessionMcpApprovalPolicyResponse = z
2842
+ .object({
2843
+ server: SessionMcpServerMetadata,
2844
+ effectiveFrom: z.literal("next_attempt"),
2845
+ })
2846
+ .strict();
2847
+ export type UpdateSessionMcpApprovalPolicyResponse = z.infer<
2848
+ typeof UpdateSessionMcpApprovalPolicyResponse
2849
+ >;
2850
+
1751
2851
  export class ResourceRefConflictError extends Error {
1752
2852
  constructor(message: string) {
1753
2853
  super(message);
@@ -1783,10 +2883,14 @@ export function mergeResourceRefs(
1783
2883
  additions: ResourceRef[],
1784
2884
  options: { rejectConflicts?: boolean } = {},
1785
2885
  ): ResourceRef[] {
2886
+ if (options.rejectConflicts) {
2887
+ assertUniqueResourceMountPaths(existing);
2888
+ }
1786
2889
  const out = [...existing];
1787
2890
  const mountPaths = new Map(
1788
- existing.flatMap((resource) =>
1789
- resource.mountPath ? [[resource.mountPath, stableJson(resource)] as const] : [],
2891
+ existing.map(
2892
+ (resource) =>
2893
+ [resourceMountPathCollisionKey(resourceMountPath(resource)), stableJson(resource)] as const,
1790
2894
  ),
1791
2895
  );
1792
2896
  const identities = new Map(
@@ -1800,11 +2904,10 @@ export function mergeResourceRefs(
1800
2904
  continue;
1801
2905
  }
1802
2906
  if (options.rejectConflicts) {
1803
- const existingAtMount = resource.mountPath ? mountPaths.get(resource.mountPath) : undefined;
2907
+ const mountPath = resourceMountPath(resource);
2908
+ const existingAtMount = mountPaths.get(resourceMountPathCollisionKey(mountPath));
1804
2909
  if (existingAtMount && existingAtMount !== serialized) {
1805
- throw new ResourceRefConflictError(
1806
- `resource mount path is already attached: ${resource.mountPath}`,
1807
- );
2910
+ throw new ResourceRefConflictError(`resource mount path is already attached: ${mountPath}`);
1808
2911
  }
1809
2912
  const identity = resourceIdentityKey(resource);
1810
2913
  const existingIdentity = identities.get(identity);
@@ -1817,9 +2920,7 @@ export function mergeResourceRefs(
1817
2920
  out.push(resource);
1818
2921
  exact.add(serialized);
1819
2922
  identities.set(resourceIdentityKey(resource), serialized);
1820
- if (resource.mountPath) {
1821
- mountPaths.set(resource.mountPath, serialized);
1822
- }
2923
+ mountPaths.set(resourceMountPathCollisionKey(resourceMountPath(resource)), serialized);
1823
2924
  }
1824
2925
  return out;
1825
2926
  }
@@ -1874,6 +2975,7 @@ export const SessionTurnStatus = z.enum([
1874
2975
  "failed",
1875
2976
  "cancelled",
1876
2977
  "superseded",
2978
+ "withdrawn_for_edit",
1877
2979
  ]);
1878
2980
  export type SessionTurnStatus = z.infer<typeof SessionTurnStatus>;
1879
2981
 
@@ -1987,7 +3089,9 @@ export type ClearSessionContextRequest = z.infer<typeof ClearSessionContextReque
1987
3089
  export const CLEARED_RUN_STATE_MARKER = "$opengeniCleared" as const;
1988
3090
 
1989
3091
  /** The canonical sentinel serializedRunState value a context clear stores. */
1990
- export const CLEARED_RUN_STATE_BLOB = JSON.stringify({ [CLEARED_RUN_STATE_MARKER]: true });
3092
+ export const CLEARED_RUN_STATE_BLOB = JSON.stringify({
3093
+ [CLEARED_RUN_STATE_MARKER]: true,
3094
+ });
1991
3095
 
1992
3096
  /**
1993
3097
  * True when a serialized run-state blob is the cleared sentinel rather than a
@@ -2027,6 +3131,169 @@ export const CompactSessionContextResult = z.object({
2027
3131
  });
2028
3132
  export type CompactSessionContextResult = z.infer<typeof CompactSessionContextResult>;
2029
3133
 
3134
+ /**
3135
+ * The principal whose authority accepted a session or turn. `subjectId` is an
3136
+ * opaque host/standalone identity and therefore must never encode `kind` by
3137
+ * convention: embedding hosts own their subject namespace.
3138
+ */
3139
+ export const TurnInitiator = z.object({
3140
+ kind: z.enum(["subject", "service"]),
3141
+ ...turnInitiatorIdentityFields,
3142
+ });
3143
+ export type TurnInitiator = z.infer<typeof TurnInitiator>;
3144
+
3145
+ // ============ embedding host session authorization ============
3146
+ //
3147
+ // Workspace permissions answer whether a principal may use an OpenGeni
3148
+ // capability. An embedding host can additionally own per-session visibility
3149
+ // (ownership, sharing, nested workspaces, revocation). This port is the one
3150
+ // host-neutral boundary for that second decision. Inputs contain OpenGeni ids
3151
+ // and immutable, non-secret authority only; host records and policy details
3152
+ // never cross the boundary.
3153
+
3154
+ export const SessionAuthorizationSurface = z.enum([
3155
+ "http",
3156
+ "core",
3157
+ "stream",
3158
+ "first_party_mcp",
3159
+ "toolspace",
3160
+ ]);
3161
+ export type SessionAuthorizationSurface = z.infer<typeof SessionAuthorizationSurface>;
3162
+
3163
+ export const SessionAuthorizationOperation = z.enum([
3164
+ "session.read",
3165
+ "session.events.read",
3166
+ "session.stream.read",
3167
+ "session.stream.acknowledge",
3168
+ "session.turns.read",
3169
+ "session.append",
3170
+ "session.steer",
3171
+ "session.control",
3172
+ "session.queue.read",
3173
+ "session.queue.control",
3174
+ "session.composer.read",
3175
+ "session.composer.write",
3176
+ "session.lineage.read",
3177
+ "session.capture.read",
3178
+ "session.files.read",
3179
+ "session.files.write",
3180
+ "session.git.read",
3181
+ "session.terminal.read",
3182
+ "session.terminal.control",
3183
+ "session.viewer.read",
3184
+ "session.viewer.control",
3185
+ "session.first_party_mcp.call",
3186
+ "session.toolspace.call",
3187
+ "session.pin.write",
3188
+ "session.codex_account.write",
3189
+ "session.context.write",
3190
+ "session.approval.write",
3191
+ "session.human_input.read",
3192
+ "session.human_input.write",
3193
+ "session.title.write",
3194
+ "session.mcp.approval_policy.write",
3195
+ "session.goal.read",
3196
+ "session.goal.write",
3197
+ "session.child.create",
3198
+ ]);
3199
+ export type SessionAuthorizationOperation = z.infer<typeof SessionAuthorizationOperation>;
3200
+
3201
+ export const SessionAuthorizationActor = z.discriminatedUnion("kind", [
3202
+ z.object({
3203
+ kind: z.literal("subject"),
3204
+ subjectId: z.string().min(1),
3205
+ subjectLabel: z.string().min(1).optional(),
3206
+ }),
3207
+ z.object({
3208
+ kind: z.literal("agent_attempt"),
3209
+ /** Technical, authenticated first-party caller (not the host authority). */
3210
+ subjectId: z.string().min(1),
3211
+ callerSessionId: z.string().uuid(),
3212
+ callerRootSessionId: z.string().uuid(),
3213
+ turnId: z.string().uuid(),
3214
+ attemptId: z.string().uuid(),
3215
+ executionGeneration: z.number().int().positive(),
3216
+ /** Frozen authority that admitted the calling turn. */
3217
+ initiator: TurnInitiator,
3218
+ initiatorContext: TurnInitiatorContext,
3219
+ }),
3220
+ ]);
3221
+ export type SessionAuthorizationActor = z.infer<typeof SessionAuthorizationActor>;
3222
+
3223
+ export const SessionAuthorizationTarget = z.object({
3224
+ sessionId: z.string().uuid(),
3225
+ /** Server-resolved workspace lineage root; never accepted from a caller. */
3226
+ rootSessionId: z.string().uuid(),
3227
+ });
3228
+ export type SessionAuthorizationTarget = z.infer<typeof SessionAuthorizationTarget>;
3229
+
3230
+ export type AuthorizeSessionInput = {
3231
+ accountId: string;
3232
+ workspaceId: string;
3233
+ actor: SessionAuthorizationActor;
3234
+ target: SessionAuthorizationTarget;
3235
+ operation: SessionAuthorizationOperation;
3236
+ surface: SessionAuthorizationSurface;
3237
+ };
3238
+
3239
+ export const SessionAuthorizationDecision = z.discriminatedUnion("allowed", [
3240
+ z.object({
3241
+ allowed: z.literal(true),
3242
+ /**
3243
+ * Whether related-session metadata may be projected with the target.
3244
+ * `target` is the fail-closed default for exact shares; `root` permits the
3245
+ * target's full lineage tree. This does not authorize a separate operation
3246
+ * against another session, which always requires its own decision.
3247
+ */
3248
+ relatedSessionAccess: z.enum(["target", "root"]).optional(),
3249
+ /** A host may request a tighter stream reauthorization bound. */
3250
+ reauthorizeAfterMs: z.number().int().min(1_000).max(60_000).optional(),
3251
+ }),
3252
+ z.object({
3253
+ allowed: z.literal(false),
3254
+ reason: z.enum(["not_found", "forbidden", "revoked"]),
3255
+ }),
3256
+ ]);
3257
+ export type SessionAuthorizationDecision = z.infer<typeof SessionAuthorizationDecision>;
3258
+
3259
+ /**
3260
+ * A database-applicable listing scope. `rootSessionIds` includes every
3261
+ * descendant of those lineage anchors; `sessionIds` authorizes only the exact
3262
+ * sessions. Supplying neither is an explicit empty scope. OpenGeni intersects
3263
+ * every id with the requested workspace and never trusts a host scope as
3264
+ * session existence evidence.
3265
+ */
3266
+ export const SESSION_AUTHORIZATION_LIST_SCOPE_MAX_IDS = 10_000;
3267
+
3268
+ export const SessionAuthorizationListScope = z.discriminatedUnion("kind", [
3269
+ z.object({ kind: z.literal("all") }),
3270
+ z.object({
3271
+ kind: z.literal("scoped"),
3272
+ rootSessionIds: z.array(z.string().uuid()).max(SESSION_AUTHORIZATION_LIST_SCOPE_MAX_IDS),
3273
+ sessionIds: z.array(z.string().uuid()).max(SESSION_AUTHORIZATION_LIST_SCOPE_MAX_IDS),
3274
+ }),
3275
+ ]);
3276
+ export type SessionAuthorizationListScope = z.infer<typeof SessionAuthorizationListScope>;
3277
+
3278
+ export type ResolveSessionAuthorizationListScopeInput = {
3279
+ accountId: string;
3280
+ workspaceId: string;
3281
+ actor: SessionAuthorizationActor;
3282
+ surface: SessionAuthorizationSurface;
3283
+ };
3284
+
3285
+ export type SessionAuthorizationPort = {
3286
+ authorizeSession(input: AuthorizeSessionInput): Promise<SessionAuthorizationDecision>;
3287
+ /**
3288
+ * Return the complete current scope used inside OpenGeni's cursor query.
3289
+ * This is deliberately not a post-filter callback: search, pinning, ordering,
3290
+ * totals, and cursor advancement must all operate on authorized rows.
3291
+ */
3292
+ resolveListScope(
3293
+ input: ResolveSessionAuthorizationListScopeInput,
3294
+ ): Promise<SessionAuthorizationListScope>;
3295
+ };
3296
+
2030
3297
  export const SessionTurn = z.object({
2031
3298
  id: z.string().uuid(),
2032
3299
  workspaceId: z.string().uuid(),
@@ -2039,6 +3306,10 @@ export const SessionTurn = z.object({
2039
3306
  prompt: z.string().min(1),
2040
3307
  resources: z.array(ResourceRef),
2041
3308
  tools: z.array(ToolRef),
3309
+ // Omitted/default discovery and explicit `tools: []` are distinct. False
3310
+ // inherits the durable session policy; true replaces it for this turn after
3311
+ // admission proves the selection is a subset.
3312
+ toolsProvided: z.boolean().optional(),
2042
3313
  model: z.string().min(1),
2043
3314
  reasoningEffort: ReasoningEffort,
2044
3315
  sandboxBackend: SandboxBackend,
@@ -2049,6 +3320,8 @@ export const SessionTurn = z.object({
2049
3320
  executionGeneration: z.number().int().nonnegative(),
2050
3321
  activeAttemptId: z.string().uuid().nullable(),
2051
3322
  lineage: z.record(z.string(), z.unknown()),
3323
+ initiator: TurnInitiator,
3324
+ initiatorContext: TurnInitiatorContext,
2052
3325
  cancelledBy: z.string().nullable(),
2053
3326
  cancelReason: z.string().nullable(),
2054
3327
  startedAt: z.string().nullable(),
@@ -2058,70 +3331,413 @@ export const SessionTurn = z.object({
2058
3331
  });
2059
3332
  export type SessionTurn = z.infer<typeof SessionTurn>;
2060
3333
 
3334
+ export const EffectiveControlBlocker = z.object({
3335
+ kind: z.enum(["session", "workspace"]),
3336
+ sessionId: z.string().uuid().optional(),
3337
+ displayName: z.string().min(1),
3338
+ actor: z.string().nullable(),
3339
+ reason: z.string().nullable(),
3340
+ changedAt: z.string().nullable(),
3341
+ revision: z.number().int().nonnegative(),
3342
+ });
3343
+ export type EffectiveControlBlocker = z.infer<typeof EffectiveControlBlocker>;
3344
+
3345
+ export const EffectiveControlResumeOption = z.object({
3346
+ scope: z.enum(["selected", "session", "workspace"]),
3347
+ targetId: z.string().uuid().optional(),
3348
+ selectedStateAfter: SessionControlState,
3349
+ remainingPrimaryBlocker: EffectiveControlBlocker.optional(),
3350
+ impactCopy: z.string().min(1),
3351
+ });
3352
+ export type EffectiveControlResumeOption = z.infer<typeof EffectiveControlResumeOption>;
3353
+
3354
+ export const EffectiveSessionControl = z.object({
3355
+ state: SessionControlState,
3356
+ controlVersion: z.number().int().nonnegative(),
3357
+ controlEtag: z.string().min(1),
3358
+ directState: SessionControlState,
3359
+ primaryBlocker: EffectiveControlBlocker.nullable(),
3360
+ additionalBlockerCount: z.number().int().nonnegative(),
3361
+ blockers: z.array(EffectiveControlBlocker),
3362
+ resumeOptions: z.array(EffectiveControlResumeOption),
3363
+ override: z
3364
+ .object({
3365
+ rootSessionId: z.string().uuid(),
3366
+ revision: z.number().int().nonnegative(),
3367
+ })
3368
+ .nullable(),
3369
+ settlement: z
3370
+ .object({
3371
+ state: z.literal("stopping"),
3372
+ attemptCount: z.number().int().positive(),
3373
+ interruptionPendingCount: z.number().int().nonnegative(),
3374
+ quiescencePendingCount: z.number().int().nonnegative(),
3375
+ })
3376
+ .nullable(),
3377
+ });
3378
+ export type EffectiveSessionControl = z.infer<typeof EffectiveSessionControl>;
3379
+
3380
+ export const SESSION_OPERATION_KEY_MAX_CHARS = 256;
3381
+ const SessionOperationKey = z.string().min(1).max(SESSION_OPERATION_KEY_MAX_CHARS);
3382
+
3383
+ export const SessionCommandReceipt = z.object({
3384
+ id: z.string().uuid(),
3385
+ action: z.string().min(1),
3386
+ operationKey: z.string().min(1).max(SESSION_OPERATION_KEY_MAX_CHARS),
3387
+ targetSessionId: z.string().uuid().nullable(),
3388
+ targetTurnId: z.string().uuid().nullable(),
3389
+ appliedControlRevision: z.number().int().nonnegative().nullable(),
3390
+ appliedQueueVersion: z.number().int().nonnegative().nullable(),
3391
+ appliedTurnVersion: z.number().int().positive().nullable(),
3392
+ appliedDraftRevision: z.number().int().positive().nullable(),
3393
+ createdAt: z.string(),
3394
+ });
3395
+ export type SessionCommandReceipt = z.infer<typeof SessionCommandReceipt>;
3396
+
3397
+ export const ComposerDraft = z.object({
3398
+ revision: z.number().int().nonnegative(),
3399
+ text: z.string(),
3400
+ resources: z.array(ResourceRef),
3401
+ tools: z.array(ToolRef),
3402
+ // False means the draft inherits the session policy. True preserves an
3403
+ // explicit array, including [], across autosave/reload and queue checkout.
3404
+ toolsProvided: z.boolean().default(false),
3405
+ model: z.string().min(1),
3406
+ reasoningEffort: ReasoningEffort,
3407
+ sourceTurnId: z.string().uuid().nullable(),
3408
+ sourceTurnVersion: z.number().int().positive().nullable(),
3409
+ updatedAt: z.string().nullable(),
3410
+ });
3411
+ export type ComposerDraft = z.infer<typeof ComposerDraft>;
3412
+
2061
3413
  export const SessionQueueSnapshot = z.object({
2062
3414
  version: z.number().int().nonnegative(),
2063
- controlState: SessionControlState,
2064
- controlGeneration: z.number().int().nonnegative(),
2065
- workspaceInferenceState: WorkspaceInferenceState,
2066
- workspaceInferenceGeneration: z.number().int().nonnegative(),
2067
- workspaceRunExceptionGeneration: z.number().int().nonnegative().nullable(),
3415
+ effectiveControl: EffectiveSessionControl,
3416
+ /**
3417
+ * True while the latest attempt is interrupted but has not durably proved
3418
+ * quiescence: no more inference, user-visible output, or workspace-persistence
3419
+ * authority. Temporal cancellation/terminalization is not that proof. This is
3420
+ * distinct from ordinary capacity queueing, remains accurate with an empty
3421
+ * visible queue, and is independent of Steer-row metadata or withdrawal.
3422
+ */
3423
+ stoppingPreviousAttempt: z.boolean(),
2068
3424
  items: z.array(SessionTurn),
2069
3425
  });
2070
3426
  export type SessionQueueSnapshot = z.infer<typeof SessionQueueSnapshot>;
2071
3427
 
2072
- export const CancelSessionQueueItemRequest = z.object({
3428
+ export const MoveSessionQueueItemRequest = z.object({
3429
+ clientEventId: SessionOperationKey,
2073
3430
  expectedQueueVersion: z.number().int().nonnegative(),
2074
- expectedItemVersion: z.number().int().positive(),
3431
+ beforeTurnId: z.string().uuid().nullable(),
3432
+ });
3433
+ export type MoveSessionQueueItemRequest = z.infer<typeof MoveSessionQueueItemRequest>;
3434
+
3435
+ export const EditSessionQueueItemRequest = z.object({
3436
+ clientEventId: SessionOperationKey,
3437
+ expectedTurnVersion: z.number().int().positive(),
3438
+ expectedDraftRevision: z.number().int().nonnegative(),
3439
+ replaceDraft: z.boolean(),
3440
+ });
3441
+ export type EditSessionQueueItemRequest = z.infer<typeof EditSessionQueueItemRequest>;
3442
+
3443
+ export const SteerSessionQueueItemRequest = z.object({
3444
+ clientEventId: SessionOperationKey,
3445
+ expectedTurnVersion: z.number().int().positive(),
3446
+ controlEtag: z.string().min(1).optional(),
3447
+ });
3448
+ export type SteerSessionQueueItemRequest = z.infer<typeof SteerSessionQueueItemRequest>;
3449
+
3450
+ export const DeleteSessionQueueItemRequest = z.object({
3451
+ clientEventId: SessionOperationKey,
3452
+ expectedTurnVersion: z.number().int().positive(),
2075
3453
  reason: z.string().min(1).optional(),
2076
3454
  });
2077
- export type CancelSessionQueueItemRequest = z.infer<typeof CancelSessionQueueItemRequest>;
3455
+ export type DeleteSessionQueueItemRequest = z.infer<typeof DeleteSessionQueueItemRequest>;
3456
+
3457
+ export const SaveComposerDraftRequest = ComposerDraft.pick({
3458
+ text: true,
3459
+ resources: true,
3460
+ tools: true,
3461
+ toolsProvided: true,
3462
+ model: true,
3463
+ reasoningEffort: true,
3464
+ }).extend({ expectedRevision: z.number().int().nonnegative() });
3465
+ export type SaveComposerDraftRequest = z.infer<typeof SaveComposerDraftRequest>;
3466
+
3467
+ export const WORKSPACE_CONTROL_REASON_MAX_BYTES = 8 * 1024;
3468
+ export const WORKSPACE_CONTROL_ACTOR_MAX_BYTES = 1024;
3469
+ export const WORKSPACE_CONTROL_EVENT_MAX_BYTES = 16 * 1024;
3470
+
3471
+ const WorkspaceControlReason = z
3472
+ .string()
3473
+ .min(1)
3474
+ .refine((value) => !value.includes("\u0000"), "reason must not contain NUL bytes")
3475
+ .refine(
3476
+ (value) => workspaceControlUtf8Bytes(value) <= WORKSPACE_CONTROL_REASON_MAX_BYTES,
3477
+ `reason must not exceed ${WORKSPACE_CONTROL_REASON_MAX_BYTES} UTF-8 bytes`,
3478
+ );
2078
3479
 
2079
3480
  export const SessionControlRequest = z.object({
2080
- mode: z.enum(["pause", "resume"]),
2081
- reason: z.string().min(1).optional(),
2082
- clientEventId: z.string().min(1).optional(),
2083
- expectedControlState: SessionControlState.optional(),
2084
- expectedControlGeneration: z.number().int().nonnegative().optional(),
2085
- expectedWorkspaceInferenceGeneration: z.number().int().nonnegative().optional(),
3481
+ action: z.enum(["pause", "resume"]),
3482
+ reason: WorkspaceControlReason.optional(),
3483
+ clientEventId: SessionOperationKey,
3484
+ expectedControlEtag: z.string().min(1).optional(),
2086
3485
  });
2087
3486
  export type SessionControlRequest = z.infer<typeof SessionControlRequest>;
2088
3487
 
2089
3488
  export const WorkspaceInferenceControlRequest = z.object({
2090
- state: WorkspaceInferenceState,
2091
- reason: z.string().min(1),
2092
- clientEventId: z.string().min(1),
2093
- expectedState: WorkspaceInferenceState,
2094
- expectedGeneration: z.number().int().nonnegative(),
2095
- exceptSessionIds: z.array(z.string().uuid()).default([]),
3489
+ action: z.enum(["pause", "resume"]),
3490
+ reason: WorkspaceControlReason.optional(),
3491
+ clientEventId: SessionOperationKey,
3492
+ expectedRevision: z.number().int().nonnegative().optional(),
2096
3493
  });
2097
3494
  export type WorkspaceInferenceControlRequest = z.infer<typeof WorkspaceInferenceControlRequest>;
2098
3495
 
2099
3496
  export const WorkspaceInferenceControlResponse = z.object({
2100
- operationId: z.string().uuid(),
2101
- state: WorkspaceInferenceState,
2102
- generation: z.number().int().nonnegative(),
2103
- affectedSessionIds: z.array(z.string().uuid()),
2104
- controlSessionIds: z.array(z.string().uuid()),
2105
- exceptionSessionIds: z.array(z.string().uuid()),
3497
+ receipt: SessionCommandReceipt,
3498
+ state: SessionControlState,
3499
+ revision: z.number().int().nonnegative(),
3500
+ interruptionCount: z.number().int().nonnegative(),
3501
+ wakeCount: z.number().int().nonnegative(),
2106
3502
  });
2107
3503
  export type WorkspaceInferenceControlResponse = z.infer<typeof WorkspaceInferenceControlResponse>;
2108
3504
 
3505
+ /**
3506
+ * One durable workspace-wide invalidation for one committed control revision.
3507
+ * It is not conversation history and never becomes queue work; clients use it
3508
+ * only to refetch authoritative workspace/session projections.
3509
+ */
3510
+ export const WorkspaceControlEventTruncation = z.object({
3511
+ truncated: z.literal(true),
3512
+ surface: z.enum([
3513
+ "durable_control",
3514
+ "database_guard",
3515
+ "http_projection",
3516
+ "nats_legacy_guard",
3517
+ "sse_legacy_guard",
3518
+ ]),
3519
+ deliveredBytes: z.number().int().nonnegative(),
3520
+ fields: z.array(
3521
+ z.object({
3522
+ field: z.enum(["reason", "actor"]),
3523
+ originalBytes: z.number().int().nonnegative(),
3524
+ deliveredBytes: z.number().int().nonnegative(),
3525
+ omittedBytes: z.number().int().nonnegative(),
3526
+ }),
3527
+ ),
3528
+ fullEvidence: z.object({
3529
+ available: z.literal(false),
3530
+ reason: z.literal("not_retained"),
3531
+ }),
3532
+ });
3533
+ export type WorkspaceControlEventTruncation = z.infer<typeof WorkspaceControlEventTruncation>;
3534
+
3535
+ export const WorkspaceControlEvent = z.object({
3536
+ id: z.string().uuid(),
3537
+ workspaceId: z.string().uuid(),
3538
+ sequence: z.number().int().positive(),
3539
+ revision: z.number().int().positive(),
3540
+ type: z.literal("workspace.control.changed"),
3541
+ scope: z.enum(["workspace", "session"]),
3542
+ rootSessionId: z.string().uuid().nullable(),
3543
+ action: z.enum(["pause", "resume"]),
3544
+ automatic: z.boolean(),
3545
+ reason: z.string().nullable(),
3546
+ actor: z.string().min(1),
3547
+ occurredAt: z.string(),
3548
+ truncation: WorkspaceControlEventTruncation.nullable().optional(),
3549
+ });
3550
+ export type WorkspaceControlEvent = z.infer<typeof WorkspaceControlEvent>;
3551
+
3552
+ export type WorkspaceControlBoundarySurface = WorkspaceControlEventTruncation["surface"];
3553
+
3554
+ export type BoundWorkspaceControlEventOptions = {
3555
+ surface?: WorkspaceControlBoundarySurface;
3556
+ reasonOriginalBytes?: number | null;
3557
+ actorOriginalBytes?: number | null;
3558
+ };
3559
+
3560
+ /** UTF-8 byte count used by workspace-control storage and transport guards. */
3561
+ export function workspaceControlUtf8Bytes(value: string): number {
3562
+ return new TextEncoder().encode(value).byteLength;
3563
+ }
3564
+
3565
+ /**
3566
+ * Canonical bounded invalidation event. The event is not a full evidence store:
3567
+ * when a producer or legacy row exceeds a field cap, the retained head carries
3568
+ * a visible marker and structured exact byte-loss facts.
3569
+ */
3570
+ export function boundWorkspaceControlEvent(
3571
+ event: WorkspaceControlEvent,
3572
+ options: BoundWorkspaceControlEventOptions = {},
3573
+ ): WorkspaceControlEvent {
3574
+ const existingFields = new Map(
3575
+ (event.truncation?.fields ?? []).map((field) => [field.field, field] as const),
3576
+ );
3577
+ const reason =
3578
+ event.reason === null
3579
+ ? null
3580
+ : boundWorkspaceControlText(event.reason, WORKSPACE_CONTROL_REASON_MAX_BYTES);
3581
+ const actor = boundWorkspaceControlText(event.actor, WORKSPACE_CONTROL_ACTOR_MAX_BYTES);
3582
+ const reasonBytes = reason === null ? 0 : workspaceControlUtf8Bytes(reason);
3583
+ const actorBytes = workspaceControlUtf8Bytes(actor);
3584
+ const reasonOriginalBytes =
3585
+ event.reason === null
3586
+ ? null
3587
+ : Math.max(
3588
+ workspaceControlUtf8Bytes(event.reason),
3589
+ normalizedWorkspaceControlOriginalBytes(options.reasonOriginalBytes),
3590
+ existingFields.get("reason")?.originalBytes ?? 0,
3591
+ );
3592
+ const actorOriginalBytes = Math.max(
3593
+ workspaceControlUtf8Bytes(event.actor),
3594
+ normalizedWorkspaceControlOriginalBytes(options.actorOriginalBytes),
3595
+ existingFields.get("actor")?.originalBytes ?? 0,
3596
+ );
3597
+ const fields: WorkspaceControlEventTruncation["fields"] = [];
3598
+ if (reasonOriginalBytes !== null && reasonOriginalBytes > reasonBytes) {
3599
+ fields.push({
3600
+ field: "reason",
3601
+ originalBytes: reasonOriginalBytes,
3602
+ deliveredBytes: reasonBytes,
3603
+ omittedBytes: reasonOriginalBytes - reasonBytes,
3604
+ });
3605
+ }
3606
+ if (actorOriginalBytes > actorBytes) {
3607
+ fields.push({
3608
+ field: "actor",
3609
+ originalBytes: actorOriginalBytes,
3610
+ deliveredBytes: actorBytes,
3611
+ omittedBytes: actorOriginalBytes - actorBytes,
3612
+ });
3613
+ }
3614
+ if (fields.length === 0 && event.truncation == null) {
3615
+ if (sessionEventJsonBytes(event) > WORKSPACE_CONTROL_EVENT_MAX_BYTES) {
3616
+ throw new RangeError("Workspace control event exceeds its bounded envelope");
3617
+ }
3618
+ return event;
3619
+ }
3620
+
3621
+ const truncation: WorkspaceControlEventTruncation = {
3622
+ truncated: true,
3623
+ surface: event.truncation?.surface ?? options.surface ?? "durable_control",
3624
+ deliveredBytes: 0,
3625
+ fields,
3626
+ fullEvidence: { available: false, reason: "not_retained" },
3627
+ };
3628
+ const bounded: WorkspaceControlEvent = {
3629
+ ...event,
3630
+ reason,
3631
+ actor,
3632
+ truncation,
3633
+ };
3634
+ settleWorkspaceControlDeliveredBytes(bounded, truncation);
3635
+ const deliveredBytes = sessionEventJsonBytes(bounded);
3636
+ if (deliveredBytes > WORKSPACE_CONTROL_EVENT_MAX_BYTES) {
3637
+ throw new RangeError(
3638
+ `Bounded workspace control event exceeds its final envelope (${deliveredBytes} > ${WORKSPACE_CONTROL_EVENT_MAX_BYTES} bytes)`,
3639
+ );
3640
+ }
3641
+ return bounded;
3642
+ }
3643
+
3644
+ function boundWorkspaceControlText(value: string, maxBytes: number): string {
3645
+ const encoder = new TextEncoder();
3646
+ const decoder = new TextDecoder();
3647
+ const bytes = encoder.encode(value);
3648
+ if (bytes.byteLength <= maxBytes) return value;
3649
+ const marker = "…[truncated]";
3650
+ const prefixBudget = Math.max(0, maxBytes - encoder.encode(marker).byteLength);
3651
+ let prefixEnd = Math.min(prefixBudget, bytes.byteLength);
3652
+ while (prefixEnd > 0 && prefixEnd < bytes.byteLength && (bytes[prefixEnd]! & 0xc0) === 0x80) {
3653
+ prefixEnd -= 1;
3654
+ }
3655
+ return `${decoder.decode(bytes.subarray(0, prefixEnd))}${marker}`;
3656
+ }
3657
+
3658
+ function normalizedWorkspaceControlOriginalBytes(value: number | null | undefined): number {
3659
+ return value === null || value === undefined || !Number.isFinite(value)
3660
+ ? 0
3661
+ : Math.max(0, Math.floor(value));
3662
+ }
3663
+
3664
+ function settleWorkspaceControlDeliveredBytes(
3665
+ event: WorkspaceControlEvent,
3666
+ truncation: WorkspaceControlEventTruncation,
3667
+ ): void {
3668
+ for (let attempt = 0; attempt < 16; attempt += 1) {
3669
+ const deliveredBytes = sessionEventJsonBytes(event);
3670
+ if (truncation.deliveredBytes === deliveredBytes) return;
3671
+ truncation.deliveredBytes = deliveredBytes;
3672
+ }
3673
+ const deliveredBytes = sessionEventJsonBytes(event);
3674
+ if (truncation.deliveredBytes !== deliveredBytes) {
3675
+ throw new RangeError("Workspace control event byte accounting did not converge");
3676
+ }
3677
+ }
3678
+
2109
3679
  export const SystemUpdateClassification = z.enum(["success", "failure", "action_required", "info"]);
2110
3680
  export type SystemUpdateClassification = z.infer<typeof SystemUpdateClassification>;
2111
3681
 
2112
3682
  export const SessionSystemUpdateKind = z.enum([
2113
- "child_session_update",
2114
- "scheduled_wake",
2115
- "lifecycle_event",
2116
- "runtime_notice",
3683
+ "scheduled_occurrence",
3684
+ "goal_continuation",
3685
+ "agent_message",
3686
+ "agent_steer_instruction",
3687
+ "child_terminal_result",
2117
3688
  ]);
2118
3689
  export type SessionSystemUpdateKind = z.infer<typeof SessionSystemUpdateKind>;
2119
3690
 
3691
+ export const SessionSystemUpdatePayload = z.discriminatedUnion("type", [
3692
+ z
3693
+ .object({
3694
+ type: z.literal("scheduled_occurrence"),
3695
+ text: z.string().min(1),
3696
+ scheduledTaskId: z.string().uuid(),
3697
+ scheduledTaskRunId: z.string().uuid(),
3698
+ resources: z.array(ResourceRef).optional(),
3699
+ tools: z.array(ToolRef).optional(),
3700
+ })
3701
+ .passthrough(),
3702
+ z
3703
+ .object({
3704
+ type: z.literal("goal_continuation"),
3705
+ goalId: z.string().uuid(),
3706
+ goalVersion: z.number().int().positive(),
3707
+ prompt: z.string().min(1),
3708
+ reason: z.string().optional(),
3709
+ })
3710
+ .passthrough(),
3711
+ z
3712
+ .object({
3713
+ type: z.literal("agent_message"),
3714
+ text: z.string().min(1),
3715
+ operationId: z.string().uuid(),
3716
+ })
3717
+ .passthrough(),
3718
+ z
3719
+ .object({
3720
+ type: z.literal("agent_steer_instruction"),
3721
+ instruction: z.string().min(1),
3722
+ operationId: z.string().uuid(),
3723
+ })
3724
+ .passthrough(),
3725
+ z
3726
+ .object({
3727
+ type: z.literal("child_terminal_result"),
3728
+ childSessionId: z.string().uuid(),
3729
+ status: z.enum(["idle", "failed"]),
3730
+ })
3731
+ .passthrough(),
3732
+ ]);
3733
+ export type SessionSystemUpdatePayload = z.infer<typeof SessionSystemUpdatePayload>;
3734
+
2120
3735
  export const SessionSystemUpdateState = z.enum([
2121
3736
  "pending",
2122
3737
  "deferred",
2123
3738
  "delivered",
2124
3739
  "cancelled",
3740
+ "superseded",
2125
3741
  "failed",
2126
3742
  ]);
2127
3743
  export type SessionSystemUpdateState = z.infer<typeof SessionSystemUpdateState>;
@@ -2134,7 +3750,7 @@ export const SessionSystemUpdate = z.object({
2134
3750
  sourceId: z.string(),
2135
3751
  dedupeKey: z.string(),
2136
3752
  summary: z.string(),
2137
- payload: z.record(z.string(), z.unknown()),
3753
+ payload: SessionSystemUpdatePayload,
2138
3754
  lineage: z.record(z.string(), z.unknown()),
2139
3755
  state: SessionSystemUpdateState,
2140
3756
  deliveredTurnId: z.string().uuid().nullable(),
@@ -2365,7 +3981,10 @@ export type RigDefinitionEditPayload = z.infer<typeof RigDefinitionEditPayload>;
2365
3981
 
2366
3982
  export const ProposeRigChangeRequest = z.discriminatedUnion("kind", [
2367
3983
  z.object({ kind: z.literal("setup_append"), payload: RigSetupAppendPayload }),
2368
- z.object({ kind: z.literal("definition_edit"), payload: RigDefinitionEditPayload }),
3984
+ z.object({
3985
+ kind: z.literal("definition_edit"),
3986
+ payload: RigDefinitionEditPayload,
3987
+ }),
2369
3988
  ]);
2370
3989
  export type ProposeRigChangeRequest = z.infer<typeof ProposeRigChangeRequest>;
2371
3990
 
@@ -2795,18 +4414,6 @@ export type ConnectionKind = z.infer<typeof ConnectionKind>;
2795
4414
  export const ConnectionStatus = z.enum(["active", "needs_reauth", "revoked", "error"]);
2796
4415
  export type ConnectionStatus = z.infer<typeof ConnectionStatus>;
2797
4416
 
2798
- export const McpServerConnectionRef = z
2799
- .object({
2800
- connectionId: z.string().uuid().optional(),
2801
- providerDomain: z.string().min(1),
2802
- kind: ConnectionKind.optional(),
2803
- scopes: z.array(z.string().min(1)).optional(),
2804
- resource: z.string().min(1).optional(),
2805
- subjectScope: z.enum(["workspace", "subject"]).optional(),
2806
- })
2807
- .strict();
2808
- export type McpServerConnectionRef = z.infer<typeof McpServerConnectionRef>;
2809
-
2810
4417
  export const ConnectionMetadata = z.object({
2811
4418
  id: z.string().uuid(),
2812
4419
  accountId: z.string().uuid(),
@@ -2925,6 +4532,7 @@ export type CapabilityKind = z.infer<typeof CapabilityKind>;
2925
4532
 
2926
4533
  export const CapabilitySource = z.enum([
2927
4534
  "built_in",
4535
+ "library",
2928
4536
  "configured",
2929
4537
  "public_registry",
2930
4538
  "registry",
@@ -2946,6 +4554,19 @@ export const CapabilityRuntime = z.object({
2946
4554
  mcpServerId: z.string().min(1).optional(),
2947
4555
  transport: z.string().min(1).optional(),
2948
4556
  notes: z.string().nullable().default(null),
4557
+ // Registry exposure provenance is server-derived and contains no endpoint or
4558
+ // credential material.
4559
+ catalogTrust: z
4560
+ .object({
4561
+ state: z.enum(["trusted", "legacy_active", "unverified"]),
4562
+ reason: z.enum([
4563
+ "trusted_source",
4564
+ "verified_probe",
4565
+ "active_installation_compatibility",
4566
+ "missing_verification",
4567
+ ]),
4568
+ })
4569
+ .optional(),
2949
4570
  });
2950
4571
  export type CapabilityRuntime = z.infer<typeof CapabilityRuntime>;
2951
4572
 
@@ -2997,6 +4618,34 @@ export const CapabilityCatalogItem = z.object({
2997
4618
  });
2998
4619
  export type CapabilityCatalogItem = z.infer<typeof CapabilityCatalogItem>;
2999
4620
 
4621
+ /**
4622
+ * Shared trust gate for catalog visibility and runtime selection. Registry rows
4623
+ * remain durable for provenance and audit, but only a reviewed real-MCP probe
4624
+ * with known authentication is exposable. API-key rows additionally need a
4625
+ * machine-actionable header contract; prose credential instructions are not a
4626
+ * runtime contract and must fail closed.
4627
+ */
4628
+ export function capabilityCatalogItemIsTrustedForExposure(
4629
+ item: Pick<CapabilityCatalogItem, "source" | "stale" | "authKind" | "metadata">,
4630
+ ): boolean {
4631
+ if (item.stale) return false;
4632
+ if (item.source !== "registry") return true;
4633
+ const probe = item.metadata.mcpProbe;
4634
+ if (!probe || typeof probe !== "object" || Array.isArray(probe)) return false;
4635
+ if ((probe as Record<string, unknown>).status !== "real") return false;
4636
+ if (item.authKind === null || item.authKind === "unknown") return false;
4637
+ if (item.authKind !== "api_key") return true;
4638
+ const contract = item.metadata.authContract;
4639
+ if (!contract || typeof contract !== "object" || Array.isArray(contract)) return false;
4640
+ const record = contract as Record<string, unknown>;
4641
+ return (
4642
+ typeof record.headerName === "string" &&
4643
+ /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(record.headerName) &&
4644
+ typeof record.scheme === "string" &&
4645
+ record.scheme.trim().length > 0
4646
+ );
4647
+ }
4648
+
3000
4649
  export const CapabilityInstallation = z.object({
3001
4650
  id: z.string().uuid(),
3002
4651
  accountId: z.string().uuid(),
@@ -3076,7 +4725,17 @@ export const Session = z.object({
3076
4725
  instructions: z.string().nullable(),
3077
4726
  resources: z.array(ResourceRef),
3078
4727
  tools: z.array(ToolRef),
4728
+ // Origin of the persisted tool allow-list. Optional for rolling client
4729
+ // compatibility; current servers emit it and legacy rows map to `legacy`.
4730
+ toolPolicy: SessionToolPolicy.optional(),
4731
+ // Secret-safe current resolution, computed at an API/read or execution
4732
+ // boundary from IDs only. Optional because internal DB readers need not load
4733
+ // the workspace runtime registry.
4734
+ effectiveToolPolicy: SessionEffectiveToolPolicy.optional(),
3079
4735
  metadata: z.record(z.string(), z.unknown()),
4736
+ /** Frozen creator fact used only for creation attribution/idempotent repair. */
4737
+ createdBy: TurnInitiator,
4738
+ createdByContext: TurnInitiatorContext,
3080
4739
  model: z.string(),
3081
4740
  sandboxBackend: SandboxBackend,
3082
4741
  // The OS the session's box runs. Defaults to 'linux' (today's only OS).
@@ -3126,12 +4785,7 @@ export const Session = z.object({
3126
4785
  queueVersion: z.number().int().nonnegative(),
3127
4786
  queueHeadPosition: z.number().int(),
3128
4787
  queueTailPosition: z.number().int(),
3129
- controlState: SessionControlState,
3130
- controlGeneration: z.number().int().nonnegative(),
3131
- controlReason: z.string().nullable(),
3132
- controlChangedBy: z.string().nullable(),
3133
- controlChangedAt: z.string().nullable(),
3134
- workspaceRunExceptionGeneration: z.number().int().nonnegative().nullable(),
4788
+ effectiveControl: EffectiveSessionControl,
3135
4789
  lastSequence: z.number().int().nonnegative(),
3136
4790
  // Multi-account Codex (P1). codexPinnedCredentialId: the account this session is
3137
4791
  // manually PINNED to (null ⇒ follow the workspace active pointer).
@@ -3159,6 +4813,8 @@ export const Session = z.object({
3159
4813
  attentionDescendants: z.number().int().nonnegative(),
3160
4814
  pausedDescendants: z.number().int().nonnegative(),
3161
4815
  failedDescendants: z.number().int().nonnegative(),
4816
+ /** Counts are lower bounds rather than exact totals when true. */
4817
+ truncated: z.boolean().default(false),
3162
4818
  })
3163
4819
  .optional(),
3164
4820
  createdAt: z.string(),
@@ -3166,16 +4822,30 @@ export const Session = z.object({
3166
4822
  });
3167
4823
  export type Session = z.infer<typeof Session>;
3168
4824
 
4825
+ /**
4826
+ * Additive receipt returned only by session creation. `activeTurnId` remains an
4827
+ * execution pointer and is correctly null while the first turn is queued;
4828
+ * embedders use this immutable identity to correlate their preallocated run.
4829
+ */
4830
+ export const CreateSessionResponse = Session.extend({
4831
+ initialTurnId: z.string().uuid().nullable(),
4832
+ });
4833
+ export type CreateSessionResponse = z.infer<typeof CreateSessionResponse>;
4834
+
3169
4835
  export type SessionSummary = Session;
3170
4836
 
3171
4837
  /**
3172
4838
  * The canonical session-list page. Pinned rows are returned separately and are
3173
4839
  * excluded from `sessions`, so a cursor can page ordinary recency rows without
3174
- * duplicating a pin. Pins are filtered by the same parent/search predicates as
3175
- * ordinary rows and ordered by pinnedAt DESC, id DESC.
4840
+ * duplicating a pin. The newest 100 matching pins are returned, ordered by
4841
+ * pinnedAt DESC, id DESC; `pinnedTruncated` makes an older-pin omission
4842
+ * explicit. Pins are filtered by the same parent/search predicates as ordinary
4843
+ * rows.
3176
4844
  */
3177
4845
  export const SessionListResponse = z.object({
3178
4846
  pinned: z.array(Session),
4847
+ /** True when older matching pins were omitted from this bounded page. */
4848
+ pinnedTruncated: z.boolean().optional(),
3179
4849
  sessions: z.array(Session),
3180
4850
  nextCursor: z.string().nullable(),
3181
4851
  });
@@ -3204,8 +4874,14 @@ export type SessionLineageResponse = z.infer<typeof SessionLineageResponse>;
3204
4874
 
3205
4875
  export const SessionEventType = z.enum([
3206
4876
  "session.created",
4877
+ // Defensive read/transport projection for a malformed or historically
4878
+ // oversized retained event envelope. The original row stays durable; this
4879
+ // explicit synthetic type prevents unbounded free-form envelope fields from
4880
+ // crossing NATS, SSE, REST, or browser boundaries.
4881
+ "session.event.envelope_omitted",
3207
4882
  "session.status.changed",
3208
4883
  "session.requiresAction",
4884
+ "session.humanInput.requested",
3209
4885
  "session.context.compaction.requested",
3210
4886
  "session.context.compacted",
3211
4887
  "session.context.compaction.skipped",
@@ -3213,6 +4889,7 @@ export const SessionEventType = z.enum([
3213
4889
  "user.message",
3214
4890
  "user.pause",
3215
4891
  "user.approvalDecision",
4892
+ "user.humanInputResponse",
3216
4893
  "turn.queued",
3217
4894
  "turn.started",
3218
4895
  "turn.completed",
@@ -3226,8 +4903,12 @@ export const SessionEventType = z.enum([
3226
4903
  "agent.reasoning.delta",
3227
4904
  "agent.toolCall.created",
3228
4905
  "agent.toolCall.output",
4906
+ // Attempt-fenced Codex Responses lifecycle metadata (request identity,
4907
+ // deadlines, first-byte/terminal phase, provider request id). Never body/auth.
4908
+ "agent.model.request",
3229
4909
  "agent.model.usage",
3230
4910
  "tool.auth_needed",
4911
+ "credential.auth_needed",
3231
4912
  "agent.updated",
3232
4913
  "rig.setup.started",
3233
4914
  "rig.setup.completed",
@@ -3252,6 +4933,7 @@ export const SessionEventType = z.enum([
3252
4933
  "session.control.steer_requested",
3253
4934
  "workspace.inference.paused",
3254
4935
  "workspace.inference.resumed",
4936
+ "session.queue.changed",
3255
4937
  "session.queue.prompt.cancelled",
3256
4938
  "session.queue.history",
3257
4939
  // A terminal/stale activity callback is retained as an audit wrapper rather
@@ -3266,15 +4948,15 @@ export const SessionEventType = z.enum([
3266
4948
  "stream.opened", // a viewer attached (audit + refcount visibility)
3267
4949
  "stream.closed", // a viewer detached / was reaped
3268
4950
  "stream.revoked", // a grant was revoked → connected clients MUST disconnect now
3269
- // Channel-B recording signals (P4.3 / module 05 §3.4). The "agent films itself
3270
- // proving the fix" loop: ffmpeg x11grab of the SAME :0 humans watch → artifact
4951
+ // Desktop recording signals. The capture loop records the same display humans
4952
+ // watch, then stores the finalized artifact for replay.
3271
4953
  // → storage. The artifact ref rides the AVAILABLE event (storageKey, NOT a
3272
4954
  // long-lived URL — clients mint a short-TTL signed GET via the route).
3273
4955
  "recording.started", // ffmpeg launched on :0 (mode/codec/dimensions)
3274
4956
  "recording.available", // finalized: bytes PUT to storage, replayable
3275
4957
  "recording.failed", // ffmpeg/box-death/rollover/upload error — no artifact
3276
- // Channel-A structured-service notifications (P4.4 / modules/08-channel-a.md
3277
- // §2.2). The A2 reads (fs/git/terminal exec) are SYNCHRONOUS API-direct point
4958
+ // Structured-service notifications. File, Git, and terminal reads are
4959
+ // synchronous API-direct point
3278
4960
  // queries (their result is the HTTP response, NEVER an event). What rides A1
3279
4961
  // here are the side-effect NOTIFICATIONS — a path changed, git state changed,
3280
4962
  // a pty opened/printed/exited — durable, sequenced, gap-filled like every
@@ -3287,14 +4969,15 @@ export const SessionEventType = z.enum([
3287
4969
  "terminal.pty.output.delta", // PTY stdout/stderr bytes (separate from command.output)
3288
4970
  "terminal.pty.exited", // PTY session ended (exitCode/reason)
3289
4971
  "session.title_set",
4972
+ "session.mcp.approval_policy.updated",
3290
4973
  // Multi-account Codex (P1): the account a session's turn runs on changed
3291
4974
  // (manual switch in P1; failover/rotation in P3 reuse the same event). Drives
3292
4975
  // the in-session "Running on:" indicator's live flip.
3293
4976
  "codex.account.switched",
3294
- // OPE-21 per-turn selection audit. Payload is metadata only: credential row
4977
+ // credential allocator per-turn selection audit. Payload is metadata only: credential row
3295
4978
  // id, bounded strategy/reason, and pool counts — never token material.
3296
4979
  "codex.credential.selected",
3297
- // OPE-21 durable zero-capacity wait lifecycle. Runtime/system events only;
4980
+ // credential allocator durable zero-capacity wait lifecycle. Runtime/system events only;
3298
4981
  // no synthetic user message is created when capacity returns.
3299
4982
  "codex.capacity.waiting",
3300
4983
  "codex.capacity.resumed",
@@ -3319,12 +5002,12 @@ export const SessionEventType = z.enum([
3319
5002
  // target id or command content. Announce-only; hits the timeline projection default
3320
5003
  // (no rendered item) like the other sandbox.* diagnostics.
3321
5004
  "session.route.reconciled",
3322
- // Workbench v2 turn-end workspace capture (dossier §10.1). ANNOUNCE-ONLY: a new
5005
+ // Workbench v2 turn-end workspace capture. ANNOUNCE-ONLY: a new
3323
5006
  // capture revision was persisted at turn end; the client refetches the latest
3324
5007
  // capture. It carries metadata only (revision/turnId/capturedAt/leaseEpoch/stats),
3325
5008
  // never file content. Hits the timeline projection default case (ignored) — it
3326
5009
  // must NEVER gain a rendered timeline item without regenerating the golden
3327
- // snapshots (dossier §7.3 golden-grammar gate).
5010
+ // snapshots (golden-grammar gate).
3328
5011
  "workspace.revision.captured",
3329
5012
  // Repository discovery could not prove a complete capture. The worker
3330
5013
  // persisted a failed/degraded revision marker and clients must fall back to
@@ -3366,19 +5049,225 @@ export const SessionEventType = z.enum([
3366
5049
  ]);
3367
5050
  export type SessionEventType = z.infer<typeof SessionEventType>;
3368
5051
 
5052
+ /**
5053
+ * Stable semantic groups for bounded session monitoring. These are a read
5054
+ * projection only: an event keeps its canonical durable `type`, and callers
5055
+ * can always combine a class with explicit type include/exclude filters.
5056
+ */
5057
+ export const SessionEventSemanticClass = z.enum([
5058
+ "control",
5059
+ "terminal",
5060
+ "failure",
5061
+ "checkpoint",
5062
+ "tool_receipt",
5063
+ "provider_account",
5064
+ ]);
5065
+ export type SessionEventSemanticClass = z.infer<typeof SessionEventSemanticClass>;
5066
+
5067
+ /**
5068
+ * The semantic classes accepted by an exclusive latest lookup. `receipt` is
5069
+ * the concise public spelling for the historical `tool_receipt` class; the
5070
+ * latter remains accepted everywhere for backwards compatibility.
5071
+ */
5072
+ export const SessionEventLatestClass = z.enum([
5073
+ "control",
5074
+ "terminal",
5075
+ "failure",
5076
+ "checkpoint",
5077
+ "tool_receipt",
5078
+ "provider_account",
5079
+ "receipt",
5080
+ ]);
5081
+ export type SessionEventLatestClass = z.infer<typeof SessionEventLatestClass>;
5082
+
5083
+ export function sessionEventLatestClassToSemanticClass(
5084
+ value: SessionEventLatestClass,
5085
+ ): SessionEventSemanticClass {
5086
+ return value === "receipt" ? "tool_receipt" : value;
5087
+ }
5088
+
5089
+ export const SessionEventPayloadMode = z.enum(["none", "summary", "full"]);
5090
+ export type SessionEventPayloadMode = z.infer<typeof SessionEventPayloadMode>;
5091
+
5092
+ /** Select the compact semantic-result projection instead of an event array. */
5093
+ export const SessionEventResultMode = z.enum(["events", "compact"]);
5094
+ export type SessionEventResultMode = z.infer<typeof SessionEventResultMode>;
5095
+
5096
+ export const SessionEventReadMode = z.enum(["monitoring", "forensic"]);
5097
+ export type SessionEventReadMode = z.infer<typeof SessionEventReadMode>;
5098
+
5099
+ export const SessionEventReadDirection = z.enum(["after", "before"]);
5100
+ export type SessionEventReadDirection = z.infer<typeof SessionEventReadDirection>;
5101
+
5102
+ export const SESSION_EVENT_RAW_DELTA_TYPES = [
5103
+ "agent.message.delta",
5104
+ "agent.reasoning.delta",
5105
+ "sandbox.command.output.delta",
5106
+ "terminal.pty.output.delta",
5107
+ ] as const satisfies readonly SessionEventType[];
5108
+
5109
+ export const SESSION_EVENT_SEMANTIC_CLASS_TYPES = {
5110
+ control: [
5111
+ "session.status.changed",
5112
+ "session.requiresAction",
5113
+ "session.humanInput.requested",
5114
+ "user.pause",
5115
+ "user.approvalDecision",
5116
+ "user.humanInputResponse",
5117
+ "goal.set",
5118
+ "goal.updated",
5119
+ "goal.completed",
5120
+ "goal.paused",
5121
+ "goal.resumed",
5122
+ "goal.cleared",
5123
+ "goal.continuation",
5124
+ "system.update.pending",
5125
+ "system.update.delivered",
5126
+ "session.control.paused",
5127
+ "session.control.resumed",
5128
+ "session.control.steer_requested",
5129
+ "workspace.inference.paused",
5130
+ "workspace.inference.resumed",
5131
+ "session.queue.changed",
5132
+ "session.queue.prompt.cancelled",
5133
+ "session.mcp.approval_policy.updated",
5134
+ ],
5135
+ terminal: [
5136
+ "turn.completed",
5137
+ "agent.message.completed",
5138
+ "turn.failed",
5139
+ "turn.cancelled",
5140
+ "turn.superseded",
5141
+ "goal.completed",
5142
+ "goal.paused",
5143
+ "rig.setup.completed",
5144
+ "rig.setup.skipped",
5145
+ "rig.setup.failed",
5146
+ "sandbox.operation.completed",
5147
+ "sandbox.operation.failed",
5148
+ "recording.available",
5149
+ "recording.failed",
5150
+ "terminal.pty.exited",
5151
+ ],
5152
+ failure: [
5153
+ "session.event.envelope_omitted",
5154
+ "turn.failed",
5155
+ "tool.auth_needed",
5156
+ "credential.auth_needed",
5157
+ "rig.setup.failed",
5158
+ "sandbox.operation.failed",
5159
+ "recording.failed",
5160
+ "sandbox.box.lost",
5161
+ "workspace.revision.degraded",
5162
+ "machine.op.failed",
5163
+ "machine.link.lost",
5164
+ ],
5165
+ checkpoint: [
5166
+ "session.context.compaction.requested",
5167
+ "session.context.compacted",
5168
+ "session.context.compaction.skipped",
5169
+ "session.context.cleared",
5170
+ "turn.recovery.requested",
5171
+ "session.queue.history",
5172
+ "sandbox.box.snapshot",
5173
+ "workspace.revision.captured",
5174
+ ],
5175
+ tool_receipt: [
5176
+ "agent.toolCall.created",
5177
+ "agent.toolCall.output",
5178
+ "tool.auth_needed",
5179
+ "artifact.created",
5180
+ ],
5181
+ provider_account: [
5182
+ "agent.model.usage",
5183
+ "codex.account.switched",
5184
+ "codex.credential.selected",
5185
+ "codex.capacity.waiting",
5186
+ "codex.capacity.resumed",
5187
+ "codex.capacity.superseded",
5188
+ "sandbox.box.created",
5189
+ "sandbox.box.lost",
5190
+ "sandbox.box.terminated",
5191
+ "sandbox.box.snapshot",
5192
+ "sandbox.env.drift",
5193
+ "session.route.reconciled",
5194
+ "machine.op.failed",
5195
+ "machine.op.recovered",
5196
+ "machine.link.lost",
5197
+ "machine.link.restored",
5198
+ "machine.runner.restarted",
5199
+ ],
5200
+ } as const satisfies Record<SessionEventSemanticClass, readonly SessionEventType[]>;
5201
+
5202
+ export type ResolveSessionEventTypeFiltersInput = {
5203
+ includeTypes?: readonly SessionEventType[] | undefined;
5204
+ excludeTypes?: readonly SessionEventType[] | undefined;
5205
+ includeClasses?: readonly SessionEventSemanticClass[] | undefined;
5206
+ excludeClasses?: readonly SessionEventSemanticClass[] | undefined;
5207
+ /** Applied unless the same type was explicitly included by type or class. */
5208
+ defaultExcludeTypes?: readonly SessionEventType[] | undefined;
5209
+ };
5210
+
5211
+ /** Resolve class/type filter algebra once so every read surface behaves alike. */
5212
+ export function resolveSessionEventTypeFilters(input: ResolveSessionEventTypeFiltersInput): {
5213
+ includeTypes: SessionEventType[];
5214
+ excludeTypes: SessionEventType[];
5215
+ } {
5216
+ const included = new Set<SessionEventType>(input.includeTypes ?? []);
5217
+ for (const semanticClass of input.includeClasses ?? []) {
5218
+ for (const type of SESSION_EVENT_SEMANTIC_CLASS_TYPES[semanticClass]) included.add(type);
5219
+ }
5220
+
5221
+ const excluded = new Set<SessionEventType>(input.excludeTypes ?? []);
5222
+ for (const semanticClass of input.excludeClasses ?? []) {
5223
+ for (const type of SESSION_EVENT_SEMANTIC_CLASS_TYPES[semanticClass]) excluded.add(type);
5224
+ }
5225
+ for (const type of input.defaultExcludeTypes ?? []) {
5226
+ if (!included.has(type)) excluded.add(type);
5227
+ }
5228
+
5229
+ // An explicit exclusion always wins over a positive selector.
5230
+ for (const type of excluded) included.delete(type);
5231
+ return { includeTypes: [...included], excludeTypes: [...excluded] };
5232
+ }
5233
+
3369
5234
  export const ToolAuthNeededPayload = z.object({
3370
5235
  serverId: z.string().min(1),
3371
5236
  toolName: z.string().min(1).nullable().optional(),
3372
5237
  providerDomain: z.string().min(1),
3373
- connectionId: z.string().uuid().nullable().optional(),
3374
- reason: z.enum(["missing_connection", "expired", "insufficient_scope", "refresh_failed"]),
5238
+ provider: z.string().min(1).max(128).optional(),
5239
+ // Embedded hosts may use an opaque connection identity; never assume an
5240
+ // OpenGeni UUID on the public event wire.
5241
+ connectionId: z.string().min(1).nullable().optional(),
5242
+ reason: z.enum([
5243
+ "missing_connection",
5244
+ "expired",
5245
+ "insufficient_scope",
5246
+ "refresh_failed",
5247
+ "unsupported_auth",
5248
+ "resource_scope_unavailable",
5249
+ ]),
3375
5250
  scopes: z.array(z.string().min(1)).optional(),
3376
5251
  resource: z.string().min(1).optional(),
5252
+ selectedResources: McpConnectionResourceScopes.optional(),
3377
5253
  authorizationUrl: z.string().url().optional(),
3378
5254
  subjectId: z.string().min(1).nullable().optional(),
3379
5255
  });
3380
5256
  export type ToolAuthNeededPayload = z.infer<typeof ToolAuthNeededPayload>;
3381
5257
 
5258
+ /** A host-owned non-tool credential needed by the active run. */
5259
+ export const CredentialAuthNeededPayload = z.object({
5260
+ credentialClass: z.literal("run"),
5261
+ providerDomain: z.string().min(1).optional(),
5262
+ connectionId: z.string().min(1).optional(),
5263
+ reason: z.enum(["missing_connection", "expired", "insufficient_scope", "refresh_failed"]),
5264
+ scopes: z.array(z.string().min(1)).optional(),
5265
+ resource: z.string().min(1).optional(),
5266
+ authorizationUrl: z.string().url().optional(),
5267
+ message: z.string().min(1).optional(),
5268
+ });
5269
+ export type CredentialAuthNeededPayload = z.infer<typeof CredentialAuthNeededPayload>;
5270
+
3382
5271
  // Channel-B stream-event payloads (07-channel-b §1.2). SessionEvent.payload is
3383
5272
  // z.unknown() (NOT a discriminated union) — these are standalone schemas parsed
3384
5273
  // explicitly at the producer (the API-direct handshake/rotation) and the SDK/
@@ -3480,7 +5369,7 @@ export const RecordingFailedPayload = z.object({
3480
5369
  });
3481
5370
  export type RecordingFailedPayload = z.infer<typeof RecordingFailedPayload>;
3482
5371
 
3483
- // ── Channel-A structured services (P4.4 / modules/08-channel-a.md) ───────────
5372
+ // ── Structured sandbox services ─────────────────────────────────────────────
3484
5373
  // Two transports on one spine: the A2 request/response shapes (FsNode tree,
3485
5374
  // GitDiff hunks, terminal exec) are returned INLINE on synchronous API-direct
3486
5375
  // routes (never the bus); the A1 notification payloads below ride the durable
@@ -3647,7 +5536,9 @@ export const FsDeleteRequest = z.object({
3647
5536
  recursive: z.boolean().default(false), // required true to delete a non-empty dir
3648
5537
  });
3649
5538
  export type FsDeleteRequest = z.infer<typeof FsDeleteRequest>;
3650
- export const FsDeleteResponse = z.object({ revision: z.number().int().nonnegative() });
5539
+ export const FsDeleteResponse = z.object({
5540
+ revision: z.number().int().nonnegative(),
5541
+ });
3651
5542
  export type FsDeleteResponse = z.infer<typeof FsDeleteResponse>;
3652
5543
 
3653
5544
  export const FsMoveRequest = z.object({
@@ -3748,6 +5639,9 @@ export const GitDiffRequest = z.object({
3748
5639
  path: z.string().default(""), // repo root
3749
5640
  // diff selectors, mutually exclusive precedence: refs > staged > worktree
3750
5641
  staged: z.boolean().default(false), // --cached (index vs HEAD)
5642
+ // Workspace review includes after-images that ordinary `git diff` omits.
5643
+ // Explicit so commit/staged consumers keep native Git semantics by default.
5644
+ includeUntracked: z.boolean().default(false),
3751
5645
  fromRef: z.string().optional(),
3752
5646
  toRef: z.string().optional(),
3753
5647
  pathspec: z.array(z.string()).default([]),
@@ -3766,7 +5660,7 @@ export const GitDiffResponse = z.object({
3766
5660
  });
3767
5661
  export type GitDiffResponse = z.infer<typeof GitDiffResponse>;
3768
5662
 
3769
- // ─── Workbench v2 turn-end workspace capture (dossier §10.1/§10.2) ────────────
5663
+ // ─── Workbench v2 turn-end workspace capture ────────────
3770
5664
  // A capture is a point-in-time snapshot of the session workspace's CHANGES,
3771
5665
  // probed live off the box at turn end (detectRepos → gitStatus/gitDiff → fsRead
3772
5666
  // after-images → fsList tree index). It is the cold/offline read source that
@@ -3783,7 +5677,7 @@ export const WorkspaceCaptureFile = z.object({
3783
5677
  status: GitFileStatusCode,
3784
5678
  // sha256 of the captured after-image bytes; null when deleted / tooLarge.
3785
5679
  hash: z.string().nullable(),
3786
- // git blob sha of the HEAD version — the wake-on-edit flush guard (dossier
5680
+ // git blob sha of the HEAD version — the wake-on-edit flush guard (design
3787
5681
  // §10.1). null when the path is new/untracked (no HEAD blob).
3788
5682
  baseHash: z.string().nullable(),
3789
5683
  // Content-addressed storage key of the after-image; null when deleted /
@@ -3821,7 +5715,7 @@ export const WorkspaceCaptureDegradedReason = z.enum([
3821
5715
  export type WorkspaceCaptureDegradedReason = z.infer<typeof WorkspaceCaptureDegradedReason>;
3822
5716
 
3823
5717
  // Rollup counters — carried on the row (jsonb) and the announce event so the UI
3824
- // can reserve layout (dossier §12 no-layout-shift) before fetching the manifest.
5718
+ // can reserve layout (no layout shift) before fetching the manifest.
3825
5719
  export const WorkspaceCaptureStats = z.object({
3826
5720
  repoCount: z.number().int().nonnegative(),
3827
5721
  fileCount: z.number().int().nonnegative(),
@@ -3858,7 +5752,7 @@ export const WorkspaceCaptureManifest = z.object({
3858
5752
  });
3859
5753
  export type WorkspaceCaptureManifest = z.infer<typeof WorkspaceCaptureManifest>;
3860
5754
 
3861
- // Announce-only event payload (dossier §10.1). Metadata only — never content.
5755
+ // Announce-only event payload. Metadata only — never content.
3862
5756
  export const WorkspaceRevisionCapturedPayload = z.object({
3863
5757
  revision: z.number().int().nonnegative(),
3864
5758
  turnId: z.string().nullable(),
@@ -3877,7 +5771,7 @@ export const WorkspaceRevisionDegradedPayload = z.object({
3877
5771
  });
3878
5772
  export type WorkspaceRevisionDegradedPayload = z.infer<typeof WorkspaceRevisionDegradedPayload>;
3879
5773
 
3880
- // --- M2 capture READ API (dossier §10.3) -------------------------------------
5774
+ // --- M2 capture READ API -------------------------------------
3881
5775
  // A short-TTL signed GET URL minted PER REQUEST (never stored). The manifest is
3882
5776
  // served inline for the ≤2MB common case (the <200ms one-round-trip paint); a
3883
5777
  // >2MB manifest and a >256KB single-file after-image fall back to one of these.
@@ -3954,14 +5848,25 @@ export const GitCommit = z.object({
3954
5848
  sha: z.string(),
3955
5849
  shortSha: z.string(),
3956
5850
  parents: z.array(z.string()),
3957
- author: z.object({ name: z.string(), email: z.string(), timestamp: z.number().int() }),
3958
- committer: z.object({ name: z.string(), email: z.string(), timestamp: z.number().int() }),
5851
+ author: z.object({
5852
+ name: z.string(),
5853
+ email: z.string(),
5854
+ timestamp: z.number().int(),
5855
+ }),
5856
+ committer: z.object({
5857
+ name: z.string(),
5858
+ email: z.string(),
5859
+ timestamp: z.number().int(),
5860
+ }),
3959
5861
  subject: z.string(),
3960
5862
  body: z.string(),
3961
5863
  refs: z.array(z.string()).default([]), // decorations: branch/tag pointers
3962
5864
  });
3963
5865
  export type GitCommit = z.infer<typeof GitCommit>;
3964
- export const GitLogResponse = z.object({ commits: z.array(GitCommit), hasMore: z.boolean() });
5866
+ export const GitLogResponse = z.object({
5867
+ commits: z.array(GitCommit),
5868
+ hasMore: z.boolean(),
5869
+ });
3965
5870
  export type GitLogResponse = z.infer<typeof GitLogResponse>;
3966
5871
 
3967
5872
  export const GitShowRequest = z.object({
@@ -4033,7 +5938,10 @@ export const PtyOpenResponse = z.object({
4033
5938
  supportsInput: z.boolean(), // false on backends without writeStdin
4034
5939
  });
4035
5940
  export type PtyOpenResponse = z.infer<typeof PtyOpenResponse>;
4036
- export const PtyWriteRequest = z.object({ ptyId: z.string().uuid(), data: z.string() }); // utf-8 stdin
5941
+ export const PtyWriteRequest = z.object({
5942
+ ptyId: z.string().uuid(),
5943
+ data: z.string(),
5944
+ }); // utf-8 stdin
4037
5945
  export type PtyWriteRequest = z.infer<typeof PtyWriteRequest>;
4038
5946
  export const PtyResizeRequest = z.object({
4039
5947
  ptyId: z.string().uuid(),
@@ -4048,7 +5956,11 @@ export type PtyCloseRequest = z.infer<typeof PtyCloseRequest>;
4048
5956
  // negotiation). The full SessionCapabilities doc already carries FileSystem /
4049
5957
  // Terminal / Git blocks (P0.1); this is the compact projection the SDK mirrors.
4050
5958
  export const SessionStructuredCapabilities = z.object({
4051
- FileSystem: z.object({ available: z.boolean(), readOnly: z.boolean(), root: z.string() }),
5959
+ FileSystem: z.object({
5960
+ available: z.boolean(),
5961
+ readOnly: z.boolean(),
5962
+ root: z.string(),
5963
+ }),
4052
5964
  Terminal: z.object({
4053
5965
  events: z.boolean(), // command.output firehose (always on if a box exists)
4054
5966
  exec: z.boolean(), // synchronous terminal exec
@@ -4066,39 +5978,992 @@ export const SessionEvent = z.object({
4066
5978
  type: SessionEventType,
4067
5979
  payload: z.unknown().default({}),
4068
5980
  occurredAt: z.string(),
4069
- clientEventId: z.string().min(1).nullable().optional(),
5981
+ clientEventId: SessionOperationKey.nullable().optional(),
4070
5982
  turnId: z.string().uuid().nullable().optional(),
4071
5983
  turnGeneration: z.number().int().nonnegative().nullable().optional(),
4072
5984
  turnAttemptId: z.string().uuid().nullable().optional(),
4073
5985
  turnAssociation: z.enum(["current", "late_rejected", "duplicate"]).nullable().optional(),
4074
5986
  duplicateOfEventId: z.string().uuid().nullable().optional(),
4075
- duplicateReason: z.string().min(1).nullable().optional(),
5987
+ duplicateReason: z.string().min(1).max(1024).nullable().optional(),
4076
5988
  });
4077
5989
  export type SessionEvent = z.infer<typeof SessionEvent>;
4078
5990
 
5991
+ export type SessionEventCompactResult = {
5992
+ version: 1;
5993
+ semanticClass: SessionEventSemanticClass;
5994
+ source: {
5995
+ id: string;
5996
+ type: SessionEventType;
5997
+ sequence: number;
5998
+ occurredAt: string;
5999
+ turnId: string | null;
6000
+ turnGeneration: number | null;
6001
+ turnAttemptId: string | null;
6002
+ turnAssociation: SessionEvent["turnAssociation"];
6003
+ };
6004
+ // These identity fields are repeated at the top level intentionally: an
6005
+ // MCP caller can act on the result without unpacking the source envelope.
6006
+ id: string;
6007
+ type: SessionEventType;
6008
+ sequence: number;
6009
+ occurredAt: string;
6010
+ turnId: string | null;
6011
+ turnGeneration: number | null;
6012
+ turnAttemptId: string | null;
6013
+ turnAssociation: SessionEvent["turnAssociation"];
6014
+ coveredSequence: { first: number; last: number };
6015
+ status:
6016
+ | "completed"
6017
+ | "failed"
6018
+ | "cancelled"
6019
+ | "superseded"
6020
+ | "checkpoint"
6021
+ | "receipt"
6022
+ | "unknown";
6023
+ text: string | null;
6024
+ output: unknown;
6025
+ result: unknown;
6026
+ failure: {
6027
+ error: string | null;
6028
+ code: string | null;
6029
+ retryable: boolean | null;
6030
+ recovery: string | null;
6031
+ } | null;
6032
+ checkpoint: unknown;
6033
+ receipt: unknown;
6034
+ truncation: {
6035
+ truncated: boolean;
6036
+ fields: string[];
6037
+ originalBytes: number | null;
6038
+ deliveredBytes: number;
6039
+ };
6040
+ };
6041
+
6042
+ const SESSION_EVENT_COMPACT_RESULT_TEXT_MAX_BYTES = 12 * 1024;
6043
+ // Five independently bounded slots plus identity/metadata must fit below the
6044
+ // 64 KiB MCP envelope even when a pathological producer supplies every slot.
6045
+ const SESSION_EVENT_COMPACT_RESULT_VALUE_MAX_BYTES = 8 * 1024;
6046
+
6047
+ type CompactValue = {
6048
+ value: unknown;
6049
+ truncated: boolean;
6050
+ originalBytes: number | null;
6051
+ };
6052
+
6053
+ type JsonRecord = Record<string, unknown>;
6054
+
6055
+ /**
6056
+ * Build the bounded semantic result used by `latest + result=compact`.
6057
+ *
6058
+ * This is intentionally a pure projection over one already-authoritative
6059
+ * event. It never reads history, invokes a model, follows a URL, or stores an
6060
+ * artifact. The DB/API/MCP layers decide which event is authoritative; this
6061
+ * helper only extracts the small result facts that can cross a client boundary.
6062
+ */
6063
+ export function compactSessionEventResult(
6064
+ event: SessionEvent,
6065
+ semanticClass: SessionEventSemanticClass,
6066
+ coveredSequence: { first: number; last: number } = {
6067
+ first: event.sequence,
6068
+ last: event.sequence,
6069
+ },
6070
+ ): SessionEventCompactResult {
6071
+ const payload = isSessionEventJsonRecord(event.payload) ? event.payload : {};
6072
+ const fields: string[] = [];
6073
+ let originalBytes = 0;
6074
+
6075
+ const textCandidate = typeof payload.text === "string" ? payload.text : null;
6076
+ const outputCandidate = Object.prototype.hasOwnProperty.call(payload, "output")
6077
+ ? payload.output
6078
+ : null;
6079
+ const resultCandidate = Object.prototype.hasOwnProperty.call(payload, "result")
6080
+ ? payload.result
6081
+ : undefined;
6082
+ const textValue = textCandidate ?? (typeof outputCandidate === "string" ? outputCandidate : null);
6083
+ const text = textValue === null ? null : compactResultText(textValue);
6084
+ if (text && text.truncated) {
6085
+ fields.push("text");
6086
+ originalBytes += text.originalBytes ?? 0;
6087
+ }
6088
+
6089
+ const output = compactResultValue(outputCandidate);
6090
+ if (outputCandidate !== null && output.truncated) {
6091
+ fields.push("output");
6092
+ originalBytes += output.originalBytes ?? 0;
6093
+ }
6094
+
6095
+ const result = compactResultValue(
6096
+ resultCandidate === undefined ? (textValue ?? outputCandidate) : resultCandidate,
6097
+ );
6098
+ if (resultCandidate !== undefined && result.truncated) {
6099
+ fields.push("result");
6100
+ originalBytes += result.originalBytes ?? 0;
6101
+ }
6102
+
6103
+ const checkpointField = firstOwnPayloadValue(payload, ["checkpoint", "summary", "snapshot"]);
6104
+ const checkpointCandidate =
6105
+ checkpointField !== undefined
6106
+ ? checkpointField
6107
+ : semanticClass === "checkpoint"
6108
+ ? payload
6109
+ : null;
6110
+ const checkpoint = compactResultValue(checkpointCandidate);
6111
+ if (checkpointCandidate !== null && checkpoint.truncated) {
6112
+ fields.push("checkpoint");
6113
+ originalBytes += checkpoint.originalBytes ?? 0;
6114
+ }
6115
+
6116
+ const receiptCandidate = firstOwnPayloadValue(payload, ["receipt", "receiptData"]);
6117
+ const receipt = compactResultValue(
6118
+ receiptCandidate !== undefined
6119
+ ? receiptCandidate
6120
+ : semanticClass === "tool_receipt"
6121
+ ? payload
6122
+ : null,
6123
+ );
6124
+ if (receipt.truncated) {
6125
+ fields.push("receipt");
6126
+ originalBytes += receipt.originalBytes ?? 0;
6127
+ }
6128
+
6129
+ const failure = compactFailure(payload, event.type);
6130
+ if (failure.truncated) {
6131
+ fields.push("failure");
6132
+ originalBytes += failure.originalBytes ?? 0;
6133
+ }
6134
+
6135
+ if (isSessionEventJsonRecord(payload.truncation) && payload.truncation.truncated === true) {
6136
+ fields.push("payload");
6137
+ }
6138
+
6139
+ const source = {
6140
+ id: event.id,
6141
+ type: event.type,
6142
+ sequence: event.sequence,
6143
+ occurredAt: event.occurredAt,
6144
+ turnId: event.turnId ?? null,
6145
+ turnGeneration: event.turnGeneration ?? null,
6146
+ turnAttemptId: event.turnAttemptId ?? null,
6147
+ turnAssociation: event.turnAssociation ?? null,
6148
+ };
6149
+ const status = compactResultStatus(event.type, semanticClass, payload);
6150
+ const outputValue = outputCandidate === null ? null : output.value;
6151
+ const resultValue = result.value;
6152
+ const checkpointValue = checkpointCandidate === null ? null : checkpoint.value;
6153
+ const receiptValue =
6154
+ receiptCandidate === null && semanticClass !== "tool_receipt" ? null : receipt.value;
6155
+ const compact: SessionEventCompactResult = {
6156
+ version: 1,
6157
+ semanticClass,
6158
+ source,
6159
+ id: source.id,
6160
+ type: source.type,
6161
+ sequence: source.sequence,
6162
+ occurredAt: source.occurredAt,
6163
+ turnId: source.turnId,
6164
+ turnGeneration: source.turnGeneration,
6165
+ turnAttemptId: source.turnAttemptId,
6166
+ turnAssociation: source.turnAssociation,
6167
+ coveredSequence,
6168
+ status,
6169
+ text: text?.value ?? null,
6170
+ output: outputValue,
6171
+ result: resultValue,
6172
+ failure: failure.value,
6173
+ checkpoint: checkpointValue,
6174
+ receipt: receiptValue,
6175
+ truncation: {
6176
+ truncated: fields.length > 0,
6177
+ fields: [...new Set(fields)],
6178
+ originalBytes: fields.length > 0 ? originalBytes || null : null,
6179
+ deliveredBytes: sessionEventJsonBytes({
6180
+ text: text?.value ?? null,
6181
+ output: outputValue,
6182
+ result: resultValue,
6183
+ failure: failure.value,
6184
+ checkpoint: checkpointValue,
6185
+ receipt: receiptValue,
6186
+ }),
6187
+ },
6188
+ };
6189
+ return compact;
6190
+ }
6191
+
6192
+ function isSessionEventJsonRecord(value: unknown): value is JsonRecord {
6193
+ return value !== null && typeof value === "object" && !Array.isArray(value);
6194
+ }
6195
+
6196
+ function firstOwnPayloadValue(payload: JsonRecord, keys: readonly string[]): unknown | undefined {
6197
+ for (const key of keys) {
6198
+ if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
6199
+ }
6200
+ return undefined;
6201
+ }
6202
+
6203
+ function compactResultText(value: string): CompactValue & { value: string } {
6204
+ const originalBytes = new TextEncoder().encode(value).byteLength;
6205
+ if (originalBytes <= SESSION_EVENT_COMPACT_RESULT_TEXT_MAX_BYTES) {
6206
+ return { value, truncated: false, originalBytes };
6207
+ }
6208
+ let omittedBytes = originalBytes - SESSION_EVENT_COMPACT_RESULT_TEXT_MAX_BYTES;
6209
+ let projected = value;
6210
+ for (let attempt = 0; attempt < 4; attempt += 1) {
6211
+ const marker = `…[${omittedBytes} UTF-8 bytes omitted from compact result]…`;
6212
+ const budget = Math.max(0, SESSION_EVENT_COMPACT_RESULT_TEXT_MAX_BYTES - utf8Bytes(marker));
6213
+ const head = utf8PrefixForResult(value, Math.floor(budget * 0.7));
6214
+ const tail = utf8SuffixForResult(value, budget - utf8Bytes(head));
6215
+ projected = `${head}${marker}${tail}`;
6216
+ const nextOmitted = Math.max(0, originalBytes - utf8Bytes(head) - utf8Bytes(tail));
6217
+ if (nextOmitted === omittedBytes) break;
6218
+ omittedBytes = nextOmitted;
6219
+ }
6220
+ return { value: projected, truncated: true, originalBytes };
6221
+ }
6222
+
6223
+ function compactResultValue(value: unknown): CompactValue {
6224
+ if (value === null || value === undefined) {
6225
+ return { value: null, truncated: false, originalBytes: null };
6226
+ }
6227
+ const measurement = measureSessionEventJson(value);
6228
+ const bounded = boundSessionEventPayload(value, {
6229
+ surface: "http_projection",
6230
+ maxBytes: SESSION_EVENT_COMPACT_RESULT_VALUE_MAX_BYTES,
6231
+ });
6232
+ const deliveredBytes = measureSessionEventJson(bounded).bytes;
6233
+ return {
6234
+ value: bounded,
6235
+ truncated:
6236
+ measurement.bytes === null || deliveredBytes === null || measurement.bytes !== deliveredBytes,
6237
+ originalBytes: measurement.bytes,
6238
+ };
6239
+ }
6240
+
6241
+ function compactFailure(
6242
+ payload: JsonRecord,
6243
+ eventType: SessionEventType,
6244
+ ): CompactValue & {
6245
+ value: SessionEventCompactResult["failure"];
6246
+ } {
6247
+ const isFailure =
6248
+ eventType === "turn.failed" ||
6249
+ eventType === "turn.cancelled" ||
6250
+ eventType === "turn.superseded";
6251
+ const hasFailureField = ["error", "code", "retryable", "recovery"].some((key) =>
6252
+ Object.prototype.hasOwnProperty.call(payload, key),
6253
+ );
6254
+ if (!isFailure && !hasFailureField) {
6255
+ return { value: null, truncated: false, originalBytes: null };
6256
+ }
6257
+ const error = compactResultStringField(payload.error);
6258
+ const code = compactResultStringField(payload.code);
6259
+ const recovery = compactResultStringField(payload.recovery);
6260
+ const retryable = typeof payload.retryable === "boolean" ? payload.retryable : null;
6261
+ const value = { error: error.value, code: code.value, retryable, recovery: recovery.value };
6262
+ const originalBytes = [error, code, recovery]
6263
+ .map((field) => field.originalBytes ?? 0)
6264
+ .reduce((sum, bytes) => sum + bytes, 0);
6265
+ return {
6266
+ value,
6267
+ truncated: error.truncated || code.truncated || recovery.truncated,
6268
+ originalBytes: originalBytes || null,
6269
+ };
6270
+ }
6271
+
6272
+ function compactResultStringField(value: unknown): CompactValue & { value: string | null } {
6273
+ if (typeof value !== "string") {
6274
+ return { value: null, truncated: false, originalBytes: null };
6275
+ }
6276
+ return compactResultText(value);
6277
+ }
6278
+
6279
+ function compactResultStatus(
6280
+ eventType: SessionEventType,
6281
+ semanticClass: SessionEventSemanticClass,
6282
+ payload: JsonRecord,
6283
+ ): SessionEventCompactResult["status"] {
6284
+ if (eventType === "turn.failed") return "failed";
6285
+ if (eventType === "turn.cancelled") return "cancelled";
6286
+ if (eventType === "turn.superseded") return "superseded";
6287
+ if (eventType === "turn.completed" || eventType === "agent.message.completed") {
6288
+ return "completed";
6289
+ }
6290
+ if (semanticClass === "checkpoint") return "checkpoint";
6291
+ if (
6292
+ semanticClass === "tool_receipt" ||
6293
+ eventType === "artifact.created" ||
6294
+ eventType === "recording.available"
6295
+ ) {
6296
+ return "receipt";
6297
+ }
6298
+ if (payload.status === "failed") return "failed";
6299
+ if (payload.status === "completed") return "completed";
6300
+ return "unknown";
6301
+ }
6302
+
6303
+ function utf8Bytes(value: string): number {
6304
+ return new TextEncoder().encode(value).byteLength;
6305
+ }
6306
+
6307
+ function utf8PrefixForResult(value: string, maxBytes: number): string {
6308
+ let bytes = 0;
6309
+ let index = 0;
6310
+ while (index < value.length) {
6311
+ const codePoint = value.codePointAt(index);
6312
+ if (codePoint === undefined) break;
6313
+ const character = String.fromCodePoint(codePoint);
6314
+ const next = utf8Bytes(character);
6315
+ if (bytes + next > maxBytes) break;
6316
+ bytes += next;
6317
+ index += character.length;
6318
+ }
6319
+ return value.slice(0, index);
6320
+ }
6321
+
6322
+ function utf8SuffixForResult(value: string, maxBytes: number): string {
6323
+ let bytes = 0;
6324
+ let index = value.length;
6325
+ while (index > 0) {
6326
+ const width =
6327
+ index > 1 && value.charCodeAt(index - 1) >= 0xdc00 && value.charCodeAt(index - 1) <= 0xdfff
6328
+ ? 2
6329
+ : 1;
6330
+ const character = value.slice(index - width, index);
6331
+ const next = utf8Bytes(character);
6332
+ if (bytes + next > maxBytes) break;
6333
+ bytes += next;
6334
+ index -= width;
6335
+ }
6336
+ return value.slice(index);
6337
+ }
6338
+
6339
+ // --- Durable host export ------------------------------------------------------
6340
+
6341
+ /** Wire revision for the durable host event/usage export stream. */
6342
+ export const OPENGENI_HOST_EXPORT_SCHEMA_REVISION = "2026-07-host-export-v1" as const;
6343
+
6344
+ /**
6345
+ * Decimal string rather than a JavaScript number: export cursors are PostgreSQL
6346
+ * bigint values and must remain exact beyond Number.MAX_SAFE_INTEGER.
6347
+ */
6348
+ export const HostExportCursor = z.string().regex(/^(0|[1-9][0-9]*)$/);
6349
+ export type HostExportCursor = z.infer<typeof HostExportCursor>;
6350
+
6351
+ export const HostExportConsumerId = z
6352
+ .string()
6353
+ .min(1)
6354
+ .max(128)
6355
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
6356
+ export type HostExportConsumerId = z.infer<typeof HostExportConsumerId>;
6357
+
6358
+ export const HostExportInitiator = TurnInitiator.extend({
6359
+ subjectId: z.string().min(1).max(1024),
6360
+ label: z.string().min(1).max(256).optional(),
6361
+ });
6362
+ export type HostExportInitiator = z.infer<typeof HostExportInitiator>;
6363
+
6364
+ export const HostExportInitiatorContext = TurnInitiatorContext.refine(
6365
+ (value) => {
6366
+ try {
6367
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength <= 4096;
6368
+ } catch {
6369
+ return false;
6370
+ }
6371
+ },
6372
+ { message: "Host export initiator context exceeds 4096 UTF-8 bytes" },
6373
+ );
6374
+ export type HostExportInitiatorContext = z.infer<typeof HostExportInitiatorContext>;
6375
+
6376
+ const HostExportAttribution = {
6377
+ initiator: HostExportInitiator.nullable(),
6378
+ initiatorContext: HostExportInitiatorContext,
6379
+ origin: SessionTurnSource.nullable(),
6380
+ } as const;
6381
+
6382
+ /**
6383
+ * Host streams are deliberately forward-tolerant across rolling upgrades.
6384
+ * OpenGeni's application contract enumerates the event types known to this
6385
+ * build, while the durable export may be read by an older host consumer after
6386
+ * a newer writer has committed a bounded type. The database remains the
6387
+ * authority for the byte bounds on these persisted strings.
6388
+ */
6389
+ export const HostSessionEvent = SessionEvent.extend({
6390
+ type: z.string().min(1).max(256),
6391
+ clientEventId: z.string().max(1024).nullable().optional(),
6392
+ turnAssociation: z.string().min(1).max(64).nullable().optional(),
6393
+ duplicateReason: z.string().max(4096).nullable().optional(),
6394
+ });
6395
+ export type HostSessionEvent = z.infer<typeof HostSessionEvent>;
6396
+
6397
+ /** Export-bounded usage fact; custom bounded metric names remain supported. */
6398
+ export const HostUsageEvent = UsageEvent.extend({
6399
+ subjectId: z.string().max(1024).nullable(),
6400
+ eventType: z.string().min(1).max(256),
6401
+ unit: z.string().min(1).max(128),
6402
+ sourceResourceType: z.string().max(256).nullable(),
6403
+ sourceResourceId: z.string().max(2048).nullable(),
6404
+ idempotencyKey: z.string().min(1).max(2048),
6405
+ billingProviderEventId: z.string().max(2048).nullable(),
6406
+ });
6407
+ export type HostUsageEvent = z.infer<typeof HostUsageEvent>;
6408
+
6409
+ /**
6410
+ * One immutable, bounded session-event snapshot from the transactional host
6411
+ * outbox. Cross-session cursor order is stable but deliberately non-causal;
6412
+ * within a session, `event.sequence` remains authoritative and monotonic.
6413
+ */
6414
+ export const HostEventExport = z.object({
6415
+ schemaRevision: z.literal(OPENGENI_HOST_EXPORT_SCHEMA_REVISION),
6416
+ cursor: HostExportCursor,
6417
+ idempotencyKey: z.string().min(1).max(2048),
6418
+ accountId: z.string().uuid(),
6419
+ workspaceId: z.string().uuid(),
6420
+ /**
6421
+ * Immutable root of event.sessionId's session lineage at capture time. Null
6422
+ * only for an unresolved pre-lineage/legacy export row.
6423
+ */
6424
+ rootSessionId: z.string().uuid().nullable(),
6425
+ ...HostExportAttribution,
6426
+ event: HostSessionEvent,
6427
+ });
6428
+ export type HostEventExport = z.infer<typeof HostEventExport>;
6429
+
6430
+ /** One exact, idempotency-keyed usage fact from the same ordered outbox. */
6431
+ export const HostUsageExport = z.object({
6432
+ schemaRevision: z.literal(OPENGENI_HOST_EXPORT_SCHEMA_REVISION),
6433
+ cursor: HostExportCursor,
6434
+ accountId: z.string().uuid(),
6435
+ workspaceId: z.string().uuid(),
6436
+ sessionId: z.string().uuid().nullable(),
6437
+ /** Null when sessionId is null or an unresolved pre-lineage legacy row. */
6438
+ rootSessionId: z.string().uuid().nullable(),
6439
+ turnId: z.string().uuid().nullable(),
6440
+ turnAttemptId: z.string().uuid().nullable(),
6441
+ ...HostExportAttribution,
6442
+ usage: HostUsageEvent,
6443
+ });
6444
+ export type HostUsageExport = z.infer<typeof HostUsageExport>;
6445
+
6446
+ export const HostEventExportBatch = z.object({
6447
+ schemaRevision: z.literal(OPENGENI_HOST_EXPORT_SCHEMA_REVISION),
6448
+ consumerId: HostExportConsumerId,
6449
+ leaseToken: z.string().uuid(),
6450
+ checkpoint: HostExportCursor,
6451
+ throughCursor: HostExportCursor,
6452
+ events: z.array(HostEventExport).min(1).max(256),
6453
+ });
6454
+ export type HostEventExportBatch = z.infer<typeof HostEventExportBatch>;
6455
+
6456
+ export const HostUsageExportBatch = z.object({
6457
+ schemaRevision: z.literal(OPENGENI_HOST_EXPORT_SCHEMA_REVISION),
6458
+ consumerId: HostExportConsumerId,
6459
+ leaseToken: z.string().uuid(),
6460
+ checkpoint: HostExportCursor,
6461
+ throughCursor: HostExportCursor,
6462
+ events: z.array(HostUsageExport).min(1).max(256),
6463
+ });
6464
+ export type HostUsageExportBatch = z.infer<typeof HostUsageExportBatch>;
6465
+
6466
+ /**
6467
+ * Optional embedded-host sinks. Delivery is at least once: the same batch may
6468
+ * be repeated after a process dies between sink success and checkpoint commit,
6469
+ * so sinks must deduplicate by event/usage idempotency key.
6470
+ */
6471
+ export type HostEventSink = {
6472
+ consumerId: HostExportConsumerId;
6473
+ deliverEvents: (batch: HostEventExportBatch) => Promise<void>;
6474
+ };
6475
+
6476
+ export type HostUsageSink = {
6477
+ consumerId: HostExportConsumerId;
6478
+ deliverUsage: (batch: HostUsageExportBatch) => Promise<void>;
6479
+ };
6480
+
6481
+ export const SESSION_EVENT_TYPE_MAX_BYTES = 256;
6482
+ export const SESSION_EVENT_CLIENT_EVENT_ID_MAX_BYTES = SESSION_OPERATION_KEY_MAX_CHARS * 4;
6483
+ export const SESSION_EVENT_TURN_ASSOCIATION_MAX_BYTES = 64;
6484
+ export const SESSION_EVENT_DUPLICATE_REASON_MAX_BYTES = 4 * 1024;
6485
+ export const SESSION_EVENT_ENVELOPE_MAX_BYTES = 80 * 1024;
6486
+
6487
+ export type BoundSessionEventOptions = {
6488
+ surface?: SessionEventBoundarySurface;
6489
+ maxBytes?: number;
6490
+ };
6491
+
6492
+ /**
6493
+ * Canonical lossy projection for a complete session event. Payload bounds alone
6494
+ * are insufficient: a malformed retained row can also carry an oversized type,
6495
+ * client id, or duplicate diagnostic. Keep cursor/UUID identity intact, bound
6496
+ * every free-form envelope string, and assert the exact final JSON envelope.
6497
+ */
6498
+ export function boundSessionEvent(
6499
+ event: SessionEvent,
6500
+ options: BoundSessionEventOptions = {},
6501
+ ): SessionEvent {
6502
+ const surface = options.surface ?? "durable_audit";
6503
+ const maxBytes = Math.max(8 * 1024, options.maxBytes ?? SESSION_EVENT_ENVELOPE_MAX_BYTES);
6504
+ // Never stringify the untrusted complete event. Measurement has one global
6505
+ // work budget and never invokes accessors/custom toJSON; serialization is
6506
+ // permitted only after the compact projection below has been constructed.
6507
+ const originalBytes = measureSessionEventJson(event).bytes;
6508
+ const source = sessionEventOwnDataFields(event);
6509
+ const id = canonicalSessionEventUuid(source.id, SESSION_EVENT_ZERO_UUID);
6510
+ const workspaceId = canonicalSessionEventUuid(source.workspaceId, SESSION_EVENT_ZERO_UUID);
6511
+ const sessionId = canonicalSessionEventUuid(source.sessionId, SESSION_EVENT_ZERO_UUID);
6512
+ const sequence =
6513
+ source.sequence.readable &&
6514
+ typeof source.sequence.value === "number" &&
6515
+ Number.isSafeInteger(source.sequence.value) &&
6516
+ source.sequence.value > 0
6517
+ ? source.sequence.value
6518
+ : 1;
6519
+ const occurredAt =
6520
+ source.occurredAt.readable &&
6521
+ typeof source.occurredAt.value === "string" &&
6522
+ sessionEventUtf8Bytes(source.occurredAt.value) <= 256
6523
+ ? source.occurredAt.value
6524
+ : "1970-01-01T00:00:00.000Z";
6525
+ const rawType = source.type.readable ? source.type.value : undefined;
6526
+ const typeIsSafe =
6527
+ typeof rawType === "string" &&
6528
+ sessionEventUtf8Bytes(rawType) <= SESSION_EVENT_TYPE_MAX_BYTES &&
6529
+ !rawType.includes("\n") &&
6530
+ !rawType.includes("\r");
6531
+ const rawClientEventId = source.clientEventId.readable ? source.clientEventId.value : undefined;
6532
+ const clientEventId = boundOptionalSessionEventText(
6533
+ typeof rawClientEventId === "string" || rawClientEventId === null
6534
+ ? rawClientEventId
6535
+ : undefined,
6536
+ SESSION_EVENT_CLIENT_EVENT_ID_MAX_BYTES,
6537
+ );
6538
+ const rawTurnAssociation = source.turnAssociation.readable
6539
+ ? source.turnAssociation.value
6540
+ : undefined;
6541
+ const turnAssociation =
6542
+ rawTurnAssociation === null ||
6543
+ rawTurnAssociation === undefined ||
6544
+ rawTurnAssociation === "current" ||
6545
+ rawTurnAssociation === "late_rejected" ||
6546
+ rawTurnAssociation === "duplicate"
6547
+ ? rawTurnAssociation
6548
+ : null;
6549
+ const rawDuplicateReason = source.duplicateReason.readable
6550
+ ? source.duplicateReason.value
6551
+ : undefined;
6552
+ const duplicateReason = boundOptionalSessionEventText(
6553
+ typeof rawDuplicateReason === "string" || rawDuplicateReason === null
6554
+ ? rawDuplicateReason
6555
+ : undefined,
6556
+ SESSION_EVENT_DUPLICATE_REASON_MAX_BYTES,
6557
+ );
6558
+ const turnId = canonicalOptionalSessionEventUuid(source.turnId);
6559
+ const turnGeneration = canonicalSessionEventGeneration(source.turnGeneration);
6560
+ const turnAttemptId = canonicalOptionalSessionEventUuid(source.turnAttemptId);
6561
+ const duplicateOfEventId = canonicalOptionalSessionEventUuid(source.duplicateOfEventId);
6562
+ const envelopeFields = [
6563
+ sessionEventCustomSerializerProjection(event),
6564
+ sessionEventAdditionalTopLevelFieldProjection(event),
6565
+ !typeIsSafe
6566
+ ? sessionEventEnvelopeFieldProjection(
6567
+ "type",
6568
+ rawType,
6569
+ "session.event.envelope_omitted",
6570
+ source.type.readable,
6571
+ )
6572
+ : null,
6573
+ !source.clientEventId.readable || rawClientEventId !== clientEventId
6574
+ ? sessionEventEnvelopeFieldProjection(
6575
+ "clientEventId",
6576
+ rawClientEventId,
6577
+ clientEventId,
6578
+ source.clientEventId.readable,
6579
+ )
6580
+ : null,
6581
+ !source.turnAssociation.readable || rawTurnAssociation !== turnAssociation
6582
+ ? sessionEventEnvelopeFieldProjection(
6583
+ "turnAssociation",
6584
+ rawTurnAssociation,
6585
+ turnAssociation,
6586
+ source.turnAssociation.readable,
6587
+ )
6588
+ : null,
6589
+ !source.duplicateReason.readable || rawDuplicateReason !== duplicateReason
6590
+ ? sessionEventEnvelopeFieldProjection(
6591
+ "duplicateReason",
6592
+ rawDuplicateReason,
6593
+ duplicateReason,
6594
+ source.duplicateReason.readable,
6595
+ )
6596
+ : null,
6597
+ ...sessionEventCanonicalFieldProjections(source, {
6598
+ id,
6599
+ workspaceId,
6600
+ sessionId,
6601
+ sequence,
6602
+ occurredAt,
6603
+ }),
6604
+ ...sessionEventOptionalFieldProjections(source, {
6605
+ turnId,
6606
+ turnGeneration,
6607
+ turnAttemptId,
6608
+ duplicateOfEventId,
6609
+ }),
6610
+ !source.payload.readable
6611
+ ? sessionEventEnvelopeFieldProjection("payload", undefined, null, false)
6612
+ : null,
6613
+ ].filter((field) => field !== null);
6614
+ const rawPayload = source.payload.readable
6615
+ ? source.payload.value
6616
+ : "[event payload accessor omitted at bounded projection boundary]";
6617
+ const payload =
6618
+ envelopeFields.length === 0
6619
+ ? boundSessionEventPayload(rawPayload, { surface })
6620
+ : boundSessionEventPayload(
6621
+ {
6622
+ preview: "[legacy event envelope normalized at bounded projection boundary]",
6623
+ originalEventBytes: originalBytes,
6624
+ originalType: typeof rawType === "string" ? boundSessionEventText(rawType, 256) : null,
6625
+ envelopeProjection: {
6626
+ truncated: true,
6627
+ surface,
6628
+ fields: envelopeFields,
6629
+ },
6630
+ fullEvidence: { available: false, reason: "not_retained" },
6631
+ },
6632
+ { surface, maxBytes: 8 * 1024 },
6633
+ );
6634
+ const bounded: SessionEvent = {
6635
+ id,
6636
+ workspaceId,
6637
+ sessionId,
6638
+ sequence,
6639
+ type: typeIsSafe ? (rawType as SessionEvent["type"]) : "session.event.envelope_omitted",
6640
+ payload,
6641
+ occurredAt,
6642
+ ...(sessionEventShouldEmitOptionalField(source.clientEventId) ? { clientEventId } : {}),
6643
+ ...(sessionEventShouldEmitOptionalField(source.turnId) ? { turnId } : {}),
6644
+ ...(sessionEventShouldEmitOptionalField(source.turnGeneration) ? { turnGeneration } : {}),
6645
+ ...(sessionEventShouldEmitOptionalField(source.turnAttemptId) ? { turnAttemptId } : {}),
6646
+ ...(sessionEventShouldEmitOptionalField(source.turnAssociation) ? { turnAssociation } : {}),
6647
+ ...(sessionEventShouldEmitOptionalField(source.duplicateOfEventId)
6648
+ ? { duplicateOfEventId }
6649
+ : {}),
6650
+ ...(sessionEventShouldEmitOptionalField(source.duplicateReason) ? { duplicateReason } : {}),
6651
+ };
6652
+ if (sessionEventJsonBytes(bounded) <= maxBytes) return bounded;
6653
+
6654
+ const fallback: SessionEvent = {
6655
+ id,
6656
+ workspaceId,
6657
+ sessionId,
6658
+ sequence,
6659
+ type: "session.event.envelope_omitted",
6660
+ payload: boundSessionEventPayload(
6661
+ {
6662
+ preview: "[legacy event envelope omitted at bounded projection boundary]",
6663
+ originalEventBytes: originalBytes,
6664
+ originalType: typeof rawType === "string" ? boundSessionEventText(rawType, 256) : null,
6665
+ fullEvidence: { available: false, reason: "not_retained" },
6666
+ },
6667
+ { surface, maxBytes: 4 * 1024 },
6668
+ ),
6669
+ occurredAt,
6670
+ ...(sessionEventShouldEmitOptionalField(source.clientEventId) ? { clientEventId } : {}),
6671
+ ...(sessionEventShouldEmitOptionalField(source.turnId) ? { turnId } : {}),
6672
+ ...(sessionEventShouldEmitOptionalField(source.turnGeneration) ? { turnGeneration } : {}),
6673
+ ...(sessionEventShouldEmitOptionalField(source.turnAttemptId) ? { turnAttemptId } : {}),
6674
+ ...(sessionEventShouldEmitOptionalField(source.turnAssociation) ? { turnAssociation } : {}),
6675
+ ...(sessionEventShouldEmitOptionalField(source.duplicateOfEventId)
6676
+ ? { duplicateOfEventId }
6677
+ : {}),
6678
+ ...(sessionEventShouldEmitOptionalField(source.duplicateReason) ? { duplicateReason } : {}),
6679
+ };
6680
+ const deliveredBytes = sessionEventJsonBytes(fallback);
6681
+ if (deliveredBytes > maxBytes) {
6682
+ throw new RangeError(
6683
+ `Bounded session event exceeds its final envelope (${deliveredBytes} > ${maxBytes} bytes)`,
6684
+ );
6685
+ }
6686
+ return fallback;
6687
+ }
6688
+
6689
+ function sessionEventEnvelopeFieldProjection(
6690
+ field: string,
6691
+ original: unknown,
6692
+ delivered: unknown,
6693
+ originalReadable = true,
6694
+ ): { field: string; originalBytes: number | null; deliveredBytes: number } {
6695
+ return {
6696
+ field,
6697
+ originalBytes: originalReadable
6698
+ ? typeof original === "string"
6699
+ ? sessionEventUtf8Bytes(original)
6700
+ : typeof original === "number" || typeof original === "boolean"
6701
+ ? sessionEventJsonBytes(original)
6702
+ : original === null || original === undefined
6703
+ ? 0
6704
+ : null
6705
+ : null,
6706
+ deliveredBytes:
6707
+ typeof delivered === "string"
6708
+ ? sessionEventUtf8Bytes(delivered)
6709
+ : typeof delivered === "number" || typeof delivered === "boolean"
6710
+ ? sessionEventJsonBytes(delivered)
6711
+ : 0,
6712
+ };
6713
+ }
6714
+
6715
+ function sessionEventCustomSerializerProjection(
6716
+ event: SessionEvent,
6717
+ ): { field: string; originalBytes: null; deliveredBytes: 0 } | null {
6718
+ const projection = {
6719
+ field: "toJSON",
6720
+ originalBytes: null,
6721
+ deliveredBytes: 0,
6722
+ } as const;
6723
+ let candidate: object | null = event;
6724
+ try {
6725
+ for (let depth = 0; depth <= SESSION_EVENT_PROTOTYPE_MAX_DEPTH; depth += 1) {
6726
+ if (candidate === null) return null;
6727
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, "toJSON");
6728
+ if (descriptor) {
6729
+ // JSON.stringify performs an ordinary lookup, so an accessor is both
6730
+ // executable behavior and an unknown possible serializer. A data
6731
+ // property shadows the rest of the chain and is relevant only when it
6732
+ // is callable.
6733
+ return !("value" in descriptor) || typeof descriptor.value === "function"
6734
+ ? projection
6735
+ : null;
6736
+ }
6737
+ candidate = Object.getPrototypeOf(candidate);
6738
+ }
6739
+ // A hostile or malformed prototype chain that exceeds the fixed lookup
6740
+ // budget cannot prove the absence of inherited serialization behavior.
6741
+ return projection;
6742
+ } catch {
6743
+ return projection;
6744
+ }
6745
+ }
6746
+
6747
+ const SESSION_EVENT_PROTOTYPE_MAX_DEPTH = 32;
6748
+ const SESSION_EVENT_ZERO_UUID = "00000000-0000-4000-8000-000000000000";
6749
+ const SESSION_EVENT_UUID_PATTERN =
6750
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6751
+
6752
+ const SESSION_EVENT_OWN_DATA_FIELDS = [
6753
+ "id",
6754
+ "workspaceId",
6755
+ "sessionId",
6756
+ "sequence",
6757
+ "type",
6758
+ "payload",
6759
+ "occurredAt",
6760
+ "clientEventId",
6761
+ "turnId",
6762
+ "turnGeneration",
6763
+ "turnAttemptId",
6764
+ "turnAssociation",
6765
+ "duplicateOfEventId",
6766
+ "duplicateReason",
6767
+ ] as const satisfies readonly (keyof SessionEvent)[];
6768
+ const SESSION_EVENT_KNOWN_ENUMERABLE_FIELDS = new Set<string>([
6769
+ ...SESSION_EVENT_OWN_DATA_FIELDS,
6770
+ "toJSON",
6771
+ ]);
6772
+
6773
+ /**
6774
+ * Detect future/legacy own enumerable envelope fields without reading their
6775
+ * values. There can be at most the fixed known-key cardinality before an
6776
+ * additional key must be observed, so the source-level iterator is bounded.
6777
+ * A proxy/enumeration failure is conservatively surfaced as unknown loss.
6778
+ */
6779
+ function sessionEventAdditionalTopLevelFieldProjection(
6780
+ event: SessionEvent,
6781
+ ): { field: string; originalBytes: null; deliveredBytes: 0 } | null {
6782
+ const projection = {
6783
+ field: "additionalTopLevelFields",
6784
+ originalBytes: null,
6785
+ deliveredBytes: 0,
6786
+ } as const;
6787
+ let inspected = 0;
6788
+ try {
6789
+ for (const key in event as SessionEvent & Record<string, unknown>) {
6790
+ inspected += 1;
6791
+ if (inspected > SESSION_EVENT_KNOWN_ENUMERABLE_FIELDS.size + 1) return projection;
6792
+ const descriptor = Object.getOwnPropertyDescriptor(event, key);
6793
+ if (descriptor?.enumerable && !SESSION_EVENT_KNOWN_ENUMERABLE_FIELDS.has(key)) {
6794
+ return projection;
6795
+ }
6796
+ }
6797
+ return null;
6798
+ } catch {
6799
+ return projection;
6800
+ }
6801
+ }
6802
+
6803
+ type SessionEventOwnField = { readable: true; value: unknown } | { readable: false };
6804
+ type SessionEventOwnDataFields = Record<keyof SessionEvent, SessionEventOwnField>;
6805
+
6806
+ function sessionEventOwnDataFields(event: SessionEvent): SessionEventOwnDataFields {
6807
+ return Object.fromEntries(
6808
+ SESSION_EVENT_OWN_DATA_FIELDS.map((key) => {
6809
+ try {
6810
+ const descriptor = Object.getOwnPropertyDescriptor(event, key);
6811
+ if (!descriptor) return [key, { readable: true, value: undefined }];
6812
+ return [
6813
+ key,
6814
+ "value" in descriptor ? { readable: true, value: descriptor.value } : { readable: false },
6815
+ ];
6816
+ } catch {
6817
+ return [key, { readable: false }];
6818
+ }
6819
+ }),
6820
+ ) as SessionEventOwnDataFields;
6821
+ }
6822
+
6823
+ function canonicalSessionEventUuid(field: SessionEventOwnField, fallback: string): string {
6824
+ return field.readable &&
6825
+ typeof field.value === "string" &&
6826
+ SESSION_EVENT_UUID_PATTERN.test(field.value)
6827
+ ? field.value
6828
+ : fallback;
6829
+ }
6830
+
6831
+ function canonicalOptionalSessionEventUuid(field: SessionEventOwnField): string | null {
6832
+ return field.readable &&
6833
+ typeof field.value === "string" &&
6834
+ SESSION_EVENT_UUID_PATTERN.test(field.value)
6835
+ ? field.value
6836
+ : null;
6837
+ }
6838
+
6839
+ function canonicalSessionEventGeneration(field: SessionEventOwnField): number | null {
6840
+ return field.readable &&
6841
+ typeof field.value === "number" &&
6842
+ Number.isSafeInteger(field.value) &&
6843
+ field.value >= 0
6844
+ ? field.value
6845
+ : null;
6846
+ }
6847
+
6848
+ function sessionEventShouldEmitOptionalField(field: SessionEventOwnField): boolean {
6849
+ return !field.readable || field.value !== undefined;
6850
+ }
6851
+
6852
+ function sessionEventCanonicalFieldProjections(
6853
+ source: SessionEventOwnDataFields,
6854
+ delivered: {
6855
+ id: string;
6856
+ workspaceId: string;
6857
+ sessionId: string;
6858
+ sequence: number;
6859
+ occurredAt: string;
6860
+ },
6861
+ ): Array<{
6862
+ field: string;
6863
+ originalBytes: number | null;
6864
+ deliveredBytes: number;
6865
+ }> {
6866
+ return (["id", "workspaceId", "sessionId", "sequence", "occurredAt"] as const).flatMap(
6867
+ (field) => {
6868
+ const original = source[field].readable ? source[field].value : undefined;
6869
+ return source[field].readable && original === delivered[field]
6870
+ ? []
6871
+ : [
6872
+ sessionEventEnvelopeFieldProjection(
6873
+ field,
6874
+ original,
6875
+ delivered[field],
6876
+ source[field].readable,
6877
+ ),
6878
+ ];
6879
+ },
6880
+ );
6881
+ }
6882
+
6883
+ function sessionEventOptionalFieldProjections(
6884
+ source: SessionEventOwnDataFields,
6885
+ delivered: {
6886
+ turnId: string | null;
6887
+ turnGeneration: number | null;
6888
+ turnAttemptId: string | null;
6889
+ duplicateOfEventId: string | null;
6890
+ },
6891
+ ): Array<{
6892
+ field: string;
6893
+ originalBytes: number | null;
6894
+ deliveredBytes: number;
6895
+ }> {
6896
+ return (["turnId", "turnGeneration", "turnAttemptId", "duplicateOfEventId"] as const).flatMap(
6897
+ (field) => {
6898
+ const original = source[field].readable ? source[field].value : undefined;
6899
+ const canonicalOriginal = original ?? null;
6900
+ return source[field].readable && canonicalOriginal === delivered[field]
6901
+ ? []
6902
+ : [
6903
+ sessionEventEnvelopeFieldProjection(
6904
+ field,
6905
+ original,
6906
+ delivered[field],
6907
+ source[field].readable,
6908
+ ),
6909
+ ];
6910
+ },
6911
+ );
6912
+ }
6913
+
6914
+ function boundOptionalSessionEventText<T extends string | null | undefined>(
6915
+ value: T,
6916
+ maxBytes: number,
6917
+ ): T {
6918
+ return (typeof value === "string" ? boundSessionEventText(value, maxBytes) : value) as T;
6919
+ }
6920
+
6921
+ function boundSessionEventText(value: string, maxBytes: number): string {
6922
+ const encoder = new TextEncoder();
6923
+ const decoder = new TextDecoder();
6924
+ const bytes = encoder.encode(value);
6925
+ if (bytes.byteLength <= maxBytes) return value;
6926
+ const marker = "…[truncated]";
6927
+ const markerBytes = encoder.encode(marker).byteLength;
6928
+ const prefixBudget = Math.max(0, maxBytes - markerBytes);
6929
+ let prefixEnd = Math.min(prefixBudget, bytes.byteLength);
6930
+ while (prefixEnd > 0 && prefixEnd < bytes.byteLength && (bytes[prefixEnd]! & 0xc0) === 0x80) {
6931
+ prefixEnd -= 1;
6932
+ }
6933
+ return `${decoder.decode(bytes.subarray(0, prefixEnd))}${marker}`;
6934
+ }
6935
+
6936
+ function sessionEventUtf8Bytes(value: string): number {
6937
+ return new TextEncoder().encode(value).byteLength;
6938
+ }
6939
+
4079
6940
  export const SessionQueueMutationResponse = z.object({
6941
+ receipt: SessionCommandReceipt,
4080
6942
  snapshot: SessionQueueSnapshot,
4081
- events: z.array(SessionEvent),
4082
- shouldWake: z.boolean(),
6943
+ draft: ComposerDraft.optional(),
4083
6944
  });
4084
6945
  export type SessionQueueMutationResponse = z.infer<typeof SessionQueueMutationResponse>;
4085
6946
 
4086
6947
  export const SessionControlResponse = z.object({
4087
- operationId: z.string().uuid(),
4088
- event: SessionEvent,
4089
- controlState: SessionControlState,
4090
- controlGeneration: z.number().int().nonnegative(),
4091
- expectedActiveTurnId: z.string().uuid().nullable(),
4092
- expectedExecutionGeneration: z.number().int().nonnegative().nullable(),
4093
- expectedAttemptId: z.string().uuid().nullable(),
4094
- deliveryEventId: z.string().uuid().nullable(),
4095
- shouldSignalControl: z.boolean(),
4096
- shouldWake: z.boolean(),
6948
+ receipt: SessionCommandReceipt,
6949
+ effectiveControl: EffectiveSessionControl,
6950
+ interruptionCount: z.number().int().nonnegative(),
6951
+ wakeCount: z.number().int().nonnegative(),
4097
6952
  });
4098
6953
  export type SessionControlResponse = z.infer<typeof SessionControlResponse>;
4099
6954
 
4100
6955
  export const CreateSessionRequest = withVariableSetIdAlias({
6956
+ /**
6957
+ * Optional UUID preallocated by an embedding host. This lets the host durably
6958
+ * link its own projection before OpenGeni admits the initial turn. Replays
6959
+ * must pair it with the same idempotency key; OpenGeni never derives host
6960
+ * identity or authorization from the UUID.
6961
+ */
6962
+ requestedSessionId: z.string().uuid().optional(),
4101
6963
  initialMessage: z.string().min(1),
6964
+ // System-level host context for the initial turn only. Unlike `instructions`,
6965
+ // this does not persist into later turns and is never emitted as a user event.
6966
+ turnInstructions: z.string().trim().min(1).max(32768).optional(),
4102
6967
  // Per-session agent persona/system instructions (org-visible metadata, NOT a
4103
6968
  // secret). Rides the SAME system-level instructions channel the per-workspace
4104
6969
  // agentInstructions rides, composed AFTER the workspace persona so it refines
@@ -4108,7 +6973,14 @@ export const CreateSessionRequest = withVariableSetIdAlias({
4108
6973
  // matches the codebase's largest free-form string convention (workspace
4109
6974
  // variable set variable values). Absent ⇒ byte-identical to today.
4110
6975
  instructions: z.string().trim().min(1).max(32768).optional(),
6976
+ // For an agent-created child, omission inherits the trusted immediate
6977
+ // parent's repository/file context. An explicit array, including [], is
6978
+ // authoritative. Top-level omission remains []. Presence is resolved from
6979
+ // the raw request because this Zod default erases absent-vs-empty.
4111
6980
  resources: z.array(ResourceRef).default([]),
6981
+ // The same child omission rule applies to selected MCP tool refs. Top-level
6982
+ // omission still applies workspace-default capability MCP tools; explicit []
6983
+ // suppresses those defaults (the first-party OpenGeni server remains added).
4112
6984
  tools: z.array(ToolRef).default([]),
4113
6985
  metadata: z.record(z.string(), z.unknown()).default({}),
4114
6986
  model: z.string().min(1).optional(),
@@ -4135,7 +7007,7 @@ export const CreateSessionRequest = withVariableSetIdAlias({
4135
7007
  // behavior). An id that does not name a rig in the workspace is a 422.
4136
7008
  rigId: z.string().uuid().optional(),
4137
7009
  goal: GoalSpec.optional(),
4138
- clientEventId: z.string().min(1).optional(),
7010
+ clientEventId: SessionOperationKey.optional(),
4139
7011
  // Workspace-scoped CREATE idempotency key: collapses concurrent/retried
4140
7012
  // create calls carrying the same key to a single session (partial unique
4141
7013
  // index on (workspace_id, create_idempotency_key)). Distinct from
@@ -4143,14 +7015,20 @@ export const CreateSessionRequest = withVariableSetIdAlias({
4143
7015
  // creation of a brand-new session. Absent means no create-dedup (each call
4144
7016
  // is an independent create).
4145
7017
  idempotencyKey: z.string().min(1).max(200).optional(),
4146
- // Permissions the session's first-party MCP token should carry instead of
4147
- // the fixed worker default how an operator hands a manager-style session
4148
- // the orchestration/variableSet/github tools. Capped at creation: every
4149
- // requested permission must be held by the creating grant (no escalation).
7018
+ // Permissions the session's first-party MCP token should carry. A top-level
7019
+ // omission uses the deployment's worker default; a child omission inherits
7020
+ // the creating session's effective grant. An explicit set is capped at
7021
+ // creation: every requested permission must be held by the creating grant.
7022
+ // A goal-bearing session whose explicit/effective set omits goals:manage is
7023
+ // rejected; creation never silently expands a child beyond that set.
4150
7024
  firstPartyMcpPermissions: z.array(Permission).optional(),
4151
- // Third-party MCP servers attached only to this session. Credential headers are
4152
- // write-only: create responses and events expose only SessionMcpServerMetadata.
4153
- mcpServers: z.array(SessionMcpServerInput).default([]),
7025
+ // Third-party MCP servers attached only to this session. For an agent-created
7026
+ // child, omission snapshots its trusted immediate parent's server definitions,
7027
+ // policies, connection refs, and encrypted credentials. Explicit arrays,
7028
+ // including [], are authoritative; non-empty explicit arrays require attach
7029
+ // permission. Credential headers are write-only: create responses and events
7030
+ // expose only SessionMcpServerMetadata.
7031
+ mcpServers: z.array(SessionMcpServerInput).max(SESSION_MCP_SERVERS_MAX).default([]),
4154
7032
  // Shared-sandbox placement (addendum 05 §D.1). Three-way union; OMITTED ⇒
4155
7033
  // today's behavior (a context-dependent default resolved server-side: from
4156
7034
  // inside a session → "shared" with the creator's box, top-level → "new").
@@ -4174,16 +7052,198 @@ export const CreateSessionRequest = withVariableSetIdAlias({
4174
7052
  });
4175
7053
  export type CreateSessionRequest = z.infer<typeof CreateSessionRequest>;
4176
7054
 
7055
+ // Generic, host-neutral structured human input. One model tool call creates one
7056
+ // request containing one or more questions; the durable response resumes that
7057
+ // exact call. This is deliberately distinct from tool approval: an answer,
7058
+ // skip, or expiry is structured tool output, never an approve/reject decision.
7059
+ export const HumanInputQuestionKind = z.enum(["text", "single_select", "multi_select"]);
7060
+ export type HumanInputQuestionKind = z.infer<typeof HumanInputQuestionKind>;
7061
+
7062
+ export const HumanInputOption = z.object({
7063
+ id: z.string().min(1).max(64),
7064
+ label: z.string().min(1).max(256),
7065
+ description: z.string().max(2048).nullable().optional(),
7066
+ });
7067
+ export type HumanInputOption = z.infer<typeof HumanInputOption>;
7068
+
7069
+ export const HumanInputQuestion = z
7070
+ .object({
7071
+ id: z.string().min(1).max(64),
7072
+ kind: HumanInputQuestionKind,
7073
+ prompt: z.string().min(1).max(4096),
7074
+ label: z.string().min(1).max(128).nullable().optional(),
7075
+ helpText: z.string().max(2048).nullable().optional(),
7076
+ options: z.array(HumanInputOption).max(20).default([]),
7077
+ required: z.boolean().default(true),
7078
+ allowOther: z.boolean().default(false),
7079
+ validation: z
7080
+ .object({
7081
+ minLength: z.number().int().nonnegative().max(8192).nullable().optional(),
7082
+ maxLength: z.number().int().positive().max(8192).nullable().optional(),
7083
+ minSelections: z.number().int().nonnegative().max(20).nullable().optional(),
7084
+ maxSelections: z.number().int().positive().max(20).nullable().optional(),
7085
+ })
7086
+ .nullable()
7087
+ .optional(),
7088
+ })
7089
+ .superRefine((question, ctx) => {
7090
+ const optionIds = new Set(question.options.map((option) => option.id));
7091
+ if (optionIds.size !== question.options.length) {
7092
+ ctx.addIssue({
7093
+ code: "custom",
7094
+ path: ["options"],
7095
+ message: "option ids must be unique",
7096
+ });
7097
+ }
7098
+ if (question.kind === "text") {
7099
+ if (question.options.length > 0) {
7100
+ ctx.addIssue({
7101
+ code: "custom",
7102
+ path: ["options"],
7103
+ message: "text questions cannot have options",
7104
+ });
7105
+ }
7106
+ if (question.allowOther) {
7107
+ ctx.addIssue({
7108
+ code: "custom",
7109
+ path: ["allowOther"],
7110
+ message: "text questions do not use Other",
7111
+ });
7112
+ }
7113
+ } else if (question.options.length === 0) {
7114
+ ctx.addIssue({
7115
+ code: "custom",
7116
+ path: ["options"],
7117
+ message: "select questions require options",
7118
+ });
7119
+ }
7120
+ const validation = question.validation;
7121
+ if (
7122
+ validation?.minLength != null &&
7123
+ validation?.maxLength != null &&
7124
+ validation.minLength > validation.maxLength
7125
+ ) {
7126
+ ctx.addIssue({
7127
+ code: "custom",
7128
+ path: ["validation"],
7129
+ message: "minLength exceeds maxLength",
7130
+ });
7131
+ }
7132
+ if (
7133
+ validation?.minSelections != null &&
7134
+ validation?.maxSelections != null &&
7135
+ validation.minSelections > validation.maxSelections
7136
+ ) {
7137
+ ctx.addIssue({
7138
+ code: "custom",
7139
+ path: ["validation"],
7140
+ message: "minSelections exceeds maxSelections",
7141
+ });
7142
+ }
7143
+ });
7144
+ export type HumanInputQuestion = z.infer<typeof HumanInputQuestion>;
7145
+
7146
+ export const HumanInputRequestStatus = z.enum([
7147
+ "pending",
7148
+ "answered",
7149
+ "skipped",
7150
+ "expired",
7151
+ "cancelled",
7152
+ ]);
7153
+ export type HumanInputRequestStatus = z.infer<typeof HumanInputRequestStatus>;
7154
+
7155
+ export const RequestHumanInputToolInput = z.object({
7156
+ questions: z.array(HumanInputQuestion).min(1).max(20),
7157
+ allowSkip: z.boolean().default(false),
7158
+ expiresInSeconds: z
7159
+ .number()
7160
+ .int()
7161
+ .positive()
7162
+ .max(30 * 24 * 60 * 60)
7163
+ .nullable()
7164
+ .optional(),
7165
+ });
7166
+ export type RequestHumanInputToolInput = z.infer<typeof RequestHumanInputToolInput>;
7167
+
7168
+ export const HumanInputAnswer = z.object({
7169
+ questionId: z.string().min(1).max(64),
7170
+ values: z.array(z.string().max(8192)).max(20),
7171
+ other: z.string().max(8192).nullable().optional(),
7172
+ });
7173
+ export type HumanInputAnswer = z.infer<typeof HumanInputAnswer>;
7174
+
7175
+ export const HumanInputResponse = z.discriminatedUnion("outcome", [
7176
+ z.object({
7177
+ outcome: z.literal("answered"),
7178
+ answers: z.array(HumanInputAnswer).max(20),
7179
+ }),
7180
+ z.object({ outcome: z.literal("skipped") }),
7181
+ z.object({ outcome: z.literal("expired") }),
7182
+ z.object({ outcome: z.literal("cancelled") }),
7183
+ ]);
7184
+ export type HumanInputResponse = z.infer<typeof HumanInputResponse>;
7185
+
7186
+ export const SubmitHumanInputResponseRequest = z.discriminatedUnion("outcome", [
7187
+ z.object({
7188
+ outcome: z.literal("answered"),
7189
+ answers: z.array(HumanInputAnswer).max(20),
7190
+ }),
7191
+ z.object({ outcome: z.literal("skipped") }),
7192
+ ]);
7193
+ export type SubmitHumanInputResponseRequest = z.infer<typeof SubmitHumanInputResponseRequest>;
7194
+
7195
+ export const SessionHumanInputRequest = z.object({
7196
+ id: z.string().uuid(),
7197
+ workspaceId: z.string().uuid(),
7198
+ sessionId: z.string().uuid(),
7199
+ turnId: z.string().uuid(),
7200
+ turnGeneration: z.number().int().positive(),
7201
+ creationAttemptId: z.string().uuid(),
7202
+ toolCallId: z.string().min(1).max(1024),
7203
+ status: HumanInputRequestStatus,
7204
+ questions: z.array(HumanInputQuestion).min(1).max(20),
7205
+ allowSkip: z.boolean(),
7206
+ response: HumanInputResponse.nullable(),
7207
+ respondedBy: z.string().max(1024).nullable(),
7208
+ respondedAt: z.string().nullable(),
7209
+ expiresAt: z.string().nullable(),
7210
+ createdAt: z.string(),
7211
+ updatedAt: z.string(),
7212
+ });
7213
+ export type SessionHumanInputRequest = z.infer<typeof SessionHumanInputRequest>;
7214
+
7215
+ /**
7216
+ * Extract the stable approval identity used by both durable admission and
7217
+ * runtime resume. Serialized SDK interruptions may place it on the wrapper or
7218
+ * its raw item; malformed entries fail closed instead of inventing an id.
7219
+ */
7220
+ export function approvalIdentifier(value: unknown): string | null {
7221
+ if (!value || typeof value !== "object") return null;
7222
+ const approval = value as Record<string, unknown>;
7223
+ const rawItem =
7224
+ approval.rawItem && typeof approval.rawItem === "object"
7225
+ ? (approval.rawItem as Record<string, unknown>)
7226
+ : null;
7227
+ const candidate = rawItem?.callId ?? rawItem?.id ?? approval.id ?? approval.name;
7228
+ if (typeof candidate !== "string" && typeof candidate !== "number") return null;
7229
+ return String(candidate);
7230
+ }
7231
+
4177
7232
  export const ClientSessionEvent = z.discriminatedUnion("type", [
4178
7233
  z.object({
4179
7234
  type: z.literal("user.message"),
4180
- clientEventId: z.string().min(1).optional(),
7235
+ clientEventId: SessionOperationKey.optional(),
4181
7236
  payload: z.object({
4182
7237
  text: z.string().min(1),
7238
+ // System-level host context for this exact turn only. Persisted on the
7239
+ // turn for retry/recovery, never copied into the visible user message.
7240
+ turnInstructions: z.string().trim().min(1).max(32768).optional(),
4183
7241
  resources: z.array(ResourceRef).default([]),
4184
7242
  tools: z.array(ToolRef).default([]),
4185
7243
  model: z.string().min(1).optional(),
4186
7244
  reasoningEffort: ReasoningEffort.optional(),
7245
+ controlEtag: z.string().min(1).optional(),
7246
+ expectedDraftRevision: z.number().int().nonnegative().optional(),
4187
7247
  // Header-value rotation only. URL/name/tool settings are immutable after
4188
7248
  // session create; persisted events expose metadata, never header values.
4189
7249
  mcpCredentialUpdates: z.array(SessionMcpCredentialUpdateInput).optional(),
@@ -4191,25 +7251,35 @@ export const ClientSessionEvent = z.discriminatedUnion("type", [
4191
7251
  }),
4192
7252
  z.object({
4193
7253
  type: z.literal("user.approvalDecision"),
4194
- clientEventId: z.string().min(1).optional(),
7254
+ clientEventId: SessionOperationKey.optional(),
4195
7255
  payload: z.object({
4196
- approvalId: z.string().min(1),
7256
+ approvalId: z.string().min(1).max(SESSION_OPERATION_KEY_MAX_CHARS),
4197
7257
  decision: z.enum(["approve", "reject"]),
4198
7258
  message: z.string().optional(),
4199
7259
  }),
4200
7260
  }),
7261
+ z.object({
7262
+ type: z.literal("user.humanInputResponse"),
7263
+ clientEventId: SessionOperationKey.optional(),
7264
+ payload: z.object({
7265
+ requestId: z.string().uuid(),
7266
+ response: SubmitHumanInputResponseRequest,
7267
+ }),
7268
+ }),
4201
7269
  ]);
4202
7270
  export type ClientSessionEvent = z.infer<typeof ClientSessionEvent>;
4203
7271
 
4204
7272
  export const SteerSessionMessageRequest = z.object({
4205
7273
  text: z.string().min(1),
7274
+ // Same per-turn system-level context as a queued user.message.
7275
+ turnInstructions: z.string().trim().min(1).max(32768).optional(),
4206
7276
  resources: z.array(ResourceRef).default([]),
4207
7277
  tools: z.array(ToolRef).default([]),
4208
7278
  model: z.string().min(1).optional(),
4209
7279
  reasoningEffort: ReasoningEffort.optional(),
4210
- clientEventId: z.string().min(1).optional(),
4211
- expectedControlGeneration: z.number().int().nonnegative().optional(),
4212
- expectedWorkspaceInferenceGeneration: z.number().int().nonnegative().optional(),
7280
+ clientEventId: SessionOperationKey.optional(),
7281
+ controlEtag: z.string().min(1).optional(),
7282
+ expectedDraftRevision: z.number().int().nonnegative().optional(),
4213
7283
  mcpCredentialUpdates: z.array(SessionMcpCredentialUpdateInput).optional(),
4214
7284
  });
4215
7285
  export type SteerSessionMessageRequest = z.infer<typeof SteerSessionMessageRequest>;
@@ -4249,6 +7319,37 @@ export const GitHubRepository = z.object({
4249
7319
  });
4250
7320
  export type GitHubRepository = z.infer<typeof GitHubRepository>;
4251
7321
 
7322
+ export const GitHubRepositoryScope = z.enum(["all", "selected"]);
7323
+ export type GitHubRepositoryScope = z.infer<typeof GitHubRepositoryScope>;
7324
+
7325
+ export const GitHubInstallationBinding = z.object({
7326
+ installationId: z.number().int().positive(),
7327
+ accountLogin: z.string().nullable(),
7328
+ accountType: z.string().nullable(),
7329
+ repositoryScope: GitHubRepositoryScope,
7330
+ repositoryCount: z.number().int().nonnegative(),
7331
+ createdAt: z.string(),
7332
+ updatedAt: z.string(),
7333
+ });
7334
+ export type GitHubInstallationBinding = z.infer<typeof GitHubInstallationBinding>;
7335
+
7336
+ export const GitHubAppInfo = z.object({
7337
+ configured: z.boolean(),
7338
+ appId: z.string().nullable(),
7339
+ clientId: z.string().nullable(),
7340
+ appSlug: z.string().nullable(),
7341
+ installUrl: z.string().nullable(),
7342
+ linkUrl: z.string().nullable(),
7343
+ installations: z.array(GitHubInstallationBinding),
7344
+ missing: z.array(z.string()),
7345
+ });
7346
+ export type GitHubAppInfo = z.infer<typeof GitHubAppInfo>;
7347
+
7348
+ export const GitHubRepositoriesResponse = z.object({
7349
+ repositories: z.array(GitHubRepository),
7350
+ });
7351
+ export type GitHubRepositoriesResponse = z.infer<typeof GitHubRepositoriesResponse>;
7352
+
4252
7353
  export const ClientAuthConfig = z.discriminatedUnion("mode", [
4253
7354
  z.object({
4254
7355
  mode: z.literal("none"),
@@ -4269,7 +7370,7 @@ export const ClientAuthConfig = z.discriminatedUnion("mode", [
4269
7370
  ]);
4270
7371
  export type ClientAuthConfig = z.infer<typeof ClientAuthConfig>;
4271
7372
 
4272
- // The negotiated capability handshake document (master-spine C.3). ONE shape;
7373
+ // The negotiated capability handshake document (sandbox contract C.3). ONE shape;
4273
7374
  // collapses the parallel per-module definitions. A capability cell is always
4274
7375
  // present with `available`/`transport` + a `reason` when unavailable — never
4275
7376
  // absent.
@@ -4402,14 +7503,14 @@ export const ViewerHolder = z.object({
4402
7503
  leaseEpoch: z.number().int().nonnegative(),
4403
7504
  viewerHeartbeatIntervalMs: z.number().int().positive(),
4404
7505
  // The desktop pixel tunnel URL the viewer connects to directly; null until
4405
- // P4 mints it (gated until then).
7506
+ // a viewer grant is minted (gated until then).
4406
7507
  dataPlaneUrl: z.string().nullable(),
4407
7508
  });
4408
7509
  export type ViewerHolder = z.infer<typeof ViewerHolder>;
4409
7510
 
4410
7511
  // POST .../stream-capabilities/acknowledge — record the calling principal's
4411
- // acknowledgment of the un-redacted pixel plane (P3.2; modules/07-channel-b.md
4412
- // §6 + addendum E.1). Reuses the acknowledgment machinery — no new endpoint
7512
+ // acknowledgment of the un-redacted pixel plane. Reuses the acknowledgment
7513
+ // machinery — no new endpoint
4413
7514
  // shape beyond this body, no new permission beyond stream:acknowledge.
4414
7515
  //
4415
7516
  // `acknowledgeShared` MUST be true when the box is shared (the group has >1
@@ -4453,7 +7554,7 @@ export type ViewerHeartbeatResponse = z.infer<typeof ViewerHeartbeatResponse>;
4453
7554
  // (DeviceAuthStart*, DeviceAuthPoll*, EnrollmentCredentials) so the Rust agent's
4454
7555
  // `enroll` command (which runs the flow over HTTP before it has NATS creds)
4455
7556
  // decodes the SAME field names (the proto's ts-proto JSON is camelCase). The
4456
- // request bodies additionally carry the consent-relevant fields the dossier brief
7557
+ // request bodies additionally carry the consent-relevant fields the design brief
4457
7558
  // mandates (the agent ed25519 pubkey + can-offer-display + requests-screen-control).
4458
7559
  // =============================================================================
4459
7560
 
@@ -4699,7 +7800,7 @@ export const EnrollTokenExchangeResponse = z.object({
4699
7800
  });
4700
7801
  export type EnrollTokenExchangeResponse = z.infer<typeof EnrollTokenExchangeResponse>;
4701
7802
 
4702
- // ── Machines dashboard + per-machine metrics (M10, dossier §10.7) ────────────
7803
+ // ── Machines dashboard + per-machine metrics (M10) ────────────
4703
7804
  //
4704
7805
  // The SHARED data contract M10 (backend) implements + M9 (UI) renders. THE
4705
7806
  // orchestrator owns this shape; M9 imports these types so the dashboard never
@@ -4836,6 +7937,239 @@ export const MachineMetricsSeriesResponse = z.object({
4836
7937
  });
4837
7938
  export type MachineMetricsSeriesResponse = z.infer<typeof MachineMetricsSeriesResponse>;
4838
7939
 
7940
+ /**
7941
+ * Keep this server-facing schema graph eager when imported while allowing
7942
+ * browser bundlers to discard it when contracts is used only for unrelated
7943
+ * helpers. Keep each call site annotated as pure; the factory argument itself
7944
+ * is side-effect-free until invoked.
7945
+ */
7946
+ function defineModelContractSchema<Schema>(factory: () => Schema): Schema {
7947
+ return factory();
7948
+ }
7949
+
7950
+ export const ModelCapabilitySupportV1 = /* @__PURE__ */ defineModelContractSchema(() =>
7951
+ z.enum(["supported", "unsupported", "unknown"]),
7952
+ );
7953
+ export type ModelCapabilitySupportV1 = z.infer<typeof ModelCapabilitySupportV1>;
7954
+
7955
+ export const ModelCapabilityStateV1 = /* @__PURE__ */ defineModelContractSchema(() =>
7956
+ z.object({
7957
+ upstream: ModelCapabilitySupportV1,
7958
+ runnable: z.boolean(),
7959
+ }),
7960
+ );
7961
+ export type ModelCapabilityStateV1 = z.infer<typeof ModelCapabilityStateV1>;
7962
+
7963
+ export const ModelCapabilitiesV1 = /* @__PURE__ */ defineModelContractSchema(() =>
7964
+ z.object({
7965
+ reasoning: ModelCapabilityStateV1.extend({
7966
+ efforts: z.array(ReasoningEffort),
7967
+ defaultEffort: ReasoningEffort.nullable(),
7968
+ required: z.boolean(),
7969
+ }),
7970
+ functionCalling: ModelCapabilityStateV1,
7971
+ structuredOutput: ModelCapabilityStateV1,
7972
+ hostedTools: z.object({
7973
+ webSearch: ModelCapabilityStateV1,
7974
+ xSearch: ModelCapabilityStateV1,
7975
+ codeExecution: ModelCapabilityStateV1,
7976
+ }),
7977
+ inputModalities: z.array(z.enum(["text", "image", "audio"])),
7978
+ outputModalities: z.array(z.enum(["text", "image", "audio"])),
7979
+ transports: z.object({
7980
+ sse: ModelCapabilityStateV1,
7981
+ responsesWebSocket: ModelCapabilityStateV1,
7982
+ realtimeAudio: ModelCapabilityStateV1,
7983
+ }),
7984
+ latencyModes: z.array(
7985
+ z.object({
7986
+ id: z.enum(["standard", "priority", "fast"]),
7987
+ upstream: ModelCapabilitySupportV1,
7988
+ runnable: z.boolean(),
7989
+ billingMultiplierBps: z.number().int().positive().optional(),
7990
+ }),
7991
+ ),
7992
+ }),
7993
+ );
7994
+ export type ModelCapabilitiesV1 = z.infer<typeof ModelCapabilitiesV1>;
7995
+
7996
+ export const ModelCredentialSourceV1 = /* @__PURE__ */ defineModelContractSchema(() =>
7997
+ z.union([
7998
+ z
7999
+ .object({ kind: z.literal("deployment"), mechanism: z.enum(["api_key", "azure_ad_bearer"]) })
8000
+ .strict(),
8001
+ z.object({ kind: z.literal("connected_subscription"), provider: z.literal("codex") }).strict(),
8002
+ z.object({ kind: z.literal("workspace_connection"), mechanism: z.literal("api_key") }).strict(),
8003
+ ]),
8004
+ );
8005
+ export type ModelCredentialSourceV1 = z.infer<typeof ModelCredentialSourceV1>;
8006
+
8007
+ export const ModelBillingAttributionV1 = /* @__PURE__ */ defineModelContractSchema(() =>
8008
+ z
8009
+ .object({
8010
+ upstreamPayer: z.enum(["deployment", "workspace", "connected_subscription"]),
8011
+ metering: z.enum(["opengeni_credits", "external"]),
8012
+ })
8013
+ .strict(),
8014
+ );
8015
+ export type ModelBillingAttributionV1 = z.infer<typeof ModelBillingAttributionV1>;
8016
+
8017
+ export const TURN_EXECUTION_POLICY_METADATA_KEY = "turnExecutionPolicyV1" as const;
8018
+
8019
+ export const TurnExecutionModelSourceV1 = /* @__PURE__ */ defineModelContractSchema(() =>
8020
+ z.enum(["explicit", "session", "deployment", "continuation"]),
8021
+ );
8022
+ export type TurnExecutionModelSourceV1 = z.infer<typeof TurnExecutionModelSourceV1>;
8023
+
8024
+ export const TurnExecutionReasoningSourceV1 = /* @__PURE__ */ defineModelContractSchema(() =>
8025
+ z.enum(["explicit", "session", "deployment", "continuation"]),
8026
+ );
8027
+ export type TurnExecutionReasoningSourceV1 = z.infer<typeof TurnExecutionReasoningSourceV1>;
8028
+
8029
+ /**
8030
+ * Secret-safe execution identity frozen onto one accepted logical turn.
8031
+ *
8032
+ * This is deliberately a strict, normalized reference to the deployment
8033
+ * definition rather than a serialized provider client. It must never contain
8034
+ * a key/token, concrete connected credential id, account label, authorization
8035
+ * header, or credential-bearing URL/query value.
8036
+ */
8037
+ export const TurnExecutionPolicyV1 = /* @__PURE__ */ defineModelContractSchema(() =>
8038
+ z
8039
+ .object({
8040
+ schemaVersion: z.literal(1),
8041
+ productModelId: z.string().min(1),
8042
+ requestedModelId: z.string().min(1).nullable(),
8043
+ modelSource: TurnExecutionModelSourceV1,
8044
+ reasoningEffort: ReasoningEffort,
8045
+ reasoningSource: TurnExecutionReasoningSourceV1,
8046
+ providerId: z.string().min(1),
8047
+ upstreamModelId: z.string().min(1),
8048
+ wireApi: z.enum(["responses", "chat"]),
8049
+ credentialSource: ModelCredentialSourceV1,
8050
+ billing: ModelBillingAttributionV1,
8051
+ definitionVersion: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
8052
+ })
8053
+ .strict()
8054
+ .superRefine((policy, context) => {
8055
+ if (policy.modelSource === "explicit" && policy.requestedModelId === null) {
8056
+ context.addIssue({
8057
+ code: "custom",
8058
+ path: ["requestedModelId"],
8059
+ message: "an explicit model source requires a requested model id",
8060
+ });
8061
+ }
8062
+ if (policy.modelSource !== "explicit" && policy.requestedModelId !== null) {
8063
+ context.addIssue({
8064
+ code: "custom",
8065
+ path: ["requestedModelId"],
8066
+ message: "only an explicit model source may retain a requested model id",
8067
+ });
8068
+ }
8069
+ }),
8070
+ );
8071
+ export type TurnExecutionPolicyV1 = z.infer<typeof TurnExecutionPolicyV1>;
8072
+
8073
+ export type TurnExecutionPolicyReadV1 =
8074
+ | { kind: "absent" }
8075
+ | { kind: "valid"; policy: TurnExecutionPolicyV1 };
8076
+
8077
+ /**
8078
+ * Read the policy from turn metadata. Only a literally absent key is legacy;
8079
+ * null, undefined, an unknown schema version, extra fields, and every other
8080
+ * malformed present value fail closed. Error text reports paths only and never
8081
+ * reflects the untrusted value into logs or events.
8082
+ */
8083
+ export function readTurnExecutionPolicyV1(metadata: unknown): TurnExecutionPolicyReadV1 {
8084
+ if (metadata === null || metadata === undefined) {
8085
+ return { kind: "absent" };
8086
+ }
8087
+ if (typeof metadata !== "object" || Array.isArray(metadata)) {
8088
+ throw new Error("Malformed turn execution policy metadata: turn metadata is not an object");
8089
+ }
8090
+ const record = metadata as Record<string, unknown>;
8091
+ if (!Object.prototype.hasOwnProperty.call(record, TURN_EXECUTION_POLICY_METADATA_KEY)) {
8092
+ return { kind: "absent" };
8093
+ }
8094
+ const parsed = TurnExecutionPolicyV1.safeParse(record[TURN_EXECUTION_POLICY_METADATA_KEY]);
8095
+ if (!parsed.success) {
8096
+ const paths = [
8097
+ ...new Set(
8098
+ parsed.error.issues.map((issue) =>
8099
+ issue.path.length === 0 ? "policy" : `policy.${issue.path.join(".")}`,
8100
+ ),
8101
+ ),
8102
+ ].join(", ");
8103
+ throw new Error(`Malformed turn execution policy metadata at ${paths || "policy"}`);
8104
+ }
8105
+ return { kind: "valid", policy: parsed.data };
8106
+ }
8107
+
8108
+ /** Merge a trusted policy into metadata without disturbing dispatch/recovery state. */
8109
+ export function metadataWithTurnExecutionPolicyV1(
8110
+ metadata: Readonly<Record<string, unknown>> | null | undefined,
8111
+ policy: TurnExecutionPolicyV1,
8112
+ ): Record<string, unknown> {
8113
+ return {
8114
+ ...(metadata ?? {}),
8115
+ [TURN_EXECUTION_POLICY_METADATA_KEY]: TurnExecutionPolicyV1.parse(policy),
8116
+ };
8117
+ }
8118
+
8119
+ /**
8120
+ * Minimal, stable evidence projection for command receipts and audit events.
8121
+ * It intentionally excludes aliases, URLs, request metadata, and all concrete
8122
+ * credential-selection identity.
8123
+ */
8124
+ export function turnExecutionPolicyAuditMetadata(
8125
+ policy: TurnExecutionPolicyV1,
8126
+ turnId: string,
8127
+ ): Record<string, unknown> {
8128
+ const parsed = TurnExecutionPolicyV1.parse(policy);
8129
+ return {
8130
+ turnId,
8131
+ requestedModelId: parsed.requestedModelId,
8132
+ effectiveModelId: parsed.productModelId,
8133
+ modelSource: parsed.modelSource,
8134
+ effectiveReasoningEffort: parsed.reasoningEffort,
8135
+ reasoningSource: parsed.reasoningSource,
8136
+ providerId: parsed.providerId,
8137
+ credentialSourceKind: parsed.credentialSource.kind,
8138
+ credentialSourceMechanism:
8139
+ parsed.credentialSource.kind === "connected_subscription"
8140
+ ? parsed.credentialSource.provider
8141
+ : parsed.credentialSource.mechanism,
8142
+ billingOwner: parsed.billing.upstreamPayer,
8143
+ billingMetering: parsed.billing.metering,
8144
+ definitionVersion: parsed.definitionVersion,
8145
+ };
8146
+ }
8147
+
8148
+ export const ModelPricingV1 = /* @__PURE__ */ defineModelContractSchema(() =>
8149
+ z.object({
8150
+ inputMicrosPerMillionTokens: z.number().int().nonnegative(),
8151
+ cachedInputMicrosPerMillionTokens: z.number().int().nonnegative().optional(),
8152
+ outputMicrosPerMillionTokens: z.number().int().nonnegative(),
8153
+ marginBps: z.number().int().min(0).max(100_000).optional(),
8154
+ }),
8155
+ );
8156
+ export type ModelPricingV1 = z.infer<typeof ModelPricingV1>;
8157
+
8158
+ export const ModelPricingScheduleV1 = /* @__PURE__ */ defineModelContractSchema(() =>
8159
+ z.object({
8160
+ default: ModelPricingV1,
8161
+ inputTokenTiers: z
8162
+ .array(
8163
+ z.object({
8164
+ minimumInputTokens: z.number().int().nonnegative(),
8165
+ pricing: ModelPricingV1,
8166
+ }),
8167
+ )
8168
+ .optional(),
8169
+ }),
8170
+ );
8171
+ export type ModelPricingScheduleV1 = z.infer<typeof ModelPricingScheduleV1>;
8172
+
4839
8173
  /**
4840
8174
  * A single host-exposed model + the provider that serves it, as surfaced to
4841
8175
  * clients (SDK + React composer) by GET /v1/config/client. The wire `api`
@@ -4843,55 +8177,187 @@ export type MachineMetricsSeriesResponse = z.infer<typeof MachineMetricsSeriesRe
4843
8177
  * provider id/label drive the picker's grouping. This mirrors the runtime's
4844
8178
  * ConfiguredModel (packages/config) projected to the client-safe fields.
4845
8179
  */
4846
- export const ClientModel = z.object({
4847
- id: z.string(),
4848
- label: z.string(),
4849
- provider: z.string(), // provider id
4850
- providerLabel: z.string(),
4851
- api: z.enum(["responses", "chat"]),
4852
- contextWindowTokens: z.number().int().positive().optional(),
4853
- });
8180
+ export const ClientModel = /* @__PURE__ */ defineModelContractSchema(() =>
8181
+ z.object({
8182
+ id: z.string(),
8183
+ label: z.string(),
8184
+ provider: z.string(), // provider id
8185
+ providerLabel: z.string(),
8186
+ api: z.enum(["responses", "chat"]),
8187
+ contextWindowTokens: z.number().int().positive().optional(),
8188
+ // Additive normalized definition metadata. Optional so older server payloads
8189
+ // remain parseable; current servers project the complete V1 set.
8190
+ schemaVersion: z.literal(1).optional(),
8191
+ aliases: z.array(z.string()).optional(),
8192
+ deployment: z
8193
+ .object({
8194
+ upstreamModelId: z.string().min(1),
8195
+ wireApi: z.enum(["responses", "chat"]),
8196
+ })
8197
+ .optional(),
8198
+ executionLimits: z
8199
+ .object({
8200
+ contextWindowTokens: z.number().int().positive().nullable(),
8201
+ effectiveContextWindowTokens: z.number().int().positive().nullable(),
8202
+ autoCompactTokenLimit: z.number().int().positive().nullable(),
8203
+ toolOutputTruncationTokens: z.number().int().positive().nullable(),
8204
+ })
8205
+ .optional(),
8206
+ credentialSource: ModelCredentialSourceV1.optional(),
8207
+ billing: ModelBillingAttributionV1.optional(),
8208
+ capabilities: ModelCapabilitiesV1.optional(),
8209
+ pricing: ModelPricingScheduleV1.optional(),
8210
+ definitionVersion: z
8211
+ .string()
8212
+ .regex(/^sha256:[a-f0-9]{64}$/u)
8213
+ .optional(),
8214
+ }),
8215
+ );
4854
8216
  export type ClientModel = z.infer<typeof ClientModel>;
4855
8217
 
4856
- export const ClientConfig = z.object({
4857
- deploymentRevision: z.string(),
4858
- // Release-train version of the server (absent on dev/source builds). The
4859
- // compatibility policy lives in docs/architecture.md — clients within the
4860
- // same major are supported; evolution is additive within a major.
4861
- serverVersion: z.string().optional(),
4862
- defaultModel: z.string(),
4863
- allowedModels: z.array(z.string()).min(1),
4864
- // Richer model list (provider-grouped) for the picker. Defaults to [] for
4865
- // back-compat: callers that only read allowedModels are unaffected.
4866
- models: z.array(ClientModel).default([]),
4867
- defaultReasoningEffort: ReasoningEffort,
4868
- allowedReasoningEfforts: z.array(ReasoningEffort).min(1),
4869
- mcpServers: z
4870
- .array(
4871
- z.object({
4872
- id: z.string(),
4873
- name: z.string(),
4874
- }),
4875
- )
4876
- .default([]),
4877
- fileUploads: z.object({
4878
- enabled: z.boolean(),
4879
- maxSizeBytes: z.number().int().positive(),
4880
- }),
4881
- productAccessMode: ProductAccessMode,
4882
- auth: ClientAuthConfig.default({ mode: "none" }),
4883
- // Server-wide hint: does this deployment support Channel-A structured services
4884
- // at all (P4.4). Per-session availability is negotiated on /stream-capabilities
4885
- // (it depends on the session's pinned backend); this is the coarse on/off the
4886
- // client uses to decide whether to even attempt the fs/git/terminal panels.
4887
- structuredServices: z
8218
+ export const ModelCredentialReadinessV1 = /* @__PURE__ */ defineModelContractSchema(() =>
8219
+ z
4888
8220
  .object({
4889
- fileSystem: z.boolean(),
4890
- git: z.boolean(),
4891
- terminalEvents: z.boolean(),
8221
+ status: z.enum(["ready", "not_ready", "error"]),
8222
+ reason: z
8223
+ .enum([
8224
+ "missing_credential",
8225
+ "needs_reauth",
8226
+ "prerequisites_missing",
8227
+ "resolver_error",
8228
+ "observation_stale",
8229
+ ])
8230
+ .nullable(),
8231
+ basis: z.enum(["configuration", "connection", "resolver"]),
8232
+ checkedAt: z.string().datetime().nullable(),
4892
8233
  })
4893
- .default({ fileSystem: false, git: false, terminalEvents: false }),
4894
- });
8234
+ .strict()
8235
+ .superRefine((readiness, context) => {
8236
+ if ((readiness.status === "ready") !== (readiness.reason === null)) {
8237
+ context.addIssue({
8238
+ code: "custom",
8239
+ path: ["reason"],
8240
+ message: "ready credential state requires no reason; non-ready state requires a reason",
8241
+ });
8242
+ }
8243
+ if ((readiness.status === "error") !== (readiness.reason === "resolver_error")) {
8244
+ context.addIssue({
8245
+ code: "custom",
8246
+ path: ["reason"],
8247
+ message:
8248
+ "credential errors require resolver_error and resolver_error requires error status",
8249
+ });
8250
+ }
8251
+ if (
8252
+ readiness.basis === "resolver" &&
8253
+ readiness.status === "ready" &&
8254
+ readiness.checkedAt === null
8255
+ ) {
8256
+ context.addIssue({
8257
+ code: "custom",
8258
+ path: ["checkedAt"],
8259
+ message: "resolver readiness requires an observation timestamp",
8260
+ });
8261
+ }
8262
+ if (readiness.reason === "observation_stale" && readiness.checkedAt === null) {
8263
+ context.addIssue({
8264
+ code: "custom",
8265
+ path: ["checkedAt"],
8266
+ message: "a stale observation requires its observation timestamp",
8267
+ });
8268
+ }
8269
+ }),
8270
+ );
8271
+ export type ModelCredentialReadinessV1 = z.infer<typeof ModelCredentialReadinessV1>;
8272
+
8273
+ export const ModelAvailabilityV1 = /* @__PURE__ */ defineModelContractSchema(() =>
8274
+ z.object({
8275
+ status: z.enum(["available", "unavailable", "degraded", "unknown"]),
8276
+ selectable: z.boolean(),
8277
+ reason: z
8278
+ .enum([
8279
+ "missing_credential",
8280
+ "needs_reauth",
8281
+ "credential_not_ready",
8282
+ "not_entitled",
8283
+ "provider_unhealthy",
8284
+ "policy_blocked",
8285
+ "unsupported",
8286
+ ])
8287
+ .nullable(),
8288
+ checkedAt: z.string().datetime().nullable(),
8289
+ }),
8290
+ );
8291
+ export type ModelAvailabilityV1 = z.infer<typeof ModelAvailabilityV1>;
8292
+
8293
+ export const WorkspaceModelCatalogModel = /* @__PURE__ */ defineModelContractSchema(() =>
8294
+ ClientModel.extend({
8295
+ credentialReadiness: ModelCredentialReadinessV1,
8296
+ availability: ModelAvailabilityV1,
8297
+ }),
8298
+ );
8299
+ export type WorkspaceModelCatalogModel = z.infer<typeof WorkspaceModelCatalogModel>;
8300
+
8301
+ export const WorkspaceModelCatalogResponse = /* @__PURE__ */ defineModelContractSchema(() =>
8302
+ z.object({
8303
+ models: z.array(WorkspaceModelCatalogModel),
8304
+ }),
8305
+ );
8306
+ export type WorkspaceModelCatalogResponse = z.infer<typeof WorkspaceModelCatalogResponse>;
8307
+
8308
+ /**
8309
+ * Exact public HTTP protocol revision spoken by this release train.
8310
+ *
8311
+ * This is deliberately independent from a deployment SHA: API and web may roll
8312
+ * at different instants, while incompatible request shapes must never cross
8313
+ * that rollout boundary. Mutating clients send this value in
8314
+ * `x-opengeni-api-contract`; the API rejects any other value before routing.
8315
+ */
8316
+ export const OPENGENI_API_CONTRACT_REVISION = "2026-07-turn-instructions-v1" as const;
8317
+ export const OPENGENI_API_CONTRACT_HEADER = "x-opengeni-api-contract" as const;
8318
+
8319
+ export const ClientConfig = /* @__PURE__ */ defineModelContractSchema(() =>
8320
+ z.object({
8321
+ deploymentRevision: z.string(),
8322
+ apiContractRevision: z.literal(OPENGENI_API_CONTRACT_REVISION),
8323
+ // Release-train version of the server (absent on dev/source builds). The
8324
+ // compatibility policy lives in docs/architecture.md — clients within the
8325
+ // same major are supported; evolution is additive within a major.
8326
+ serverVersion: z.string().optional(),
8327
+ defaultModel: z.string(),
8328
+ allowedModels: z.array(z.string()).min(1),
8329
+ // Richer model list (provider-grouped) for the picker. Defaults to [] for
8330
+ // back-compat: callers that only read allowedModels are unaffected.
8331
+ models: z.array(ClientModel).default([]),
8332
+ defaultReasoningEffort: ReasoningEffort,
8333
+ allowedReasoningEfforts: z.array(ReasoningEffort).min(1),
8334
+ mcpServers: z
8335
+ .array(
8336
+ z.object({
8337
+ id: z.string(),
8338
+ name: z.string(),
8339
+ }),
8340
+ )
8341
+ .default([]),
8342
+ fileUploads: z.object({
8343
+ enabled: z.boolean(),
8344
+ maxSizeBytes: z.number().int().positive(),
8345
+ }),
8346
+ productAccessMode: ProductAccessMode,
8347
+ auth: ClientAuthConfig.default({ mode: "none" }),
8348
+ // Server-wide hint: does this deployment support Channel-A structured services
8349
+ // at all (P4.4). Per-session availability is negotiated on /stream-capabilities
8350
+ // (it depends on the session's pinned backend); this is the coarse on/off the
8351
+ // client uses to decide whether to even attempt the fs/git/terminal panels.
8352
+ structuredServices: z
8353
+ .object({
8354
+ fileSystem: z.boolean(),
8355
+ git: z.boolean(),
8356
+ terminalEvents: z.boolean(),
8357
+ })
8358
+ .default({ fileSystem: false, git: false, terminalEvents: false }),
8359
+ }),
8360
+ );
4895
8361
  export type ClientConfig = z.infer<typeof ClientConfig>;
4896
8362
 
4897
8363
  function base64UrlEncode(value: string): string {