@opengeni/contracts 0.10.0 → 0.15.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,26 @@
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";
2
24
 
3
25
  export const SessionStatus = z.enum([
4
26
  "queued",
@@ -7,7 +29,6 @@ export const SessionStatus = z.enum([
7
29
  "requires_action",
8
30
  "recovering",
9
31
  "waiting_capacity",
10
- "paused",
11
32
  "failed",
12
33
  "cancelled",
13
34
  ]);
@@ -62,9 +83,16 @@ export type CapabilityDescriptor = {
62
83
  os: { supported: SandboxOs[]; default: SandboxOs };
63
84
  capabilities: {
64
85
  FileSystem: { available: boolean; readOnly: boolean };
65
- Terminal: { available: boolean; transport: "sse-events" | "pty-ws" | null; pty: boolean };
86
+ Terminal: {
87
+ available: boolean;
88
+ transport: "sse-events" | "pty-ws" | null;
89
+ pty: boolean;
90
+ };
66
91
  Git: { available: boolean };
67
- DesktopStream: { available: boolean; transport: "vnc-ws" | "rdp-ws" | "webrtc" | null };
92
+ DesktopStream: {
93
+ available: boolean;
94
+ transport: "vnc-ws" | "rdp-ws" | "webrtc" | null;
95
+ };
68
96
  // Feasibility only (== DesktopStream.available && os==linux); NOT a request.
69
97
  Recording: { available: boolean };
70
98
  };
@@ -97,7 +125,7 @@ export const DESKTOP_STREAM_PORT = 6080;
97
125
  // Terminal cell's `url` is the tunnel address resolved against this port.
98
126
  export const TERMINAL_STREAM_PORT = 7681;
99
127
 
100
- // The Part-D matrix (master-spine PART D + module 03-providers). One row per
128
+ // The provider capability matrix (sandbox contract PART D + module 03-providers). One row per
101
129
  // backend (10 rows). v1 reachable cells are all Linux; macos/windows are seam
102
130
  // placeholders (no enum members shipped). Reading rule: a capability cell is
103
131
  // `available:false` + a reason in the negotiated doc, never absent.
@@ -444,7 +472,7 @@ export const Permission = z.enum([
444
472
  "sessions:create",
445
473
  "sessions:read",
446
474
  "sessions:control",
447
- // Sandbox-surfacing (master-spine §C.3 / crosscut PART 1.2). stream:view is a
475
+ // sandbox workspace (sandbox contract §C.3 / crosscut PART 1.2). stream:view is a
448
476
  // REAL, distinct permission — strictly BROADER than sessions:read — because the
449
477
  // pixel plane (Channel B) is UN-REDACTED: a viewer of raw pixels can see cloud
450
478
  // creds the agent cat's into a terminal, which the redacted Channel-A event log
@@ -553,11 +581,13 @@ export const Workspace = z.object({
553
581
  // validated by WorkspaceSettingsSchema; unknown keys are preserved across
554
582
  // PATCH merges so newer settings survive an older server.
555
583
  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(),
584
+ inferenceControl: z.object({
585
+ state: z.enum(["active", "paused"]),
586
+ revision: z.number().int().nonnegative(),
587
+ reason: z.string().nullable(),
588
+ changedBy: z.string().nullable(),
589
+ changedAt: z.string().nullable(),
590
+ }),
561
591
  // Workspace default rig used by session/scheduled-task create fallback.
562
592
  defaultRigId: z.string().uuid().nullable(),
563
593
  createdAt: z.string(),
@@ -565,12 +595,297 @@ export const Workspace = z.object({
565
595
  });
566
596
  export type Workspace = z.infer<typeof Workspace>;
567
597
 
598
+ export const WorkspaceTranscriptionTarget = z
599
+ .object({
600
+ provider: z.string().trim().min(1).max(128),
601
+ model: z.string().trim().min(1).max(256).nullable(),
602
+ credentialMode: z.enum(["managed", "byok"]),
603
+ // A workspace-scoped connection reference, never credential material.
604
+ credentialConnectionId: z.string().uuid().nullable(),
605
+ region: z.string().trim().min(1).max(128).nullable(),
606
+ })
607
+ .strict()
608
+ .superRefine((target, context) => {
609
+ if (target.provider === "azure-speech" && target.credentialMode !== "byok") {
610
+ context.addIssue({
611
+ code: "custom",
612
+ path: ["credentialMode"],
613
+ message: "Azure Speech is supported only through workspace BYOK",
614
+ });
615
+ }
616
+ if (target.credentialMode === "byok" && target.credentialConnectionId === null) {
617
+ context.addIssue({
618
+ code: "custom",
619
+ path: ["credentialConnectionId"],
620
+ message: "BYOK transcription targets require a workspace connection reference",
621
+ });
622
+ }
623
+ if (target.credentialMode === "managed" && target.credentialConnectionId !== null) {
624
+ context.addIssue({
625
+ code: "custom",
626
+ path: ["credentialConnectionId"],
627
+ message: "managed transcription targets cannot name a BYOK connection",
628
+ });
629
+ }
630
+ });
631
+ export type WorkspaceTranscriptionTarget = z.infer<typeof WorkspaceTranscriptionTarget>;
632
+
633
+ export const TranscriptionErrorCode = z.enum([
634
+ "permission_denied",
635
+ "not_supported",
636
+ "network",
637
+ "provider",
638
+ "policy_blocked",
639
+ "timeout",
640
+ "cancelled",
641
+ "unknown",
642
+ ]);
643
+ export type TranscriptionErrorCode = z.infer<typeof TranscriptionErrorCode>;
644
+
645
+ export const TranscriptionTimeSpan = z
646
+ .object({
647
+ startMilliseconds: z.number().finite().nonnegative(),
648
+ endMilliseconds: z.number().finite().nonnegative(),
649
+ })
650
+ .strict()
651
+ .superRefine((span, context) => {
652
+ if (span.endMilliseconds < span.startMilliseconds) {
653
+ context.addIssue({
654
+ code: "custom",
655
+ path: ["endMilliseconds"],
656
+ message: "transcription spans must not end before they start",
657
+ });
658
+ }
659
+ });
660
+ export type TranscriptionTimeSpan = z.infer<typeof TranscriptionTimeSpan>;
661
+
662
+ export const TranscriptionSpeaker = z
663
+ .object({
664
+ id: z.string().trim().min(1).max(128),
665
+ label: z.string().trim().min(1).max(128).optional(),
666
+ })
667
+ .strict();
668
+ export type TranscriptionSpeaker = z.infer<typeof TranscriptionSpeaker>;
669
+
670
+ export const TranscriptionWord = z
671
+ .object({
672
+ text: z.string().min(1).max(4096),
673
+ span: TranscriptionTimeSpan,
674
+ confidence: z.number().finite().min(0).max(1).optional(),
675
+ speaker: TranscriptionSpeaker.optional(),
676
+ })
677
+ .strict();
678
+ export type TranscriptionWord = z.infer<typeof TranscriptionWord>;
679
+
680
+ export const TranscriptionResultMetadata = z
681
+ .object({
682
+ detectedLanguage: z.string().trim().min(1).max(64).optional(),
683
+ span: TranscriptionTimeSpan.optional(),
684
+ confidence: z.number().finite().min(0).max(1).optional(),
685
+ speaker: TranscriptionSpeaker.optional(),
686
+ words: z.array(TranscriptionWord).max(10_000).optional(),
687
+ })
688
+ .strict()
689
+ .superRefine((metadata, context) => {
690
+ let previousStart = -1;
691
+ for (const [index, word] of (metadata.words ?? []).entries()) {
692
+ if (word.span.startMilliseconds < previousStart) {
693
+ context.addIssue({
694
+ code: "custom",
695
+ path: ["words", index, "span", "startMilliseconds"],
696
+ message: "transcription words must be ordered by start time",
697
+ });
698
+ }
699
+ previousStart = word.span.startMilliseconds;
700
+ if (
701
+ metadata.span &&
702
+ (word.span.startMilliseconds < metadata.span.startMilliseconds ||
703
+ word.span.endMilliseconds > metadata.span.endMilliseconds)
704
+ ) {
705
+ context.addIssue({
706
+ code: "custom",
707
+ path: ["words", index, "span"],
708
+ message: "transcription word spans must fall within the result span",
709
+ });
710
+ }
711
+ }
712
+ });
713
+ export type TranscriptionResultMetadata = z.infer<typeof TranscriptionResultMetadata>;
714
+
715
+ const TranscriptionEventBase = z
716
+ .object({
717
+ localSessionId: z.string().min(1).max(256),
718
+ sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
719
+ occurredAt: z.string().datetime({ offset: true }),
720
+ })
721
+ .strict();
722
+
723
+ /** Strict provider-neutral event surface; provider payload bags are rejected. */
724
+ export const TranscriptionEvent = z.discriminatedUnion("type", [
725
+ TranscriptionEventBase.extend({ type: z.literal("permission.requested") }),
726
+ TranscriptionEventBase.extend({
727
+ type: z.literal("session.opened"),
728
+ providerSessionId: z.string().min(1).max(512),
729
+ }),
730
+ TranscriptionEventBase.extend({
731
+ type: z.literal("transcript.partial"),
732
+ segmentId: z.string().min(1).max(512),
733
+ text: z.string().max(1_000_000),
734
+ metadata: TranscriptionResultMetadata.optional(),
735
+ }),
736
+ TranscriptionEventBase.extend({
737
+ type: z.literal("transcript.final"),
738
+ segmentId: z.string().min(1).max(512),
739
+ text: z.string().max(1_000_000),
740
+ providerAcceptanceId: z.string().min(1).max(512),
741
+ metadata: TranscriptionResultMetadata.optional(),
742
+ }),
743
+ TranscriptionEventBase.extend({
744
+ type: z.literal("usage"),
745
+ audioMilliseconds: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
746
+ costUsd: z.number().finite().nonnegative().max(1_000_000_000).nullable(),
747
+ }),
748
+ TranscriptionEventBase.extend({
749
+ type: z.literal("session.reconnecting"),
750
+ attempt: z.number().int().nonnegative().max(10_000),
751
+ reason: z.string().min(1).max(256),
752
+ }),
753
+ TranscriptionEventBase.extend({
754
+ type: z.literal("session.error"),
755
+ code: TranscriptionErrorCode,
756
+ recoverable: z.boolean(),
757
+ }),
758
+ TranscriptionEventBase.extend({
759
+ type: z.literal("session.closed"),
760
+ reason: z.enum(["completed", "cancelled", "error", "replaced"]),
761
+ }),
762
+ ]);
763
+ export type TranscriptionEvent = z.infer<typeof TranscriptionEvent>;
764
+
765
+ /**
766
+ * Workspace-only policy for the distinct speech-to-text capability. It never
767
+ * authorizes a turn model/provider and contains connection references rather
768
+ * than secrets. `acceptanceId` changes whenever an admin accepts a new target
769
+ * set, so clients can bind a microphone session to one exact policy revision.
770
+ */
771
+ export const WorkspaceTranscriptionPolicy = z
772
+ .object({
773
+ enabled: z.boolean(),
774
+ acceptanceId: z.string().uuid().nullable(),
775
+ primary: WorkspaceTranscriptionTarget.nullable(),
776
+ language: z.string().trim().min(1).max(64).nullable(),
777
+ autoDetectLanguage: z.boolean(),
778
+ diarization: z
779
+ .object({
780
+ enabled: z.boolean(),
781
+ maxSpeakers: z.number().int().min(2).max(100).nullable(),
782
+ })
783
+ .strict(),
784
+ retention: z
785
+ .object({
786
+ mode: z.enum(["none", "provider-policy"]),
787
+ maxDays: z.number().int().nonnegative().max(3650).nullable(),
788
+ })
789
+ .strict(),
790
+ privacy: z
791
+ .object({
792
+ allowProviderLogging: z.boolean(),
793
+ allowProviderTraining: z.boolean(),
794
+ })
795
+ .strict(),
796
+ fallback: z
797
+ .object({
798
+ mode: z.enum(["disabled", "explicit"]),
799
+ targets: z.array(WorkspaceTranscriptionTarget).max(8),
800
+ })
801
+ .strict(),
802
+ cost: z
803
+ .object({
804
+ currency: z.literal("USD"),
805
+ maxPerHour: z.number().finite().nonnegative().max(10_000).nullable(),
806
+ maxPerMonth: z.number().finite().nonnegative().max(1_000_000).nullable(),
807
+ })
808
+ .strict(),
809
+ })
810
+ .strict()
811
+ .superRefine((policy, context) => {
812
+ if (policy.enabled && policy.acceptanceId === null) {
813
+ context.addIssue({
814
+ code: "custom",
815
+ path: ["acceptanceId"],
816
+ message: "enabled transcription requires an accepted policy identity",
817
+ });
818
+ }
819
+ if (policy.enabled && policy.primary === null) {
820
+ context.addIssue({
821
+ code: "custom",
822
+ path: ["primary"],
823
+ message: "enabled transcription requires a primary target",
824
+ });
825
+ }
826
+ if (policy.enabled && !policy.autoDetectLanguage && policy.language === null) {
827
+ context.addIssue({
828
+ code: "custom",
829
+ path: ["language"],
830
+ message: "enabled transcription requires a language or accepted automatic detection",
831
+ });
832
+ }
833
+ if (policy.autoDetectLanguage && policy.language !== null) {
834
+ context.addIssue({
835
+ code: "custom",
836
+ path: ["language"],
837
+ message: "automatic language detection and a fixed language are mutually exclusive",
838
+ });
839
+ }
840
+ if (!policy.diarization.enabled && policy.diarization.maxSpeakers !== null) {
841
+ context.addIssue({
842
+ code: "custom",
843
+ path: ["diarization", "maxSpeakers"],
844
+ message: "disabled diarization cannot retain a speaker limit",
845
+ });
846
+ }
847
+ if (policy.fallback.mode === "disabled" && policy.fallback.targets.length > 0) {
848
+ context.addIssue({
849
+ code: "custom",
850
+ path: ["fallback", "targets"],
851
+ message: "disabled fallback cannot retain accepted targets",
852
+ });
853
+ }
854
+ if (policy.fallback.mode === "explicit" && policy.fallback.targets.length === 0) {
855
+ context.addIssue({
856
+ code: "custom",
857
+ path: ["fallback", "targets"],
858
+ message: "explicit fallback requires at least one accepted target",
859
+ });
860
+ }
861
+ const targetKeys = [policy.primary, ...policy.fallback.targets]
862
+ .filter((target): target is WorkspaceTranscriptionTarget => target !== null)
863
+ .map((target) =>
864
+ [
865
+ target.provider,
866
+ target.model ?? "",
867
+ target.credentialMode,
868
+ target.credentialConnectionId ?? "",
869
+ target.region ?? "",
870
+ ].join("\u0000"),
871
+ );
872
+ if (new Set(targetKeys).size !== targetKeys.length) {
873
+ context.addIssue({
874
+ code: "custom",
875
+ path: ["fallback", "targets"],
876
+ message: "transcription targets must be unique",
877
+ });
878
+ }
879
+ });
880
+ export type WorkspaceTranscriptionPolicy = z.infer<typeof WorkspaceTranscriptionPolicy>;
881
+
568
882
  // 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.
883
+ // (future) keys rather than stripping them. memoryEnabled and transcription are
884
+ // both default-off capabilities.
571
885
  export const WorkspaceSettingsSchema = z
572
886
  .object({
573
887
  memoryEnabled: z.boolean().optional(),
888
+ transcription: WorkspaceTranscriptionPolicy.optional(),
574
889
  })
575
890
  .passthrough();
576
891
  export type WorkspaceSettings = z.infer<typeof WorkspaceSettingsSchema>;
@@ -581,12 +896,13 @@ export function resolveWorkspaceMemoryEnabled(settings: unknown): boolean {
581
896
  return parsed.success ? parsed.data.memoryEnabled === true : false;
582
897
  }
583
898
 
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.
899
+ // PATCH body for workspace settings: a partial top-level patch that merges into
900
+ // the stored bag. Nested transcription policy updates are therefore full
901
+ // replacements; passthrough carries forward-compatible unknown keys.
587
902
  export const UpdateWorkspaceSettingsRequest = z
588
903
  .object({
589
904
  memoryEnabled: z.boolean().optional(),
905
+ transcription: WorkspaceTranscriptionPolicy.optional(),
590
906
  })
591
907
  .passthrough();
592
908
  export type UpdateWorkspaceSettingsRequest = z.infer<typeof UpdateWorkspaceSettingsRequest>;
@@ -606,6 +922,78 @@ export const UpdateWorkspaceModelPolicyRequest = z.object({
606
922
  });
607
923
  export type UpdateWorkspaceModelPolicyRequest = z.infer<typeof UpdateWorkspaceModelPolicyRequest>;
608
924
 
925
+ const turnInitiatorIdentityFields = {
926
+ subjectId: z.string().min(1),
927
+ /** Immutable display snapshot; never an authorization input. */
928
+ label: z.string().min(1).optional(),
929
+ } as const;
930
+
931
+ /** Reserved creator/initiator id used only by legacy-row migration defaults. */
932
+ export const UNATTRIBUTED_LEGACY_INITIATOR_SUBJECT_ID = "unattributed-legacy" as const;
933
+
934
+ /**
935
+ * A named machine/service principal asserted by a trusted embedding host. This
936
+ * deliberately excludes `kind: "subject"`: a delegated service assertion may
937
+ * describe causal machine work, but it is not a generic human-impersonation
938
+ * mechanism. The authenticated grant remains the authorization boundary.
939
+ */
940
+ export const ServiceTurnInitiator = z.object({
941
+ kind: z.literal("service"),
942
+ subjectId: z
943
+ .string()
944
+ .min(1)
945
+ .max(1024)
946
+ .refine((value) => value !== UNATTRIBUTED_LEGACY_INITIATOR_SUBJECT_ID, {
947
+ message: "unattributed-legacy is reserved for migrated rows",
948
+ }),
949
+ /** Immutable display snapshot; never an authorization input. */
950
+ label: z.string().min(1).max(256).optional(),
951
+ });
952
+ export type ServiceTurnInitiator = z.infer<typeof ServiceTurnInitiator>;
953
+
954
+ /**
955
+ * Immutable, non-secret provenance captured with an initiator. This is audit
956
+ * context (for example an external occurrence id), not a second identity or
957
+ * authorization surface.
958
+ */
959
+ export const TurnInitiatorContext = z.record(z.string(), z.unknown());
960
+ export type TurnInitiatorContext = z.infer<typeof TurnInitiatorContext>;
961
+
962
+ const reservedServiceTurnInitiatorContextKeys = new Set([
963
+ "backfill",
964
+ "label",
965
+ "provenanceError",
966
+ "via",
967
+ "viaTruncated",
968
+ ]);
969
+
970
+ /** Bounded host provenance that cannot forge OpenGeni-owned lineage fields. */
971
+ export const ServiceTurnInitiatorContext = TurnInitiatorContext.superRefine((value, ctx) => {
972
+ for (const key of reservedServiceTurnInitiatorContextKeys) {
973
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
974
+ ctx.addIssue({
975
+ code: z.ZodIssueCode.custom,
976
+ path: [key],
977
+ message: `${key} is reserved OpenGeni initiator context`,
978
+ });
979
+ }
980
+ }
981
+ try {
982
+ if (new TextEncoder().encode(JSON.stringify(value)).byteLength > 4096) {
983
+ ctx.addIssue({
984
+ code: z.ZodIssueCode.custom,
985
+ message: "service initiator context exceeds 4096 UTF-8 bytes",
986
+ });
987
+ }
988
+ } catch {
989
+ ctx.addIssue({
990
+ code: z.ZodIssueCode.custom,
991
+ message: "service initiator context must be JSON-serializable",
992
+ });
993
+ }
994
+ });
995
+ export type ServiceTurnInitiatorContext = z.infer<typeof ServiceTurnInitiatorContext>;
996
+
609
997
  export const AccountGrant = z.object({
610
998
  accountId: z.string().uuid(),
611
999
  subjectId: z.string().min(1),
@@ -623,6 +1011,10 @@ export const AccessGrant = z.object({
623
1011
  subjectLabel: z.string().optional(),
624
1012
  permissions: z.array(Permission),
625
1013
  metadata: z.record(z.string(), z.unknown()).optional(),
1014
+ // Optional trusted causal principal for a command submitted by an embedding
1015
+ // host. Authorization still uses subjectId + permissions above.
1016
+ serviceInitiator: ServiceTurnInitiator.optional(),
1017
+ serviceInitiatorContext: ServiceTurnInitiatorContext.optional(),
626
1018
  });
627
1019
  export type AccessGrant = z.infer<typeof AccessGrant>;
628
1020
 
@@ -637,34 +1029,76 @@ export const AccessContext = z.object({
637
1029
  });
638
1030
  export type AccessContext = z.infer<typeof AccessContext>;
639
1031
 
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
- });
1032
+ export const DelegatedAccessTokenPayload = z
1033
+ .object({
1034
+ accountId: z.string().uuid(),
1035
+ workspaceId: z.string().uuid(),
1036
+ subjectId: z.string().min(1),
1037
+ subjectLabel: z.string().optional(),
1038
+ permissions: z.array(Permission).min(1),
1039
+ // Trusted embedding hosts can sign a causal service principal separately
1040
+ // from the grant subject that authorizes the request. The claim is consumed
1041
+ // only when a command creates a new session/turn.
1042
+ serviceInitiator: ServiceTurnInitiator.optional(),
1043
+ serviceInitiatorContext: ServiceTurnInitiatorContext.optional(),
1044
+ // Worker-asserted session scope for first-party MCP calls (HMAC-signed, not
1045
+ // agent-controlled); enables session-scoped tools such as goal management.
1046
+ sessionId: z.string().uuid().optional(),
1047
+ // The turn making the call (the caller's identity), HMAC-signed by the worker
1048
+ // at turn setup. Lets a tool classify WHO is calling from the token itself,
1049
+ // instead of racily re-reading the session's live active_turn_id — e.g. the
1050
+ // sacred-pause guard must know if the CALLER is a machine child-notification
1051
+ // turn, and the active pointer can flip to another turn mid-check.
1052
+ turnId: z.string().uuid().optional(),
1053
+ // Exact execution owner. Agent control commands are accepted only while this
1054
+ // attempt still owns the signed turn.
1055
+ attemptId: z.string().uuid().optional(),
1056
+ executionGeneration: z.number().int().positive().optional(),
1057
+ exp: z.number().int().positive(),
1058
+ })
1059
+ .superRefine((payload, ctx) => {
1060
+ if (payload.serviceInitiatorContext && !payload.serviceInitiator) {
1061
+ ctx.addIssue({
1062
+ code: z.ZodIssueCode.custom,
1063
+ path: ["serviceInitiatorContext"],
1064
+ message: "serviceInitiatorContext requires serviceInitiator",
1065
+ });
1066
+ }
1067
+ if (
1068
+ payload.serviceInitiator &&
1069
+ (payload.turnId !== undefined ||
1070
+ payload.attemptId !== undefined ||
1071
+ payload.executionGeneration !== undefined)
1072
+ ) {
1073
+ ctx.addIssue({
1074
+ code: z.ZodIssueCode.custom,
1075
+ path: ["serviceInitiator"],
1076
+ message: "serviceInitiator cannot replace an exact agent-attempt initiator",
1077
+ });
1078
+ }
1079
+ });
657
1080
  export type DelegatedAccessTokenPayload = z.infer<typeof DelegatedAccessTokenPayload>;
658
1081
 
1082
+ const delegatedAccessTokenPrefix = "ogd_";
1083
+ const delegatedServiceAccessTokenPrefix = "ogd2_";
1084
+
659
1085
  export async function signDelegatedAccessToken(
660
1086
  secret: string,
661
1087
  payload: DelegatedAccessTokenPayload,
662
1088
  ): Promise<string> {
663
- const encodedPayload = base64UrlEncode(
664
- JSON.stringify(DelegatedAccessTokenPayload.parse(payload)),
1089
+ const parsed = DelegatedAccessTokenPayload.parse(payload);
1090
+ const prefix = parsed.serviceInitiator
1091
+ ? delegatedServiceAccessTokenPrefix
1092
+ : delegatedAccessTokenPrefix;
1093
+ const encodedPayload = base64UrlEncode(JSON.stringify(parsed));
1094
+ // The service-capable envelope binds its prefix into the signature. An old
1095
+ // verifier accepts only ogd_ and therefore fails closed during a rolling
1096
+ // deploy; changing ogd2_ to ogd_ cannot turn provenance loss into success.
1097
+ const signature = await hmacSha256Base64Url(
1098
+ secret,
1099
+ prefix === delegatedServiceAccessTokenPrefix ? `${prefix}${encodedPayload}` : encodedPayload,
665
1100
  );
666
- const signature = await hmacSha256Base64Url(secret, encodedPayload);
667
- return `ogd_${encodedPayload}.${signature}`;
1101
+ return `${prefix}${encodedPayload}.${signature}`;
668
1102
  }
669
1103
 
670
1104
  export async function verifyDelegatedAccessToken(
@@ -672,30 +1106,48 @@ export async function verifyDelegatedAccessToken(
672
1106
  token: string,
673
1107
  nowSeconds = Math.floor(Date.now() / 1000),
674
1108
  ): Promise<DelegatedAccessTokenPayload | null> {
675
- if (!token.startsWith("ogd_")) {
1109
+ const prefix = token.startsWith(delegatedServiceAccessTokenPrefix)
1110
+ ? delegatedServiceAccessTokenPrefix
1111
+ : token.startsWith(delegatedAccessTokenPrefix)
1112
+ ? delegatedAccessTokenPrefix
1113
+ : null;
1114
+ if (!prefix) {
676
1115
  return null;
677
1116
  }
678
- const withoutPrefix = token.slice("ogd_".length);
1117
+ const withoutPrefix = token.slice(prefix.length);
679
1118
  const dot = withoutPrefix.lastIndexOf(".");
680
1119
  if (dot <= 0) {
681
1120
  return null;
682
1121
  }
683
1122
  const encodedPayload = withoutPrefix.slice(0, dot);
684
1123
  const signature = withoutPrefix.slice(dot + 1);
685
- const expected = await hmacSha256Base64Url(secret, encodedPayload);
1124
+ const expected = await hmacSha256Base64Url(
1125
+ secret,
1126
+ prefix === delegatedServiceAccessTokenPrefix ? `${prefix}${encodedPayload}` : encodedPayload,
1127
+ );
686
1128
  if (!constantTimeEqual(signature, expected)) {
687
1129
  return null;
688
1130
  }
689
- const payload = DelegatedAccessTokenPayload.safeParse(
690
- JSON.parse(base64UrlDecode(encodedPayload)),
691
- );
1131
+ let decoded: unknown;
1132
+ try {
1133
+ decoded = JSON.parse(base64UrlDecode(encodedPayload));
1134
+ } catch {
1135
+ return null;
1136
+ }
1137
+ const payload = DelegatedAccessTokenPayload.safeParse(decoded);
692
1138
  if (!payload.success || payload.data.exp < nowSeconds) {
693
1139
  return null;
694
1140
  }
1141
+ if (
1142
+ (prefix === delegatedServiceAccessTokenPrefix) !==
1143
+ (payload.data.serviceInitiator !== undefined)
1144
+ ) {
1145
+ return null;
1146
+ }
695
1147
  return payload.data;
696
1148
  }
697
1149
 
698
- // --- Enrollment bearer credential (bring-your-own-compute M5, dossier §10.2) ---
1150
+ // --- Enrollment bearer credential (bring-your-own-compute M5) ---
699
1151
  //
700
1152
  // The signed bearer the agent presents to the control plane after enrollment (the
701
1153
  // EnrollmentCredentials.bearer the poll returns). REUSES the SAME HMAC envelope as
@@ -828,7 +1280,7 @@ export async function verifyEnrollToken(
828
1280
  return payload.data;
829
1281
  }
830
1282
 
831
- // --- Scoped data-plane stream token (master-spine §C.3 / crosscut PART 1.3) ---
1283
+ // --- Scoped data-plane stream token (sandbox contract §C.3 / crosscut PART 1.3) ---
832
1284
  //
833
1285
  // REUSES the existing HMAC envelope (sign/verifyDelegatedAccessToken's
834
1286
  // base64Url + hmacSha256Base64Url) — NOT a second crypto — but with a distinct
@@ -913,7 +1365,7 @@ export async function verifyStreamToken(
913
1365
  return payload.data;
914
1366
  }
915
1367
 
916
- // --- Relay PRODUCER token (bring-your-own-compute M8b, dossier §10.5) ---
1368
+ // --- Relay PRODUCER token (bring-your-own-compute M8b) ---
917
1369
  //
918
1370
  // The token the AGENT presents to the relay edge when it registers a pty/desktop
919
1371
  // stream channel (role=AGENT) — distinct from the viewer's `ogs_` token. It is
@@ -1147,7 +1599,11 @@ export type Entitlements = z.infer<typeof Entitlements>;
1147
1599
 
1148
1600
  export const LimitDecision = z.discriminatedUnion("allowed", [
1149
1601
  z.object({ allowed: z.literal(true) }),
1150
- z.object({ allowed: z.literal(false), code: z.string(), message: z.string() }),
1602
+ z.object({
1603
+ allowed: z.literal(false),
1604
+ code: z.string(),
1605
+ message: z.string(),
1606
+ }),
1151
1607
  ]);
1152
1608
  export type LimitDecision = z.infer<typeof LimitDecision>;
1153
1609
 
@@ -1195,10 +1651,21 @@ export type EntitlementsPort = {
1195
1651
  export const GitCredentialProvider = z.enum(["github", "gitlab", "azure_devops"]);
1196
1652
  export type GitCredentialProvider = z.infer<typeof GitCredentialProvider>;
1197
1653
 
1654
+ // Host-opaque identity for one independently mintable Git credential. It is
1655
+ // deliberately NOT constrained to a filesystem-safe alphabet: runtimes hash it
1656
+ // before using it in paths, command text, or environment variable names.
1657
+ export const GitCredentialBindingId = z.string().min(1).max(256);
1658
+ export type GitCredentialBindingId = z.infer<typeof GitCredentialBindingId>;
1659
+
1660
+ export const GitRepositoryAccess = z.enum(["read", "write"]);
1661
+ export type GitRepositoryAccess = z.infer<typeof GitRepositoryAccess>;
1662
+
1198
1663
  const GitProviderRepositoryId = z.union([z.number().int().positive(), z.string().min(1)]);
1199
1664
 
1200
1665
  export const GitCredentialRepositoryRef = z.object({
1201
1666
  provider: GitCredentialProvider.optional(),
1667
+ credentialBindingId: GitCredentialBindingId.optional(),
1668
+ access: GitRepositoryAccess.optional(),
1202
1669
  uri: z.string().min(1),
1203
1670
  ref: z.string().min(1),
1204
1671
  repositoryId: GitProviderRepositoryId.optional(),
@@ -1208,10 +1675,10 @@ export const GitCredentialRepositoryRef = z.object({
1208
1675
  });
1209
1676
  export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryRef>;
1210
1677
 
1211
- // ============ P4a — Connection-credential provider (§7.6) ============
1678
+ // ============ connection-credential provider — Connection-credential provider (§7.6) ============
1212
1679
  //
1213
- // The host-providable per-run credential-mint seam over OpenGeni's TWO
1214
- // run-scoped credential sites in the worker:
1680
+ // The host-providable credential seam over OpenGeni's run-scoped credential
1681
+ // sites in the worker and API:
1215
1682
  // - GIT credentials: run-scoped provider tokens minted in
1216
1683
  // `sandboxEnvironmentForRun` (standalone self-mints GitHub App tokens from
1217
1684
  // `settings`; embedded hosts can broker GitHub, GitLab, and Azure DevOps)
@@ -1219,6 +1686,8 @@ export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryR
1219
1686
  // - SANDBOX secrets: the decrypted variable set values loaded in
1220
1687
  // `loadVariableSetForRun` (today decrypted with
1221
1688
  // `environmentsEncryptionKeyBytes(settings)`).
1689
+ // - MCP credentials: request-time transport headers for connection-backed
1690
+ // servers, shared by normal model tools and Toolspace/Code Mode.
1222
1691
  //
1223
1692
  // In embedded/separate topologies the HOST owns these external connections
1224
1693
  // (its GitHub App, its secret vault + encryption key). When a host binds this
@@ -1226,7 +1695,7 @@ export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryR
1226
1695
  // from `settings`. Unset (standalone default) → byte-for-byte today's
1227
1696
  // self-mint.
1228
1697
  //
1229
- // FORK-7 CROSS-CHECK (the host-mapping safety guardrail): a credential
1698
+ // Workspace-scope cross-check (the host-mapping safety guardrail): a credential
1230
1699
  // provider returns the `workspaceId` it scoped the credential to, and the
1231
1700
  // activity ASSERTS it agrees with the run's workspace BEFORE injecting
1232
1701
  // any git provider token seed (or applying decrypted environment values). A host mapping bug that
@@ -1236,10 +1705,26 @@ export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryR
1236
1705
  export type GitCredentialsRequest = {
1237
1706
  accountId: string;
1238
1707
  workspaceId: string;
1708
+ /** Immutable authority admitted with the turn requesting this credential. */
1709
+ sessionId: string;
1710
+ rootSessionId: string;
1711
+ turnId: string;
1712
+ attemptId: string;
1713
+ executionGeneration: number;
1714
+ initiator: TurnInitiator;
1715
+ initiatorContext: TurnInitiatorContext;
1239
1716
  // Provider defaults to "github" for the legacy request shape. GitHub-only
1240
1717
  // hosts can keep reading installationId/repositoryIds exactly as before;
1241
1718
  // provider-aware hosts should branch on this and repositoryRefs.
1242
1719
  provider?: GitCredentialProvider;
1720
+ // Present when the host supplied an explicit binding or when more than one
1721
+ // independently mintable credential exists for this provider. A host must
1722
+ // mint only this binding; OpenGeni never treats provider identity as enough
1723
+ // to select among multiple accounts/installations.
1724
+ credentialBindingId?: GitCredentialBindingId;
1725
+ // Canonical lower-case host shared by this binding's repository refs when
1726
+ // there is exactly one. Binding-aware providers echo it when present.
1727
+ providerHost?: string;
1243
1728
  // Token requests are the existing behavior. Identity requests let lazy
1244
1729
  // sandbox provisioning resolve stable git author/committer identity before
1245
1730
  // the box exists while deferring the rotating token value to first provision.
@@ -1257,9 +1742,16 @@ export type GitCredentials = {
1257
1742
  // purpose="identity" so hosts can return only stable git identity before lazy
1258
1743
  // sandbox provision. The value never enters the manifest.
1259
1744
  token?: string;
1260
- // FORK-7 echo: the workspace the provider scoped this token to. The activity
1745
+ // workspace-scope cross-check echo: the workspace the provider scoped this token to. The activity
1261
1746
  // asserts `workspaceId === request.workspaceId` before injecting.
1262
1747
  workspaceId: string;
1748
+ // Strict request echoes for binding-aware requests. OpenGeni validates these
1749
+ // before accepting a token, preventing a host routing bug from returning a
1750
+ // sibling connection's credential. They remain optional for legacy single-
1751
+ // binding/provider hosts.
1752
+ credentialBindingId?: GitCredentialBindingId;
1753
+ provider?: GitCredentialProvider;
1754
+ providerHost?: string;
1263
1755
  // Optional provider expiry for host-managed proactive renewal. ISO-8601;
1264
1756
  // null/omitted means the host does not expose a deadline and OpenGeni uses
1265
1757
  // its conservative bounded refresh cadence instead.
@@ -1282,7 +1774,7 @@ export type SandboxSecrets = {
1282
1774
  // `environmentsEncryptionKeyBytes` decrypt. Same shape the self-mint path
1283
1775
  // produces (plaintext name→value).
1284
1776
  values: Record<string, string>;
1285
- // FORK-7 echo: the workspace the provider scoped these secrets to.
1777
+ // workspace-scope cross-check echo: the workspace the provider scoped these secrets to.
1286
1778
  workspaceId: string;
1287
1779
  // Optional variableSet metadata; when omitted the activity uses the
1288
1780
  // variableSetId as both id and name (the local decrypt carries the row's
@@ -1292,24 +1784,266 @@ export type SandboxSecrets = {
1292
1784
  description?: string | null;
1293
1785
  };
1294
1786
 
1787
+ export type CredentialAuthNeededReason =
1788
+ | "missing_connection"
1789
+ | "expired"
1790
+ | "insufficient_scope"
1791
+ | "refresh_failed";
1792
+
1793
+ /**
1794
+ * Host-owned run credentials are materialized below one OpenGeni-owned sandbox
1795
+ * directory. Paths are relative POSIX names; the runtime validates traversal,
1796
+ * collisions, bounds, and modes before any content reaches a sandbox.
1797
+ */
1798
+ export type RunCredentialFile = {
1799
+ path: string;
1800
+ content: string;
1801
+ mode?: "0400" | "0600";
1802
+ };
1803
+
1804
+ export type RunCredentialAuthNeeded = {
1805
+ reason: CredentialAuthNeededReason;
1806
+ providerDomain?: string;
1807
+ connectionId?: string;
1808
+ scopes?: string[];
1809
+ resource?: string;
1810
+ authorizationUrl?: string;
1811
+ /** Bounded non-secret guidance. Never place credential material here. */
1812
+ message?: string;
1813
+ };
1814
+
1815
+ export type RunCredentialRedaction = {
1816
+ /** Bounded diagnostic label used only in the replacement marker. */
1817
+ name: string;
1818
+ /** One atomic secret value that must be removed from streamed/audit output. */
1819
+ value: string;
1820
+ };
1821
+
1822
+ export type RunCredentialsRequest = {
1823
+ accountId: string;
1824
+ workspaceId: string;
1825
+ sessionId: string;
1826
+ parentSessionId: string | null;
1827
+ rootSessionId: string;
1828
+ /** All sessions sharing this sandbox group share one OS/filesystem trust boundary. */
1829
+ sandboxGroupId: string;
1830
+ turnId: string;
1831
+ attemptId: string;
1832
+ executionGeneration: number;
1833
+ /** Immutable authority admitted with this turn. */
1834
+ initiator: TurnInitiator;
1835
+ initiatorContext: TurnInitiatorContext;
1836
+ effectiveSandboxBackend: SandboxBackend;
1837
+ sandboxOs: SandboxOs;
1838
+ purpose: "provision" | "renewal";
1839
+ forceRefresh: boolean;
1840
+ /** Informational standalone variable-set identity; never gates host resolution. */
1841
+ variableSet: { id: string; name: string } | null;
1842
+ };
1843
+
1844
+ export type RunCredentialsResolution =
1845
+ | {
1846
+ /**
1847
+ * The frozen target/attempt must not receive host material. Hosts use
1848
+ * this for unsupported OSes/backends and policy-based opt-out; the
1849
+ * decision must remain stable for the attempt.
1850
+ */
1851
+ status: "not_applicable";
1852
+ accountId: string;
1853
+ workspaceId: string;
1854
+ sessionId: string;
1855
+ }
1856
+ | {
1857
+ status: "ok";
1858
+ /** Scope echoes are mandatory and checked before materialization. */
1859
+ accountId: string;
1860
+ workspaceId: string;
1861
+ sessionId: string;
1862
+ /** Secret environment values. Always delivered off-manifest. */
1863
+ environment: Record<string, string>;
1864
+ files?: RunCredentialFile[];
1865
+ /** Environment name to one returned relative file path. */
1866
+ fileEnvironment?: Record<string, string>;
1867
+ /**
1868
+ * Atomic sensitive values embedded inside credential files or derived
1869
+ * material. Environment values are registered automatically; hosts list
1870
+ * additional file-contained values here so chunked output is redacted.
1871
+ */
1872
+ redactions?: RunCredentialRedaction[];
1873
+ /** Earliest material expiry. Null/omitted uses a bounded refresh cadence. */
1874
+ expiresAt?: string | null;
1875
+ /** Partial degradation: usable material may coexist with reconnect notices. */
1876
+ authNeeded?: RunCredentialAuthNeeded[];
1877
+ }
1878
+ | {
1879
+ status: "auth_needed";
1880
+ accountId: string;
1881
+ workspaceId: string;
1882
+ sessionId: string;
1883
+ authNeeded: RunCredentialAuthNeeded[];
1884
+ };
1885
+
1886
+ export const McpConnectionResourceScope = z
1887
+ .object({
1888
+ /** Provider-stable repository identity, serialized as a string on the wire. */
1889
+ id: z.string().min(1).max(512),
1890
+ kind: z.literal("repository"),
1891
+ })
1892
+ .strict();
1893
+ export type McpConnectionResourceScope = z.infer<typeof McpConnectionResourceScope>;
1894
+
1895
+ const McpConnectionResourceScopes = z
1896
+ .array(McpConnectionResourceScope)
1897
+ .min(1)
1898
+ .max(256)
1899
+ .superRefine((resources, context) => {
1900
+ const seen = new Set<string>();
1901
+ for (const [index, resource] of resources.entries()) {
1902
+ const key = `${resource.kind}\0${resource.id}`;
1903
+ if (seen.has(key)) {
1904
+ context.addIssue({
1905
+ code: "custom",
1906
+ message: "selectedResources must not contain duplicates",
1907
+ path: [index],
1908
+ });
1909
+ }
1910
+ seen.add(key);
1911
+ }
1912
+ });
1913
+
1914
+ export const McpServerConnectionRef = z
1915
+ .object({
1916
+ /** Opaque host or standalone connection identifier. */
1917
+ connectionId: z.string().min(1).optional(),
1918
+ /** Stable provider family (for example github, gitlab, or azure_devops). */
1919
+ provider: z.string().min(1).max(128).optional(),
1920
+ /** Provider host or tenant domain. */
1921
+ providerDomain: z.string().min(1),
1922
+ kind: z.enum(["oauth2", "api_key", "app_install", "delegated"]).optional(),
1923
+ scopes: z.array(z.string().min(1)).optional(),
1924
+ /** OAuth resource indicator. This is distinct from selectedResources. */
1925
+ resource: z.string().min(1).optional(),
1926
+ /** Exact provider resources this MCP binding is allowed to operate on. */
1927
+ selectedResources: McpConnectionResourceScopes.optional(),
1928
+ subjectScope: z.enum(["workspace", "subject"]).optional(),
1929
+ })
1930
+ .strict()
1931
+ .superRefine((reference, context) => {
1932
+ if (!reference.selectedResources) return;
1933
+ if (!reference.connectionId) {
1934
+ context.addIssue({
1935
+ code: "custom",
1936
+ message: "selectedResources requires connectionId",
1937
+ path: ["connectionId"],
1938
+ });
1939
+ }
1940
+ if (!reference.provider) {
1941
+ context.addIssue({
1942
+ code: "custom",
1943
+ message: "selectedResources requires provider",
1944
+ path: ["provider"],
1945
+ });
1946
+ }
1947
+ });
1948
+ export type McpServerConnectionRef = z.infer<typeof McpServerConnectionRef>;
1949
+
1950
+ export type McpCredentialsRequest = {
1951
+ accountId: string;
1952
+ workspaceId: string;
1953
+ /** Immediate session whose model or Toolspace call needs the credential. */
1954
+ sessionId: string;
1955
+ /** Workspace-scoped lineage root for host authorization and binding lookup. */
1956
+ rootSessionId: string;
1957
+ turnId: string;
1958
+ /** Null only while a durable turn exists without a currently executing attempt. */
1959
+ attemptId: string | null;
1960
+ executionGeneration: number;
1961
+ /** The immutable authority that admitted this turn. Never substitute the sandbox caller. */
1962
+ initiator: TurnInitiator;
1963
+ initiatorContext: TurnInitiatorContext;
1964
+ /** Immediate technical caller, retained only as non-authoritative audit context. */
1965
+ callerSubjectId?: string;
1966
+ surface: "model" | "toolspace";
1967
+ serverId: string;
1968
+ toolName?: string;
1969
+ connectionRef: McpServerConnectionRef;
1970
+ forceRefresh: boolean;
1971
+ };
1972
+
1973
+ export type McpCredentialAuthNeededReason =
1974
+ | CredentialAuthNeededReason
1975
+ | "unsupported_auth"
1976
+ | "resource_scope_unavailable";
1977
+
1978
+ export type McpCredentialResolution =
1979
+ | {
1980
+ status: "ok";
1981
+ /** Scope echoes are mandatory and verified before any header is used. */
1982
+ accountId: string;
1983
+ workspaceId: string;
1984
+ sessionId: string;
1985
+ headers: Record<string, string>;
1986
+ connectionId: string;
1987
+ providerDomain: string;
1988
+ provider?: string;
1989
+ scopes?: string[];
1990
+ resource?: string;
1991
+ selectedResources?: McpConnectionResourceScope[];
1992
+ expiresAt?: string | null;
1993
+ }
1994
+ | {
1995
+ status: "auth_needed";
1996
+ /** Scope echoes are mandatory even when the credential cannot be resolved. */
1997
+ accountId: string;
1998
+ workspaceId: string;
1999
+ sessionId: string;
2000
+ reason: McpCredentialAuthNeededReason;
2001
+ providerDomain: string;
2002
+ provider?: string;
2003
+ connectionId?: string;
2004
+ scopes?: string[];
2005
+ resource?: string;
2006
+ selectedResources?: McpConnectionResourceScope[];
2007
+ authorizationUrl?: string;
2008
+ };
2009
+
1295
2010
  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.
2011
+ // Every leg is optional: a host may drive only the credential classes it
2012
+ // owns. An unset leg falls through to today's standalone implementation for
2013
+ // that leg only.
1299
2014
  gitCredentials?(input: GitCredentialsRequest): Promise<GitCredentials>;
1300
2015
  sandboxSecrets?(input: SandboxSecretsRequest): Promise<SandboxSecrets>;
2016
+ /**
2017
+ * Resolve host-owned, session-aware sandbox credentials independently of an
2018
+ * OpenGeni variable set. OpenGeni transports and renews the material; the host
2019
+ * remains the sole owner of connection selection and credential policy.
2020
+ */
2021
+ runCredentials?(input: RunCredentialsRequest): Promise<RunCredentialsResolution>;
2022
+ /**
2023
+ * Resolve rotating MCP transport credentials at request time. Embedded hosts
2024
+ * use this to keep their provider connection as the sole credential source;
2025
+ * OpenGeni never requires a duplicate connection record. The same resolver is
2026
+ * used by model-visible MCP tools and the additive Toolspace/Code Mode proxy.
2027
+ */
2028
+ mcpCredentials?(input: McpCredentialsRequest): Promise<McpCredentialResolution>;
1301
2029
  };
1302
2030
 
1303
- // ============ P4a — GitHub App API port (BYO-App, §7.6 / SPIKE-2 remainder) ===
2031
+ // ============ connection-credential provider — GitHub App API port (BYO-App, §7.6 / GitHub credential prototype remainder) ===
1304
2032
  //
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
2033
+ // The host-driven GitHub-API credential leg. GitHub credential prototype closed the establishment +
2034
+ // gate (storage) axis; this closes the credential leg by making the live
1307
2035
  // GitHub-API calls host-PROVIDABLE so a BYO-GitHub-App host drives its OWN App
1308
2036
  // credentials (its own JWT-signing key, its own OAuth client) instead of
1309
2037
  // OpenGeni self-minting from `settings`:
1310
- // - verifyInstallationAccessForUser: the OAuth code→token + installation
1311
- // lookup that PROVES the install is real (today
1312
- // `verifyGitHubInstallationAccessForUser(settings, …)`).
2038
+ // - authorizeUser: OAuth code exchange + user-visible installation and
2039
+ // repository permission discovery. Retained for provider ABI compatibility;
2040
+ // visibility is not proof of installation authority and core does not use
2041
+ // this method for new workspace binding.
2042
+ // - verifyInstallationAccessForUser: OAuth code→token + installation lookup,
2043
+ // also retained for provider ABI compatibility and not used for binding.
2044
+ // - getInstallation: retained in the provider ABI for compatibility. Direct
2045
+ // existing-installation selection is fail-closed and core does not call
2046
+ // this method for binding.
1313
2047
  // - listRepositories: the installation-scoped repo listing behind
1314
2048
  // `GET /v1/workspaces/:id/github/repositories` (today
1315
2049
  // `listGitHubAppRepositories(settings, …)`).
@@ -1324,11 +2058,31 @@ export type GitHubInstallationSummary = {
1324
2058
  suspended: boolean;
1325
2059
  };
1326
2060
 
2061
+ export type GitHubRepositoryPermissions = {
2062
+ admin: boolean;
2063
+ maintain: boolean;
2064
+ push: boolean;
2065
+ triage: boolean;
2066
+ pull: boolean;
2067
+ };
2068
+
2069
+ export type GitHubUserRepositoryAccess = GitHubRepository & {
2070
+ permissions: GitHubRepositoryPermissions;
2071
+ };
2072
+
2073
+ export type GitHubUserInstallationAccess = GitHubInstallationSummary & {
2074
+ repositories: GitHubUserRepositoryAccess[];
2075
+ };
2076
+
1327
2077
  export type GitHubAppApiPort = {
2078
+ authorizeUser?: (input: { code: string }) => Promise<GitHubUserInstallationAccess[]>;
1328
2079
  verifyInstallationAccessForUser?: (input: {
1329
2080
  code: string;
1330
2081
  installationId: number;
1331
2082
  }) => Promise<GitHubInstallationSummary>;
2083
+ getInstallation?: (input: {
2084
+ installationId: number;
2085
+ }) => Promise<GitHubInstallationSummary | null>;
1332
2086
  listRepositories?: (input: { installationIds?: number[] }) => Promise<GitHubRepository[]>;
1333
2087
  };
1334
2088
 
@@ -1368,6 +2122,8 @@ export const RepositoryResourceRef = z.object({
1368
2122
  mountPath: z.string().min(1).optional(),
1369
2123
  subpath: z.string().min(1).optional(),
1370
2124
  provider: GitCredentialProvider.optional(),
2125
+ credentialBindingId: GitCredentialBindingId.optional(),
2126
+ access: GitRepositoryAccess.optional(),
1371
2127
  repositoryId: GitProviderRepositoryId.optional(),
1372
2128
  installationId: GitProviderRepositoryId.optional(),
1373
2129
  projectId: GitProviderRepositoryId.optional(),
@@ -1377,6 +2133,53 @@ export const RepositoryResourceRef = z.object({
1377
2133
  });
1378
2134
  export type RepositoryResourceRef = z.infer<typeof RepositoryResourceRef>;
1379
2135
 
2136
+ function positiveGitProviderInteger(value: unknown): number | null {
2137
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
2138
+ if (typeof value === "string" && /^\d+$/.test(value) && Number(value) > 0) {
2139
+ return Number(value);
2140
+ }
2141
+ return null;
2142
+ }
2143
+
2144
+ /**
2145
+ * Resolve whether a repository participates in platform-brokered Git auth.
2146
+ * Provider-less public repositories return null; legacy GitHub aliases infer
2147
+ * GitHub only when both positive installation and repository ids are present.
2148
+ */
2149
+ export function gitCredentialProviderForRepository(
2150
+ resource: RepositoryResourceRef,
2151
+ ): GitCredentialProvider | null {
2152
+ if (resource.provider) return resource.provider;
2153
+ if (
2154
+ positiveGitProviderInteger(resource.githubInstallationId) &&
2155
+ positiveGitProviderInteger(resource.githubRepositoryId)
2156
+ ) {
2157
+ return "github";
2158
+ }
2159
+ return null;
2160
+ }
2161
+
2162
+ /**
2163
+ * Derive the one canonical runtime/broker identity for a repository credential.
2164
+ * Every consumer must use this helper so mint grouping, token filenames, and
2165
+ * credential-helper routing cannot diverge on legacy provider ids.
2166
+ */
2167
+ export function gitCredentialBindingIdForRepository(
2168
+ resource: RepositoryResourceRef,
2169
+ provider: GitCredentialProvider | null = gitCredentialProviderForRepository(resource),
2170
+ ): GitCredentialBindingId | null {
2171
+ if (!provider) return null;
2172
+ const installationId =
2173
+ provider === "github"
2174
+ ? positiveGitProviderInteger(resource.githubInstallationId ?? resource.installationId)
2175
+ : null;
2176
+ return (
2177
+ resource.credentialBindingId ??
2178
+ resource.connectionId ??
2179
+ (installationId ? `github-installation:${installationId}` : provider)
2180
+ );
2181
+ }
2182
+
1380
2183
  export const FileResourceRef = z.object({
1381
2184
  kind: z.literal("file"),
1382
2185
  fileId: z.string().uuid(),
@@ -1387,6 +2190,109 @@ export type FileResourceRef = z.infer<typeof FileResourceRef>;
1387
2190
  export const ResourceRef = z.discriminatedUnion("kind", [RepositoryResourceRef, FileResourceRef]);
1388
2191
  export type ResourceRef = z.infer<typeof ResourceRef>;
1389
2192
 
2193
+ export class ResourceMountPathError extends Error {
2194
+ constructor(message: string) {
2195
+ super(message);
2196
+ this.name = "ResourceMountPathError";
2197
+ }
2198
+ }
2199
+
2200
+ /**
2201
+ * Normalize one workspace-relative resource mount path for every runtime.
2202
+ *
2203
+ * Backslashes are treated as separators so a path cannot be harmless on Linux
2204
+ * but become traversal on a connected Windows machine. Empty, absolute,
2205
+ * drive-qualified, dot-segment, NUL-containing, and repeated-separator paths
2206
+ * fail closed instead of being silently reinterpreted.
2207
+ */
2208
+ export function normalizeResourceMountPath(path: string): string {
2209
+ const normalizedSeparators = path.trim().replace(/\\/g, "/");
2210
+ if (
2211
+ !normalizedSeparators ||
2212
+ normalizedSeparators.startsWith("/") ||
2213
+ /^[A-Za-z]:\//.test(normalizedSeparators) ||
2214
+ normalizedSeparators.includes("\0")
2215
+ ) {
2216
+ throw new ResourceMountPathError(`invalid resource mount path: ${path}`);
2217
+ }
2218
+ const segments = normalizedSeparators.split("/");
2219
+ if (
2220
+ segments.some(
2221
+ (segment) =>
2222
+ !segment ||
2223
+ segment === "." ||
2224
+ segment === ".." ||
2225
+ /[<>:"|?*\u0000-\u001f]/.test(segment) ||
2226
+ /[ .]$/.test(segment) ||
2227
+ /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(segment),
2228
+ )
2229
+ ) {
2230
+ throw new ResourceMountPathError(`invalid resource mount path: ${path}`);
2231
+ }
2232
+ return segments.join("/");
2233
+ }
2234
+
2235
+ /** Normalize a repository-internal subpath while preserving legacy `/path/` input. */
2236
+ export function normalizeRepositorySubpath(path: string): string {
2237
+ const relative = path
2238
+ .trim()
2239
+ .replace(/\\/g, "/")
2240
+ .replace(/^\/+|\/+$/g, "");
2241
+ return normalizeResourceMountPath(relative);
2242
+ }
2243
+
2244
+ /** A conservative collision identity that is portable to case-insensitive hosts. */
2245
+ export function resourceMountPathCollisionKey(path: string): string {
2246
+ return normalizeResourceMountPath(path).normalize("NFKC").toLowerCase();
2247
+ }
2248
+
2249
+ /**
2250
+ * Default repository mount identity. The normalized remote host (including a
2251
+ * non-default port) is part of the path, so equal owner/repo names on GitHub,
2252
+ * GitLab, Azure DevOps, or a custom host do not collide. Encoding the host keeps
2253
+ * IPv6/custom-port identities inside one portable path segment.
2254
+ */
2255
+ export function defaultRepositoryMountPath(uri: string): string {
2256
+ let url: URL;
2257
+ try {
2258
+ url = new URL(uri);
2259
+ } catch {
2260
+ throw new ResourceMountPathError(`invalid repository URI for mount path: ${uri}`);
2261
+ }
2262
+ if (url.protocol !== "https:" || !url.host) {
2263
+ throw new ResourceMountPathError(`invalid repository URI for mount path: ${uri}`);
2264
+ }
2265
+ const repositoryPath = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
2266
+ const segments = repositoryPath.split("/").filter(Boolean);
2267
+ if (segments.length < 2) {
2268
+ throw new ResourceMountPathError(`repository URI must include owner and repo: ${uri}`);
2269
+ }
2270
+ return normalizeResourceMountPath(
2271
+ `repos/${encodeURIComponent(url.host.toLowerCase())}/${segments.join("/")}`,
2272
+ );
2273
+ }
2274
+
2275
+ /** Resolve the exact mount used by API normalization, manifests, and clone hooks. */
2276
+ export function resourceMountPath(resource: ResourceRef): string {
2277
+ if (resource.mountPath) return normalizeResourceMountPath(resource.mountPath);
2278
+ return resource.kind === "file"
2279
+ ? normalizeResourceMountPath(`files/${resource.fileId}`)
2280
+ : defaultRepositoryMountPath(resource.uri);
2281
+ }
2282
+
2283
+ /** Fail before sandbox execution when two resources share a portable path. */
2284
+ export function assertUniqueResourceMountPaths(resources: readonly ResourceRef[]): void {
2285
+ const mounted = new Set<string>();
2286
+ for (const resource of resources) {
2287
+ const path = resourceMountPath(resource);
2288
+ const key = resourceMountPathCollisionKey(path);
2289
+ if (mounted.has(key)) {
2290
+ throw new ResourceRefConflictError(`resource mount path is already attached: ${path}`);
2291
+ }
2292
+ mounted.add(key);
2293
+ }
2294
+ }
2295
+
1390
2296
  export const FileStatus = z.enum(["pending_upload", "ready", "failed", "expired", "deleted"]);
1391
2297
  export type FileStatus = z.infer<typeof FileStatus>;
1392
2298
 
@@ -1728,6 +2634,9 @@ export const SessionMcpServerInput = z.object({
1728
2634
  // Write-only credential headers. Values are encrypted at rest and never
1729
2635
  // returned in session responses or events; response metadata exposes names.
1730
2636
  headers: z.record(z.string(), z.string()).optional(),
2637
+ // Non-secret opaque pointer resolved at request time by the standalone
2638
+ // connection broker or an embedding host's mcpCredentials port.
2639
+ connectionRef: McpServerConnectionRef.optional(),
1731
2640
  });
1732
2641
  export type SessionMcpServerInput = z.infer<typeof SessionMcpServerInput>;
1733
2642
 
@@ -1744,6 +2653,7 @@ export const SessionMcpServerMetadata = z
1744
2653
  url: httpsUrl,
1745
2654
  headerNames: z.array(z.string()).default([]),
1746
2655
  credentialVersion: z.number().int().positive(),
2656
+ connectionRef: McpServerConnectionRef.nullable().default(null),
1747
2657
  })
1748
2658
  .strict();
1749
2659
  export type SessionMcpServerMetadata = z.infer<typeof SessionMcpServerMetadata>;
@@ -1783,10 +2693,14 @@ export function mergeResourceRefs(
1783
2693
  additions: ResourceRef[],
1784
2694
  options: { rejectConflicts?: boolean } = {},
1785
2695
  ): ResourceRef[] {
2696
+ if (options.rejectConflicts) {
2697
+ assertUniqueResourceMountPaths(existing);
2698
+ }
1786
2699
  const out = [...existing];
1787
2700
  const mountPaths = new Map(
1788
- existing.flatMap((resource) =>
1789
- resource.mountPath ? [[resource.mountPath, stableJson(resource)] as const] : [],
2701
+ existing.map(
2702
+ (resource) =>
2703
+ [resourceMountPathCollisionKey(resourceMountPath(resource)), stableJson(resource)] as const,
1790
2704
  ),
1791
2705
  );
1792
2706
  const identities = new Map(
@@ -1800,11 +2714,10 @@ export function mergeResourceRefs(
1800
2714
  continue;
1801
2715
  }
1802
2716
  if (options.rejectConflicts) {
1803
- const existingAtMount = resource.mountPath ? mountPaths.get(resource.mountPath) : undefined;
2717
+ const mountPath = resourceMountPath(resource);
2718
+ const existingAtMount = mountPaths.get(resourceMountPathCollisionKey(mountPath));
1804
2719
  if (existingAtMount && existingAtMount !== serialized) {
1805
- throw new ResourceRefConflictError(
1806
- `resource mount path is already attached: ${resource.mountPath}`,
1807
- );
2720
+ throw new ResourceRefConflictError(`resource mount path is already attached: ${mountPath}`);
1808
2721
  }
1809
2722
  const identity = resourceIdentityKey(resource);
1810
2723
  const existingIdentity = identities.get(identity);
@@ -1817,9 +2730,7 @@ export function mergeResourceRefs(
1817
2730
  out.push(resource);
1818
2731
  exact.add(serialized);
1819
2732
  identities.set(resourceIdentityKey(resource), serialized);
1820
- if (resource.mountPath) {
1821
- mountPaths.set(resource.mountPath, serialized);
1822
- }
2733
+ mountPaths.set(resourceMountPathCollisionKey(resourceMountPath(resource)), serialized);
1823
2734
  }
1824
2735
  return out;
1825
2736
  }
@@ -1874,6 +2785,7 @@ export const SessionTurnStatus = z.enum([
1874
2785
  "failed",
1875
2786
  "cancelled",
1876
2787
  "superseded",
2788
+ "withdrawn_for_edit",
1877
2789
  ]);
1878
2790
  export type SessionTurnStatus = z.infer<typeof SessionTurnStatus>;
1879
2791
 
@@ -1987,7 +2899,9 @@ export type ClearSessionContextRequest = z.infer<typeof ClearSessionContextReque
1987
2899
  export const CLEARED_RUN_STATE_MARKER = "$opengeniCleared" as const;
1988
2900
 
1989
2901
  /** The canonical sentinel serializedRunState value a context clear stores. */
1990
- export const CLEARED_RUN_STATE_BLOB = JSON.stringify({ [CLEARED_RUN_STATE_MARKER]: true });
2902
+ export const CLEARED_RUN_STATE_BLOB = JSON.stringify({
2903
+ [CLEARED_RUN_STATE_MARKER]: true,
2904
+ });
1991
2905
 
1992
2906
  /**
1993
2907
  * True when a serialized run-state blob is the cleared sentinel rather than a
@@ -2027,6 +2941,168 @@ export const CompactSessionContextResult = z.object({
2027
2941
  });
2028
2942
  export type CompactSessionContextResult = z.infer<typeof CompactSessionContextResult>;
2029
2943
 
2944
+ /**
2945
+ * The principal whose authority accepted a session or turn. `subjectId` is an
2946
+ * opaque host/standalone identity and therefore must never encode `kind` by
2947
+ * convention: embedding hosts own their subject namespace.
2948
+ */
2949
+ export const TurnInitiator = z.object({
2950
+ kind: z.enum(["subject", "service"]),
2951
+ ...turnInitiatorIdentityFields,
2952
+ });
2953
+ export type TurnInitiator = z.infer<typeof TurnInitiator>;
2954
+
2955
+ // ============ embedding host session authorization ============
2956
+ //
2957
+ // Workspace permissions answer whether a principal may use an OpenGeni
2958
+ // capability. An embedding host can additionally own per-session visibility
2959
+ // (ownership, sharing, nested workspaces, revocation). This port is the one
2960
+ // host-neutral boundary for that second decision. Inputs contain OpenGeni ids
2961
+ // and immutable, non-secret authority only; host records and policy details
2962
+ // never cross the boundary.
2963
+
2964
+ export const SessionAuthorizationSurface = z.enum([
2965
+ "http",
2966
+ "core",
2967
+ "stream",
2968
+ "first_party_mcp",
2969
+ "toolspace",
2970
+ ]);
2971
+ export type SessionAuthorizationSurface = z.infer<typeof SessionAuthorizationSurface>;
2972
+
2973
+ export const SessionAuthorizationOperation = z.enum([
2974
+ "session.read",
2975
+ "session.events.read",
2976
+ "session.stream.read",
2977
+ "session.stream.acknowledge",
2978
+ "session.turns.read",
2979
+ "session.append",
2980
+ "session.steer",
2981
+ "session.control",
2982
+ "session.queue.read",
2983
+ "session.queue.control",
2984
+ "session.composer.read",
2985
+ "session.composer.write",
2986
+ "session.lineage.read",
2987
+ "session.capture.read",
2988
+ "session.files.read",
2989
+ "session.files.write",
2990
+ "session.git.read",
2991
+ "session.terminal.read",
2992
+ "session.terminal.control",
2993
+ "session.viewer.read",
2994
+ "session.viewer.control",
2995
+ "session.first_party_mcp.call",
2996
+ "session.toolspace.call",
2997
+ "session.pin.write",
2998
+ "session.codex_account.write",
2999
+ "session.context.write",
3000
+ "session.approval.write",
3001
+ "session.human_input.read",
3002
+ "session.human_input.write",
3003
+ "session.title.write",
3004
+ "session.goal.read",
3005
+ "session.goal.write",
3006
+ "session.child.create",
3007
+ ]);
3008
+ export type SessionAuthorizationOperation = z.infer<typeof SessionAuthorizationOperation>;
3009
+
3010
+ export const SessionAuthorizationActor = z.discriminatedUnion("kind", [
3011
+ z.object({
3012
+ kind: z.literal("subject"),
3013
+ subjectId: z.string().min(1),
3014
+ subjectLabel: z.string().min(1).optional(),
3015
+ }),
3016
+ z.object({
3017
+ kind: z.literal("agent_attempt"),
3018
+ /** Technical, authenticated first-party caller (not the host authority). */
3019
+ subjectId: z.string().min(1),
3020
+ callerSessionId: z.string().uuid(),
3021
+ callerRootSessionId: z.string().uuid(),
3022
+ turnId: z.string().uuid(),
3023
+ attemptId: z.string().uuid(),
3024
+ executionGeneration: z.number().int().positive(),
3025
+ /** Frozen authority that admitted the calling turn. */
3026
+ initiator: TurnInitiator,
3027
+ initiatorContext: TurnInitiatorContext,
3028
+ }),
3029
+ ]);
3030
+ export type SessionAuthorizationActor = z.infer<typeof SessionAuthorizationActor>;
3031
+
3032
+ export const SessionAuthorizationTarget = z.object({
3033
+ sessionId: z.string().uuid(),
3034
+ /** Server-resolved workspace lineage root; never accepted from a caller. */
3035
+ rootSessionId: z.string().uuid(),
3036
+ });
3037
+ export type SessionAuthorizationTarget = z.infer<typeof SessionAuthorizationTarget>;
3038
+
3039
+ export type AuthorizeSessionInput = {
3040
+ accountId: string;
3041
+ workspaceId: string;
3042
+ actor: SessionAuthorizationActor;
3043
+ target: SessionAuthorizationTarget;
3044
+ operation: SessionAuthorizationOperation;
3045
+ surface: SessionAuthorizationSurface;
3046
+ };
3047
+
3048
+ export const SessionAuthorizationDecision = z.discriminatedUnion("allowed", [
3049
+ z.object({
3050
+ allowed: z.literal(true),
3051
+ /**
3052
+ * Whether related-session metadata may be projected with the target.
3053
+ * `target` is the fail-closed default for exact shares; `root` permits the
3054
+ * target's full lineage tree. This does not authorize a separate operation
3055
+ * against another session, which always requires its own decision.
3056
+ */
3057
+ relatedSessionAccess: z.enum(["target", "root"]).optional(),
3058
+ /** A host may request a tighter stream reauthorization bound. */
3059
+ reauthorizeAfterMs: z.number().int().min(1_000).max(60_000).optional(),
3060
+ }),
3061
+ z.object({
3062
+ allowed: z.literal(false),
3063
+ reason: z.enum(["not_found", "forbidden", "revoked"]),
3064
+ }),
3065
+ ]);
3066
+ export type SessionAuthorizationDecision = z.infer<typeof SessionAuthorizationDecision>;
3067
+
3068
+ /**
3069
+ * A database-applicable listing scope. `rootSessionIds` includes every
3070
+ * descendant of those lineage anchors; `sessionIds` authorizes only the exact
3071
+ * sessions. Supplying neither is an explicit empty scope. OpenGeni intersects
3072
+ * every id with the requested workspace and never trusts a host scope as
3073
+ * session existence evidence.
3074
+ */
3075
+ export const SESSION_AUTHORIZATION_LIST_SCOPE_MAX_IDS = 10_000;
3076
+
3077
+ export const SessionAuthorizationListScope = z.discriminatedUnion("kind", [
3078
+ z.object({ kind: z.literal("all") }),
3079
+ z.object({
3080
+ kind: z.literal("scoped"),
3081
+ rootSessionIds: z.array(z.string().uuid()).max(SESSION_AUTHORIZATION_LIST_SCOPE_MAX_IDS),
3082
+ sessionIds: z.array(z.string().uuid()).max(SESSION_AUTHORIZATION_LIST_SCOPE_MAX_IDS),
3083
+ }),
3084
+ ]);
3085
+ export type SessionAuthorizationListScope = z.infer<typeof SessionAuthorizationListScope>;
3086
+
3087
+ export type ResolveSessionAuthorizationListScopeInput = {
3088
+ accountId: string;
3089
+ workspaceId: string;
3090
+ actor: SessionAuthorizationActor;
3091
+ surface: SessionAuthorizationSurface;
3092
+ };
3093
+
3094
+ export type SessionAuthorizationPort = {
3095
+ authorizeSession(input: AuthorizeSessionInput): Promise<SessionAuthorizationDecision>;
3096
+ /**
3097
+ * Return the complete current scope used inside OpenGeni's cursor query.
3098
+ * This is deliberately not a post-filter callback: search, pinning, ordering,
3099
+ * totals, and cursor advancement must all operate on authorized rows.
3100
+ */
3101
+ resolveListScope(
3102
+ input: ResolveSessionAuthorizationListScopeInput,
3103
+ ): Promise<SessionAuthorizationListScope>;
3104
+ };
3105
+
2030
3106
  export const SessionTurn = z.object({
2031
3107
  id: z.string().uuid(),
2032
3108
  workspaceId: z.string().uuid(),
@@ -2049,6 +3125,8 @@ export const SessionTurn = z.object({
2049
3125
  executionGeneration: z.number().int().nonnegative(),
2050
3126
  activeAttemptId: z.string().uuid().nullable(),
2051
3127
  lineage: z.record(z.string(), z.unknown()),
3128
+ initiator: TurnInitiator,
3129
+ initiatorContext: TurnInitiatorContext,
2052
3130
  cancelledBy: z.string().nullable(),
2053
3131
  cancelReason: z.string().nullable(),
2054
3132
  startedAt: z.string().nullable(),
@@ -2058,70 +3136,409 @@ export const SessionTurn = z.object({
2058
3136
  });
2059
3137
  export type SessionTurn = z.infer<typeof SessionTurn>;
2060
3138
 
3139
+ export const EffectiveControlBlocker = z.object({
3140
+ kind: z.enum(["session", "workspace"]),
3141
+ sessionId: z.string().uuid().optional(),
3142
+ displayName: z.string().min(1),
3143
+ actor: z.string().nullable(),
3144
+ reason: z.string().nullable(),
3145
+ changedAt: z.string().nullable(),
3146
+ revision: z.number().int().nonnegative(),
3147
+ });
3148
+ export type EffectiveControlBlocker = z.infer<typeof EffectiveControlBlocker>;
3149
+
3150
+ export const EffectiveControlResumeOption = z.object({
3151
+ scope: z.enum(["selected", "session", "workspace"]),
3152
+ targetId: z.string().uuid().optional(),
3153
+ selectedStateAfter: SessionControlState,
3154
+ remainingPrimaryBlocker: EffectiveControlBlocker.optional(),
3155
+ impactCopy: z.string().min(1),
3156
+ });
3157
+ export type EffectiveControlResumeOption = z.infer<typeof EffectiveControlResumeOption>;
3158
+
3159
+ export const EffectiveSessionControl = z.object({
3160
+ state: SessionControlState,
3161
+ controlVersion: z.number().int().nonnegative(),
3162
+ controlEtag: z.string().min(1),
3163
+ directState: SessionControlState,
3164
+ primaryBlocker: EffectiveControlBlocker.nullable(),
3165
+ additionalBlockerCount: z.number().int().nonnegative(),
3166
+ blockers: z.array(EffectiveControlBlocker),
3167
+ resumeOptions: z.array(EffectiveControlResumeOption),
3168
+ override: z
3169
+ .object({
3170
+ rootSessionId: z.string().uuid(),
3171
+ revision: z.number().int().nonnegative(),
3172
+ })
3173
+ .nullable(),
3174
+ settlement: z
3175
+ .object({
3176
+ state: z.literal("stopping"),
3177
+ attemptCount: z.number().int().positive(),
3178
+ interruptionPendingCount: z.number().int().nonnegative(),
3179
+ quiescencePendingCount: z.number().int().nonnegative(),
3180
+ })
3181
+ .nullable(),
3182
+ });
3183
+ export type EffectiveSessionControl = z.infer<typeof EffectiveSessionControl>;
3184
+
3185
+ export const SESSION_OPERATION_KEY_MAX_CHARS = 256;
3186
+ const SessionOperationKey = z.string().min(1).max(SESSION_OPERATION_KEY_MAX_CHARS);
3187
+
3188
+ export const SessionCommandReceipt = z.object({
3189
+ id: z.string().uuid(),
3190
+ action: z.string().min(1),
3191
+ operationKey: z.string().min(1).max(SESSION_OPERATION_KEY_MAX_CHARS),
3192
+ targetSessionId: z.string().uuid().nullable(),
3193
+ targetTurnId: z.string().uuid().nullable(),
3194
+ appliedControlRevision: z.number().int().nonnegative().nullable(),
3195
+ appliedQueueVersion: z.number().int().nonnegative().nullable(),
3196
+ appliedTurnVersion: z.number().int().positive().nullable(),
3197
+ appliedDraftRevision: z.number().int().positive().nullable(),
3198
+ createdAt: z.string(),
3199
+ });
3200
+ export type SessionCommandReceipt = z.infer<typeof SessionCommandReceipt>;
3201
+
3202
+ export const ComposerDraft = z.object({
3203
+ revision: z.number().int().nonnegative(),
3204
+ text: z.string(),
3205
+ resources: z.array(ResourceRef),
3206
+ tools: z.array(ToolRef),
3207
+ model: z.string().min(1),
3208
+ reasoningEffort: ReasoningEffort,
3209
+ sourceTurnId: z.string().uuid().nullable(),
3210
+ sourceTurnVersion: z.number().int().positive().nullable(),
3211
+ updatedAt: z.string().nullable(),
3212
+ });
3213
+ export type ComposerDraft = z.infer<typeof ComposerDraft>;
3214
+
2061
3215
  export const SessionQueueSnapshot = z.object({
2062
3216
  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(),
3217
+ effectiveControl: EffectiveSessionControl,
3218
+ /**
3219
+ * True while the latest attempt is interrupted but has not durably proved
3220
+ * quiescence: no more inference, user-visible output, or workspace-persistence
3221
+ * authority. Temporal cancellation/terminalization is not that proof. This is
3222
+ * distinct from ordinary capacity queueing, remains accurate with an empty
3223
+ * visible queue, and is independent of Steer-row metadata or withdrawal.
3224
+ */
3225
+ stoppingPreviousAttempt: z.boolean(),
2068
3226
  items: z.array(SessionTurn),
2069
3227
  });
2070
3228
  export type SessionQueueSnapshot = z.infer<typeof SessionQueueSnapshot>;
2071
3229
 
2072
- export const CancelSessionQueueItemRequest = z.object({
3230
+ export const MoveSessionQueueItemRequest = z.object({
3231
+ clientEventId: SessionOperationKey,
2073
3232
  expectedQueueVersion: z.number().int().nonnegative(),
2074
- expectedItemVersion: z.number().int().positive(),
3233
+ beforeTurnId: z.string().uuid().nullable(),
3234
+ });
3235
+ export type MoveSessionQueueItemRequest = z.infer<typeof MoveSessionQueueItemRequest>;
3236
+
3237
+ export const EditSessionQueueItemRequest = z.object({
3238
+ clientEventId: SessionOperationKey,
3239
+ expectedTurnVersion: z.number().int().positive(),
3240
+ expectedDraftRevision: z.number().int().nonnegative(),
3241
+ replaceDraft: z.boolean(),
3242
+ });
3243
+ export type EditSessionQueueItemRequest = z.infer<typeof EditSessionQueueItemRequest>;
3244
+
3245
+ export const SteerSessionQueueItemRequest = z.object({
3246
+ clientEventId: SessionOperationKey,
3247
+ expectedTurnVersion: z.number().int().positive(),
3248
+ controlEtag: z.string().min(1).optional(),
3249
+ });
3250
+ export type SteerSessionQueueItemRequest = z.infer<typeof SteerSessionQueueItemRequest>;
3251
+
3252
+ export const DeleteSessionQueueItemRequest = z.object({
3253
+ clientEventId: SessionOperationKey,
3254
+ expectedTurnVersion: z.number().int().positive(),
2075
3255
  reason: z.string().min(1).optional(),
2076
3256
  });
2077
- export type CancelSessionQueueItemRequest = z.infer<typeof CancelSessionQueueItemRequest>;
3257
+ export type DeleteSessionQueueItemRequest = z.infer<typeof DeleteSessionQueueItemRequest>;
3258
+
3259
+ export const SaveComposerDraftRequest = ComposerDraft.pick({
3260
+ text: true,
3261
+ resources: true,
3262
+ tools: true,
3263
+ model: true,
3264
+ reasoningEffort: true,
3265
+ }).extend({ expectedRevision: z.number().int().nonnegative() });
3266
+ export type SaveComposerDraftRequest = z.infer<typeof SaveComposerDraftRequest>;
3267
+
3268
+ export const WORKSPACE_CONTROL_REASON_MAX_BYTES = 8 * 1024;
3269
+ export const WORKSPACE_CONTROL_ACTOR_MAX_BYTES = 1024;
3270
+ export const WORKSPACE_CONTROL_EVENT_MAX_BYTES = 16 * 1024;
3271
+
3272
+ const WorkspaceControlReason = z
3273
+ .string()
3274
+ .min(1)
3275
+ .refine((value) => !value.includes("\u0000"), "reason must not contain NUL bytes")
3276
+ .refine(
3277
+ (value) => workspaceControlUtf8Bytes(value) <= WORKSPACE_CONTROL_REASON_MAX_BYTES,
3278
+ `reason must not exceed ${WORKSPACE_CONTROL_REASON_MAX_BYTES} UTF-8 bytes`,
3279
+ );
2078
3280
 
2079
3281
  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(),
3282
+ action: z.enum(["pause", "resume"]),
3283
+ reason: WorkspaceControlReason.optional(),
3284
+ clientEventId: SessionOperationKey,
3285
+ expectedControlEtag: z.string().min(1).optional(),
2086
3286
  });
2087
3287
  export type SessionControlRequest = z.infer<typeof SessionControlRequest>;
2088
3288
 
2089
3289
  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([]),
3290
+ action: z.enum(["pause", "resume"]),
3291
+ reason: WorkspaceControlReason.optional(),
3292
+ clientEventId: SessionOperationKey,
3293
+ expectedRevision: z.number().int().nonnegative().optional(),
2096
3294
  });
2097
3295
  export type WorkspaceInferenceControlRequest = z.infer<typeof WorkspaceInferenceControlRequest>;
2098
3296
 
2099
3297
  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()),
3298
+ receipt: SessionCommandReceipt,
3299
+ state: SessionControlState,
3300
+ revision: z.number().int().nonnegative(),
3301
+ interruptionCount: z.number().int().nonnegative(),
3302
+ wakeCount: z.number().int().nonnegative(),
2106
3303
  });
2107
3304
  export type WorkspaceInferenceControlResponse = z.infer<typeof WorkspaceInferenceControlResponse>;
2108
3305
 
3306
+ /**
3307
+ * One durable workspace-wide invalidation for one committed control revision.
3308
+ * It is not conversation history and never becomes queue work; clients use it
3309
+ * only to refetch authoritative workspace/session projections.
3310
+ */
3311
+ export const WorkspaceControlEventTruncation = z.object({
3312
+ truncated: z.literal(true),
3313
+ surface: z.enum([
3314
+ "durable_control",
3315
+ "database_guard",
3316
+ "http_projection",
3317
+ "nats_legacy_guard",
3318
+ "sse_legacy_guard",
3319
+ ]),
3320
+ deliveredBytes: z.number().int().nonnegative(),
3321
+ fields: z.array(
3322
+ z.object({
3323
+ field: z.enum(["reason", "actor"]),
3324
+ originalBytes: z.number().int().nonnegative(),
3325
+ deliveredBytes: z.number().int().nonnegative(),
3326
+ omittedBytes: z.number().int().nonnegative(),
3327
+ }),
3328
+ ),
3329
+ fullEvidence: z.object({
3330
+ available: z.literal(false),
3331
+ reason: z.literal("not_retained"),
3332
+ }),
3333
+ });
3334
+ export type WorkspaceControlEventTruncation = z.infer<typeof WorkspaceControlEventTruncation>;
3335
+
3336
+ export const WorkspaceControlEvent = z.object({
3337
+ id: z.string().uuid(),
3338
+ workspaceId: z.string().uuid(),
3339
+ sequence: z.number().int().positive(),
3340
+ revision: z.number().int().positive(),
3341
+ type: z.literal("workspace.control.changed"),
3342
+ scope: z.enum(["workspace", "session"]),
3343
+ rootSessionId: z.string().uuid().nullable(),
3344
+ action: z.enum(["pause", "resume"]),
3345
+ automatic: z.boolean(),
3346
+ reason: z.string().nullable(),
3347
+ actor: z.string().min(1),
3348
+ occurredAt: z.string(),
3349
+ truncation: WorkspaceControlEventTruncation.nullable().optional(),
3350
+ });
3351
+ export type WorkspaceControlEvent = z.infer<typeof WorkspaceControlEvent>;
3352
+
3353
+ export type WorkspaceControlBoundarySurface = WorkspaceControlEventTruncation["surface"];
3354
+
3355
+ export type BoundWorkspaceControlEventOptions = {
3356
+ surface?: WorkspaceControlBoundarySurface;
3357
+ reasonOriginalBytes?: number | null;
3358
+ actorOriginalBytes?: number | null;
3359
+ };
3360
+
3361
+ /** UTF-8 byte count used by workspace-control storage and transport guards. */
3362
+ export function workspaceControlUtf8Bytes(value: string): number {
3363
+ return new TextEncoder().encode(value).byteLength;
3364
+ }
3365
+
3366
+ /**
3367
+ * Canonical bounded invalidation event. The event is not a full evidence store:
3368
+ * when a producer or legacy row exceeds a field cap, the retained head carries
3369
+ * a visible marker and structured exact byte-loss facts.
3370
+ */
3371
+ export function boundWorkspaceControlEvent(
3372
+ event: WorkspaceControlEvent,
3373
+ options: BoundWorkspaceControlEventOptions = {},
3374
+ ): WorkspaceControlEvent {
3375
+ const existingFields = new Map(
3376
+ (event.truncation?.fields ?? []).map((field) => [field.field, field] as const),
3377
+ );
3378
+ const reason =
3379
+ event.reason === null
3380
+ ? null
3381
+ : boundWorkspaceControlText(event.reason, WORKSPACE_CONTROL_REASON_MAX_BYTES);
3382
+ const actor = boundWorkspaceControlText(event.actor, WORKSPACE_CONTROL_ACTOR_MAX_BYTES);
3383
+ const reasonBytes = reason === null ? 0 : workspaceControlUtf8Bytes(reason);
3384
+ const actorBytes = workspaceControlUtf8Bytes(actor);
3385
+ const reasonOriginalBytes =
3386
+ event.reason === null
3387
+ ? null
3388
+ : Math.max(
3389
+ workspaceControlUtf8Bytes(event.reason),
3390
+ normalizedWorkspaceControlOriginalBytes(options.reasonOriginalBytes),
3391
+ existingFields.get("reason")?.originalBytes ?? 0,
3392
+ );
3393
+ const actorOriginalBytes = Math.max(
3394
+ workspaceControlUtf8Bytes(event.actor),
3395
+ normalizedWorkspaceControlOriginalBytes(options.actorOriginalBytes),
3396
+ existingFields.get("actor")?.originalBytes ?? 0,
3397
+ );
3398
+ const fields: WorkspaceControlEventTruncation["fields"] = [];
3399
+ if (reasonOriginalBytes !== null && reasonOriginalBytes > reasonBytes) {
3400
+ fields.push({
3401
+ field: "reason",
3402
+ originalBytes: reasonOriginalBytes,
3403
+ deliveredBytes: reasonBytes,
3404
+ omittedBytes: reasonOriginalBytes - reasonBytes,
3405
+ });
3406
+ }
3407
+ if (actorOriginalBytes > actorBytes) {
3408
+ fields.push({
3409
+ field: "actor",
3410
+ originalBytes: actorOriginalBytes,
3411
+ deliveredBytes: actorBytes,
3412
+ omittedBytes: actorOriginalBytes - actorBytes,
3413
+ });
3414
+ }
3415
+ if (fields.length === 0 && event.truncation == null) {
3416
+ if (sessionEventJsonBytes(event) > WORKSPACE_CONTROL_EVENT_MAX_BYTES) {
3417
+ throw new RangeError("Workspace control event exceeds its bounded envelope");
3418
+ }
3419
+ return event;
3420
+ }
3421
+
3422
+ const truncation: WorkspaceControlEventTruncation = {
3423
+ truncated: true,
3424
+ surface: event.truncation?.surface ?? options.surface ?? "durable_control",
3425
+ deliveredBytes: 0,
3426
+ fields,
3427
+ fullEvidence: { available: false, reason: "not_retained" },
3428
+ };
3429
+ const bounded: WorkspaceControlEvent = {
3430
+ ...event,
3431
+ reason,
3432
+ actor,
3433
+ truncation,
3434
+ };
3435
+ settleWorkspaceControlDeliveredBytes(bounded, truncation);
3436
+ const deliveredBytes = sessionEventJsonBytes(bounded);
3437
+ if (deliveredBytes > WORKSPACE_CONTROL_EVENT_MAX_BYTES) {
3438
+ throw new RangeError(
3439
+ `Bounded workspace control event exceeds its final envelope (${deliveredBytes} > ${WORKSPACE_CONTROL_EVENT_MAX_BYTES} bytes)`,
3440
+ );
3441
+ }
3442
+ return bounded;
3443
+ }
3444
+
3445
+ function boundWorkspaceControlText(value: string, maxBytes: number): string {
3446
+ const encoder = new TextEncoder();
3447
+ const decoder = new TextDecoder();
3448
+ const bytes = encoder.encode(value);
3449
+ if (bytes.byteLength <= maxBytes) return value;
3450
+ const marker = "…[truncated]";
3451
+ const prefixBudget = Math.max(0, maxBytes - encoder.encode(marker).byteLength);
3452
+ let prefixEnd = Math.min(prefixBudget, bytes.byteLength);
3453
+ while (prefixEnd > 0 && prefixEnd < bytes.byteLength && (bytes[prefixEnd]! & 0xc0) === 0x80) {
3454
+ prefixEnd -= 1;
3455
+ }
3456
+ return `${decoder.decode(bytes.subarray(0, prefixEnd))}${marker}`;
3457
+ }
3458
+
3459
+ function normalizedWorkspaceControlOriginalBytes(value: number | null | undefined): number {
3460
+ return value === null || value === undefined || !Number.isFinite(value)
3461
+ ? 0
3462
+ : Math.max(0, Math.floor(value));
3463
+ }
3464
+
3465
+ function settleWorkspaceControlDeliveredBytes(
3466
+ event: WorkspaceControlEvent,
3467
+ truncation: WorkspaceControlEventTruncation,
3468
+ ): void {
3469
+ for (let attempt = 0; attempt < 16; attempt += 1) {
3470
+ const deliveredBytes = sessionEventJsonBytes(event);
3471
+ if (truncation.deliveredBytes === deliveredBytes) return;
3472
+ truncation.deliveredBytes = deliveredBytes;
3473
+ }
3474
+ const deliveredBytes = sessionEventJsonBytes(event);
3475
+ if (truncation.deliveredBytes !== deliveredBytes) {
3476
+ throw new RangeError("Workspace control event byte accounting did not converge");
3477
+ }
3478
+ }
3479
+
2109
3480
  export const SystemUpdateClassification = z.enum(["success", "failure", "action_required", "info"]);
2110
3481
  export type SystemUpdateClassification = z.infer<typeof SystemUpdateClassification>;
2111
3482
 
2112
3483
  export const SessionSystemUpdateKind = z.enum([
2113
- "child_session_update",
2114
- "scheduled_wake",
2115
- "lifecycle_event",
2116
- "runtime_notice",
3484
+ "scheduled_occurrence",
3485
+ "goal_continuation",
3486
+ "agent_message",
3487
+ "agent_steer_instruction",
3488
+ "child_terminal_result",
2117
3489
  ]);
2118
3490
  export type SessionSystemUpdateKind = z.infer<typeof SessionSystemUpdateKind>;
2119
3491
 
3492
+ export const SessionSystemUpdatePayload = z.discriminatedUnion("type", [
3493
+ z
3494
+ .object({
3495
+ type: z.literal("scheduled_occurrence"),
3496
+ text: z.string().min(1),
3497
+ scheduledTaskId: z.string().uuid(),
3498
+ scheduledTaskRunId: z.string().uuid(),
3499
+ resources: z.array(ResourceRef).optional(),
3500
+ tools: z.array(ToolRef).optional(),
3501
+ })
3502
+ .passthrough(),
3503
+ z
3504
+ .object({
3505
+ type: z.literal("goal_continuation"),
3506
+ goalId: z.string().uuid(),
3507
+ goalVersion: z.number().int().positive(),
3508
+ prompt: z.string().min(1),
3509
+ reason: z.string().optional(),
3510
+ })
3511
+ .passthrough(),
3512
+ z
3513
+ .object({
3514
+ type: z.literal("agent_message"),
3515
+ text: z.string().min(1),
3516
+ operationId: z.string().uuid(),
3517
+ })
3518
+ .passthrough(),
3519
+ z
3520
+ .object({
3521
+ type: z.literal("agent_steer_instruction"),
3522
+ instruction: z.string().min(1),
3523
+ operationId: z.string().uuid(),
3524
+ })
3525
+ .passthrough(),
3526
+ z
3527
+ .object({
3528
+ type: z.literal("child_terminal_result"),
3529
+ childSessionId: z.string().uuid(),
3530
+ status: z.enum(["idle", "failed"]),
3531
+ })
3532
+ .passthrough(),
3533
+ ]);
3534
+ export type SessionSystemUpdatePayload = z.infer<typeof SessionSystemUpdatePayload>;
3535
+
2120
3536
  export const SessionSystemUpdateState = z.enum([
2121
3537
  "pending",
2122
3538
  "deferred",
2123
3539
  "delivered",
2124
3540
  "cancelled",
3541
+ "superseded",
2125
3542
  "failed",
2126
3543
  ]);
2127
3544
  export type SessionSystemUpdateState = z.infer<typeof SessionSystemUpdateState>;
@@ -2134,7 +3551,7 @@ export const SessionSystemUpdate = z.object({
2134
3551
  sourceId: z.string(),
2135
3552
  dedupeKey: z.string(),
2136
3553
  summary: z.string(),
2137
- payload: z.record(z.string(), z.unknown()),
3554
+ payload: SessionSystemUpdatePayload,
2138
3555
  lineage: z.record(z.string(), z.unknown()),
2139
3556
  state: SessionSystemUpdateState,
2140
3557
  deliveredTurnId: z.string().uuid().nullable(),
@@ -2365,7 +3782,10 @@ export type RigDefinitionEditPayload = z.infer<typeof RigDefinitionEditPayload>;
2365
3782
 
2366
3783
  export const ProposeRigChangeRequest = z.discriminatedUnion("kind", [
2367
3784
  z.object({ kind: z.literal("setup_append"), payload: RigSetupAppendPayload }),
2368
- z.object({ kind: z.literal("definition_edit"), payload: RigDefinitionEditPayload }),
3785
+ z.object({
3786
+ kind: z.literal("definition_edit"),
3787
+ payload: RigDefinitionEditPayload,
3788
+ }),
2369
3789
  ]);
2370
3790
  export type ProposeRigChangeRequest = z.infer<typeof ProposeRigChangeRequest>;
2371
3791
 
@@ -2785,27 +4205,15 @@ export const CreateSocialPostRequest = z.object({
2785
4205
  text: z.string().min(1),
2786
4206
  publishedAt: z.string().datetime({ offset: true }),
2787
4207
  metrics: z.record(z.string(), z.number()).default({}),
2788
- raw: z.record(z.string(), z.unknown()).default({}),
2789
- });
2790
- export type CreateSocialPostRequest = z.infer<typeof CreateSocialPostRequest>;
2791
-
2792
- export const ConnectionKind = z.enum(["oauth2", "api_key", "app_install", "delegated"]);
2793
- export type ConnectionKind = z.infer<typeof ConnectionKind>;
2794
-
2795
- export const ConnectionStatus = z.enum(["active", "needs_reauth", "revoked", "error"]);
2796
- export type ConnectionStatus = z.infer<typeof ConnectionStatus>;
2797
-
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>;
4208
+ raw: z.record(z.string(), z.unknown()).default({}),
4209
+ });
4210
+ export type CreateSocialPostRequest = z.infer<typeof CreateSocialPostRequest>;
4211
+
4212
+ export const ConnectionKind = z.enum(["oauth2", "api_key", "app_install", "delegated"]);
4213
+ export type ConnectionKind = z.infer<typeof ConnectionKind>;
4214
+
4215
+ export const ConnectionStatus = z.enum(["active", "needs_reauth", "revoked", "error"]);
4216
+ export type ConnectionStatus = z.infer<typeof ConnectionStatus>;
2809
4217
 
2810
4218
  export const ConnectionMetadata = z.object({
2811
4219
  id: z.string().uuid(),
@@ -2925,6 +4333,7 @@ export type CapabilityKind = z.infer<typeof CapabilityKind>;
2925
4333
 
2926
4334
  export const CapabilitySource = z.enum([
2927
4335
  "built_in",
4336
+ "library",
2928
4337
  "configured",
2929
4338
  "public_registry",
2930
4339
  "registry",
@@ -3077,6 +4486,9 @@ export const Session = z.object({
3077
4486
  resources: z.array(ResourceRef),
3078
4487
  tools: z.array(ToolRef),
3079
4488
  metadata: z.record(z.string(), z.unknown()),
4489
+ /** Frozen creator fact used only for creation attribution/idempotent repair. */
4490
+ createdBy: TurnInitiator,
4491
+ createdByContext: TurnInitiatorContext,
3080
4492
  model: z.string(),
3081
4493
  sandboxBackend: SandboxBackend,
3082
4494
  // The OS the session's box runs. Defaults to 'linux' (today's only OS).
@@ -3126,12 +4538,7 @@ export const Session = z.object({
3126
4538
  queueVersion: z.number().int().nonnegative(),
3127
4539
  queueHeadPosition: z.number().int(),
3128
4540
  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(),
4541
+ effectiveControl: EffectiveSessionControl,
3135
4542
  lastSequence: z.number().int().nonnegative(),
3136
4543
  // Multi-account Codex (P1). codexPinnedCredentialId: the account this session is
3137
4544
  // manually PINNED to (null ⇒ follow the workspace active pointer).
@@ -3159,6 +4566,8 @@ export const Session = z.object({
3159
4566
  attentionDescendants: z.number().int().nonnegative(),
3160
4567
  pausedDescendants: z.number().int().nonnegative(),
3161
4568
  failedDescendants: z.number().int().nonnegative(),
4569
+ /** Counts are lower bounds rather than exact totals when true. */
4570
+ truncated: z.boolean().default(false),
3162
4571
  })
3163
4572
  .optional(),
3164
4573
  createdAt: z.string(),
@@ -3166,16 +4575,30 @@ export const Session = z.object({
3166
4575
  });
3167
4576
  export type Session = z.infer<typeof Session>;
3168
4577
 
4578
+ /**
4579
+ * Additive receipt returned only by session creation. `activeTurnId` remains an
4580
+ * execution pointer and is correctly null while the first turn is queued;
4581
+ * embedders use this immutable identity to correlate their preallocated run.
4582
+ */
4583
+ export const CreateSessionResponse = Session.extend({
4584
+ initialTurnId: z.string().uuid().nullable(),
4585
+ });
4586
+ export type CreateSessionResponse = z.infer<typeof CreateSessionResponse>;
4587
+
3169
4588
  export type SessionSummary = Session;
3170
4589
 
3171
4590
  /**
3172
4591
  * The canonical session-list page. Pinned rows are returned separately and are
3173
4592
  * 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.
4593
+ * duplicating a pin. The newest 100 matching pins are returned, ordered by
4594
+ * pinnedAt DESC, id DESC; `pinnedTruncated` makes an older-pin omission
4595
+ * explicit. Pins are filtered by the same parent/search predicates as ordinary
4596
+ * rows.
3176
4597
  */
3177
4598
  export const SessionListResponse = z.object({
3178
4599
  pinned: z.array(Session),
4600
+ /** True when older matching pins were omitted from this bounded page. */
4601
+ pinnedTruncated: z.boolean().optional(),
3179
4602
  sessions: z.array(Session),
3180
4603
  nextCursor: z.string().nullable(),
3181
4604
  });
@@ -3204,8 +4627,14 @@ export type SessionLineageResponse = z.infer<typeof SessionLineageResponse>;
3204
4627
 
3205
4628
  export const SessionEventType = z.enum([
3206
4629
  "session.created",
4630
+ // Defensive read/transport projection for a malformed or historically
4631
+ // oversized retained event envelope. The original row stays durable; this
4632
+ // explicit synthetic type prevents unbounded free-form envelope fields from
4633
+ // crossing NATS, SSE, REST, or browser boundaries.
4634
+ "session.event.envelope_omitted",
3207
4635
  "session.status.changed",
3208
4636
  "session.requiresAction",
4637
+ "session.humanInput.requested",
3209
4638
  "session.context.compaction.requested",
3210
4639
  "session.context.compacted",
3211
4640
  "session.context.compaction.skipped",
@@ -3213,6 +4642,7 @@ export const SessionEventType = z.enum([
3213
4642
  "user.message",
3214
4643
  "user.pause",
3215
4644
  "user.approvalDecision",
4645
+ "user.humanInputResponse",
3216
4646
  "turn.queued",
3217
4647
  "turn.started",
3218
4648
  "turn.completed",
@@ -3228,6 +4658,7 @@ export const SessionEventType = z.enum([
3228
4658
  "agent.toolCall.output",
3229
4659
  "agent.model.usage",
3230
4660
  "tool.auth_needed",
4661
+ "credential.auth_needed",
3231
4662
  "agent.updated",
3232
4663
  "rig.setup.started",
3233
4664
  "rig.setup.completed",
@@ -3252,6 +4683,7 @@ export const SessionEventType = z.enum([
3252
4683
  "session.control.steer_requested",
3253
4684
  "workspace.inference.paused",
3254
4685
  "workspace.inference.resumed",
4686
+ "session.queue.changed",
3255
4687
  "session.queue.prompt.cancelled",
3256
4688
  "session.queue.history",
3257
4689
  // A terminal/stale activity callback is retained as an audit wrapper rather
@@ -3266,15 +4698,15 @@ export const SessionEventType = z.enum([
3266
4698
  "stream.opened", // a viewer attached (audit + refcount visibility)
3267
4699
  "stream.closed", // a viewer detached / was reaped
3268
4700
  "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
4701
+ // Desktop recording signals. The capture loop records the same display humans
4702
+ // watch, then stores the finalized artifact for replay.
3271
4703
  // → storage. The artifact ref rides the AVAILABLE event (storageKey, NOT a
3272
4704
  // long-lived URL — clients mint a short-TTL signed GET via the route).
3273
4705
  "recording.started", // ffmpeg launched on :0 (mode/codec/dimensions)
3274
4706
  "recording.available", // finalized: bytes PUT to storage, replayable
3275
4707
  "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
4708
+ // Structured-service notifications. File, Git, and terminal reads are
4709
+ // synchronous API-direct point
3278
4710
  // queries (their result is the HTTP response, NEVER an event). What rides A1
3279
4711
  // here are the side-effect NOTIFICATIONS — a path changed, git state changed,
3280
4712
  // a pty opened/printed/exited — durable, sequenced, gap-filled like every
@@ -3291,10 +4723,10 @@ export const SessionEventType = z.enum([
3291
4723
  // (manual switch in P1; failover/rotation in P3 reuse the same event). Drives
3292
4724
  // the in-session "Running on:" indicator's live flip.
3293
4725
  "codex.account.switched",
3294
- // OPE-21 per-turn selection audit. Payload is metadata only: credential row
4726
+ // credential allocator per-turn selection audit. Payload is metadata only: credential row
3295
4727
  // id, bounded strategy/reason, and pool counts — never token material.
3296
4728
  "codex.credential.selected",
3297
- // OPE-21 durable zero-capacity wait lifecycle. Runtime/system events only;
4729
+ // credential allocator durable zero-capacity wait lifecycle. Runtime/system events only;
3298
4730
  // no synthetic user message is created when capacity returns.
3299
4731
  "codex.capacity.waiting",
3300
4732
  "codex.capacity.resumed",
@@ -3319,12 +4751,12 @@ export const SessionEventType = z.enum([
3319
4751
  // target id or command content. Announce-only; hits the timeline projection default
3320
4752
  // (no rendered item) like the other sandbox.* diagnostics.
3321
4753
  "session.route.reconciled",
3322
- // Workbench v2 turn-end workspace capture (dossier §10.1). ANNOUNCE-ONLY: a new
4754
+ // Workbench v2 turn-end workspace capture. ANNOUNCE-ONLY: a new
3323
4755
  // capture revision was persisted at turn end; the client refetches the latest
3324
4756
  // capture. It carries metadata only (revision/turnId/capturedAt/leaseEpoch/stats),
3325
4757
  // never file content. Hits the timeline projection default case (ignored) — it
3326
4758
  // must NEVER gain a rendered timeline item without regenerating the golden
3327
- // snapshots (dossier §7.3 golden-grammar gate).
4759
+ // snapshots (golden-grammar gate).
3328
4760
  "workspace.revision.captured",
3329
4761
  // Repository discovery could not prove a complete capture. The worker
3330
4762
  // persisted a failed/degraded revision marker and clients must fall back to
@@ -3366,19 +4798,197 @@ export const SessionEventType = z.enum([
3366
4798
  ]);
3367
4799
  export type SessionEventType = z.infer<typeof SessionEventType>;
3368
4800
 
4801
+ /**
4802
+ * Stable semantic groups for bounded session monitoring. These are a read
4803
+ * projection only: an event keeps its canonical durable `type`, and callers
4804
+ * can always combine a class with explicit type include/exclude filters.
4805
+ */
4806
+ export const SessionEventSemanticClass = z.enum([
4807
+ "control",
4808
+ "terminal",
4809
+ "failure",
4810
+ "checkpoint",
4811
+ "tool_receipt",
4812
+ "provider_account",
4813
+ ]);
4814
+ export type SessionEventSemanticClass = z.infer<typeof SessionEventSemanticClass>;
4815
+
4816
+ export const SessionEventPayloadMode = z.enum(["none", "summary", "full"]);
4817
+ export type SessionEventPayloadMode = z.infer<typeof SessionEventPayloadMode>;
4818
+
4819
+ export const SessionEventReadMode = z.enum(["monitoring", "forensic"]);
4820
+ export type SessionEventReadMode = z.infer<typeof SessionEventReadMode>;
4821
+
4822
+ export const SessionEventReadDirection = z.enum(["after", "before"]);
4823
+ export type SessionEventReadDirection = z.infer<typeof SessionEventReadDirection>;
4824
+
4825
+ export const SESSION_EVENT_RAW_DELTA_TYPES = [
4826
+ "agent.message.delta",
4827
+ "agent.reasoning.delta",
4828
+ "sandbox.command.output.delta",
4829
+ "terminal.pty.output.delta",
4830
+ ] as const satisfies readonly SessionEventType[];
4831
+
4832
+ export const SESSION_EVENT_SEMANTIC_CLASS_TYPES = {
4833
+ control: [
4834
+ "session.status.changed",
4835
+ "session.requiresAction",
4836
+ "session.humanInput.requested",
4837
+ "user.pause",
4838
+ "user.approvalDecision",
4839
+ "user.humanInputResponse",
4840
+ "goal.set",
4841
+ "goal.updated",
4842
+ "goal.completed",
4843
+ "goal.paused",
4844
+ "goal.resumed",
4845
+ "goal.cleared",
4846
+ "goal.continuation",
4847
+ "system.update.pending",
4848
+ "system.update.delivered",
4849
+ "session.control.paused",
4850
+ "session.control.resumed",
4851
+ "session.control.steer_requested",
4852
+ "workspace.inference.paused",
4853
+ "workspace.inference.resumed",
4854
+ "session.queue.changed",
4855
+ "session.queue.prompt.cancelled",
4856
+ ],
4857
+ terminal: [
4858
+ "turn.completed",
4859
+ "turn.failed",
4860
+ "turn.cancelled",
4861
+ "turn.superseded",
4862
+ "goal.completed",
4863
+ "goal.paused",
4864
+ "rig.setup.completed",
4865
+ "rig.setup.skipped",
4866
+ "rig.setup.failed",
4867
+ "sandbox.operation.completed",
4868
+ "sandbox.operation.failed",
4869
+ "recording.available",
4870
+ "recording.failed",
4871
+ "terminal.pty.exited",
4872
+ ],
4873
+ failure: [
4874
+ "session.event.envelope_omitted",
4875
+ "turn.failed",
4876
+ "tool.auth_needed",
4877
+ "credential.auth_needed",
4878
+ "rig.setup.failed",
4879
+ "sandbox.operation.failed",
4880
+ "recording.failed",
4881
+ "sandbox.box.lost",
4882
+ "workspace.revision.degraded",
4883
+ "machine.op.failed",
4884
+ "machine.link.lost",
4885
+ ],
4886
+ checkpoint: [
4887
+ "session.context.compaction.requested",
4888
+ "session.context.compacted",
4889
+ "session.context.compaction.skipped",
4890
+ "session.context.cleared",
4891
+ "turn.recovery.requested",
4892
+ "session.queue.history",
4893
+ "sandbox.box.snapshot",
4894
+ "workspace.revision.captured",
4895
+ ],
4896
+ tool_receipt: [
4897
+ "agent.toolCall.created",
4898
+ "agent.toolCall.output",
4899
+ "tool.auth_needed",
4900
+ "artifact.created",
4901
+ ],
4902
+ provider_account: [
4903
+ "agent.model.usage",
4904
+ "codex.account.switched",
4905
+ "codex.credential.selected",
4906
+ "codex.capacity.waiting",
4907
+ "codex.capacity.resumed",
4908
+ "codex.capacity.superseded",
4909
+ "sandbox.box.created",
4910
+ "sandbox.box.lost",
4911
+ "sandbox.box.terminated",
4912
+ "sandbox.box.snapshot",
4913
+ "sandbox.env.drift",
4914
+ "session.route.reconciled",
4915
+ "machine.op.failed",
4916
+ "machine.op.recovered",
4917
+ "machine.link.lost",
4918
+ "machine.link.restored",
4919
+ "machine.runner.restarted",
4920
+ ],
4921
+ } as const satisfies Record<SessionEventSemanticClass, readonly SessionEventType[]>;
4922
+
4923
+ export type ResolveSessionEventTypeFiltersInput = {
4924
+ includeTypes?: readonly SessionEventType[] | undefined;
4925
+ excludeTypes?: readonly SessionEventType[] | undefined;
4926
+ includeClasses?: readonly SessionEventSemanticClass[] | undefined;
4927
+ excludeClasses?: readonly SessionEventSemanticClass[] | undefined;
4928
+ /** Applied unless the same type was explicitly included by type or class. */
4929
+ defaultExcludeTypes?: readonly SessionEventType[] | undefined;
4930
+ };
4931
+
4932
+ /** Resolve class/type filter algebra once so every read surface behaves alike. */
4933
+ export function resolveSessionEventTypeFilters(input: ResolveSessionEventTypeFiltersInput): {
4934
+ includeTypes: SessionEventType[];
4935
+ excludeTypes: SessionEventType[];
4936
+ } {
4937
+ const included = new Set<SessionEventType>(input.includeTypes ?? []);
4938
+ for (const semanticClass of input.includeClasses ?? []) {
4939
+ for (const type of SESSION_EVENT_SEMANTIC_CLASS_TYPES[semanticClass]) included.add(type);
4940
+ }
4941
+
4942
+ const excluded = new Set<SessionEventType>(input.excludeTypes ?? []);
4943
+ for (const semanticClass of input.excludeClasses ?? []) {
4944
+ for (const type of SESSION_EVENT_SEMANTIC_CLASS_TYPES[semanticClass]) excluded.add(type);
4945
+ }
4946
+ for (const type of input.defaultExcludeTypes ?? []) {
4947
+ if (!included.has(type)) excluded.add(type);
4948
+ }
4949
+
4950
+ // An explicit exclusion always wins over a positive selector.
4951
+ for (const type of excluded) included.delete(type);
4952
+ return { includeTypes: [...included], excludeTypes: [...excluded] };
4953
+ }
4954
+
3369
4955
  export const ToolAuthNeededPayload = z.object({
3370
4956
  serverId: z.string().min(1),
3371
4957
  toolName: z.string().min(1).nullable().optional(),
3372
4958
  providerDomain: z.string().min(1),
3373
- connectionId: z.string().uuid().nullable().optional(),
3374
- reason: z.enum(["missing_connection", "expired", "insufficient_scope", "refresh_failed"]),
4959
+ provider: z.string().min(1).max(128).optional(),
4960
+ // Embedded hosts may use an opaque connection identity; never assume an
4961
+ // OpenGeni UUID on the public event wire.
4962
+ connectionId: z.string().min(1).nullable().optional(),
4963
+ reason: z.enum([
4964
+ "missing_connection",
4965
+ "expired",
4966
+ "insufficient_scope",
4967
+ "refresh_failed",
4968
+ "unsupported_auth",
4969
+ "resource_scope_unavailable",
4970
+ ]),
3375
4971
  scopes: z.array(z.string().min(1)).optional(),
3376
4972
  resource: z.string().min(1).optional(),
4973
+ selectedResources: McpConnectionResourceScopes.optional(),
3377
4974
  authorizationUrl: z.string().url().optional(),
3378
4975
  subjectId: z.string().min(1).nullable().optional(),
3379
4976
  });
3380
4977
  export type ToolAuthNeededPayload = z.infer<typeof ToolAuthNeededPayload>;
3381
4978
 
4979
+ /** A host-owned non-tool credential needed by the active run. */
4980
+ export const CredentialAuthNeededPayload = z.object({
4981
+ credentialClass: z.literal("run"),
4982
+ providerDomain: z.string().min(1).optional(),
4983
+ connectionId: z.string().min(1).optional(),
4984
+ reason: z.enum(["missing_connection", "expired", "insufficient_scope", "refresh_failed"]),
4985
+ scopes: z.array(z.string().min(1)).optional(),
4986
+ resource: z.string().min(1).optional(),
4987
+ authorizationUrl: z.string().url().optional(),
4988
+ message: z.string().min(1).optional(),
4989
+ });
4990
+ export type CredentialAuthNeededPayload = z.infer<typeof CredentialAuthNeededPayload>;
4991
+
3382
4992
  // Channel-B stream-event payloads (07-channel-b §1.2). SessionEvent.payload is
3383
4993
  // z.unknown() (NOT a discriminated union) — these are standalone schemas parsed
3384
4994
  // explicitly at the producer (the API-direct handshake/rotation) and the SDK/
@@ -3480,7 +5090,7 @@ export const RecordingFailedPayload = z.object({
3480
5090
  });
3481
5091
  export type RecordingFailedPayload = z.infer<typeof RecordingFailedPayload>;
3482
5092
 
3483
- // ── Channel-A structured services (P4.4 / modules/08-channel-a.md) ───────────
5093
+ // ── Structured sandbox services ─────────────────────────────────────────────
3484
5094
  // Two transports on one spine: the A2 request/response shapes (FsNode tree,
3485
5095
  // GitDiff hunks, terminal exec) are returned INLINE on synchronous API-direct
3486
5096
  // routes (never the bus); the A1 notification payloads below ride the durable
@@ -3647,7 +5257,9 @@ export const FsDeleteRequest = z.object({
3647
5257
  recursive: z.boolean().default(false), // required true to delete a non-empty dir
3648
5258
  });
3649
5259
  export type FsDeleteRequest = z.infer<typeof FsDeleteRequest>;
3650
- export const FsDeleteResponse = z.object({ revision: z.number().int().nonnegative() });
5260
+ export const FsDeleteResponse = z.object({
5261
+ revision: z.number().int().nonnegative(),
5262
+ });
3651
5263
  export type FsDeleteResponse = z.infer<typeof FsDeleteResponse>;
3652
5264
 
3653
5265
  export const FsMoveRequest = z.object({
@@ -3748,6 +5360,9 @@ export const GitDiffRequest = z.object({
3748
5360
  path: z.string().default(""), // repo root
3749
5361
  // diff selectors, mutually exclusive precedence: refs > staged > worktree
3750
5362
  staged: z.boolean().default(false), // --cached (index vs HEAD)
5363
+ // Workspace review includes after-images that ordinary `git diff` omits.
5364
+ // Explicit so commit/staged consumers keep native Git semantics by default.
5365
+ includeUntracked: z.boolean().default(false),
3751
5366
  fromRef: z.string().optional(),
3752
5367
  toRef: z.string().optional(),
3753
5368
  pathspec: z.array(z.string()).default([]),
@@ -3766,7 +5381,7 @@ export const GitDiffResponse = z.object({
3766
5381
  });
3767
5382
  export type GitDiffResponse = z.infer<typeof GitDiffResponse>;
3768
5383
 
3769
- // ─── Workbench v2 turn-end workspace capture (dossier §10.1/§10.2) ────────────
5384
+ // ─── Workbench v2 turn-end workspace capture ────────────
3770
5385
  // A capture is a point-in-time snapshot of the session workspace's CHANGES,
3771
5386
  // probed live off the box at turn end (detectRepos → gitStatus/gitDiff → fsRead
3772
5387
  // after-images → fsList tree index). It is the cold/offline read source that
@@ -3783,7 +5398,7 @@ export const WorkspaceCaptureFile = z.object({
3783
5398
  status: GitFileStatusCode,
3784
5399
  // sha256 of the captured after-image bytes; null when deleted / tooLarge.
3785
5400
  hash: z.string().nullable(),
3786
- // git blob sha of the HEAD version — the wake-on-edit flush guard (dossier
5401
+ // git blob sha of the HEAD version — the wake-on-edit flush guard (design
3787
5402
  // §10.1). null when the path is new/untracked (no HEAD blob).
3788
5403
  baseHash: z.string().nullable(),
3789
5404
  // Content-addressed storage key of the after-image; null when deleted /
@@ -3821,7 +5436,7 @@ export const WorkspaceCaptureDegradedReason = z.enum([
3821
5436
  export type WorkspaceCaptureDegradedReason = z.infer<typeof WorkspaceCaptureDegradedReason>;
3822
5437
 
3823
5438
  // 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.
5439
+ // can reserve layout (no layout shift) before fetching the manifest.
3825
5440
  export const WorkspaceCaptureStats = z.object({
3826
5441
  repoCount: z.number().int().nonnegative(),
3827
5442
  fileCount: z.number().int().nonnegative(),
@@ -3858,7 +5473,7 @@ export const WorkspaceCaptureManifest = z.object({
3858
5473
  });
3859
5474
  export type WorkspaceCaptureManifest = z.infer<typeof WorkspaceCaptureManifest>;
3860
5475
 
3861
- // Announce-only event payload (dossier §10.1). Metadata only — never content.
5476
+ // Announce-only event payload. Metadata only — never content.
3862
5477
  export const WorkspaceRevisionCapturedPayload = z.object({
3863
5478
  revision: z.number().int().nonnegative(),
3864
5479
  turnId: z.string().nullable(),
@@ -3877,7 +5492,7 @@ export const WorkspaceRevisionDegradedPayload = z.object({
3877
5492
  });
3878
5493
  export type WorkspaceRevisionDegradedPayload = z.infer<typeof WorkspaceRevisionDegradedPayload>;
3879
5494
 
3880
- // --- M2 capture READ API (dossier §10.3) -------------------------------------
5495
+ // --- M2 capture READ API -------------------------------------
3881
5496
  // A short-TTL signed GET URL minted PER REQUEST (never stored). The manifest is
3882
5497
  // served inline for the ≤2MB common case (the <200ms one-round-trip paint); a
3883
5498
  // >2MB manifest and a >256KB single-file after-image fall back to one of these.
@@ -3954,14 +5569,25 @@ export const GitCommit = z.object({
3954
5569
  sha: z.string(),
3955
5570
  shortSha: z.string(),
3956
5571
  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() }),
5572
+ author: z.object({
5573
+ name: z.string(),
5574
+ email: z.string(),
5575
+ timestamp: z.number().int(),
5576
+ }),
5577
+ committer: z.object({
5578
+ name: z.string(),
5579
+ email: z.string(),
5580
+ timestamp: z.number().int(),
5581
+ }),
3959
5582
  subject: z.string(),
3960
5583
  body: z.string(),
3961
5584
  refs: z.array(z.string()).default([]), // decorations: branch/tag pointers
3962
5585
  });
3963
5586
  export type GitCommit = z.infer<typeof GitCommit>;
3964
- export const GitLogResponse = z.object({ commits: z.array(GitCommit), hasMore: z.boolean() });
5587
+ export const GitLogResponse = z.object({
5588
+ commits: z.array(GitCommit),
5589
+ hasMore: z.boolean(),
5590
+ });
3965
5591
  export type GitLogResponse = z.infer<typeof GitLogResponse>;
3966
5592
 
3967
5593
  export const GitShowRequest = z.object({
@@ -4033,7 +5659,10 @@ export const PtyOpenResponse = z.object({
4033
5659
  supportsInput: z.boolean(), // false on backends without writeStdin
4034
5660
  });
4035
5661
  export type PtyOpenResponse = z.infer<typeof PtyOpenResponse>;
4036
- export const PtyWriteRequest = z.object({ ptyId: z.string().uuid(), data: z.string() }); // utf-8 stdin
5662
+ export const PtyWriteRequest = z.object({
5663
+ ptyId: z.string().uuid(),
5664
+ data: z.string(),
5665
+ }); // utf-8 stdin
4037
5666
  export type PtyWriteRequest = z.infer<typeof PtyWriteRequest>;
4038
5667
  export const PtyResizeRequest = z.object({
4039
5668
  ptyId: z.string().uuid(),
@@ -4048,7 +5677,11 @@ export type PtyCloseRequest = z.infer<typeof PtyCloseRequest>;
4048
5677
  // negotiation). The full SessionCapabilities doc already carries FileSystem /
4049
5678
  // Terminal / Git blocks (P0.1); this is the compact projection the SDK mirrors.
4050
5679
  export const SessionStructuredCapabilities = z.object({
4051
- FileSystem: z.object({ available: z.boolean(), readOnly: z.boolean(), root: z.string() }),
5680
+ FileSystem: z.object({
5681
+ available: z.boolean(),
5682
+ readOnly: z.boolean(),
5683
+ root: z.string(),
5684
+ }),
4052
5685
  Terminal: z.object({
4053
5686
  events: z.boolean(), // command.output firehose (always on if a box exists)
4054
5687
  exec: z.boolean(), // synchronous terminal exec
@@ -4066,39 +5699,644 @@ export const SessionEvent = z.object({
4066
5699
  type: SessionEventType,
4067
5700
  payload: z.unknown().default({}),
4068
5701
  occurredAt: z.string(),
4069
- clientEventId: z.string().min(1).nullable().optional(),
5702
+ clientEventId: SessionOperationKey.nullable().optional(),
4070
5703
  turnId: z.string().uuid().nullable().optional(),
4071
5704
  turnGeneration: z.number().int().nonnegative().nullable().optional(),
4072
5705
  turnAttemptId: z.string().uuid().nullable().optional(),
4073
5706
  turnAssociation: z.enum(["current", "late_rejected", "duplicate"]).nullable().optional(),
4074
5707
  duplicateOfEventId: z.string().uuid().nullable().optional(),
4075
- duplicateReason: z.string().min(1).nullable().optional(),
5708
+ duplicateReason: z.string().min(1).max(1024).nullable().optional(),
4076
5709
  });
4077
5710
  export type SessionEvent = z.infer<typeof SessionEvent>;
4078
5711
 
5712
+ // --- Durable host export ------------------------------------------------------
5713
+
5714
+ /** Wire revision for the durable host event/usage export stream. */
5715
+ export const OPENGENI_HOST_EXPORT_SCHEMA_REVISION = "2026-07-host-export-v1" as const;
5716
+
5717
+ /**
5718
+ * Decimal string rather than a JavaScript number: export cursors are PostgreSQL
5719
+ * bigint values and must remain exact beyond Number.MAX_SAFE_INTEGER.
5720
+ */
5721
+ export const HostExportCursor = z.string().regex(/^(0|[1-9][0-9]*)$/);
5722
+ export type HostExportCursor = z.infer<typeof HostExportCursor>;
5723
+
5724
+ export const HostExportConsumerId = z
5725
+ .string()
5726
+ .min(1)
5727
+ .max(128)
5728
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
5729
+ export type HostExportConsumerId = z.infer<typeof HostExportConsumerId>;
5730
+
5731
+ export const HostExportInitiator = TurnInitiator.extend({
5732
+ subjectId: z.string().min(1).max(1024),
5733
+ label: z.string().min(1).max(256).optional(),
5734
+ });
5735
+ export type HostExportInitiator = z.infer<typeof HostExportInitiator>;
5736
+
5737
+ export const HostExportInitiatorContext = TurnInitiatorContext.refine(
5738
+ (value) => {
5739
+ try {
5740
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength <= 4096;
5741
+ } catch {
5742
+ return false;
5743
+ }
5744
+ },
5745
+ { message: "Host export initiator context exceeds 4096 UTF-8 bytes" },
5746
+ );
5747
+ export type HostExportInitiatorContext = z.infer<typeof HostExportInitiatorContext>;
5748
+
5749
+ const HostExportAttribution = {
5750
+ initiator: HostExportInitiator.nullable(),
5751
+ initiatorContext: HostExportInitiatorContext,
5752
+ origin: SessionTurnSource.nullable(),
5753
+ } as const;
5754
+
5755
+ /**
5756
+ * Host streams are deliberately forward-tolerant across rolling upgrades.
5757
+ * OpenGeni's application contract enumerates the event types known to this
5758
+ * build, while the durable export may be read by an older host consumer after
5759
+ * a newer writer has committed a bounded type. The database remains the
5760
+ * authority for the byte bounds on these persisted strings.
5761
+ */
5762
+ export const HostSessionEvent = SessionEvent.extend({
5763
+ type: z.string().min(1).max(256),
5764
+ clientEventId: z.string().max(1024).nullable().optional(),
5765
+ turnAssociation: z.string().min(1).max(64).nullable().optional(),
5766
+ duplicateReason: z.string().max(4096).nullable().optional(),
5767
+ });
5768
+ export type HostSessionEvent = z.infer<typeof HostSessionEvent>;
5769
+
5770
+ /** Export-bounded usage fact; custom bounded metric names remain supported. */
5771
+ export const HostUsageEvent = UsageEvent.extend({
5772
+ subjectId: z.string().max(1024).nullable(),
5773
+ eventType: z.string().min(1).max(256),
5774
+ unit: z.string().min(1).max(128),
5775
+ sourceResourceType: z.string().max(256).nullable(),
5776
+ sourceResourceId: z.string().max(2048).nullable(),
5777
+ idempotencyKey: z.string().min(1).max(2048),
5778
+ billingProviderEventId: z.string().max(2048).nullable(),
5779
+ });
5780
+ export type HostUsageEvent = z.infer<typeof HostUsageEvent>;
5781
+
5782
+ /**
5783
+ * One immutable, bounded session-event snapshot from the transactional host
5784
+ * outbox. Cross-session cursor order is stable but deliberately non-causal;
5785
+ * within a session, `event.sequence` remains authoritative and monotonic.
5786
+ */
5787
+ export const HostEventExport = z.object({
5788
+ schemaRevision: z.literal(OPENGENI_HOST_EXPORT_SCHEMA_REVISION),
5789
+ cursor: HostExportCursor,
5790
+ idempotencyKey: z.string().min(1).max(2048),
5791
+ accountId: z.string().uuid(),
5792
+ workspaceId: z.string().uuid(),
5793
+ /**
5794
+ * Immutable root of event.sessionId's session lineage at capture time. Null
5795
+ * only for an unresolved pre-lineage/legacy export row.
5796
+ */
5797
+ rootSessionId: z.string().uuid().nullable(),
5798
+ ...HostExportAttribution,
5799
+ event: HostSessionEvent,
5800
+ });
5801
+ export type HostEventExport = z.infer<typeof HostEventExport>;
5802
+
5803
+ /** One exact, idempotency-keyed usage fact from the same ordered outbox. */
5804
+ export const HostUsageExport = z.object({
5805
+ schemaRevision: z.literal(OPENGENI_HOST_EXPORT_SCHEMA_REVISION),
5806
+ cursor: HostExportCursor,
5807
+ accountId: z.string().uuid(),
5808
+ workspaceId: z.string().uuid(),
5809
+ sessionId: z.string().uuid().nullable(),
5810
+ /** Null when sessionId is null or an unresolved pre-lineage legacy row. */
5811
+ rootSessionId: z.string().uuid().nullable(),
5812
+ turnId: z.string().uuid().nullable(),
5813
+ turnAttemptId: z.string().uuid().nullable(),
5814
+ ...HostExportAttribution,
5815
+ usage: HostUsageEvent,
5816
+ });
5817
+ export type HostUsageExport = z.infer<typeof HostUsageExport>;
5818
+
5819
+ export const HostEventExportBatch = z.object({
5820
+ schemaRevision: z.literal(OPENGENI_HOST_EXPORT_SCHEMA_REVISION),
5821
+ consumerId: HostExportConsumerId,
5822
+ leaseToken: z.string().uuid(),
5823
+ checkpoint: HostExportCursor,
5824
+ throughCursor: HostExportCursor,
5825
+ events: z.array(HostEventExport).min(1).max(256),
5826
+ });
5827
+ export type HostEventExportBatch = z.infer<typeof HostEventExportBatch>;
5828
+
5829
+ export const HostUsageExportBatch = z.object({
5830
+ schemaRevision: z.literal(OPENGENI_HOST_EXPORT_SCHEMA_REVISION),
5831
+ consumerId: HostExportConsumerId,
5832
+ leaseToken: z.string().uuid(),
5833
+ checkpoint: HostExportCursor,
5834
+ throughCursor: HostExportCursor,
5835
+ events: z.array(HostUsageExport).min(1).max(256),
5836
+ });
5837
+ export type HostUsageExportBatch = z.infer<typeof HostUsageExportBatch>;
5838
+
5839
+ /**
5840
+ * Optional embedded-host sinks. Delivery is at least once: the same batch may
5841
+ * be repeated after a process dies between sink success and checkpoint commit,
5842
+ * so sinks must deduplicate by event/usage idempotency key.
5843
+ */
5844
+ export type HostEventSink = {
5845
+ consumerId: HostExportConsumerId;
5846
+ deliverEvents: (batch: HostEventExportBatch) => Promise<void>;
5847
+ };
5848
+
5849
+ export type HostUsageSink = {
5850
+ consumerId: HostExportConsumerId;
5851
+ deliverUsage: (batch: HostUsageExportBatch) => Promise<void>;
5852
+ };
5853
+
5854
+ export const SESSION_EVENT_TYPE_MAX_BYTES = 256;
5855
+ export const SESSION_EVENT_CLIENT_EVENT_ID_MAX_BYTES = SESSION_OPERATION_KEY_MAX_CHARS * 4;
5856
+ export const SESSION_EVENT_TURN_ASSOCIATION_MAX_BYTES = 64;
5857
+ export const SESSION_EVENT_DUPLICATE_REASON_MAX_BYTES = 4 * 1024;
5858
+ export const SESSION_EVENT_ENVELOPE_MAX_BYTES = 80 * 1024;
5859
+
5860
+ export type BoundSessionEventOptions = {
5861
+ surface?: SessionEventBoundarySurface;
5862
+ maxBytes?: number;
5863
+ };
5864
+
5865
+ /**
5866
+ * Canonical lossy projection for a complete session event. Payload bounds alone
5867
+ * are insufficient: a malformed retained row can also carry an oversized type,
5868
+ * client id, or duplicate diagnostic. Keep cursor/UUID identity intact, bound
5869
+ * every free-form envelope string, and assert the exact final JSON envelope.
5870
+ */
5871
+ export function boundSessionEvent(
5872
+ event: SessionEvent,
5873
+ options: BoundSessionEventOptions = {},
5874
+ ): SessionEvent {
5875
+ const surface = options.surface ?? "durable_audit";
5876
+ const maxBytes = Math.max(8 * 1024, options.maxBytes ?? SESSION_EVENT_ENVELOPE_MAX_BYTES);
5877
+ // Never stringify the untrusted complete event. Measurement has one global
5878
+ // work budget and never invokes accessors/custom toJSON; serialization is
5879
+ // permitted only after the compact projection below has been constructed.
5880
+ const originalBytes = measureSessionEventJson(event).bytes;
5881
+ const source = sessionEventOwnDataFields(event);
5882
+ const id = canonicalSessionEventUuid(source.id, SESSION_EVENT_ZERO_UUID);
5883
+ const workspaceId = canonicalSessionEventUuid(source.workspaceId, SESSION_EVENT_ZERO_UUID);
5884
+ const sessionId = canonicalSessionEventUuid(source.sessionId, SESSION_EVENT_ZERO_UUID);
5885
+ const sequence =
5886
+ source.sequence.readable &&
5887
+ typeof source.sequence.value === "number" &&
5888
+ Number.isSafeInteger(source.sequence.value) &&
5889
+ source.sequence.value > 0
5890
+ ? source.sequence.value
5891
+ : 1;
5892
+ const occurredAt =
5893
+ source.occurredAt.readable &&
5894
+ typeof source.occurredAt.value === "string" &&
5895
+ sessionEventUtf8Bytes(source.occurredAt.value) <= 256
5896
+ ? source.occurredAt.value
5897
+ : "1970-01-01T00:00:00.000Z";
5898
+ const rawType = source.type.readable ? source.type.value : undefined;
5899
+ const typeIsSafe =
5900
+ typeof rawType === "string" &&
5901
+ sessionEventUtf8Bytes(rawType) <= SESSION_EVENT_TYPE_MAX_BYTES &&
5902
+ !rawType.includes("\n") &&
5903
+ !rawType.includes("\r");
5904
+ const rawClientEventId = source.clientEventId.readable ? source.clientEventId.value : undefined;
5905
+ const clientEventId = boundOptionalSessionEventText(
5906
+ typeof rawClientEventId === "string" || rawClientEventId === null
5907
+ ? rawClientEventId
5908
+ : undefined,
5909
+ SESSION_EVENT_CLIENT_EVENT_ID_MAX_BYTES,
5910
+ );
5911
+ const rawTurnAssociation = source.turnAssociation.readable
5912
+ ? source.turnAssociation.value
5913
+ : undefined;
5914
+ const turnAssociation =
5915
+ rawTurnAssociation === null ||
5916
+ rawTurnAssociation === undefined ||
5917
+ rawTurnAssociation === "current" ||
5918
+ rawTurnAssociation === "late_rejected" ||
5919
+ rawTurnAssociation === "duplicate"
5920
+ ? rawTurnAssociation
5921
+ : null;
5922
+ const rawDuplicateReason = source.duplicateReason.readable
5923
+ ? source.duplicateReason.value
5924
+ : undefined;
5925
+ const duplicateReason = boundOptionalSessionEventText(
5926
+ typeof rawDuplicateReason === "string" || rawDuplicateReason === null
5927
+ ? rawDuplicateReason
5928
+ : undefined,
5929
+ SESSION_EVENT_DUPLICATE_REASON_MAX_BYTES,
5930
+ );
5931
+ const turnId = canonicalOptionalSessionEventUuid(source.turnId);
5932
+ const turnGeneration = canonicalSessionEventGeneration(source.turnGeneration);
5933
+ const turnAttemptId = canonicalOptionalSessionEventUuid(source.turnAttemptId);
5934
+ const duplicateOfEventId = canonicalOptionalSessionEventUuid(source.duplicateOfEventId);
5935
+ const envelopeFields = [
5936
+ sessionEventCustomSerializerProjection(event),
5937
+ sessionEventAdditionalTopLevelFieldProjection(event),
5938
+ !typeIsSafe
5939
+ ? sessionEventEnvelopeFieldProjection(
5940
+ "type",
5941
+ rawType,
5942
+ "session.event.envelope_omitted",
5943
+ source.type.readable,
5944
+ )
5945
+ : null,
5946
+ !source.clientEventId.readable || rawClientEventId !== clientEventId
5947
+ ? sessionEventEnvelopeFieldProjection(
5948
+ "clientEventId",
5949
+ rawClientEventId,
5950
+ clientEventId,
5951
+ source.clientEventId.readable,
5952
+ )
5953
+ : null,
5954
+ !source.turnAssociation.readable || rawTurnAssociation !== turnAssociation
5955
+ ? sessionEventEnvelopeFieldProjection(
5956
+ "turnAssociation",
5957
+ rawTurnAssociation,
5958
+ turnAssociation,
5959
+ source.turnAssociation.readable,
5960
+ )
5961
+ : null,
5962
+ !source.duplicateReason.readable || rawDuplicateReason !== duplicateReason
5963
+ ? sessionEventEnvelopeFieldProjection(
5964
+ "duplicateReason",
5965
+ rawDuplicateReason,
5966
+ duplicateReason,
5967
+ source.duplicateReason.readable,
5968
+ )
5969
+ : null,
5970
+ ...sessionEventCanonicalFieldProjections(source, {
5971
+ id,
5972
+ workspaceId,
5973
+ sessionId,
5974
+ sequence,
5975
+ occurredAt,
5976
+ }),
5977
+ ...sessionEventOptionalFieldProjections(source, {
5978
+ turnId,
5979
+ turnGeneration,
5980
+ turnAttemptId,
5981
+ duplicateOfEventId,
5982
+ }),
5983
+ !source.payload.readable
5984
+ ? sessionEventEnvelopeFieldProjection("payload", undefined, null, false)
5985
+ : null,
5986
+ ].filter((field) => field !== null);
5987
+ const rawPayload = source.payload.readable
5988
+ ? source.payload.value
5989
+ : "[event payload accessor omitted at bounded projection boundary]";
5990
+ const payload =
5991
+ envelopeFields.length === 0
5992
+ ? boundSessionEventPayload(rawPayload, { surface })
5993
+ : boundSessionEventPayload(
5994
+ {
5995
+ preview: "[legacy event envelope normalized at bounded projection boundary]",
5996
+ originalEventBytes: originalBytes,
5997
+ originalType: typeof rawType === "string" ? boundSessionEventText(rawType, 256) : null,
5998
+ envelopeProjection: {
5999
+ truncated: true,
6000
+ surface,
6001
+ fields: envelopeFields,
6002
+ },
6003
+ fullEvidence: { available: false, reason: "not_retained" },
6004
+ },
6005
+ { surface, maxBytes: 8 * 1024 },
6006
+ );
6007
+ const bounded: SessionEvent = {
6008
+ id,
6009
+ workspaceId,
6010
+ sessionId,
6011
+ sequence,
6012
+ type: typeIsSafe ? (rawType as SessionEvent["type"]) : "session.event.envelope_omitted",
6013
+ payload,
6014
+ occurredAt,
6015
+ ...(sessionEventShouldEmitOptionalField(source.clientEventId) ? { clientEventId } : {}),
6016
+ ...(sessionEventShouldEmitOptionalField(source.turnId) ? { turnId } : {}),
6017
+ ...(sessionEventShouldEmitOptionalField(source.turnGeneration) ? { turnGeneration } : {}),
6018
+ ...(sessionEventShouldEmitOptionalField(source.turnAttemptId) ? { turnAttemptId } : {}),
6019
+ ...(sessionEventShouldEmitOptionalField(source.turnAssociation) ? { turnAssociation } : {}),
6020
+ ...(sessionEventShouldEmitOptionalField(source.duplicateOfEventId)
6021
+ ? { duplicateOfEventId }
6022
+ : {}),
6023
+ ...(sessionEventShouldEmitOptionalField(source.duplicateReason) ? { duplicateReason } : {}),
6024
+ };
6025
+ if (sessionEventJsonBytes(bounded) <= maxBytes) return bounded;
6026
+
6027
+ const fallback: SessionEvent = {
6028
+ id,
6029
+ workspaceId,
6030
+ sessionId,
6031
+ sequence,
6032
+ type: "session.event.envelope_omitted",
6033
+ payload: boundSessionEventPayload(
6034
+ {
6035
+ preview: "[legacy event envelope omitted at bounded projection boundary]",
6036
+ originalEventBytes: originalBytes,
6037
+ originalType: typeof rawType === "string" ? boundSessionEventText(rawType, 256) : null,
6038
+ fullEvidence: { available: false, reason: "not_retained" },
6039
+ },
6040
+ { surface, maxBytes: 4 * 1024 },
6041
+ ),
6042
+ occurredAt,
6043
+ ...(sessionEventShouldEmitOptionalField(source.clientEventId) ? { clientEventId } : {}),
6044
+ ...(sessionEventShouldEmitOptionalField(source.turnId) ? { turnId } : {}),
6045
+ ...(sessionEventShouldEmitOptionalField(source.turnGeneration) ? { turnGeneration } : {}),
6046
+ ...(sessionEventShouldEmitOptionalField(source.turnAttemptId) ? { turnAttemptId } : {}),
6047
+ ...(sessionEventShouldEmitOptionalField(source.turnAssociation) ? { turnAssociation } : {}),
6048
+ ...(sessionEventShouldEmitOptionalField(source.duplicateOfEventId)
6049
+ ? { duplicateOfEventId }
6050
+ : {}),
6051
+ ...(sessionEventShouldEmitOptionalField(source.duplicateReason) ? { duplicateReason } : {}),
6052
+ };
6053
+ const deliveredBytes = sessionEventJsonBytes(fallback);
6054
+ if (deliveredBytes > maxBytes) {
6055
+ throw new RangeError(
6056
+ `Bounded session event exceeds its final envelope (${deliveredBytes} > ${maxBytes} bytes)`,
6057
+ );
6058
+ }
6059
+ return fallback;
6060
+ }
6061
+
6062
+ function sessionEventEnvelopeFieldProjection(
6063
+ field: string,
6064
+ original: unknown,
6065
+ delivered: unknown,
6066
+ originalReadable = true,
6067
+ ): { field: string; originalBytes: number | null; deliveredBytes: number } {
6068
+ return {
6069
+ field,
6070
+ originalBytes: originalReadable
6071
+ ? typeof original === "string"
6072
+ ? sessionEventUtf8Bytes(original)
6073
+ : typeof original === "number" || typeof original === "boolean"
6074
+ ? sessionEventJsonBytes(original)
6075
+ : original === null || original === undefined
6076
+ ? 0
6077
+ : null
6078
+ : null,
6079
+ deliveredBytes:
6080
+ typeof delivered === "string"
6081
+ ? sessionEventUtf8Bytes(delivered)
6082
+ : typeof delivered === "number" || typeof delivered === "boolean"
6083
+ ? sessionEventJsonBytes(delivered)
6084
+ : 0,
6085
+ };
6086
+ }
6087
+
6088
+ function sessionEventCustomSerializerProjection(
6089
+ event: SessionEvent,
6090
+ ): { field: string; originalBytes: null; deliveredBytes: 0 } | null {
6091
+ const projection = {
6092
+ field: "toJSON",
6093
+ originalBytes: null,
6094
+ deliveredBytes: 0,
6095
+ } as const;
6096
+ let candidate: object | null = event;
6097
+ try {
6098
+ for (let depth = 0; depth <= SESSION_EVENT_PROTOTYPE_MAX_DEPTH; depth += 1) {
6099
+ if (candidate === null) return null;
6100
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, "toJSON");
6101
+ if (descriptor) {
6102
+ // JSON.stringify performs an ordinary lookup, so an accessor is both
6103
+ // executable behavior and an unknown possible serializer. A data
6104
+ // property shadows the rest of the chain and is relevant only when it
6105
+ // is callable.
6106
+ return !("value" in descriptor) || typeof descriptor.value === "function"
6107
+ ? projection
6108
+ : null;
6109
+ }
6110
+ candidate = Object.getPrototypeOf(candidate);
6111
+ }
6112
+ // A hostile or malformed prototype chain that exceeds the fixed lookup
6113
+ // budget cannot prove the absence of inherited serialization behavior.
6114
+ return projection;
6115
+ } catch {
6116
+ return projection;
6117
+ }
6118
+ }
6119
+
6120
+ const SESSION_EVENT_PROTOTYPE_MAX_DEPTH = 32;
6121
+ const SESSION_EVENT_ZERO_UUID = "00000000-0000-4000-8000-000000000000";
6122
+ const SESSION_EVENT_UUID_PATTERN =
6123
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6124
+
6125
+ const SESSION_EVENT_OWN_DATA_FIELDS = [
6126
+ "id",
6127
+ "workspaceId",
6128
+ "sessionId",
6129
+ "sequence",
6130
+ "type",
6131
+ "payload",
6132
+ "occurredAt",
6133
+ "clientEventId",
6134
+ "turnId",
6135
+ "turnGeneration",
6136
+ "turnAttemptId",
6137
+ "turnAssociation",
6138
+ "duplicateOfEventId",
6139
+ "duplicateReason",
6140
+ ] as const satisfies readonly (keyof SessionEvent)[];
6141
+ const SESSION_EVENT_KNOWN_ENUMERABLE_FIELDS = new Set<string>([
6142
+ ...SESSION_EVENT_OWN_DATA_FIELDS,
6143
+ "toJSON",
6144
+ ]);
6145
+
6146
+ /**
6147
+ * Detect future/legacy own enumerable envelope fields without reading their
6148
+ * values. There can be at most the fixed known-key cardinality before an
6149
+ * additional key must be observed, so the source-level iterator is bounded.
6150
+ * A proxy/enumeration failure is conservatively surfaced as unknown loss.
6151
+ */
6152
+ function sessionEventAdditionalTopLevelFieldProjection(
6153
+ event: SessionEvent,
6154
+ ): { field: string; originalBytes: null; deliveredBytes: 0 } | null {
6155
+ const projection = {
6156
+ field: "additionalTopLevelFields",
6157
+ originalBytes: null,
6158
+ deliveredBytes: 0,
6159
+ } as const;
6160
+ let inspected = 0;
6161
+ try {
6162
+ for (const key in event as SessionEvent & Record<string, unknown>) {
6163
+ inspected += 1;
6164
+ if (inspected > SESSION_EVENT_KNOWN_ENUMERABLE_FIELDS.size + 1) return projection;
6165
+ const descriptor = Object.getOwnPropertyDescriptor(event, key);
6166
+ if (descriptor?.enumerable && !SESSION_EVENT_KNOWN_ENUMERABLE_FIELDS.has(key)) {
6167
+ return projection;
6168
+ }
6169
+ }
6170
+ return null;
6171
+ } catch {
6172
+ return projection;
6173
+ }
6174
+ }
6175
+
6176
+ type SessionEventOwnField = { readable: true; value: unknown } | { readable: false };
6177
+ type SessionEventOwnDataFields = Record<keyof SessionEvent, SessionEventOwnField>;
6178
+
6179
+ function sessionEventOwnDataFields(event: SessionEvent): SessionEventOwnDataFields {
6180
+ return Object.fromEntries(
6181
+ SESSION_EVENT_OWN_DATA_FIELDS.map((key) => {
6182
+ try {
6183
+ const descriptor = Object.getOwnPropertyDescriptor(event, key);
6184
+ if (!descriptor) return [key, { readable: true, value: undefined }];
6185
+ return [
6186
+ key,
6187
+ "value" in descriptor ? { readable: true, value: descriptor.value } : { readable: false },
6188
+ ];
6189
+ } catch {
6190
+ return [key, { readable: false }];
6191
+ }
6192
+ }),
6193
+ ) as SessionEventOwnDataFields;
6194
+ }
6195
+
6196
+ function canonicalSessionEventUuid(field: SessionEventOwnField, fallback: string): string {
6197
+ return field.readable &&
6198
+ typeof field.value === "string" &&
6199
+ SESSION_EVENT_UUID_PATTERN.test(field.value)
6200
+ ? field.value
6201
+ : fallback;
6202
+ }
6203
+
6204
+ function canonicalOptionalSessionEventUuid(field: SessionEventOwnField): string | null {
6205
+ return field.readable &&
6206
+ typeof field.value === "string" &&
6207
+ SESSION_EVENT_UUID_PATTERN.test(field.value)
6208
+ ? field.value
6209
+ : null;
6210
+ }
6211
+
6212
+ function canonicalSessionEventGeneration(field: SessionEventOwnField): number | null {
6213
+ return field.readable &&
6214
+ typeof field.value === "number" &&
6215
+ Number.isSafeInteger(field.value) &&
6216
+ field.value >= 0
6217
+ ? field.value
6218
+ : null;
6219
+ }
6220
+
6221
+ function sessionEventShouldEmitOptionalField(field: SessionEventOwnField): boolean {
6222
+ return !field.readable || field.value !== undefined;
6223
+ }
6224
+
6225
+ function sessionEventCanonicalFieldProjections(
6226
+ source: SessionEventOwnDataFields,
6227
+ delivered: {
6228
+ id: string;
6229
+ workspaceId: string;
6230
+ sessionId: string;
6231
+ sequence: number;
6232
+ occurredAt: string;
6233
+ },
6234
+ ): Array<{
6235
+ field: string;
6236
+ originalBytes: number | null;
6237
+ deliveredBytes: number;
6238
+ }> {
6239
+ return (["id", "workspaceId", "sessionId", "sequence", "occurredAt"] as const).flatMap(
6240
+ (field) => {
6241
+ const original = source[field].readable ? source[field].value : undefined;
6242
+ return source[field].readable && original === delivered[field]
6243
+ ? []
6244
+ : [
6245
+ sessionEventEnvelopeFieldProjection(
6246
+ field,
6247
+ original,
6248
+ delivered[field],
6249
+ source[field].readable,
6250
+ ),
6251
+ ];
6252
+ },
6253
+ );
6254
+ }
6255
+
6256
+ function sessionEventOptionalFieldProjections(
6257
+ source: SessionEventOwnDataFields,
6258
+ delivered: {
6259
+ turnId: string | null;
6260
+ turnGeneration: number | null;
6261
+ turnAttemptId: string | null;
6262
+ duplicateOfEventId: string | null;
6263
+ },
6264
+ ): Array<{
6265
+ field: string;
6266
+ originalBytes: number | null;
6267
+ deliveredBytes: number;
6268
+ }> {
6269
+ return (["turnId", "turnGeneration", "turnAttemptId", "duplicateOfEventId"] as const).flatMap(
6270
+ (field) => {
6271
+ const original = source[field].readable ? source[field].value : undefined;
6272
+ const canonicalOriginal = original ?? null;
6273
+ return source[field].readable && canonicalOriginal === delivered[field]
6274
+ ? []
6275
+ : [
6276
+ sessionEventEnvelopeFieldProjection(
6277
+ field,
6278
+ original,
6279
+ delivered[field],
6280
+ source[field].readable,
6281
+ ),
6282
+ ];
6283
+ },
6284
+ );
6285
+ }
6286
+
6287
+ function boundOptionalSessionEventText<T extends string | null | undefined>(
6288
+ value: T,
6289
+ maxBytes: number,
6290
+ ): T {
6291
+ return (typeof value === "string" ? boundSessionEventText(value, maxBytes) : value) as T;
6292
+ }
6293
+
6294
+ function boundSessionEventText(value: string, maxBytes: number): string {
6295
+ const encoder = new TextEncoder();
6296
+ const decoder = new TextDecoder();
6297
+ const bytes = encoder.encode(value);
6298
+ if (bytes.byteLength <= maxBytes) return value;
6299
+ const marker = "…[truncated]";
6300
+ const markerBytes = encoder.encode(marker).byteLength;
6301
+ const prefixBudget = Math.max(0, maxBytes - markerBytes);
6302
+ let prefixEnd = Math.min(prefixBudget, bytes.byteLength);
6303
+ while (prefixEnd > 0 && prefixEnd < bytes.byteLength && (bytes[prefixEnd]! & 0xc0) === 0x80) {
6304
+ prefixEnd -= 1;
6305
+ }
6306
+ return `${decoder.decode(bytes.subarray(0, prefixEnd))}${marker}`;
6307
+ }
6308
+
6309
+ function sessionEventUtf8Bytes(value: string): number {
6310
+ return new TextEncoder().encode(value).byteLength;
6311
+ }
6312
+
4079
6313
  export const SessionQueueMutationResponse = z.object({
6314
+ receipt: SessionCommandReceipt,
4080
6315
  snapshot: SessionQueueSnapshot,
4081
- events: z.array(SessionEvent),
4082
- shouldWake: z.boolean(),
6316
+ draft: ComposerDraft.optional(),
4083
6317
  });
4084
6318
  export type SessionQueueMutationResponse = z.infer<typeof SessionQueueMutationResponse>;
4085
6319
 
4086
6320
  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(),
6321
+ receipt: SessionCommandReceipt,
6322
+ effectiveControl: EffectiveSessionControl,
6323
+ interruptionCount: z.number().int().nonnegative(),
6324
+ wakeCount: z.number().int().nonnegative(),
4097
6325
  });
4098
6326
  export type SessionControlResponse = z.infer<typeof SessionControlResponse>;
4099
6327
 
4100
6328
  export const CreateSessionRequest = withVariableSetIdAlias({
6329
+ /**
6330
+ * Optional UUID preallocated by an embedding host. This lets the host durably
6331
+ * link its own projection before OpenGeni admits the initial turn. Replays
6332
+ * must pair it with the same idempotency key; OpenGeni never derives host
6333
+ * identity or authorization from the UUID.
6334
+ */
6335
+ requestedSessionId: z.string().uuid().optional(),
4101
6336
  initialMessage: z.string().min(1),
6337
+ // System-level host context for the initial turn only. Unlike `instructions`,
6338
+ // this does not persist into later turns and is never emitted as a user event.
6339
+ turnInstructions: z.string().trim().min(1).max(32768).optional(),
4102
6340
  // Per-session agent persona/system instructions (org-visible metadata, NOT a
4103
6341
  // secret). Rides the SAME system-level instructions channel the per-workspace
4104
6342
  // agentInstructions rides, composed AFTER the workspace persona so it refines
@@ -4108,7 +6346,14 @@ export const CreateSessionRequest = withVariableSetIdAlias({
4108
6346
  // matches the codebase's largest free-form string convention (workspace
4109
6347
  // variable set variable values). Absent ⇒ byte-identical to today.
4110
6348
  instructions: z.string().trim().min(1).max(32768).optional(),
6349
+ // For an agent-created child, omission inherits the trusted immediate
6350
+ // parent's repository/file context. An explicit array, including [], is
6351
+ // authoritative. Top-level omission remains []. Presence is resolved from
6352
+ // the raw request because this Zod default erases absent-vs-empty.
4111
6353
  resources: z.array(ResourceRef).default([]),
6354
+ // The same child omission rule applies to selected MCP tool refs. Top-level
6355
+ // omission still applies workspace-default capability MCP tools; explicit []
6356
+ // suppresses those defaults (the first-party OpenGeni server remains added).
4112
6357
  tools: z.array(ToolRef).default([]),
4113
6358
  metadata: z.record(z.string(), z.unknown()).default({}),
4114
6359
  model: z.string().min(1).optional(),
@@ -4135,7 +6380,7 @@ export const CreateSessionRequest = withVariableSetIdAlias({
4135
6380
  // behavior). An id that does not name a rig in the workspace is a 422.
4136
6381
  rigId: z.string().uuid().optional(),
4137
6382
  goal: GoalSpec.optional(),
4138
- clientEventId: z.string().min(1).optional(),
6383
+ clientEventId: SessionOperationKey.optional(),
4139
6384
  // Workspace-scoped CREATE idempotency key: collapses concurrent/retried
4140
6385
  // create calls carrying the same key to a single session (partial unique
4141
6386
  // index on (workspace_id, create_idempotency_key)). Distinct from
@@ -4143,13 +6388,19 @@ export const CreateSessionRequest = withVariableSetIdAlias({
4143
6388
  // creation of a brand-new session. Absent means no create-dedup (each call
4144
6389
  // is an independent create).
4145
6390
  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).
6391
+ // Permissions the session's first-party MCP token should carry. A top-level
6392
+ // omission uses the deployment's worker default; a child omission inherits
6393
+ // the creating session's effective grant. An explicit set is capped at
6394
+ // creation: every requested permission must be held by the creating grant.
6395
+ // A goal-bearing session whose explicit/effective set omits goals:manage is
6396
+ // rejected; creation never silently expands a child beyond that set.
4150
6397
  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.
6398
+ // Third-party MCP servers attached only to this session. For an agent-created
6399
+ // child, omission snapshots its trusted immediate parent's server definitions,
6400
+ // policies, connection refs, and encrypted credentials. Explicit arrays,
6401
+ // including [], are authoritative; non-empty explicit arrays require attach
6402
+ // permission. Credential headers are write-only: create responses and events
6403
+ // expose only SessionMcpServerMetadata.
4153
6404
  mcpServers: z.array(SessionMcpServerInput).default([]),
4154
6405
  // Shared-sandbox placement (addendum 05 §D.1). Three-way union; OMITTED ⇒
4155
6406
  // today's behavior (a context-dependent default resolved server-side: from
@@ -4174,16 +6425,198 @@ export const CreateSessionRequest = withVariableSetIdAlias({
4174
6425
  });
4175
6426
  export type CreateSessionRequest = z.infer<typeof CreateSessionRequest>;
4176
6427
 
6428
+ // Generic, host-neutral structured human input. One model tool call creates one
6429
+ // request containing one or more questions; the durable response resumes that
6430
+ // exact call. This is deliberately distinct from tool approval: an answer,
6431
+ // skip, or expiry is structured tool output, never an approve/reject decision.
6432
+ export const HumanInputQuestionKind = z.enum(["text", "single_select", "multi_select"]);
6433
+ export type HumanInputQuestionKind = z.infer<typeof HumanInputQuestionKind>;
6434
+
6435
+ export const HumanInputOption = z.object({
6436
+ id: z.string().min(1).max(64),
6437
+ label: z.string().min(1).max(256),
6438
+ description: z.string().max(2048).nullable().optional(),
6439
+ });
6440
+ export type HumanInputOption = z.infer<typeof HumanInputOption>;
6441
+
6442
+ export const HumanInputQuestion = z
6443
+ .object({
6444
+ id: z.string().min(1).max(64),
6445
+ kind: HumanInputQuestionKind,
6446
+ prompt: z.string().min(1).max(4096),
6447
+ label: z.string().min(1).max(128).nullable().optional(),
6448
+ helpText: z.string().max(2048).nullable().optional(),
6449
+ options: z.array(HumanInputOption).max(20).default([]),
6450
+ required: z.boolean().default(true),
6451
+ allowOther: z.boolean().default(false),
6452
+ validation: z
6453
+ .object({
6454
+ minLength: z.number().int().nonnegative().max(8192).nullable().optional(),
6455
+ maxLength: z.number().int().positive().max(8192).nullable().optional(),
6456
+ minSelections: z.number().int().nonnegative().max(20).nullable().optional(),
6457
+ maxSelections: z.number().int().positive().max(20).nullable().optional(),
6458
+ })
6459
+ .nullable()
6460
+ .optional(),
6461
+ })
6462
+ .superRefine((question, ctx) => {
6463
+ const optionIds = new Set(question.options.map((option) => option.id));
6464
+ if (optionIds.size !== question.options.length) {
6465
+ ctx.addIssue({
6466
+ code: "custom",
6467
+ path: ["options"],
6468
+ message: "option ids must be unique",
6469
+ });
6470
+ }
6471
+ if (question.kind === "text") {
6472
+ if (question.options.length > 0) {
6473
+ ctx.addIssue({
6474
+ code: "custom",
6475
+ path: ["options"],
6476
+ message: "text questions cannot have options",
6477
+ });
6478
+ }
6479
+ if (question.allowOther) {
6480
+ ctx.addIssue({
6481
+ code: "custom",
6482
+ path: ["allowOther"],
6483
+ message: "text questions do not use Other",
6484
+ });
6485
+ }
6486
+ } else if (question.options.length === 0) {
6487
+ ctx.addIssue({
6488
+ code: "custom",
6489
+ path: ["options"],
6490
+ message: "select questions require options",
6491
+ });
6492
+ }
6493
+ const validation = question.validation;
6494
+ if (
6495
+ validation?.minLength != null &&
6496
+ validation?.maxLength != null &&
6497
+ validation.minLength > validation.maxLength
6498
+ ) {
6499
+ ctx.addIssue({
6500
+ code: "custom",
6501
+ path: ["validation"],
6502
+ message: "minLength exceeds maxLength",
6503
+ });
6504
+ }
6505
+ if (
6506
+ validation?.minSelections != null &&
6507
+ validation?.maxSelections != null &&
6508
+ validation.minSelections > validation.maxSelections
6509
+ ) {
6510
+ ctx.addIssue({
6511
+ code: "custom",
6512
+ path: ["validation"],
6513
+ message: "minSelections exceeds maxSelections",
6514
+ });
6515
+ }
6516
+ });
6517
+ export type HumanInputQuestion = z.infer<typeof HumanInputQuestion>;
6518
+
6519
+ export const HumanInputRequestStatus = z.enum([
6520
+ "pending",
6521
+ "answered",
6522
+ "skipped",
6523
+ "expired",
6524
+ "cancelled",
6525
+ ]);
6526
+ export type HumanInputRequestStatus = z.infer<typeof HumanInputRequestStatus>;
6527
+
6528
+ export const RequestHumanInputToolInput = z.object({
6529
+ questions: z.array(HumanInputQuestion).min(1).max(20),
6530
+ allowSkip: z.boolean().default(false),
6531
+ expiresInSeconds: z
6532
+ .number()
6533
+ .int()
6534
+ .positive()
6535
+ .max(30 * 24 * 60 * 60)
6536
+ .nullable()
6537
+ .optional(),
6538
+ });
6539
+ export type RequestHumanInputToolInput = z.infer<typeof RequestHumanInputToolInput>;
6540
+
6541
+ export const HumanInputAnswer = z.object({
6542
+ questionId: z.string().min(1).max(64),
6543
+ values: z.array(z.string().max(8192)).max(20),
6544
+ other: z.string().max(8192).nullable().optional(),
6545
+ });
6546
+ export type HumanInputAnswer = z.infer<typeof HumanInputAnswer>;
6547
+
6548
+ export const HumanInputResponse = z.discriminatedUnion("outcome", [
6549
+ z.object({
6550
+ outcome: z.literal("answered"),
6551
+ answers: z.array(HumanInputAnswer).max(20),
6552
+ }),
6553
+ z.object({ outcome: z.literal("skipped") }),
6554
+ z.object({ outcome: z.literal("expired") }),
6555
+ z.object({ outcome: z.literal("cancelled") }),
6556
+ ]);
6557
+ export type HumanInputResponse = z.infer<typeof HumanInputResponse>;
6558
+
6559
+ export const SubmitHumanInputResponseRequest = z.discriminatedUnion("outcome", [
6560
+ z.object({
6561
+ outcome: z.literal("answered"),
6562
+ answers: z.array(HumanInputAnswer).max(20),
6563
+ }),
6564
+ z.object({ outcome: z.literal("skipped") }),
6565
+ ]);
6566
+ export type SubmitHumanInputResponseRequest = z.infer<typeof SubmitHumanInputResponseRequest>;
6567
+
6568
+ export const SessionHumanInputRequest = z.object({
6569
+ id: z.string().uuid(),
6570
+ workspaceId: z.string().uuid(),
6571
+ sessionId: z.string().uuid(),
6572
+ turnId: z.string().uuid(),
6573
+ turnGeneration: z.number().int().positive(),
6574
+ creationAttemptId: z.string().uuid(),
6575
+ toolCallId: z.string().min(1).max(1024),
6576
+ status: HumanInputRequestStatus,
6577
+ questions: z.array(HumanInputQuestion).min(1).max(20),
6578
+ allowSkip: z.boolean(),
6579
+ response: HumanInputResponse.nullable(),
6580
+ respondedBy: z.string().max(1024).nullable(),
6581
+ respondedAt: z.string().nullable(),
6582
+ expiresAt: z.string().nullable(),
6583
+ createdAt: z.string(),
6584
+ updatedAt: z.string(),
6585
+ });
6586
+ export type SessionHumanInputRequest = z.infer<typeof SessionHumanInputRequest>;
6587
+
6588
+ /**
6589
+ * Extract the stable approval identity used by both durable admission and
6590
+ * runtime resume. Serialized SDK interruptions may place it on the wrapper or
6591
+ * its raw item; malformed entries fail closed instead of inventing an id.
6592
+ */
6593
+ export function approvalIdentifier(value: unknown): string | null {
6594
+ if (!value || typeof value !== "object") return null;
6595
+ const approval = value as Record<string, unknown>;
6596
+ const rawItem =
6597
+ approval.rawItem && typeof approval.rawItem === "object"
6598
+ ? (approval.rawItem as Record<string, unknown>)
6599
+ : null;
6600
+ const candidate = rawItem?.callId ?? rawItem?.id ?? approval.id ?? approval.name;
6601
+ if (typeof candidate !== "string" && typeof candidate !== "number") return null;
6602
+ return String(candidate);
6603
+ }
6604
+
4177
6605
  export const ClientSessionEvent = z.discriminatedUnion("type", [
4178
6606
  z.object({
4179
6607
  type: z.literal("user.message"),
4180
- clientEventId: z.string().min(1).optional(),
6608
+ clientEventId: SessionOperationKey.optional(),
4181
6609
  payload: z.object({
4182
6610
  text: z.string().min(1),
6611
+ // System-level host context for this exact turn only. Persisted on the
6612
+ // turn for retry/recovery, never copied into the visible user message.
6613
+ turnInstructions: z.string().trim().min(1).max(32768).optional(),
4183
6614
  resources: z.array(ResourceRef).default([]),
4184
6615
  tools: z.array(ToolRef).default([]),
4185
6616
  model: z.string().min(1).optional(),
4186
6617
  reasoningEffort: ReasoningEffort.optional(),
6618
+ controlEtag: z.string().min(1).optional(),
6619
+ expectedDraftRevision: z.number().int().nonnegative().optional(),
4187
6620
  // Header-value rotation only. URL/name/tool settings are immutable after
4188
6621
  // session create; persisted events expose metadata, never header values.
4189
6622
  mcpCredentialUpdates: z.array(SessionMcpCredentialUpdateInput).optional(),
@@ -4191,25 +6624,35 @@ export const ClientSessionEvent = z.discriminatedUnion("type", [
4191
6624
  }),
4192
6625
  z.object({
4193
6626
  type: z.literal("user.approvalDecision"),
4194
- clientEventId: z.string().min(1).optional(),
6627
+ clientEventId: SessionOperationKey.optional(),
4195
6628
  payload: z.object({
4196
- approvalId: z.string().min(1),
6629
+ approvalId: z.string().min(1).max(SESSION_OPERATION_KEY_MAX_CHARS),
4197
6630
  decision: z.enum(["approve", "reject"]),
4198
6631
  message: z.string().optional(),
4199
6632
  }),
4200
6633
  }),
6634
+ z.object({
6635
+ type: z.literal("user.humanInputResponse"),
6636
+ clientEventId: SessionOperationKey.optional(),
6637
+ payload: z.object({
6638
+ requestId: z.string().uuid(),
6639
+ response: SubmitHumanInputResponseRequest,
6640
+ }),
6641
+ }),
4201
6642
  ]);
4202
6643
  export type ClientSessionEvent = z.infer<typeof ClientSessionEvent>;
4203
6644
 
4204
6645
  export const SteerSessionMessageRequest = z.object({
4205
6646
  text: z.string().min(1),
6647
+ // Same per-turn system-level context as a queued user.message.
6648
+ turnInstructions: z.string().trim().min(1).max(32768).optional(),
4206
6649
  resources: z.array(ResourceRef).default([]),
4207
6650
  tools: z.array(ToolRef).default([]),
4208
6651
  model: z.string().min(1).optional(),
4209
6652
  reasoningEffort: ReasoningEffort.optional(),
4210
- clientEventId: z.string().min(1).optional(),
4211
- expectedControlGeneration: z.number().int().nonnegative().optional(),
4212
- expectedWorkspaceInferenceGeneration: z.number().int().nonnegative().optional(),
6653
+ clientEventId: SessionOperationKey.optional(),
6654
+ controlEtag: z.string().min(1).optional(),
6655
+ expectedDraftRevision: z.number().int().nonnegative().optional(),
4213
6656
  mcpCredentialUpdates: z.array(SessionMcpCredentialUpdateInput).optional(),
4214
6657
  });
4215
6658
  export type SteerSessionMessageRequest = z.infer<typeof SteerSessionMessageRequest>;
@@ -4249,6 +6692,37 @@ export const GitHubRepository = z.object({
4249
6692
  });
4250
6693
  export type GitHubRepository = z.infer<typeof GitHubRepository>;
4251
6694
 
6695
+ export const GitHubRepositoryScope = z.enum(["all", "selected"]);
6696
+ export type GitHubRepositoryScope = z.infer<typeof GitHubRepositoryScope>;
6697
+
6698
+ export const GitHubInstallationBinding = z.object({
6699
+ installationId: z.number().int().positive(),
6700
+ accountLogin: z.string().nullable(),
6701
+ accountType: z.string().nullable(),
6702
+ repositoryScope: GitHubRepositoryScope,
6703
+ repositoryCount: z.number().int().nonnegative(),
6704
+ createdAt: z.string(),
6705
+ updatedAt: z.string(),
6706
+ });
6707
+ export type GitHubInstallationBinding = z.infer<typeof GitHubInstallationBinding>;
6708
+
6709
+ export const GitHubAppInfo = z.object({
6710
+ configured: z.boolean(),
6711
+ appId: z.string().nullable(),
6712
+ clientId: z.string().nullable(),
6713
+ appSlug: z.string().nullable(),
6714
+ installUrl: z.string().nullable(),
6715
+ linkUrl: z.string().nullable(),
6716
+ installations: z.array(GitHubInstallationBinding),
6717
+ missing: z.array(z.string()),
6718
+ });
6719
+ export type GitHubAppInfo = z.infer<typeof GitHubAppInfo>;
6720
+
6721
+ export const GitHubRepositoriesResponse = z.object({
6722
+ repositories: z.array(GitHubRepository),
6723
+ });
6724
+ export type GitHubRepositoriesResponse = z.infer<typeof GitHubRepositoriesResponse>;
6725
+
4252
6726
  export const ClientAuthConfig = z.discriminatedUnion("mode", [
4253
6727
  z.object({
4254
6728
  mode: z.literal("none"),
@@ -4269,7 +6743,7 @@ export const ClientAuthConfig = z.discriminatedUnion("mode", [
4269
6743
  ]);
4270
6744
  export type ClientAuthConfig = z.infer<typeof ClientAuthConfig>;
4271
6745
 
4272
- // The negotiated capability handshake document (master-spine C.3). ONE shape;
6746
+ // The negotiated capability handshake document (sandbox contract C.3). ONE shape;
4273
6747
  // collapses the parallel per-module definitions. A capability cell is always
4274
6748
  // present with `available`/`transport` + a `reason` when unavailable — never
4275
6749
  // absent.
@@ -4402,14 +6876,14 @@ export const ViewerHolder = z.object({
4402
6876
  leaseEpoch: z.number().int().nonnegative(),
4403
6877
  viewerHeartbeatIntervalMs: z.number().int().positive(),
4404
6878
  // The desktop pixel tunnel URL the viewer connects to directly; null until
4405
- // P4 mints it (gated until then).
6879
+ // a viewer grant is minted (gated until then).
4406
6880
  dataPlaneUrl: z.string().nullable(),
4407
6881
  });
4408
6882
  export type ViewerHolder = z.infer<typeof ViewerHolder>;
4409
6883
 
4410
6884
  // 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
6885
+ // acknowledgment of the un-redacted pixel plane. Reuses the acknowledgment
6886
+ // machinery — no new endpoint
4413
6887
  // shape beyond this body, no new permission beyond stream:acknowledge.
4414
6888
  //
4415
6889
  // `acknowledgeShared` MUST be true when the box is shared (the group has >1
@@ -4453,7 +6927,7 @@ export type ViewerHeartbeatResponse = z.infer<typeof ViewerHeartbeatResponse>;
4453
6927
  // (DeviceAuthStart*, DeviceAuthPoll*, EnrollmentCredentials) so the Rust agent's
4454
6928
  // `enroll` command (which runs the flow over HTTP before it has NATS creds)
4455
6929
  // 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
6930
+ // request bodies additionally carry the consent-relevant fields the design brief
4457
6931
  // mandates (the agent ed25519 pubkey + can-offer-display + requests-screen-control).
4458
6932
  // =============================================================================
4459
6933
 
@@ -4699,7 +7173,7 @@ export const EnrollTokenExchangeResponse = z.object({
4699
7173
  });
4700
7174
  export type EnrollTokenExchangeResponse = z.infer<typeof EnrollTokenExchangeResponse>;
4701
7175
 
4702
- // ── Machines dashboard + per-machine metrics (M10, dossier §10.7) ────────────
7176
+ // ── Machines dashboard + per-machine metrics (M10) ────────────
4703
7177
  //
4704
7178
  // The SHARED data contract M10 (backend) implements + M9 (UI) renders. THE
4705
7179
  // orchestrator owns this shape; M9 imports these types so the dashboard never
@@ -4853,8 +7327,20 @@ export const ClientModel = z.object({
4853
7327
  });
4854
7328
  export type ClientModel = z.infer<typeof ClientModel>;
4855
7329
 
7330
+ /**
7331
+ * Exact public HTTP protocol revision spoken by this release train.
7332
+ *
7333
+ * This is deliberately independent from a deployment SHA: API and web may roll
7334
+ * at different instants, while incompatible request shapes must never cross
7335
+ * that rollout boundary. Mutating clients send this value in
7336
+ * `x-opengeni-api-contract`; the API rejects any other value before routing.
7337
+ */
7338
+ export const OPENGENI_API_CONTRACT_REVISION = "2026-07-turn-instructions-v1" as const;
7339
+ export const OPENGENI_API_CONTRACT_HEADER = "x-opengeni-api-contract" as const;
7340
+
4856
7341
  export const ClientConfig = z.object({
4857
7342
  deploymentRevision: z.string(),
7343
+ apiContractRevision: z.literal(OPENGENI_API_CONTRACT_REVISION),
4858
7344
  // Release-train version of the server (absent on dev/source builds). The
4859
7345
  // compatibility policy lives in docs/architecture.md — clients within the
4860
7346
  // same major are supported; evolution is additive within a major.