11import type { JsonObject } from "./effect_program.ts" ;
2- import { requireNonEmptyString as requiredString } from "./runtime_decode.ts" ;
2+ import {
3+ assertNever ,
4+ requireNonEmptyString as requiredString ,
5+ requireStringLiteral ,
6+ } from "./runtime_decode.ts" ;
37
48export const EXTERNAL_EFFECT_RECEIPT_SCHEMA_VERSION =
59 "loopx_external_effect_receipt_v0" ;
10+ export const CONTINUOUS_MONITOR_PROPOSAL_SCHEMA_VERSION =
11+ "loopx_continuous_monitor_proposal_v0" ;
612
713const RESULT_FIELDS = new Set ( [
814 "schema_version" ,
@@ -25,6 +31,54 @@ const EFFECT_RECEIPT_FIELDS = new Set([
2531 "evidence_digest" ,
2632] ) ;
2733
34+ const TRANSITION_CONTRACT_FIELDS = new Set ( [
35+ "proposal_kinds" ,
36+ "monitor_key_prefixes" ,
37+ "monitor_action_kinds" ,
38+ "monitor_target_key_prefixes" ,
39+ "monitor_required_capabilities" ,
40+ ] ) ;
41+ const MONITOR_UPSERT_FIELDS = new Set ( [
42+ "schema_version" ,
43+ "proposal_id" ,
44+ "kind" ,
45+ "monitor_key" ,
46+ "text" ,
47+ "action_kind" ,
48+ "target_key" ,
49+ "cadence" ,
50+ "next_due_at" ,
51+ "expires_at" ,
52+ "required_capabilities" ,
53+ ] ) ;
54+ const MONITOR_COMPLETE_FIELDS = new Set ( [
55+ "schema_version" ,
56+ "proposal_id" ,
57+ "kind" ,
58+ "monitor_key" ,
59+ "evidence" ,
60+ ] ) ;
61+ const TRANSITION_PROPOSAL_KINDS = [
62+ "continuous_monitor_upsert" ,
63+ "continuous_monitor_complete" ,
64+ ] as const ;
65+ type TransitionProposalKind = typeof TRANSITION_PROPOSAL_KINDS [ number ] ;
66+ type ValidatedTransitionProposal =
67+ | ( JsonObject & {
68+ kind : "continuous_monitor_upsert" ;
69+ proposal_id : string ;
70+ monitor_key : string ;
71+ required_capabilities : string [ ] ;
72+ } )
73+ | ( JsonObject & {
74+ kind : "continuous_monitor_complete" ;
75+ proposal_id : string ;
76+ monitor_key : string ;
77+ } ) ;
78+ const PROPOSAL_ID_RE = / ^ [ a - z ] [ a - z 0 - 9 _ . : - ] { 2 , 95 } $ / ;
79+ const MONITOR_KEY_RE = / ^ [ a - z ] [ a - z 0 - 9 _ . - ] { 0 , 31 } : [ a - z ] [ a - z 0 - 9 _ . : - ] { 2 , 95 } $ / ;
80+ const ACTION_KIND_RE = / ^ [ a - z ] [ a - z 0 - 9 _ ] { 0 , 63 } $ / ;
81+
2882function requiredObject ( value : unknown , label : string ) : JsonObject {
2983 if ( typeof value !== "object" || value === null || Array . isArray ( value ) ) {
3084 throw new Error ( `${ label } must be an object` ) ;
@@ -39,6 +93,29 @@ function boundedArray(value: unknown, label: string): unknown[] {
3993 return [ ...value ] ;
4094}
4195
96+ function boundedString ( value : unknown , label : string , limit : number ) : string {
97+ const result = requiredString ( value , label ) ;
98+ if ( result . length > limit || result . includes ( "\n" ) || result . includes ( "\r" ) ) {
99+ throw new Error ( `${ label } must be bounded single-line text` ) ;
100+ }
101+ return result ;
102+ }
103+
104+ function boundedStringArray (
105+ value : unknown ,
106+ label : string ,
107+ limit : number ,
108+ ) : string [ ] {
109+ const result = boundedArray ( value , label ) ;
110+ if (
111+ result . length > limit ||
112+ result . some ( ( item ) => typeof item !== "string" || item . length === 0 )
113+ ) {
114+ throw new Error ( `${ label } must contain bounded non-empty strings` ) ;
115+ }
116+ return result as string [ ] ;
117+ }
118+
42119function requireExactFields (
43120 value : JsonObject ,
44121 expected : ReadonlySet < string > ,
@@ -89,6 +166,160 @@ function externalEffectReceipt(
89166 return receipt ;
90167}
91168
169+ function validateTransitionProposals ( input : {
170+ value : unknown ;
171+ status : string ;
172+ transition_contract : unknown ;
173+ } ) : ValidatedTransitionProposal [ ] {
174+ const proposals = boundedArray (
175+ input . value ,
176+ "external capability result transition_proposals" ,
177+ ) ;
178+ if ( proposals . length === 0 ) {
179+ return [ ] ;
180+ }
181+ const contract = requiredObject (
182+ input . transition_contract ,
183+ "governed capability transition_contract" ,
184+ ) ;
185+ requireExactFields (
186+ contract ,
187+ TRANSITION_CONTRACT_FIELDS ,
188+ "governed capability transition_contract" ,
189+ ) ;
190+ const allowedKinds = boundedStringArray (
191+ contract . proposal_kinds ,
192+ "governed capability transition_contract proposal_kinds" ,
193+ 8 ,
194+ ) ;
195+ const allowedActions = boundedStringArray (
196+ contract . monitor_action_kinds ,
197+ "governed capability transition_contract monitor_action_kinds" ,
198+ 32 ,
199+ ) ;
200+ const allowedMonitorKeyPrefixes = boundedStringArray (
201+ contract . monitor_key_prefixes ,
202+ "governed capability transition_contract monitor_key_prefixes" ,
203+ 32 ,
204+ ) ;
205+ const allowedTargetPrefixes = boundedStringArray (
206+ contract . monitor_target_key_prefixes ,
207+ "governed capability transition_contract monitor_target_key_prefixes" ,
208+ 32 ,
209+ ) ;
210+ const allowedCapabilities = boundedStringArray (
211+ contract . monitor_required_capabilities ,
212+ "governed capability transition_contract monitor_required_capabilities" ,
213+ 32 ,
214+ ) ;
215+ const seenProposalIds = new Set < string > ( ) ;
216+ return proposals . map ( ( raw , index ) => {
217+ const label = `external capability transition_proposals[${ index } ]` ;
218+ const proposal = requiredObject ( raw , label ) ;
219+ const kind : TransitionProposalKind = requireStringLiteral (
220+ proposal . kind ,
221+ TRANSITION_PROPOSAL_KINDS ,
222+ `${ label } kind` ,
223+ `${ label } kind is unsupported` ,
224+ ) ;
225+ if ( ! allowedKinds . includes ( kind ) ) {
226+ throw new Error ( `${ label } kind is not admitted by transition_contract` ) ;
227+ }
228+ if ( input . status === "running" && kind !== "continuous_monitor_upsert" ) {
229+ throw new Error ( `${ label } running result may only upsert a monitor` ) ;
230+ }
231+ if ( proposal . schema_version !== CONTINUOUS_MONITOR_PROPOSAL_SCHEMA_VERSION ) {
232+ throw new Error ( `${ label } schema_version is invalid` ) ;
233+ }
234+ const proposalId = boundedString (
235+ proposal . proposal_id ,
236+ `${ label } proposal_id` ,
237+ 96 ,
238+ ) ;
239+ if ( ! PROPOSAL_ID_RE . test ( proposalId ) || seenProposalIds . has ( proposalId ) ) {
240+ throw new Error ( `${ label } proposal_id is invalid or duplicated` ) ;
241+ }
242+ seenProposalIds . add ( proposalId ) ;
243+ const monitorKey = boundedString (
244+ proposal . monitor_key ,
245+ `${ label } monitor_key` ,
246+ 128 ,
247+ ) ;
248+ if ( ! MONITOR_KEY_RE . test ( monitorKey ) ) {
249+ throw new Error ( `${ label } monitor_key is invalid` ) ;
250+ }
251+ if ( ! allowedMonitorKeyPrefixes . some ( ( prefix ) => monitorKey . startsWith ( prefix ) ) ) {
252+ throw new Error ( `${ label } monitor_key is not admitted` ) ;
253+ }
254+ switch ( kind ) {
255+ case "continuous_monitor_upsert" : {
256+ requireExactFields ( proposal , MONITOR_UPSERT_FIELDS , label ) ;
257+ const actionKind = boundedString (
258+ proposal . action_kind ,
259+ `${ label } action_kind` ,
260+ 64 ,
261+ ) ;
262+ if ( ! ACTION_KIND_RE . test ( actionKind ) || ! allowedActions . includes ( actionKind ) ) {
263+ throw new Error ( `${ label } action_kind is not admitted` ) ;
264+ }
265+ const targetKey = boundedString (
266+ proposal . target_key ,
267+ `${ label } target_key` ,
268+ 128 ,
269+ ) ;
270+ if ( ! allowedTargetPrefixes . some ( ( prefix ) => targetKey . startsWith ( prefix ) ) ) {
271+ throw new Error ( `${ label } target_key is not admitted` ) ;
272+ }
273+ const requiredCapabilities = boundedStringArray (
274+ proposal . required_capabilities ,
275+ `${ label } required_capabilities` ,
276+ 16 ,
277+ ) ;
278+ if (
279+ new Set ( requiredCapabilities ) . size !== requiredCapabilities . length ||
280+ requiredCapabilities . some ( ( capability ) =>
281+ ! ACTION_KIND_RE . test ( capability ) || ! allowedCapabilities . includes ( capability )
282+ )
283+ ) {
284+ throw new Error ( `${ label } required_capabilities are not admitted` ) ;
285+ }
286+ return {
287+ ...proposal ,
288+ kind,
289+ proposal_id : proposalId ,
290+ monitor_key : monitorKey ,
291+ text : boundedString ( proposal . text , `${ label } text` , 240 ) ,
292+ action_kind : actionKind ,
293+ target_key : targetKey ,
294+ cadence : boundedString ( proposal . cadence , `${ label } cadence` , 32 ) ,
295+ next_due_at : boundedString (
296+ proposal . next_due_at ,
297+ `${ label } next_due_at` ,
298+ 64 ,
299+ ) ,
300+ expires_at : boundedString (
301+ proposal . expires_at ,
302+ `${ label } expires_at` ,
303+ 64 ,
304+ ) ,
305+ required_capabilities : requiredCapabilities ,
306+ } ;
307+ }
308+ case "continuous_monitor_complete" :
309+ requireExactFields ( proposal , MONITOR_COMPLETE_FIELDS , label ) ;
310+ return {
311+ ...proposal ,
312+ kind,
313+ proposal_id : proposalId ,
314+ monitor_key : monitorKey ,
315+ evidence : boundedString ( proposal . evidence , `${ label } evidence` , 240 ) ,
316+ } ;
317+ default :
318+ return assertNever ( kind , `${ label } kind dispatch is incomplete` ) ;
319+ }
320+ } ) ;
321+ }
322+
92323export function validateGovernedCapabilityAdmission ( input : {
93324 admission : unknown ;
94325 todo_id : string ;
@@ -160,6 +391,7 @@ export function validateGovernedCapabilityResult(input: {
160391 effect_id : string ;
161392 result_schema : string ;
162393 effect_class : string ;
394+ transition_contract ?: unknown ;
163395} ) : { result : JsonObject ; journal_status : "running" | "ready_to_settle" } {
164396 if ( input . effect_class !== "external_write" ) {
165397 throw new Error ( "governed capability execution requires external_write" ) ;
@@ -183,10 +415,14 @@ export function validateGovernedCapabilityResult(input: {
183415 "observations" ,
184416 "domain_state_mutations" ,
185417 "domain_transition_receipts" ,
186- "transition_proposals" ,
187418 ] ) {
188419 result [ field ] = boundedArray ( result [ field ] , `external capability result ${ field } ` ) ;
189420 }
421+ result . transition_proposals = validateTransitionProposals ( {
422+ value : result . transition_proposals ,
423+ status : result . status as string ,
424+ transition_contract : input . transition_contract ,
425+ } ) ;
190426 result . follow_up = requiredObject (
191427 result . follow_up ,
192428 "external capability result follow_up" ,
@@ -200,7 +436,6 @@ export function validateGovernedCapabilityResult(input: {
200436 for ( const field of [
201437 "domain_state_mutations" ,
202438 "domain_transition_receipts" ,
203- "transition_proposals" ,
204439 ] ) {
205440 if ( ( result [ field ] as unknown [ ] ) . length > 0 ) {
206441 throw new Error (
0 commit comments